@temir.ra/create-ts-lib
v0.13.5
Published
A template for a distributable TypeScript library package.
Readme
Introduction
A template for TypeScript libraries distributed via npm-compatible registries. Provides TypeScript configuration, build tooling for ESM and bundled outputs, and build metadata generation.
Table of Contents
Quick Start
# placeholder:
# <TEMPLATE_PACKAGE_NAME: @temir.ra/create-ts-lib
# <TEMPLATE_NAME: @temir.ra/ts-lib
# print the latest version
npm info "@temir.ra/create-ts-lib" version
# create/update a package from the template in the current directory
npm create --no-install --no-git "@temir.ra/ts-lib@latest" .
# set metadata in package.json
npm updateDocumentation
The following sections explain the configurations and conventions baked into the generated package. Useful when adapting it to fit specific needs.
The major addition compared to the workspace template is a build pipeline for distributing the library. Two build strategies are supported:
- Bundling (
scripts/build-bundle.ts) - bundles the library to ESM and IIFE formats usingesbuild. - TSC compilation (
tsconfig.build.json+build:tsc) - compiles the library to declaration files, and optionally ESM JavaScript, usingtsc.
Both strategies can be combined.
package.json
Selected fields are documented in the workspace template README.
The following fields are specific to this template:
{
// ... ,
// the package is anticipated to be published
"private": false,
// ... ,
// treats all .js files as ES modules; use .cjs extension for CommonJS files
"type": "module",
// files to include in the published package
"files": [
"dist/"
],
// CLI entry point
"bin": "./dist/cli.bundle.js",
// package entry points
// multiple entry points can be configured (".", "./module/", etc.)
//
// scripts/build-bundle.ts (non-standard) export condition:
// "entrypoint" - locates the source entry point for bundling
//
// standard export conditions:
// "types" - TypeScript consumers; resolves to the declaration files;
// "browser" - browser bundler consumers; resolves to the bundled output
// "import" - ESM consumers; resolves to the bundled/compiled output
"exports": {
".": {
"entrypoint": "./src/index.ts",
"types": "./dist/index.d.ts",
"browser": "./dist/index.bundle.js",
"import": "./dist/index.bundle.js"
}
},
// convenience alias for source-execution only
// does NOT survive transpilation or bundling
"imports": {
"#src/*.js": "./src/*.ts"
},
"scripts": {
// ... ,
// generates buildinfo.txt with version + git hash
"buildinfo": "tsx scripts/buildinfo.ts",
// removes the dist/ directory generated by the build steps
"clean:dist": "rm -rf dist/",
// removes .tsbuildinfo files generated by TypeScript's incremental build feature
"clean:tsbuildinfo": "rm -f tsconfig.tsbuildinfo tsconfig.build.tsbuildinfo",
// convenience script to run the clean steps in sequence
"clean": "npm run clean:dist && npm run clean:tsbuildinfo",
// discovers and runs test files
"tests": "node --import tsx --test tests/**/*.test.ts",
// executed before build; generates buildinfo.txt
"prebuild": "npm run buildinfo",
// convenience script to run the build steps in sequence
"build": "npm run build:bundle && npm run build:tsc",
// bundles the library into ESM and IIFE formats for distribution
"build:bundle": "tsx scripts/build-bundle.ts",
// compiles the library to declaration files and ESM JavaScript in dist/
"build:tsc": "tsc --project tsconfig.build.json",
},
"devDependencies": {
"@types/node": "latest",
"esbuild": "latest",
"tsx": "latest",
"typescript": "^6.0.3"
}
}Script scripts/buildinfo.ts
Writes buildinfo.txt with the package version from package.json, appending the git short hash as semver build metadata (<version>+<hash>). If the version already contains +, the hash is appended as <version>+<existing>.<hash> instead. Falls back to the bare version when git is unavailable.
Script scripts/build-bundle.ts
Bundles the library to ESM and IIFE formats using esbuild. Entry points are resolved from the entrypoint condition in the exports field of the package.json. Note, the entrypoint condition is a custom, non-standard export condition used solely for build tooling.
Packages can be marked as external. Such packages must be available at runtime (e.g. in node_modules/ or on a CDN) and are not bundled into the output. To mark an import as external, add it to the Import Map scripts/import-map.json.
Files in the COPY_FILES array are copied to the output directory after esm bundling.
CSS processing
CSS processing can be enabled with the following addition to scripts/build-bundle.ts:
import { globSync } from 'node:fs';
// ...
console.log('[scripts/build-bundle.ts] CSS processing...');
const cssEntryPoints = globSync('src/**/*.css');
if (cssEntryPoints.length > 0) {
try {
await build({
entryPoints: cssEntryPoints,
outdir: 'dist/',
outbase: 'src',
entryNames: '[dir]/[name]',
assetNames: '[dir]/[name]',
platform: 'browser',
bundle: true,
minify: true,
sourcemap: 'external',
loader: {
'.woff': 'file',
'.woff2': 'file',
'.ttf': 'file',
'.eot': 'file',
'.svg': 'file',
'.png': 'file',
'.jpg': 'file',
'.jpeg': 'file',
'.gif': 'file',
},
plugins: [esbuildLog],
});
} catch (error) {
console.error('[scripts/build-bundle.ts] CSS processing failed:');
for (const message of (error as BuildFailure).errors) {
console.error(message);
}
process.exit(1);
}
}
console.log('[scripts/build-bundle.ts] CSS processing finished.');Then, add an exports entry in package.json for the CSS files:
{
// ... ,
"exports": {
// ... ,
"./*.css": "./dist/*.css"
},
}Consumers can then import the CSS files directly from the package:
import '@scope/package-name/path-in-dist/styles.css';Import Map scripts/import-map.json
Marks imports as external and optionally rewrites them.
- Falsy value: marks the import external without rewriting; the original specifier is preserved.
- Truthy value: rewrites the import to the given specifier and marks it external.
{
"rewrite-package": "https://cdn.jsdelivr.net/npm/rewrite-package@<VERSION>/dist/index.js",
"rewrite-package-without-version": "https://cdn.jsdelivr.net/npm/rewrite-package/dist/index.js",
"external-only-package": null
}<VERSION> can be added in the given specifier. It is replaced with the version of the matching package from package.json.
tsconfig.build.json
tsconfig.json is intended for development only. tsconfig.build.json extends it with settings for compiling the source files for distribution.
See workspace template README and tsconfig.json for detailed explanations of all options.
{
"extends": "./tsconfig.json",
"compilerOptions": {
// narrows root to src/ for distribution output
"rootDir": "./src/",
// nodenext enforces strict ESM compliance; imports must use explicit .js file extensions
"module": "nodenext",
"moduleResolution": "nodenext",
// emit .d.ts declaration files
"declaration": true,
// emit .d.ts.map files mapping declarations back to source
"declarationMap": true,
// emit only .d.ts files, no JavaScript
"emitDeclarationOnly": true,
// output directory for emitted files
"outDir": "./dist/"
},
// include only src/ files for distribution
"include": [
"src/**/*.ts"
],
// exclude development files and directories from distribution
"exclude": [
"node_modules/",
"dist/",
"tests/",
"scripts/"
]
}package.json
{
// ... ,
"scripts": {
// ... ,
"build": "... && bun run build:tsc",
"build:tsc": "tsc --project tsconfig.build.json"
}
}Exports condition import may point to the compiled output instead of the bundled output ./dist/index.bundle.js if "emitDeclarationOnly": false:
{
// ... ,
"exports": {
".": {
// ... ,
"import": "./dist/index.js"
}
},
// ... ,
}When "declaration": true, then exports condition types can be added to point to the declaration files:
{
// ... ,
"exports": {
".": {
// ... ,
"types": "./dist/index.d.ts",
// ... ,
}
},
// ... ,
}Package Files Resolution
# placeholder:
# <@SCOPE: <@SCOPE>
# <PACKAGE_NAME: <PACKAGE_NAME>The key question to ask is: does the package retain its import.meta.url identity at runtime? Everything else follows from it.
import.meta.url resolves to the URL of the containing file or bundle:
https://cdn.example.com/<@SCOPE>/<PACKAGE_NAME>/dist/index.bundle.js
file:///project/node_modules/<@SCOPE>/<PACKAGE_NAME>/dist/index.js
file:///project/scripts/dev.tsWhen the package is NOT bundled into the consumer's output (is loaded as a discrete module at runtime), the package retains its import.meta.url identity: import.meta.url points to the containing file or bundle. URLs derived from import.meta.url resolve to the expected location regardless of how the package is distributed (transpiled only or bundled).
When the package is bundled into the consumer's output, it loses its import.meta.url identity: import.meta.url points to the consumer's bundle or file, not the original location of the package. URLs derived from import.meta.url silently resolve relative to the consumer's output.
The generated src/package-urls.ts scaffolds convenience URL exports based on import.meta.url. Its location in the package, the constructed URLs, and its exports entry in package.json are designed such that the exported URLs are at the same relative path to the package root regardless of the build strategy (bundled, transpiled, or imported). This has the following implications:
- If the package is used as a discrete module at runtime (e.g. loaded from
node_modules/or a CDN), then the URLs exported insrc/package-urls.tsresolve to the expected location without requiring any special handling from the consumer. This is the recommended way to consume the generated package. - The
exportsentry inpackage.jsonfor./package-urlscan be marked external independently from the rest of the package, so that it retains itsimport.meta.urlidentity. See Import Mapscripts/import-map.json. - If the package is bundled into the consumer's output, then the consumer must consult the exports in
src/package-urls.tsand ensure that the directories and files for the exported URLs exist in the final output (e.g. by copying them fromnode_modules/).
The exports entry in package.json for ./package-urls is not generated by default. The package author may add it to signal the consumers that the package resolves package URLs at ./package-urls and that they should be treated as a special case if the package is bundled.
// ... ,
"exports": {
"./package-urls": {
"entrypoint": "./src/package-urls.ts",
"types": "./dist/package-urls.d.ts",
"browser": "./dist/package-urls.bundle.js",
"import": "./dist/package-urls.bundle.js"
},
// ...
},
// ...Accessing package files at runtime
// src/package-urls.ts and dist/package-urls.js are at the same relative path to the package root
const packageUrl = new URL('../', import.meta.url);
const assetsUrl = new URL('assets/<@SCOPE>/<PACKAGE_NAME>/', packageUrl);
// for `--target browser` and `--target node` (isomorphic)
export { };
const assetUrl = new URL('<ASSET>', assetsUrl);
const asset = await fetch(assetUrl).then(response => response.body);
// for `--target node` only
import { readFile } from 'node:fs/promises';
const assetUrl = new URL('<ASSET>', assetsUrl);
const asset = await readFile(assetUrl, 'utf-8');⚠️ Beware that import.meta.url is replaced by document.currentScript.src during bundling in IIFE format since it is available only under ESM (research "import.meta.url vs document.currentScript.src").
"bin" field in package.json
For CLI packages, add the following to package.json:
{
// ... ,
"bin": "./dist/cli.bundle.js",
"exports": {
".": {
"entrypoint": "./src/cli.ts"
}
},
// ...
}src/cli.ts must begin with a hashbang so the OS knows which interpreter to invoke when the binary is executed directly.
#!/usr/bin/env nodeDevOps
npm install
npm update
npm run clean
npm run build
npm run tests
npx tsx dist/cli.bundle.js -- example/Change Management
- Create a new branch for the change.
- Make the changes and commit.
- Bump the version in
package.json. - Add an entry for the new version in
CHANGELOG.md. - Pull-request the branch.
- Ensure package artifacts are current.
- Publish.
Publish
~/.npmrc or .npmrc:
@temir.ra:registry=https://registry.npmjs.org/~/.bunfig.toml or bunfig.toml:
[install.scopes]
"temir.ra" = { url = "https://registry.npmjs.org/" }# registry.npmjs.org/
npm login
npm publish