No description
Find a file
2026-03-06 22:19:43 +01:00
scripts carryover from dev as 0.1.0 (#1) 2026-02-27 20:14:40 +00:00
src 1. Fixed CHANGELOG.md and buildinfo.txt copy paths in cli.ts: they were resolved relative to template/ instead of the package root, causing the copy to fail. Refactored URL/path construction to derive all paths from a single packageUrl, eliminating redundant fileURLToPath calls at each copy site. 2026-03-01 22:47:27 +01:00
template version 0.2.6 2026-03-06 22:18:24 +01:00
tests Fixed buildinfo.test.ts regex to accept semver prerelease identifiers. 2026-03-06 14:35:08 +01:00
.gitignore 1. Fixed CHANGELOG.md and buildinfo.txt copy paths in cli.ts: they were resolved relative to template/ instead of the package root, causing the copy to fail. Refactored URL/path construction to derive all paths from a single packageUrl, eliminating redundant fileURLToPath calls at each copy site. 2026-03-01 22:47:27 +01:00
AGENTS.md carryover from dev as 0.1.0 (#1) 2026-02-27 20:14:40 +00:00
CHANGELOG.md version 0.2.6 2026-03-06 22:18:24 +01:00
CLAUDE.md updating CLAUDE.md with new developments 2026-03-06 00:52:31 +01:00
LICENSE carryover from dev as 0.1.0 (#1) 2026-02-27 20:14:40 +00:00
package.json version 0.2.6 2026-03-06 22:18:24 +01:00
README.md version 0.2.6 2026-03-06 22:18:24 +01:00
tsconfig.build.json carryover from dev as 0.1.0 (#1) 2026-02-27 20:14:40 +00:00
tsconfig.json carryover from dev as 0.1.0 (#1) 2026-02-27 20:14:40 +00:00

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

  1. Quick Start
  2. Documentation
    1. tsconfig.json and tsconfig.build.json
    2. package.json
    3. Script scripts/buildinfo.ts
    4. Script scripts/build-lib-bundle.ts
    5. CDN Map scripts/cdn-rewrite-map.json
    6. "bin" field in package.json
    7. Asset Resolution
      1. Externalized - loaded from CDN or node_modules/
      2. Bundled - absorbed into the consumer's output
      3. Contract
        1. Scoped assets directory convention
        2. Accessing assets
        3. README statement for library consumers
        4. When the library is bundled by the consumer
    8. src/dev.ts
    9. CLAUDE.md / AGENTS.md
  3. DevOps
    1. Change Management
    2. Publish
      1. npmjs.org
      2. Custom registry

Quick Start

bun create caches the template package - a newer published version will not be picked up automatically. Pin the version or clear the cache to use the latest.

# placeholder:
    # <NEW_PACKAGE: <NEW_PACKAGE>
    # <@_VERSION: <@_VERSION>

# identify the latest version of the template package as <@_VERSION.
bun info "@temir.ra/create-ts-lib" version
# create a new library from the template version
bun create --no-install --no-git "@temir.ra/ts-lib<@_VERSION>" <NEW_PACKAGE>

# or

# clear package manager cache to ensure the latest template version is used
bun pm cache rm
# create a new library from the latest template version
bun create --no-install --no-git "@temir.ra/ts-lib" <NEW_PACKAGE>

# dependencies must be installed manually
cd <NEW_PACKAGE>
bun install

Documentation

The following sections explain the configurations and conventions baked into the generated package. Useful when adapting the generated package to fit a specific library's needs.

tsconfig.json and tsconfig.build.json

{

  "compilerOptions": {

    // ECMAScript version of emitted output
    "target": "ES2022",

    // output module format; ESNext passes ES module syntax through unchanged
    "module": "ESNext",

    // type definitions for built-in APIs
    "lib": [
      "ES2022", // standard JavaScript runtime APIs
      "DOM"     // browser globals for bundled output
    ],

    // module resolution strategy; bundler mode allows omitting file extensions in imports
    // tsconfig.build.json overrides this to nodenext for strict ESM compliance
    "moduleResolution": "bundler",

    // enables all strict type-checking options
    "strict": true,

    // enforces import type for type-only imports; emitted module syntax matches source exactly
    "verbatimModuleSyntax": true,

    // array indexing and index signature access returns T | undefined instead of T
    "noUncheckedIndexedAccess": true,

    // distinguishes absent optional properties from those explicitly set to undefined
    "exactOptionalPropertyTypes": true,

    // requires explicit override keyword when overriding base class methods
    "noImplicitOverride": true,

    // requires explicit types on all exported declarations; enables parallel .d.ts generation by external tools
    "isolatedDeclarations": true,

    // allows default imports from CommonJS modules
    "esModuleInterop": true,

    // enables project references and incremental builds via *.tsbuildinfo
    "composite": true,

    // do not type-check `.d.ts` files in `node_modules/`
    "skipLibCheck": true,

    // enforce consistent casing across import statements
    "forceConsistentCasingInFileNames": true,

    // allows importing JSON files as typed modules
    "resolveJsonModule": true,

    // 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; overridden in tsconfig.build.json
    "emitDeclarationOnly": true,

    // output directory for emitted files
    "outDir": "./dist",

    // root directory mirrored into outDir; set to project root during development, overridden to src/ in tsconfig.build.json
    "rootDir": ".",

  },

  // includes src/, tests/, and scripts/ for type-checking and IDE support; overridden in tsconfig.build.json
  "include": [
    "src/**/*.ts",
    "tests/**/*.ts",
    "scripts/**/*.ts",
    "scripts/**/*.json"
  ],
  "exclude": [
    "node_modules",
    "dist"
  ]

}

tsconfig.json is the development configuration. tsconfig.build.json extends it, narrowing scope to src/ and enabling JavaScript output for distribution.

declarationMap: true enables go-to-definition for npm/bun consumers. For this to work, the original .ts source files must be accessible to the consumer. Consider adding src/ to the files field in package.json.

{

  "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 both JavaScript files and declaration files for distribution
    "emitDeclarationOnly": false,

  },

  "include": [
    "src/**/*.ts"
  ],
  "exclude": [
    "src/dev.ts",
    "node_modules",
    "dist",
    "tests",
    "scripts"
  ]

}

package.json

{
  "name": "",
  "version": "0.0.0",

  "description": "",
  "author": "",
  "license": "",

  "keywords": ["typescript"],

  "repository": {
    "type": "git",
    "url": ""
  },

  // treats all .js files as ES modules; use .cjs extension for CommonJS files
  "type": "module",

  // no module has side effects; enables full tree-shaking by bundlers
  // set to an array of file paths if some modules do have side effects, e.g. ["./src/polyfill.ts"]
  "sideEffects": false,

  // package entry points by consumer type
  // "entrypoint" - custom condition; used by the bundle script to locate the source entry point
  // "types"      - TypeScript consumers; resolves to the declaration files; must precede "import" so TypeScript matches it before the JS condition
  // "browser"    - browser bundler consumers; resolves to the bundled output
  // "import"     - ESM consumers; resolves to the compiled module output
  "exports": {
    ".": {
      "entrypoint": "./src/index.ts",
      "types": "./dist/index.d.ts",
      "browser": "./dist/index.bundle.js",
      "import": "./dist/index.js"
    }
  },

  // package-internal import alias resolved natively by Node.js and Bun at runtime; key must start with #
  // for use in dev.ts, tests/, and scripts/ only - NOT in library source files compiled by tsconfig.build.json
  // the .js extension is required in import statements (nodenext compliance);
  // the runtime maps it to the actual .ts source file via this field
  "imports": {
    "#src/*.js": "./src/*.ts"
  },

  // CLI entry point; omit if the package is not a CLI tool
  "bin": "./dist/cli.bundle.js",

  // files to include in the published package
  "files": [
    "dist",
    "CHANGELOG.md",
    "buildinfo.txt"
  ],

  "scripts": {

    "clean:dist": "rm -rf dist/",
    "clean:tsbuildinfo": "rm -f tsconfig.build.tsbuildinfo",
    "clean": "bun run clean:dist && bun run clean:tsbuildinfo",

    // lifecycle hook; runs automatically before "build"; generates buildinfo.txt
    "prebuild": "bun run scripts/buildinfo.ts",

    "tests": "bun test",

    "build": "bun run build:lib && bun run build:lib-bundle",

    "build:lib": "tsc --project tsconfig.build.json",
    // bundles the library into ESM and IIFE formats for distribution
    "build:lib-bundle": "bun run scripts/build-lib-bundle.ts",

    "build:cli-bundle": "bun build src/cli.ts --entry-naming \"[dir]/[name].bundle.[ext]\" --outdir dist --target node --format esm --minify --sourcemap=external",

    "typecheck": "tsc --noEmit",

    // runs src/dev.ts in watch mode
    "dev": "bun run --watch src/dev.ts"

  },
  "devDependencies": {
    "@types/bun": "latest",
    "typescript": "^5.9.3"
  }
}

Script scripts/buildinfo.ts

Generates buildinfo.txt containing the version from package.json and the git commit hash (if available). Included in the published package for build traceability.

Script scripts/build-lib-bundle.ts

Bundles the library to ESM and IIFE formats. Entry points are resolved from the entrypoint condition in the exports field of package.json. Import specifiers can be rewritten to CDN URLs via scripts/cdn-rewrite-map.json.

CDN Map scripts/cdn-rewrite-map.json

Maps import specifiers to CDN URLs. During bundling, matching specifiers in source files are rewritten to their CDN equivalents. <VERSION> in a URL is replaced with the version of the matching package from package.json.

{
  "import-specifier": "https://cdn.jsdelivr.net/npm/package-name@<VERSION>/dist/index.bundle.js"
}

"bin" field in package.json

For CLI packages, add the following to package.json:

{
  "exports": ...,
  "bin": "./dist/cli.bundle.js",
  "files": ...,
  "scripts": {
    ...,
    "build": "... && bun run build:cli-bundle",
    ...,
    "build:cli-bundle": "bun build src/cli.ts --entry-naming \"[dir]/[name].bundle.[ext]\" --outdir dist --target node --format esm --minify --sourcemap=external",
    ...,
  }
}

Asset Resolution

The key question to ask is: does my library retain its own URL at runtime? Everything else follows from it.

Externalized - loaded from CDN or node_modules/

The consumer does not bundle the library. It is loaded as a discrete module at runtime. Module identity is preserved regardless of the URL form:

https://cdn.jsdelivr.net/npm/@scope/lib-name@1.0.0/dist/index.js
file:///project/node_modules/@scope/lib-name/dist/index.js
https://your-own-cdn.com/lib-name/index.js

import.meta.url is reliable in all these cases. fetch() is the correct I/O mechanism because it accepts both https:// and file:// URLs, making it universally correct for isomorphic (--target node or --target browser) libraries.

Bundled - absorbed into the consumer's output

The consumer's bundler absorbs the library into their own output. Module identity is destroyed - import.meta.url now points to the consumer's bundle, not the library's. However, the path relationship can be preserved by convention: if the consumer copies the library's assets into their build output at the same relative path the library expects, import.meta.url resolution continues to work correctly.

Contract

If the library has runtime assets, the following contract applies:

The library must:

  1. Place assets in a scoped assets directory - see Scoped assets directory convention below.
  2. Access assets via import.meta.url - see Accessing assets below.
  3. Document the bundled case - see README statement for library consumers below.

The consumer must,

  • when loading the library externalized: do nothing - the library's import.meta.url works as-is, and assets load from their original location (CDN or node_modules/).
  • when bundling the library:
    1. Copy the assets - see When the library is bundled by the consumer below.
    2. Ensure the assets are served
      • if the consumer is a web server, ensure the copied assets are served as static files
      • if the consumer is a bundler, ensure the copied assets are included in the bundle output

Scoped assets directory convention

Place all runtime assets under a scoped directory that mirrors the package name:

assets/
└── @scope/
    └── lib-name/
        └── <ASSETS>

This prevents naming collisions when multiple libraries are bundled into the same consumer app. Each library's assets live under their own namespace; no library can shadow another's files.

Add assets/ to the files field in package.json so that the assets are included in the published package:

  "files": [
    "dist",
    "CHANGELOG.md",
    "buildinfo.txt",
    "assets"
  ],

Accessing assets

Construct asset URLs directly from import.meta.url.

const packageUrl = new URL('../', import.meta.url);
const assetsUrl  = new URL('assets/@scope/lib-name/', packageUrl);

// for --target browser
const asset = await fetch(new URL('<ASSET>', assetsUrl)).then(r => r.json());

// for --target node
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'url';
const asset = JSON.parse(await readFile(fileURLToPath(new URL('<ASSET>', assetsUrl)), 'utf-8'));

README statement for library consumers

<!-- placeholder:
    <SCOPE: <SCOPE>
    <LIB_NAME: <LIB_NAME>
 -->

## Asset resolution

This library resolves assets at runtime using `import.meta.url`. If you bundle this library into your application, copy `node_modules/<SCOPE>/<LIB_NAME>/assets/<SCOPE>/<LIB_NAME>/` into your build output directory alongside your bundle.

When the library is bundled by the consumer

The consumer must copy the assets into their build output at the same relative path the library expects:

consumer output/
├── bundle.js               ← import.meta.url points here
└── assets/
    └── @scope/
        └── lib-name/
            └── <ASSETS>    ← copied; relative path preserved

From the bundle's perspective assets/@scope/lib-name/ is at the same directory level as bundle.js, so new URL('assets/@scope/lib-name/<ASSETS>', import.meta.url) resolves correctly. No code configuration is needed - only the file copy.

src/dev.ts

Development scratchpad - not published, excluded from tsconfig.build.json. Run with bun run dev in watch mode. Use it to manually test and explore library code during development.

CLAUDE.md / AGENTS.md

AI assistant context files. Provide project layout, commands, and architecture notes. AGENTS.md references CLAUDE.md. The template ships two pairs:

  • Root pair - describes the template package itself (scaffolding tool, CLI, how template/ maps to generated projects)
  • template/ pair - describes the generated library project; update to reflect the specific library being developed

DevOps

# remove dist/ and tsconfig.build.tsbuildinfo
bun run clean

# remove dist/ only
bun run clean:dist

# remove tsconfig.build.tsbuildinfo only
bun run clean:tsbuildinfo

# compile + bundle
bun run build

# create a new test library in example/
bun run dist/cli.bundle.js -- example

Change Management

  1. Create a new branch for the change.
  2. Make the changes and commit.
  3. Bump the version in package.json.
  4. Add an entry for the new version in CHANGELOG.md.
  5. Pull request the branch.
  6. After merge, run bun run build.
  7. Publish.

Publish

See the following sources to configure the target registry and authentication.

⚠️ Package Scope and the authentication for the target registry must be aligned.

npmjs.org

Publish to the public npm registry.

# authenticate
npm login
# publish
bun publish --registry https://registry.npmjs.org/ --access public

Custom registry

# placeholder:
    # <SCOPE_WITHOUT_AT: <SCOPE_WITHOUT_AT>
    # <REGISTRY_URL: <REGISTRY_URL>
    # <BUN_PUBLISH_AUTH_TOKEN: <BUN_PUBLISH_AUTH_TOKEN>

~/.bunfig.toml or bunfig.toml:

[install.scopes]
"<SCOPE_WITHOUT_AT>" = { url = "<REGISTRY_URL>", token = "$BUN_PUBLISH_AUTH_TOKEN" }
# authenticate
$env:BUN_PUBLISH_AUTH_TOKEN = "<BUN_PUBLISH_AUTH_TOKEN>"
# publish
bun publish