Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ npm-debug.log
coverage
.public
.server
.src
.src.bak
package
.cache
.env
tsconfig.tsbuildinfo
Expand Down
125 changes: 125 additions & 0 deletions docs/BUILDING_THE_PACKAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
---
layout: default
title: Building the package
render_with_liquid: false
nav_order: 5
---

# Building the package

1. [Overview](#overview)
2. [Build steps](#build-steps)
3. [Path alias resolution](#path-alias-resolution)
4. [Packaging for npm](#packaging-for-npm)
5. [Package contents](#package-contents)

## Overview

The build pipeline compiles TypeScript and JavaScript source files into a publishable npm package. It produces server-side JavaScript, client-side bundles, TypeScript declaration files and a copy of the source files with resolved import paths.

To run the full build:

```shell
npm run build
```

This executes four steps in sequence: `build:server`, `build:client`, `build:types` and `build:src`.

## Build steps

### `build:server`

Compiles the server-side source code using [Babel](https://babeljs.io/). TypeScript and JavaScript files in `src/` are transpiled to JavaScript and output to `.server/`. Test files (`**/*.test.ts`) are excluded. Source maps are generated alongside each output file.

Babel is configured with `babel-plugin-module-resolver` which resolves the `~` path alias (see [Path alias resolution](#path-alias-resolution)) to relative paths in the compiled `.js` output.

### `build:client`

Bundles client-side JavaScript and stylesheets using [webpack](https://webpack.js.org/). The output is written to `.public/` (minified assets) and `.server/client/` (shared scripts and styles). The `NODE_ENV` defaults to `production`.

### `build:types`

Generates TypeScript declaration files (`.d.ts`) from the source and outputs them to `.server/`. This step runs two tools in sequence:

1. **`tsc`** compiles declarations using `tsconfig.build.json`. Because TypeScript preserves path aliases in its output, the generated `.d.ts` files initially contain unresolved `~` imports.
2. **`tsc-alias`** post-processes the `.d.ts` files using `tsconfig.alias.json` to replace the `~` path aliases with relative paths.

`tsconfig.alias.json` exists separately from `tsconfig.build.json` because the path mappings need to be adjusted for `tsc-alias` to work correctly. The build config has `rootDir: ./src` which strips the `src/` prefix from output paths. This means `tsc-alias` needs the mapping `~/src/* -> ./*` (rather than `~/* -> ./*`) so it can locate the target files within the `.server/` output directory.

### `build:src`

Runs `scripts/resolve-tilde-imports.js` which copies the `src/` directory to `.src/` and resolves all `~/src/...` import paths to relative paths in the copy. The original `src/` directory is left untouched.

This is necessary because the source files are shipped in the npm package (for source map support) and consumers cannot resolve the `~` path alias.

## Path alias resolution

During development, the codebase uses a `~` path alias as a shorthand for the project root. For example:

```typescript
import { config } from '~/src/config/index.js'
```

This alias is defined in `tsconfig.json`:

```json
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"~/*": ["./*"]
}
}
}
```

The `~` alias improves the developer experience by avoiding deeply nested relative paths like `../../../../config/index.js`. However, package consumers do not have this alias configured, so all `~` references must be resolved to relative paths before the package is published.

Three separate mechanisms handle this resolution across the different output types:

| Output | Tool | Config |
| ------------------- | ---------------------------------- | --------------------- |
| `.server/**/*.js` | `babel-plugin-module-resolver` | `babel.config.cjs` |
| `.server/**/*.d.ts` | `tsc-alias` | `tsconfig.alias.json` |
| `.src/**/*.ts` | `scripts/resolve-tilde-imports.js` | N/A |

## Packaging for npm

The `package.json` `files` field controls which directories are included in the published package:

```json
{
"files": [".server", ".public", "src"]
}
```

Note that `src` is listed here (not `.src`). The `prepack` and `postpack` lifecycle scripts handle the swap:

1. **`prepack`** runs before `npm pack` or `npm publish`. It moves the original `src/` to `.src.bak/` and moves the resolved `.src/` into `src/`. This means npm packs the resolved copy under the `src` directory name.
2. **`postpack`** runs after packing completes. It restores the original `src/` and moves the resolved copy back to `.src/`.

This swap approach avoids destructive operations on the working `src/` directory. At no point are the original source files modified.

### Build and publish workflow

```shell
npm run build # Produces .server/, .public/ and .src/
npm pack # prepack swaps .src -> src, packs, postpack restores
```

Or equivalently:

```shell
npm run build
npm publish
```

## Package contents

The published package contains:

| Directory | Contents |
| ---------- | ------------------------------------------------------------------------------------ |
| `.server/` | Compiled JavaScript (`.js`), declaration files (`.d.ts`) and source maps (`.js.map`) |
| `.public/` | Minified client-side assets |
| `src/` | TypeScript and JavaScript source files with resolved import paths |
8 changes: 7 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ export default tseslint.config(
'build',
'build/**',
'.docusaurus',
'.docusaurus/**'
'.docusaurus/**',
'package',
'package/**',
'.src',
'.src/**',
'.src.bak',
'.src.bak/**'
]
},

Expand Down
121 changes: 121 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@
"./package.json": "./package.json"
},
"scripts": {
"build": "rm -rf ./.server && npm run build:server && npm run build:client && npm run build:types",
"build": "rm -rf ./.server ./.src && npm run build:server && npm run build:client && npm run build:types && npm run build:src",
"build:client": "NODE_ENV=${NODE_ENV:-production} webpack",
"build:server": "babel --delete-dir-on-start --extensions \".js\",\".ts\" --ignore \"**/*.test.ts\" --copy-files --no-copy-ignored --source-maps --out-dir ./.server ./src",
"build:types": "tsc -p tsconfig.build.json",
"build:src": "node scripts/resolve-tilde-imports.js",
"build:types": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.alias.json",
"dev": "concurrently \"npm run client:watch\" \"npm run server:watch:dev\" --kill-others --names \"client,server\" --prefix-colors \"red.dim,blue.dim\"",
"dev:debug": "concurrently \"npm run client:watch\" \"npm run server:watch:debug\" --kill-others --names \"client,server\" --prefix-colors \"red.dim,blue.dim\"",
"format": "npm run format:check -- --write",
Expand All @@ -49,10 +50,12 @@
"docs:clear": "docusaurus clear",
"generate-schema-docs": "node scripts/generate-schema-docs.js",
"postinstall": "npm run setup:husky",
"postpack": "mv src .src && mv .src.bak src",
"prepack": "mv src .src.bak && mv .src src",
"lint": "npm run lint:editorconfig && npm run lint:js && npm run lint:types",
"lint:editorconfig": "editorconfig-checker",
"lint:fix": "npm run lint:js -- --fix",
"lint:js": "eslint --cache --cache-location .cache/eslint --cache-strategy content --color .",
"lint:js": "node --max-old-space-size=8192 ./node_modules/.bin/eslint --cache --cache-location .cache/eslint --cache-strategy content --color .",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we need 8gb??

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, sadly, since the upgrade to the new version of eslint.

"lint:scss": "stylelint --cache --cache-location .cache/stylelint --cache-strategy content --color --ignore-path .gitignore --max-warnings 0 \"**/*.scss\"",
"lint:types": "tsc --noEmit",
"test": "jest --color --coverage --verbose",
Expand Down Expand Up @@ -206,6 +209,7 @@
"stylelint": "^16.25.0",
"stylelint-config-gds": "^2.0.0",
"terser-webpack-plugin": "^5.3.14",
"tsc-alias": "^1.8.16",
"tsx": "^4.20.6",
"typescript": "^5.9.3",
"webpack": "^5.102.1",
Expand Down
44 changes: 44 additions & 0 deletions scripts/resolve-tilde-imports.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { cp, glob, readFile, writeFile } from 'node:fs/promises'
import { dirname, relative, sep } from 'node:path'

/**
* Copies `src` to `.src` and resolves `~/src/...` path aliases to relative
* paths. This is needed because the `src` directory is shipped in the npm
* package and consumers cannot resolve the `~` alias.
*/

// Copy src to .src
await cp('src', '.src', { recursive: true })

for await (const entry of glob('.src/**/*.{ts,js}')) {
const content = await readFile(entry, 'utf-8')

// Match from '~/src/...' and from "~/src/..."
if (!content.includes("'~/") && !content.includes('"~/')) {
continue
}

const updated = content.replace(
/(from\s+['"])~\/src\/(.*?)(['"])/g,
(match, prefix, importPath, suffix) => {
// .src mirrors src, so resolve relative to .src
const fileDir = dirname(entry)
const targetPath = `.src/${importPath}`
let relativePath = relative(fileDir, targetPath)

// Ensure it starts with ./ or ../
if (!relativePath.startsWith('.')) {
relativePath = `./${relativePath}`
}

// Normalise path separators for Windows compatibility
relativePath = relativePath.split(sep).join('/')

return `${prefix}${relativePath}${suffix}`
}
)

if (updated !== content) {
await writeFile(entry, updated)
}
}
Loading