Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ _Second external review, this one of the codebase rather than the experience. Ra
- [ ] **A resolved domain model** — the config carries ~16 derived booleans (`isReact`, `hasApp`, `publishable`, `usesVite`…) that can in principle contradict each other. Features should consume `resolved.targets` / `resolved.build` / `resolved.package` instead of re-interpreting raw selections.
- [x] ~~**Make feature ordering explicit**~~ — **Shipped (3.1).** Rather than build speculative phase/requires/conflictsWith machinery, the real risk — order silently deciding a collision — is now an enforced invariant: `test/feature-registry.test.js` asserts every feature has a unique id and that **no two features collide** on a file or package.json field across the full config matrix (1683 configs). That immediately surfaced a real one — `bundler` and `service` both set `scripts.dev` on `node-service`, resolved only by array position — now fixed by making the service the explicit owner of its `dev` script. Ordering is no longer load-bearing for correctness; a colliding new feature fails the test instead of shipping.
- [ ] **Pairwise combination testing** — the integration matrix covers presets, which are the *common* paths. The risk is cross-feature interaction (dual + Rollup + Jest + Biome). Generate a matrix where every option pair appears at least once.
- [ ] **Dependency version catalog** — template versions are embedded across feature modules. Centralize them with compatibility notes and a last-reviewed date; the freshness workflow already treats them as product data, this makes that explicit.
- [x] ~~**Dependency version catalog**~~**Shipped (3.1).** Every template version now lives in `src/core/versions.js` (`V` for specs, `PEER` for peer ranges, `HELD` for deliberately-held deps + reasons, `LAST_REVIEWED`). All 16 feature/monorepo modules reference it instead of hard-coding specs — verified **byte-identical across 3740 configs**, and a conformance test asserts every generated version comes from the catalog (it caught a wrong `ts-jest` spec and two missing deps while being built). The freshness workflow imports `HELD` instead of duplicating it. A bump is now one line: proven by fixing #19 (`release-it` ^20 → ^21) in the catalog alone.
- [ ] **Unify monorepo and single-package generation** — `generate()` returns early for monorepos, and 2.9 added a second early return inside that. Modelling a project as workspaces (a plain package being a project with one) would stop features having to be implemented twice.

### The strategic one
Expand Down
285 changes: 187 additions & 98 deletions docs/packkit-core.js

Large diffs are not rendered by default.

10 changes: 4 additions & 6 deletions scripts/check-template-deps.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,14 @@
// dependency that is a full major version behind. Exits 1 if anything is stale.

import { generate, normalizeConfig, fromPreset, PRESET_NAMES } from '../src/core/index.js';
import { HELD } from '../src/core/versions.js';

// Ignore version-pinned-to-something-else or non-semver specifiers.
const IGNORE = new Set(['@types/node']);

// Intentionally held back for compatibility — reported separately, not as failures.
const HELD = {
typescript: 'typescript-eslint peers typescript <6.1.0 (no TS 7 support yet)',
knip: 'v6 crashes on the oxc-parser native binding',
'lint-staged': 'v17 requires Node >=22.22.1, above our Node 22 engines floor; hold at 16 to keep the Maintenance-LTS line working',
};
// HELD (deps deliberately kept below their latest major, with the reason) is the
// single source of truth in src/core/versions.js — imported here so the reasons
// aren't duplicated and drift.
const isSemverish = (v) => /^[\^~>=]*\d+\.\d+/.test(v) || /^[\^~>=]*\d+$/.test(v);
const floorMajor = (v) => parseInt(String(v).replace(/^[\^~>=v\s]+/, ''), 10);

Expand Down
15 changes: 8 additions & 7 deletions src/core/features/bundler.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { V } from '../versions.js';
// Always-on. Owns the package entry points (exports/main/module/types/files)
// and the build tooling for the chosen bundler.

Expand Down Expand Up @@ -48,24 +49,24 @@ export default {
files[`${tool}.config.${cfg.ext}`] = tsupConfig(cfg, entries, formats, tool);
pkg.scripts.build = tool;
pkg.scripts.dev = `${tool} --watch`;
pkg.devDependencies = { [tool]: tool === 'tsup' ? '^8.0.0' : '^0.6.0' };
pkg.devDependencies = { [tool]: tool === 'tsup' ? V.tsup : V.tsdown };
// tsup/tsdown resolve `typescript` even for plain-JS builds; TS projects
// already get it from the typescript feature.
if (!cfg.isTs) pkg.devDependencies.typescript = '^5.9.3';
if (!cfg.isTs) pkg.devDependencies.typescript = V.typescript;
} else if (cfg.bundler === 'unbuild') {
files['build.config.ts'] = unbuildConfig(cfg);
pkg.scripts.build = 'unbuild';
pkg.scripts.dev = 'unbuild --stub';
pkg.devDependencies = { unbuild: '^3.0.0' };
pkg.devDependencies = { unbuild: V.unbuild };
} else if (cfg.bundler === 'rollup') {
// Always a .js config — rollup can't load a .ts config without a loader.
files['rollup.config.js'] = rollupConfig(cfg, formats);
pkg.scripts.build = 'rollup -c';
pkg.scripts.dev = 'rollup -c -w';
pkg.devDependencies = {
rollup: '^4.0.0',
...(cfg.isTs ? { '@rollup/plugin-typescript': '^12.0.0', tslib: '^2.6.0' } : {}),
...(cfg.minify ? { '@rollup/plugin-terser': '^1.0.0' } : {}),
rollup: V.rollup,
...(cfg.isTs ? { '@rollup/plugin-typescript': V['@rollup/plugin-typescript'], tslib: V.tslib } : {}),
...(cfg.minify ? { '@rollup/plugin-terser': V['@rollup/plugin-terser'] } : {}),
};
} else if (cfg.bundler === 'none' && cfg.isTs) {
// tsc-only build for TypeScript.
Expand All @@ -80,7 +81,7 @@ export default {

if (build) pkg.scripts.prepublishOnly = pkg.scripts.build;
pkg.scripts.clean = 'rimraf dist';
if (build) pkg.devDependencies = { ...pkg.devDependencies, rimraf: '^6.0.0' };
if (build) pkg.devDependencies = { ...pkg.devDependencies, rimraf: V.rimraf };

return { files, pkg };
},
Expand Down
7 changes: 4 additions & 3 deletions src/core/features/checks.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { V } from '../versions.js';
// Package-correctness + hygiene checks (opt-in):
// - publint validates package.json shape (exports/main/module)
// - are-the-types-wrong verifies TS consumers resolve your .d.ts correctly
Expand All @@ -14,12 +15,12 @@ export default {
// node16 profile flags a "false" failure — use the esm-only profile.
const attw = cfg.moduleFormat === 'esm' ? 'attw --pack --profile esm-only' : 'attw --pack';
pkg.scripts['check:pkg'] = `publint && ${attw}`;
pkg.devDependencies.publint = '^0.3.0';
pkg.devDependencies['@arethetypeswrong/cli'] = '^0.18.0';
pkg.devDependencies.publint = V.publint;
pkg.devDependencies['@arethetypeswrong/cli'] = V['@arethetypeswrong/cli'];
}
if (cfg.knip) {
pkg.scripts.knip = 'knip';
pkg.devDependencies.knip = '^5.0.0';
pkg.devDependencies.knip = V.knip;
}

return { files: {}, pkg };
Expand Down
3 changes: 2 additions & 1 deletion src/core/features/env.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { V } from '../versions.js';
// Type-safe environment-variable validation for server-side runtimes (services
// and CLIs). Validates process.env once at startup with a Zod schema, so a
// misconfigured deploy fails fast with a clear message instead of a surprise
Expand All @@ -12,7 +13,7 @@ export default {
files[`src/env.${cfg.ext}`] = cfg.isTs ? envTs() : envJs();
files['.env.example'] = ['NODE_ENV=development', 'PORT=3000', ''].join('\n');

return { files, pkg: { dependencies: { zod: '^4.0.0' } } };
return { files, pkg: { dependencies: { zod: V.zod } } };
},
};

Expand Down
29 changes: 15 additions & 14 deletions src/core/features/frameworks.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { V, PEER } from '../versions.js';
// React / Vue / Svelte source + dependencies, for both component libraries and
// Vite apps. The Vite build wiring lives in vite.js; here we emit the source
// files, the runtime deps (peer for libs, direct for apps), and — for Svelte
Expand Down Expand Up @@ -40,7 +41,7 @@ function react(cfg, files, pkg, forApp) {
`}`,
``,
].join('\n');
pkg.dependencies = { react: '^19.0.0', 'react-dom': '^19.0.0' };
pkg.dependencies = { react: V.react, 'react-dom': V['react-dom'] };
} else {
files[`src/index.${x}`] = cfg.isTs
? [
Expand All @@ -55,13 +56,13 @@ function react(cfg, files, pkg, forApp) {
``,
].join('\n')
: [`export function Button({ label, onClick }) {`, `\treturn <button onClick={onClick}>{label}</button>;`, `}`, ``].join('\n');
pkg.peerDependencies = { react: '>=18', 'react-dom': '>=18' };
pkg.devDependencies.react = '^19.0.0';
pkg.devDependencies['react-dom'] = '^19.0.0';
pkg.peerDependencies = { react: PEER.react, 'react-dom': PEER['react-dom'] };
pkg.devDependencies.react = V.react;
pkg.devDependencies['react-dom'] = V['react-dom'];
}
if (cfg.isTs) {
pkg.devDependencies['@types/react'] = '^19.0.0';
pkg.devDependencies['@types/react-dom'] = '^19.0.0';
pkg.devDependencies['@types/react'] = V['@types/react'];
pkg.devDependencies['@types/react-dom'] = V['@types/react-dom'];
}
}

Expand All @@ -78,7 +79,7 @@ function vue(cfg, files, pkg, forApp) {
``,
].join('\n');
files['src/App.vue'] = [script, `</script>`, ``, `<template>`, `\t<h1>Hello from ${cfg.name}</h1>`, `</template>`, ``].join('\n');
pkg.dependencies = { vue: '^3.4.0' };
pkg.dependencies = { vue: V.vue };
} else {
files[`src/index.${cfg.ext}`] = `export { default as Button } from './Button.vue';\n`;
files['src/Button.vue'] = [
Expand All @@ -91,8 +92,8 @@ function vue(cfg, files, pkg, forApp) {
`</template>`,
``,
].join('\n');
pkg.peerDependencies = { vue: '>=3' };
pkg.devDependencies.vue = '^3.4.0';
pkg.peerDependencies = { vue: PEER.vue };
pkg.devDependencies.vue = V.vue;
}
}

Expand All @@ -101,8 +102,8 @@ function svelte(cfg, files, pkg, forApp) {
const script = cfg.isTs ? `<script lang="ts">` : `<script>`;
// Shared by the Vite app build and the Vitest (test) config; enables TS in SFCs.
files['svelte.config.js'] = `import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';\n\nexport default { preprocess: vitePreprocess() };\n`;
pkg.devDependencies['@sveltejs/vite-plugin-svelte'] = '^7.0.0';
if (cfg.isTs) pkg.devDependencies['svelte-check'] = '^4.0.0';
pkg.devDependencies['@sveltejs/vite-plugin-svelte'] = V['@sveltejs/vite-plugin-svelte'];
if (cfg.isTs) pkg.devDependencies['svelte-check'] = V['svelte-check'];
if (forApp) {
files['index.html'] = htmlShell(cfg, `/src/main.${cfg.ext}`);
files[`src/main.${cfg.ext}`] = [
Expand All @@ -114,7 +115,7 @@ function svelte(cfg, files, pkg, forApp) {
``,
].join('\n');
files['src/App.svelte'] = [script, `</script>`, ``, `<h1>Hello from ${cfg.name}</h1>`, ``].join('\n');
pkg.dependencies = { svelte: '^5.0.0' };
pkg.dependencies = { svelte: V.svelte };
} else {
// Svelte libraries ship uncompiled source; the consumer's bundler compiles.
files[`src/index.${cfg.ext}`] = `export { default as Button } from './Button.svelte';\n`;
Expand All @@ -127,8 +128,8 @@ function svelte(cfg, files, pkg, forApp) {
`<button>{label}</button>`,
``,
].filter((l) => l !== ``).join('\n') + '\n';
pkg.peerDependencies = { svelte: '>=5' };
pkg.devDependencies.svelte = '^5.0.0';
pkg.peerDependencies = { svelte: PEER.svelte };
pkg.devDependencies.svelte = V.svelte;
// Ship source; point consumers at it.
pkg.svelte = `./src/index.${cfg.ext}`;
pkg.exports = { '.': { svelte: `./src/index.${cfg.ext}`, default: `./src/index.${cfg.ext}` } };
Expand Down
9 changes: 5 additions & 4 deletions src/core/features/githooks.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { V } from '../versions.js';
export default {
id: 'githooks',
active: (cfg) => cfg.gitHooks !== 'none',
Expand All @@ -21,24 +22,24 @@ export default {
if (cfg.gitHooks === 'simple-git-hooks') {
pkg['simple-git-hooks'] = { 'pre-commit': needsLintStaged ? 'npx lint-staged' : 'npm test' };
pkg.scripts.prepare = 'simple-git-hooks';
pkg.devDependencies['simple-git-hooks'] = '^2.11.0';
pkg.devDependencies['simple-git-hooks'] = V['simple-git-hooks'];
} else if (cfg.gitHooks === 'husky') {
files['.husky/pre-commit'] = (needsLintStaged ? 'npx lint-staged\n' : 'npm test\n');
pkg.scripts.prepare = 'husky';
pkg.devDependencies.husky = '^9.1.0';
pkg.devDependencies.husky = V.husky;
} else if (cfg.gitHooks === 'lefthook') {
files['lefthook.yml'] = lefthookYml(cfg);
// `|| true` so `npm install` doesn't fail before `git init` — lefthook
// (unlike husky/simple-git-hooks) errors hard without a .git directory.
pkg.scripts.prepare = 'lefthook install || true';
pkg.devDependencies.lefthook = '^2.0.0';
pkg.devDependencies.lefthook = V.lefthook;
}

if (needsLintStaged) {
pkg['lint-staged'] = staged;
// Held at 16: v17 requires Node >=22.22.1, above our Node 22 engines
// floor (22.13) — it would break the Maintenance-LTS line. See ENGINE_MIN.
pkg.devDependencies['lint-staged'] = '^16.2.0';
pkg.devDependencies['lint-staged'] = V['lint-staged'];
}

return { files, pkg };
Expand Down
13 changes: 7 additions & 6 deletions src/core/features/lint.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { toJson } from '../render.js';
import { V } from '../versions.js';

export default {
id: 'lint',
Expand All @@ -19,19 +20,19 @@ export default {
files['.prettierignore'] = 'dist\ncoverage\n';
pkg.scripts.format = 'prettier --write .';
pkg.scripts['format:check'] = 'prettier --check .';
pkg.devDependencies.prettier = '^3.3.0';
pkg.devDependencies.prettier = V.prettier;
}

if (cfg.lint === 'eslint-prettier') {
files['eslint.config.js'] = eslintFlatConfig(cfg);
pkg.scripts.lint = 'eslint .';
pkg.scripts['lint:fix'] = 'eslint . --fix';
pkg.devDependencies.eslint = '^10.0.0';
pkg.devDependencies['@eslint/js'] = '^10.0.0';
if (cfg.isTs) pkg.devDependencies['typescript-eslint'] = '^8.0.0';
pkg.devDependencies.eslint = V.eslint;
pkg.devDependencies['@eslint/js'] = V['@eslint/js'];
if (cfg.isTs) pkg.devDependencies['typescript-eslint'] = V['typescript-eslint'];
} else if (cfg.lint === 'oxlint') {
pkg.scripts.lint = 'oxlint';
pkg.devDependencies.oxlint = '^1.0.0';
pkg.devDependencies.oxlint = V.oxlint;
} else if (cfg.lint === 'biome') {
files['biome.json'] = toJson({
$schema: 'https://biomejs.dev/schemas/2.1.2/schema.json',
Expand All @@ -42,7 +43,7 @@ export default {
pkg.scripts.lint = 'biome lint .';
pkg.scripts['lint:fix'] = 'biome lint --write .';
pkg.scripts.format = 'biome format --write .';
pkg.devDependencies['@biomejs/biome'] = '^2.0.0';
pkg.devDependencies['@biomejs/biome'] = V['@biomejs/biome'];
}

return { files, pkg };
Expand Down
7 changes: 4 additions & 3 deletions src/core/features/release.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { toJson } from '../render.js';
import { V } from '../versions.js';

export default {
id: 'release',
Expand All @@ -21,18 +22,18 @@ export default {
pkg.scripts.changeset = 'changeset';
pkg.scripts.version = 'changeset version';
pkg.scripts.release = `${buildThen(cfg)}changeset publish`;
pkg.devDependencies['@changesets/cli'] = '^2.27.0';
pkg.devDependencies['@changesets/cli'] = V['@changesets/cli'];
} else if (cfg.release === 'release-it') {
files['.release-it.json'] = toJson({
git: { commitMessage: 'chore: release v${version}' },
github: { release: true },
npm: { publish: true },
});
pkg.scripts.release = 'release-it';
pkg.devDependencies['release-it'] = '^20.0.0';
pkg.devDependencies['release-it'] = V['release-it'];
} else if (cfg.release === 'np') {
pkg.scripts.release = 'np';
pkg.devDependencies.np = '^12.0.0';
pkg.devDependencies.np = V.np;
}

return { files, pkg };
Expand Down
11 changes: 6 additions & 5 deletions src/core/features/service.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { V } from '../versions.js';
// HTTP service target. Supports Hono (default), Fastify and Express. Splits the
// app (testable) from the server entry (which listens), adds start/dev scripts,
// and a production Dockerfile.
Expand All @@ -24,7 +25,7 @@ export default {
},
dependencies: deps(fw),
devDependencies: {
...(cfg.isTs ? { tsx: '^4.0.0' } : {}),
...(cfg.isTs ? { tsx: V.tsx } : {}),
...(cfg.isTs ? typeDeps(fw) : {}),
},
},
Expand All @@ -33,13 +34,13 @@ export default {
};

function deps(fw) {
if (fw === 'fastify') return { fastify: '^5.0.0' };
if (fw === 'express') return { express: '^5.0.0' };
return { hono: '^4.5.0', '@hono/node-server': '^2.0.0' };
if (fw === 'fastify') return { fastify: V.fastify };
if (fw === 'express') return { express: V.express };
return { hono: V.hono, '@hono/node-server': V['@hono/node-server'] };
}

function typeDeps(fw) {
if (fw === 'express') return { '@types/express': '^5.0.0' };
if (fw === 'express') return { '@types/express': V['@types/express'] };
return {};
}

Expand Down
5 changes: 3 additions & 2 deletions src/core/features/sizelimit.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// brotli-compressed size of the built entry and fails when it exceeds the
// limit — cheap insurance against a dependency silently bloating the package.
import { toJson } from '../render.js';
import { V } from '../versions.js';

export default {
id: 'sizelimit',
Expand All @@ -14,8 +15,8 @@ export default {
pkg: {
scripts: { size: 'size-limit' },
devDependencies: {
'size-limit': '^11.0.0',
'@size-limit/preset-small-lib': '^11.0.0',
'size-limit': V['size-limit'],
'@size-limit/preset-small-lib': V['@size-limit/preset-small-lib'],
},
},
};
Expand Down
9 changes: 5 additions & 4 deletions src/core/features/storybook.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { V } from '../versions.js';
// Storybook for component libraries (React / Vue / Svelte), on the Vite builder.

const FW = {
Expand Down Expand Up @@ -37,10 +38,10 @@ export default {
'build-storybook': 'storybook build',
},
devDependencies: {
storybook: '^10.0.0',
[fw.builder]: '^10.0.0',
[fw.renderer]: '^10.0.0',
vite: '^8.0.0',
storybook: V.storybook,
[fw.builder]: V[fw.builder],
[fw.renderer]: V[fw.renderer],
vite: V.vite,
},
},
};
Expand Down
Loading