This document is the source of truth for Node.js libraries created under
packages/ for use inside the Ghost monorepo. Start from _template
and keep the resulting package aligned with these rules throughout its lifetime.
These defaults do not automatically apply to browser applications, test-only source helpers, or packages that Ghost intentionally publishes for third-party consumers. Record and justify exceptions in the package README and configuration.
New internal packages are private, TypeScript-only ESM libraries:
- use an
@tryghost/<name>package name; - set
"version": "0.0.0"and"private": true; - set
"ghostPackage": {"goldenPath": "compliant"}; - set
"type": "module"; - keep authored code in
src/**/*.tsand tests intest/**/*.ts; - compile production code to
build/withtsc; - publish no independent npm releases.
Making a package public or independently versioned is a product and maintenance
decision, not a packaging convenience. Establish its compatibility, release and
support policy before removing private or adding publishing automation.
Every private package under packages/ declares its lifecycle state in
ghostPackage.goldenPath:
compliantmeans the package is mechanically checked against this document;migrationis temporary while a history-preserving import awaits a separate modernization PR;exemptrecords an intentional long-term exception such as a test-only or multi-runtime package.
Both migration and exempt require a non-empty ghostPackage.reason.
Public, independently versioned packages do not declare this metadata because
this internal-only contract does not apply to them. Run pnpm lint:packages to
validate the status and all mechanically enforceable rules.
Use the template's repository, author and license metadata. Point repository metadata at the package's actual directory.
Expose each entry point with the source, types and default conditions, in
that order:
{
"type": "module",
"exports": {
".": {
"source": "./src/index.ts",
"types": "./build/index.d.ts",
"default": "./build/index.js"
}
},
"main": "build/index.js",
"types": "build/index.d.ts",
"files": ["build"]
}Add explicit export entries for additional public modules. Do not expose broad filesystem patterns unless consumers genuinely need them.
The source condition gives Ghost development and tests build-free access to
raw TypeScript. Plain Node.js ignores it and loads compiled ESM from build/.
Keep src/ out of files so release components cannot depend on source-only
behavior accidentally.
Use the standard configuration packages as workspace:* dev dependencies:
@internal/cfg-eslintwithnodeLibConfig();@internal/cfg-typescriptwithesm.json;@internal/cfg-vitestwithcreateVitestConfig().
Keep package config files minimal. Add overrides only for behavior that differs from the shared contract, and explain non-obvious exceptions next to the override.
The source TypeScript config should set rootDir to src, outDir to build
and include only production source. Use a separate test/tsconfig.json that
extends the source config, sets noEmit, and includes both source and tests.
Use the standard script surface so Nx, CI and agents can operate on every package consistently:
{
"scripts": {
"build": "tsc",
"test:unit": "NODE_ENV=testing vitest run --coverage",
"test:types": "tsc --noEmit -p test/tsconfig.json",
"test": "pnpm run '/^test:/'",
"lint:code": "eslint src/ --cache",
"lint:test": "eslint test/ --cache",
"lint": "pnpm run '/^lint:/'"
},
"nx": {
"targets": {
"build": {
"outputs": ["{projectRoot}/build"]
}
}
}
}Add a dev watcher only when it is useful. Do not add aliases around these
commands without a concrete need.
- Use
workspace:*for dependencies on another Ghost workspace package. - Use
catalog:for external dependencies managed by the root catalog. - Declare every dependency the package imports; do not rely on root hoisting.
- Keep build, lint, test and type tooling in
devDependencies. - Keep runtime imports in
dependencies.
The shared TypeScript config uses NodeNext semantics. Relative imports in
TypeScript source must include their real .ts extension; the compiler rewrites
it to .js on emit.
Ghost Core is CommonJS but runs on Node versions that support require(esm).
Internal ESM packages may therefore serve both import and require() consumers
from one build. This requires the entire imported module graph to avoid top-level
await; ESLint enforces that restriction.
Do not add a CommonJS build or forwarding shim by default. Add multiple formats only when a verified consumer cannot use the standard ESM output.
Production behavior must work from build/ alone. Import JSON or other assets
from TypeScript when the compiler can copy them. Otherwise add an explicit,
portable build step and include the emitted asset path in package exports or
files as required.
Never rely on files that are present in the repository but absent from the packed package or Ghost release component.
For package changes, run at least from the package directory:
pnpm build
pnpm test
pnpm lintAlso verify:
- representative consumers through the same import or
require()path they use in production; - both raw
sourceresolution and compiled output when a consumer uses both; - the packed package or Ghost archive when runtime assets or exports change;
pnpm buildwhen build graph or packaging behavior changes.
The golden path is the default for new and modernized internal libraries. Some existing packages have intentional public-release, browser, dual-format or test-only contracts. Do not mechanically rewrite them to match the template.
When touching a divergent package, determine whether the difference is required by a current consumer. Remove accidental drift in a focused change; document and test necessary exceptions.