@hyperfrontend/builderPublish a TypeScript library to npm
You will turn a directory holding one TypeScript file into a package that imports, requires, and type-checks correctly wherever it is installed, without hand-writing an exports map or wiring two bundlers together.
Everything comes from @hyperfrontend/builder. It drives declaration emit through your own TypeScript, so both arrive as dev dependencies.
Set up the project
Three files, none of them about the builder. In a new directory called bytesize, package.json carries the facts only you know:
{
"name": "bytesize",
"version": "1.0.0",
"description": "Format byte counts as human-readable strings.",
"license": "MIT",
"keywords": ["bytes", "format"]
}
src/index.ts is the library:
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB']
/**
* Formats a byte count as a human-readable string.
*/
export function formatBytes(bytes: number): string {
let value = bytes
let unit = 0
while (value >= 1024 && unit < UNITS.length - 1) {
value /= 1024
unit += 1
}
return `${Math.round(value * 10) / 10} ${UNITS[unit]}`
}
And tsconfig.json, where rootDir is the one setting worth attention: it names src as the base of the tree, so the emitted .d.ts lands beside the bundles instead of inside a src/ folder nobody imports from.
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"strict": true,
"rootDir": "src"
},
"include": ["src"]
}
Then install the toolchain:
npm install --save-dev @hyperfrontend/builder typescript
Describe the build
Create builder.config.json. Every key is a field of BuildConfig:
{
"projectRoot": ".",
"workspaceRoot": ".",
"outputPath": "dist/bytesize",
"tsConfig": "tsconfig.json",
"esm": {},
"cjs": {}
}
esm and cjs are empty on purpose. Naming a format asks for it; its contents tune it. Leave one out and that format is not built.
Run it
npx hf-build --config ./builder.config.json
A successful build is quiet. Pass --verbose to watch each phase report its progress, timing, and memory. What landed:
dist/bytesize/
index.cjs.js
index.esm.js
index.d.ts
index.d.ts.map
package.json
One source file, both module formats, and declarations, out of a config that named none of them.
Read the manifest it wrote
Open dist/bytesize/package.json:
{
"name": "bytesize",
"version": "1.0.0",
"description": "Format byte counts as human-readable strings.",
"license": "MIT",
"keywords": ["bytes", "format"],
"devDependencies": {
"@hyperfrontend/builder": "^0.2.0",
"typescript": "^7.0.2"
},
"sideEffects": false,
"exports": {
"./package.json": "./package.json",
".": {
"types": "./index.d.ts",
"import": "./index.esm.js",
"require": "./index.cjs.js"
}
},
"main": "./index.cjs.js",
"module": "./index.esm.js",
"types": "./index.d.ts",
"files": ["**/index.*", "**/index.d.ts", "!**/*.js.map"]
}
What you wrote came through as written. Everything from sideEffects down is synthesized from what the build actually emitted: the exports map points each condition at the file that satisfies it, main, module and types repeat that for tooling older than exports, and files reflects the emitted tree rather than a list you have to remember to update. Add an entry point later and this manifest grows to match on the next build.
Prove both module systems
The output directory is the package, so pack that directory rather than the project root:
npm pack ./dist/bytesize
Then install the tarball into a directory that knows nothing about the project, and load it each way:
mkdir ../bytesize-check && cd ../bytesize-check
npm install ../bytesize/bytesize-1.0.0.tgz
node --input-type=module -e "import { formatBytes } from 'bytesize'; console.log(formatBytes(1536))"
node --input-type=commonjs -e "const { formatBytes } = require('bytesize'); console.log(formatBytes(5368709120))"
1.5 KB
5 GB
The named export resolves under import, the same name destructures out of require, and tsc follows the types condition to index.d.ts, so formatBytes('2048') is a compile error in the consumer rather than a runtime surprise.
Publish
Point npm publish at the same directory. The source root is not the package and must never be the thing you publish:
npm publish ./dist/bytesize
What you have
A package whose consumers get ESM, CJS, and types from one source file, described by a six-line config. The shape worth carrying forward is that split: you state the facts about the package, the build states what it produced, and no manifest has to be kept in agreement with a bundler by hand.
Reference and background
Next steps
How to replace commitizen and commitlint with one package
Guided commit authoring and commit-message linting take four packages and two config files that have to be kept agreeing with each other, and the prompt still offers types the linter rejects.
How to publish release notes from your CHANGELOG.md
My release job has a version and a CHANGELOG.md, and every attempt to get one from the other is a regex over markdown headings that breaks the first time an entry carries a compare link, a scope, or a breaking marker.