No description
  • TypeScript 92.7%
  • JavaScript 7.3%
Find a file
TR 6125d3b439
All checks were successful
Build and Publish / publish (push) Successful in 10s
Merge pull request 'version 0.15.0' (#40) from temir/create-ts-lib:dev into main
Reviewed-on: #40
2026-07-05 00:48:48 +02:00
.forgejo/workflows chore: Added Build and Publish Workflow. 2026-07-05 00:44:33 +02:00
scripts version 0.13.4 2026-06-08 10:19:07 +02:00
src chore: Updated from template@0.5.0 template. 2026-06-01 15:01:34 +02:00
template chore: Updated build and publish workflow to use npm ci instead of npm install. Packages must commit package-lock.json to the repository for the workflow to work. 2026-07-04 23:34:06 +02:00
tests chore: Updated from template@0.4.4-pre.5 template. 2026-06-01 12:57:23 +02:00
.gitignore version 0.8.2 2026-05-22 16:31:31 +02:00
.npmrc chore: Added Build and Publish Workflow. 2026-07-05 00:44:33 +02:00
CHANGELOG.md version 0.15.0 2026-07-05 00:48:22 +02:00
LICENSE carryover from dev as 0.1.0 (#1) 2026-02-27 20:14:40 +00:00
package-lock.json chore: Added Build and Publish Workflow. 2026-07-05 00:44:33 +02:00
package.json version 0.15.0 2026-07-05 00:48:22 +02:00
README.md chore: Updated from template@0.6.0 template. 2026-07-05 00:41:18 +02:00
tsconfig.json chore: Updated from template@0.4.0-pre.9 template. 2026-05-30 01:05:06 +02: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. package.json
    2. Script scripts/buildinfo.ts
    3. Script scripts/build-bundle.ts
      1. Import Map scripts/import-map.json
    4. tsconfig.build.json
    5. Package Files Resolution
      1. Accessing package files at runtime
    6. "bin" field in package.json
  3. DevOps
    1. Change Management
    2. Registry
    3. CI/CD Workflows

Quick Start

# 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 update

Documentation

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 using esbuild.
  • TSC compilation (tsconfig.build.json + build:tsc) - compiles the library to declaration files, and optionally ESM JavaScript, using tsc.

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": {
    "<COMMAND_NAME>": "./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.ts

When 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:

  1. If the package is used as a discrete module at runtime (e.g. loaded from node_modules/ or a CDN), then the URLs exported in src/package-urls.ts resolve to the expected location without requiring any special handling from the consumer. This is the recommended way to consume the generated package.
  2. The exports entry in package.json for ./package-urls can be marked external independently from the rest of the package, so that it retains its import.meta.url identity. See Import Map scripts/import-map.json.
  3. If the package is bundled into the consumer's output, then the consumer must consult the exports in src/package-urls.ts and ensure that the directories and files for the exported URLs exist in the final output (e.g. by copying them from node_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": {
    "<COMMAND_NAME>": "./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 node

DevOps

npm install
npm update

npm run clean
npm run build
npm run tests

npx tsx dist/cli.bundle.js -- example/
git fetch upstream
git fetch origin
git fetch . upstream/main:origin/main
git fetch . origin/main:main
git push origin main
git merge --ff-only main
git push

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. Ensure package artifacts are current.
  7. Publish.

Registry

.npmrc:

@temir.ra:registry=https://registry.npmjs.org/
//registry.npmjs.org/:_authToken=${NPMJSORG_REGISTRY_AUTH_TOKEN}

or bunfig.toml:

[install.scopes]
"temir.ra" = { url = "https://registry.npmjs.org/", token = "$NPMJSORG_REGISTRY_AUTH_TOKEN" }
# registry.npmjs.org/
export NPMJSORG_REGISTRY_AUTH_TOKEN=<AUTH_TOKEN>
# or
$env:NPMJSORG_REGISTRY_AUTH_TOKEN = "<AUTH_TOKEN>"
npm publish

CI/CD Workflows

Build and Publish

⚠️ .npmrc configuring the package registry and its authentication token is required for the workflow to work.

Parameter Type Description
RUNNER_LABEL Variable The label of the runner to use for the workflow.
ACCESS_TOKEN Secret The authentication token for the package registry.
# placeholder:
  # <REGISTRY_AUTH_TOKEN_ENV_VAR: NPMJSORG_REGISTRY_AUTH_TOKEN

.github/workflows/build-publish.yml:

name: Build and Publish

on:
  push:
    branches:
      - main

jobs:
  publish:
    runs-on: ${{ vars.RUNNER_LABEL }}
    steps:
      - uses: actions/checkout@v4

      - name: Install
        env:
          NPMJSORG_REGISTRY_AUTH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
        run: npm ci

      - name: Build
        run: npm run build

      - name: Test
        run: npm run tests

      - name: Publish
        env:
          NPMJSORG_REGISTRY_AUTH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
        run: |
          PKG_NAME=$(node -p "require('./package.json').name")
          PKG_VERSION=$(node -p "require('./package.json').version")
          NPM_TAG="latest"
          if echo "$PKG_VERSION" | grep -q -- '-'; then
            NPM_TAG="next"
          fi
          if npm view "$PKG_NAME@$PKG_VERSION" version >/dev/null 2>&1; then
            echo "$PKG_NAME@$PKG_VERSION already published, skipping."
          else
            npm publish --tag "$NPM_TAG"
          fi