feat: add Handlebars template engine with peer-aware render context and consolidate eslint-prettier into a single adaptive template

- Replace per-framework template files (`eslint.config.next.mjs`, `eslint.config.tanstack.mjs`) with a single `eslint.config.mjs` that branches via Handlebars `{{#if peers.framework}}` conditionals; add `handlebars` as a direct dependency to `@stanza/registry`, `apps/cli`, and `apps/web`
- Extend the template render context with a `peers: Partial<Record<CategoryId, string>>` map so any template can inspect the active framework, auth provider, etc.; thread it through `buildRenderContext`, `synthesizeTemplates`, the CLI `codemod-runner`, and the web `getBuilderState` server function
- Extract and export `activePeerIds(manifest, appId?)` from `@stanza/registry` to derive the peer map from a manifest (app-scoped for `home: app` categories); add `selectedPeerIds(selections)` in `apps/web/src/lib/selection.ts` as the URL-params-derived equivalent
- Add integration tests verifying framework-conditional eslint-prettier output for `default`, `next`, and `tanstack-start` adapters; expand resolver tests for `activePeerIds` app-scoping and for modules that declare both a `default` adapter and a framework-specific override
This commit is contained in:
jake committed 2026-05-25 13:35:27 -04:00
1 parent 3304abbe98
commit e28eba2702
19 files changed
+858 -697

No files matched your search

+1
View File
@@ -48,6 +48,7 @@
"dependencies": { "dependencies": {
"@clack/prompts": "^1.4.0", "@clack/prompts": "^1.4.0",
"citty": "^0.2.2", "citty": "^0.2.2",
"handlebars": "^4.7.9",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"semver": "^7.8.1", "semver": "^7.8.1",
"ts-morph": "^28.0.0", "ts-morph": "^28.0.0",
+88
View File
@@ -330,3 +330,91 @@ describe("tooling slot (single-choice, repo-scoped)", () => {
expect(manifest.modules.tooling[0].id).toBe("biome"); expect(manifest.modules.tooling[0].id).toBe("biome");
}); });
}); });
describe("tooling-eslint-prettier — framework-conditional rendering", () => {
it("installs without a framework via the default adapter (pure-TS config)", async () => {
await cmdInit(args({ name: "app", yes: true, tooling: "eslint-prettier" }));
expect(process.exitCode).toBeFalsy();
const projectRoot = path.join(tmp, "app");
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "stanza.json"), "utf8"));
expect(manifest.modules.tooling?.[0]).toMatchObject({
id: "eslint-prettier",
adapter: "default",
});
const config = fs.readFileSync(path.join(projectRoot, "eslint.config.mjs"), "utf8");
// Framework-specific blocks must be absent.
expect(config).not.toContain("@next/eslint-plugin-next");
expect(config).not.toContain("eslint-plugin-react");
expect(config).not.toContain("core-web-vitals");
expect(config).not.toContain(".next");
expect(config).not.toContain("routeTree.gen.ts");
// Pure-TS base is still present.
expect(config).toContain('import tseslint from "typescript-eslint";');
expect(config).toContain('import prettier from "eslint-config-prettier";');
const rootPkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"));
expect(rootPkg.devDependencies.eslint).toBeTruthy();
expect(rootPkg.devDependencies["@next/eslint-plugin-next"]).toBeUndefined();
expect(rootPkg.devDependencies["eslint-plugin-react"]).toBeUndefined();
expect(rootPkg.devDependencies["eslint-plugin-react-hooks"]).toBeUndefined();
});
it("layers Next-specific plugins when framework=next", async () => {
await cmdInit(args({ name: "app", yes: true, framework: "next", tooling: "eslint-prettier" }));
expect(process.exitCode).toBeFalsy();
const projectRoot = path.join(tmp, "app");
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "stanza.json"), "utf8"));
expect(manifest.modules.tooling?.[0]).toMatchObject({
id: "eslint-prettier",
adapter: "next",
});
const config = fs.readFileSync(path.join(projectRoot, "eslint.config.mjs"), "utf8");
expect(config).toContain('import next from "@next/eslint-plugin-next";');
expect(config).toContain("core-web-vitals");
expect(config).toContain("**/.next/**");
// TanStack-specific bits must not leak in.
expect(config).not.toContain('import react from "eslint-plugin-react";');
expect(config).not.toContain("routeTree.gen.ts");
const rootPkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"));
expect(rootPkg.devDependencies["@next/eslint-plugin-next"]).toBeTruthy();
expect(rootPkg.devDependencies["eslint-plugin-react-hooks"]).toBeTruthy();
expect(rootPkg.devDependencies["eslint-plugin-react"]).toBeUndefined();
});
it("layers TanStack-specific plugins when framework=tanstack-start", async () => {
await cmdInit(
args({
name: "app",
yes: true,
framework: "tanstack-start",
tooling: "eslint-prettier",
}),
);
expect(process.exitCode).toBeFalsy();
const projectRoot = path.join(tmp, "app");
const manifest = JSON.parse(fs.readFileSync(path.join(projectRoot, "stanza.json"), "utf8"));
expect(manifest.modules.tooling?.[0]).toMatchObject({
id: "eslint-prettier",
adapter: "tanstack-start",
});
const config = fs.readFileSync(path.join(projectRoot, "eslint.config.mjs"), "utf8");
expect(config).toContain('import react from "eslint-plugin-react";');
expect(config).toContain("routeTree.gen.ts");
expect(config).toContain("react/react-in-jsx-scope");
// Next-specific bits must not leak in.
expect(config).not.toContain("@next/eslint-plugin-next");
expect(config).not.toContain("core-web-vitals");
const rootPkg = JSON.parse(fs.readFileSync(path.join(projectRoot, "package.json"), "utf8"));
expect(rootPkg.devDependencies["eslint-plugin-react"]).toBeTruthy();
expect(rootPkg.devDependencies["eslint-plugin-react-hooks"]).toBeTruthy();
expect(rootPkg.devDependencies["@next/eslint-plugin-next"]).toBeUndefined();
});
});
+5
View File
@@ -15,6 +15,7 @@ import type {
TemplateRef, TemplateRef,
} from "@stanza/registry"; } from "@stanza/registry";
import { import {
activePeerIds,
buildRenderContext, buildRenderContext,
categoryHome, categoryHome,
installPackageJsonTargets, installPackageJsonTargets,
@@ -122,11 +123,14 @@ export async function applyModule(args: {
// the active target so a module shipped into multiple apps renders correctly // the active target so a module shipped into multiple apps renders correctly
// per app. `seedApp` covers repo/package-scoped templates — they don't // per app. `seedApp` covers repo/package-scoped templates — they don't
// reference `app.*` but `buildRenderContext` still needs a valid `AppSpec`. // reference `app.*` but `buildRenderContext` still needs a valid `AppSpec`.
// Peers are resolved per-app so app-scoped templates see the right framework
// (e.g. web's framework vs native's) when conditionally branching.
const renderContextFor = (app: AppSpec): TemplateContext => const renderContextFor = (app: AppSpec): TemplateContext =>
buildRenderContext({ buildRenderContext({
projectName: manifest.name, projectName: manifest.name,
app, app,
packageName, packageName,
peers: activePeerIds(manifest, app.id),
}); });
const seedApp = targetApps[0]!; const seedApp = targetApps[0]!;
@@ -593,6 +597,7 @@ export async function revertCodemods(args: {
projectName: manifest.name, projectName: manifest.name,
app, app,
packageName, packageName,
peers: activePeerIds(manifest, app.id),
}); });
const project = lazyProject(appRoot); const project = lazyProject(appRoot);
const ctx = buildContext({ const ctx = buildContext({
+5 -4
View File
@@ -20,20 +20,21 @@
"@stanza/registry": "workspace:*", "@stanza/registry": "workspace:*",
"@tabler/icons-react": "^3.44.0", "@tabler/icons-react": "^3.44.0",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@takumi-rs/image-response": "^1.4.1", "@takumi-rs/image-response": "^1.5.1",
"@tanstack/react-devtools": "^0.10.5", "@tanstack/react-devtools": "^0.10.5",
"@tanstack/react-hotkeys": "^0.10.0", "@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-router": "^1.170.7", "@tanstack/react-router": "^1.170.8",
"@tanstack/react-router-devtools": "^1.167.0", "@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/react-router-ssr-query": "^1.167.0", "@tanstack/react-router-ssr-query": "^1.167.0",
"@tanstack/react-start": "^1.168.10", "@tanstack/react-start": "^1.168.13",
"@tanstack/router-plugin": "^1.168.10", "@tanstack/router-plugin": "^1.168.11",
"@vercel/functions": "^3.6.0", "@vercel/functions": "^3.6.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"fumadocs-core": "^16.9.1", "fumadocs-core": "^16.9.1",
"fumadocs-mdx": "^15.0.8", "fumadocs-mdx": "^15.0.8",
"fumadocs-ui": "npm:@fumadocs/base-ui@^16.9.1", "fumadocs-ui": "npm:@fumadocs/base-ui@^16.9.1",
"handlebars": "^4.7.9",
"lru-cache": "^11.5.0", "lru-cache": "^11.5.0",
"nitro": "^3.0.260522-beta", "nitro": "^3.0.260522-beta",
"posthog-js": "^1.376.0", "posthog-js": "^1.376.0",
+15
View File
@@ -93,6 +93,21 @@ function pendingPeers(
return pending; return pending;
} }
/**
* Active peer ids — the URL-derived equivalent of the CLI runner's
* `activePeerIds(manifest, app.id)`. Single source of truth for what
* `synthesizeTemplates` should expose under `{{peers.<category>}}` in the
* Handlebars render context.
*/
export function selectedPeerIds(selections: Selections): Partial<Record<CategoryId, string>> {
const out: Partial<Record<CategoryId, string>> = {};
for (const category of PEER_CATEGORIES) {
const id = selections[category]?.[0];
if (id) out[category] = id;
}
return out;
}
/** /**
* Resolve each selected module's adapter against the chosen one-cardinality * Resolve each selected module's adapter against the chosen one-cardinality
* peers — the same logic the CLI uses. Modules whose peers aren't satisfied are * peers — the same logic the CLI uses. Modules whose peers aren't satisfied are
@@ -7,7 +7,12 @@ import {
} from "@stanza/registry"; } from "@stanza/registry";
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { DEFAULT_BUILDER_APPS, parseSelections, resolveSelected } from "@/lib/selection"; import {
DEFAULT_BUILDER_APPS,
parseSelections,
resolveSelected,
selectedPeerIds,
} from "@/lib/selection";
import type { BuilderSearch } from "@/lib/selection"; import type { BuilderSearch } from "@/lib/selection";
import type { Preview } from "@/server/highlighter"; import type { Preview } from "@/server/highlighter";
import { getHighlighter, renderPreview } from "@/server/highlighter.server"; import { getHighlighter, renderPreview } from "@/server/highlighter.server";
@@ -47,6 +52,7 @@ export const getBuilderState = createServerFn({ method: "GET" })
const { name, pm, selections } = parseSelections(data); const { name, pm, selections } = parseSelections(data);
const resolved = resolveSelected(modules, selections); const resolved = resolveSelected(modules, selections);
const peers = selectedPeerIds(selections);
// Templates carry their own content; package.json, stanza.json, and // Templates carry their own content; package.json, stanza.json, and
// .env.example are synthesized — the CLI never ships them as templates, it // .env.example are synthesized — the CLI never ships them as templates, it
@@ -61,6 +67,7 @@ export const getBuilderState = createServerFn({ method: "GET" })
const previewFiles: { path: string; content: string }[] = synthesizeTemplates(resolved, { const previewFiles: { path: string; content: string }[] = synthesizeTemplates(resolved, {
name, name,
apps, apps,
peers,
}).map((tpl) => ({ path: tpl.path, content: tpl.content })); }).map((tpl) => ({ path: tpl.path, content: tpl.content }));
if (hasSelection) { if (hasSelection) {
const pkgJsons = synthesizePackageJsons(resolved, { name, apps, packageManager: pm }); const pkgJsons = synthesizePackageJsons(resolved, { name, apps, packageManager: pm });
+1
View File
@@ -25,6 +25,7 @@
"@changesets/cli": "^2.31.0", "@changesets/cli": "^2.31.0",
"@stanza/registry": "workspace:*", "@stanza/registry": "workspace:*",
"@types/node": "^25.9.1", "@types/node": "^25.9.1",
"jiti": "^2.7.0",
"typescript": "^6.0.3", "typescript": "^6.0.3",
"vite-plus": "catalog:" "vite-plus": "catalog:"
}, },
+1
View File
@@ -13,6 +13,7 @@
"./resolver": "./src/resolver.ts" "./resolver": "./src/resolver.ts"
}, },
"dependencies": { "dependencies": {
"handlebars": "^4.7.9",
"validate-npm-package-name": "^8.0.0", "validate-npm-package-name": "^8.0.0",
"zod": "^4.4.3" "zod": "^4.4.3"
}, },
+1 -1
View File
@@ -54,7 +54,7 @@ export {
} from "./manifest"; } from "./manifest";
export type { ResolveContext, ResolveResult, ResolveError } from "./resolver"; export type { ResolveContext, ResolveResult, ResolveError } from "./resolver";
export { resolveAdapter, isCompatible, categoryOrder } from "./resolver"; export { resolveAdapter, isCompatible, categoryOrder, activePeerIds } from "./resolver";
export type { export type {
PackageManager, PackageManager,
+67 -1
View File
@@ -2,7 +2,7 @@ import { assert, describe, expect, it } from "vite-plus/test";
import { emptyManifest } from "./manifest"; import { emptyManifest } from "./manifest";
import { defineModule, type Module } from "./module"; import { defineModule, type Module } from "./module";
import { resolveAdapter } from "./resolver"; import { activePeerIds, resolveAdapter } from "./resolver";
const drizzle: Module = defineModule({ const drizzle: Module = defineModule({
id: "drizzle", id: "drizzle",
@@ -156,3 +156,69 @@ describe("resolveAdapter — add-ons", () => {
expect(result.error.kind).toBe("missing-peer"); expect(result.error.kind).toBe("missing-peer");
}); });
}); });
// Module with both a generic default and a framework-specific override —
// mirrors the tooling-eslint-prettier shape after its standalone refactor.
const eslintLike: Module = defineModule({
id: "eslint-like",
category: "tooling",
label: "ESLint-like",
description: "",
version: "0.1.0",
adapters: [
{ key: "next", match: { framework: "next" } },
{ key: "default", match: {} },
],
});
describe("resolveAdapter — default + framework-specific adapters coexist", () => {
it("picks the framework-specific adapter when its peer is active", () => {
const result = resolveAdapter(eslintLike, {
manifest: emptyManifest({ name: "t" }),
pending: { framework: next },
});
expect(result.ok).toBe(true);
assert(result.ok);
expect(result.adapter.key).toBe("next");
});
it("falls back to the empty-match default when no framework is selected", () => {
const result = resolveAdapter(eslintLike, {
manifest: emptyManifest({ name: "t" }),
pending: {},
});
expect(result.ok).toBe(true);
assert(result.ok);
expect(result.adapter.key).toBe("default");
});
});
describe("activePeerIds", () => {
it("returns an empty record for a manifest with no selections", () => {
expect(activePeerIds(emptyManifest({ name: "t" }))).toEqual({});
});
it("reads the installed framework id from the manifest", () => {
const manifest = {
...emptyManifest({ name: "t" }),
modules: {
framework: [{ id: "next", version: "0.1.0", adapter: "default", apps: ["web"] }],
},
};
expect(activePeerIds(manifest, "web")).toEqual({ framework: "next" });
});
it("scopes app-home peer lookups to the targetAppId", () => {
const manifest = {
...emptyManifest({ name: "t" }),
modules: {
framework: [
{ id: "next", version: "0.1.0", adapter: "default", apps: ["web"] },
{ id: "expo", version: "0.1.0", adapter: "default", apps: ["native"] },
],
},
};
expect(activePeerIds(manifest, "web").framework).toBe("next");
expect(activePeerIds(manifest, "native").framework).toBe("expo");
});
});
+29 -10
View File
@@ -45,7 +45,7 @@ export type ResolveResult =
* always wins on tiebreak when no peers are required. * always wins on tiebreak when no peers are required.
*/ */
export function resolveAdapter(module: Module, context: ResolveContext): ResolveResult { export function resolveAdapter(module: Module, context: ResolveContext): ResolveResult {
const activePeers = activePeerIds(context); const activePeers = activePeerIdsForContext(context);
// Check declared peers are satisfied (id present + on allow-list if specified). // Check declared peers are satisfied (id present + on allow-list if specified).
for (const category of PEER_CATEGORIES) { for (const category of PEER_CATEGORIES) {
@@ -87,20 +87,39 @@ export function isCompatible(module: Module, context: ResolveContext): boolean {
} }
/** /**
* Active peer ids — only `cardinality: "one"` categories can be peers, so we * Active peer ids from the manifest alone — only `cardinality: "one"`
* iterate `PEER_CATEGORIES` and read the single installed/pending pick. When * categories can be peers, so we iterate `PEER_CATEGORIES` and read the
* `targetAppId` is set, the installed lookup is scoped to that app so e.g. * single installed pick. When `targetAppId` is set, the lookup is scoped to
* web's styling adapter peer-matches against web's framework, not native's. * that app so e.g. web's styling adapter peer-matches against web's
* framework, not native's.
*
* Exported so callers outside the resolver (the CLI runner and web
* synthesize, which both feed peers into the template render context) share
* exactly one implementation.
*/
export function activePeerIds(
manifest: StanzaManifest,
targetAppId?: string,
): Partial<Record<CategoryId, string>> {
const out: Partial<Record<CategoryId, string>> = {};
for (const category of PEER_CATEGORIES) {
const installed = selectedOne(manifest, category, targetAppId)?.id;
if (installed) out[category] = installed;
}
return out;
}
/**
* Layer in-flight `pending` picks over the manifest's installed peers.
* Pending picks aren't yet app-scoped — they belong to the same in-flight * Pending picks aren't yet app-scoped — they belong to the same in-flight
* resolution and only one app is being processed at a time. * resolution and only one app is being processed at a time.
*/ */
function activePeerIds(context: ResolveContext): Partial<Record<CategoryId, string>> { function activePeerIdsForContext(context: ResolveContext): Partial<Record<CategoryId, string>> {
const out: Partial<Record<CategoryId, string>> = {}; const installed = activePeerIds(context.manifest, context.targetAppId);
const out: Partial<Record<CategoryId, string>> = { ...installed };
for (const category of PEER_CATEGORIES) { for (const category of PEER_CATEGORIES) {
const pending = context.pending[category]?.id; const pending = context.pending[category]?.id;
const installed = selectedOne(context.manifest, category, context.targetAppId)?.id; if (pending) out[category] = pending;
const chosen = pending ?? installed;
if (chosen) out[category] = chosen;
} }
return out; return out;
} }
+2 -1
View File
@@ -146,7 +146,7 @@ export type SynthesizedTemplate = {
*/ */
export function synthesizeTemplates( export function synthesizeTemplates(
resolved: Partial<Record<CategoryId, SynthesizeEntry[]>>, resolved: Partial<Record<CategoryId, SynthesizeEntry[]>>,
opts: { name: string; apps?: AppSpec[] }, opts: { name: string; apps?: AppSpec[]; peers?: Partial<Record<CategoryId, string>> },
): SynthesizedTemplate[] { ): SynthesizedTemplate[] {
const apps = opts.apps && opts.apps.length > 0 ? opts.apps : [defaultWebApp()]; const apps = opts.apps && opts.apps.length > 0 ? opts.apps : [defaultWebApp()];
const out: SynthesizedTemplate[] = []; const out: SynthesizedTemplate[] = [];
@@ -170,6 +170,7 @@ export function synthesizeTemplates(
projectName: opts.name, projectName: opts.name,
app, app,
packageName, packageName,
peers: opts.peers,
}); });
for (const tpl of entry.adapter.templates ?? []) { for (const tpl of entry.adapter.templates ?? []) {
+61 -8
View File
@@ -40,18 +40,58 @@ describe("renderTemplate", () => {
expect(renderTemplate("{{ project.name }}", ctx)).toBe("acme"); expect(renderTemplate("{{ project.name }}", ctx)).toBe("acme");
}); });
it("throws on a missing top-level key", () => { it("renders missing keys as empty strings (non-strict Handlebars)", () => {
expect(() => renderTemplate("{{missing}}", ctx)).toThrow(/missing or non-string key "missing"/); expect(renderTemplate("[{{missing}}]", ctx)).toBe("[]");
expect(renderTemplate("[{{packages.nope.name}}]", ctx)).toBe("[]");
}); });
it("throws on a missing nested key", () => { it("does not HTML-escape values (noEscape=true)", () => {
expect(() => renderTemplate("{{packages.nope.name}}", ctx)).toThrow( // Without noEscape, the "/" in package names would become "&#x2F;".
/missing or non-string key "packages.nope.name"/, expect(renderTemplate("{{package.name}}", ctx)).toBe("@acme/auth");
);
}); });
it("throws when a path resolves to an object instead of a string", () => { describe("peers + conditionals", () => {
expect(() => renderTemplate("{{project}}", ctx)).toThrow(/missing or non-string key "project"/); const withNext = buildRenderContext({
projectName: "acme",
app: webApp,
packageName: "",
peers: { framework: "next" },
});
const withTanstack = buildRenderContext({
projectName: "acme",
app: webApp,
packageName: "",
peers: { framework: "tanstack-start" },
});
const noFramework = buildRenderContext({
projectName: "acme",
app: webApp,
packageName: "",
});
it("renders an `if (eq)` block when the active peer matches", () => {
const tpl = '{{#if (eq peers.framework "next")}}NEXT{{/if}}';
expect(renderTemplate(tpl, withNext)).toBe("NEXT");
expect(renderTemplate(tpl, withTanstack)).toBe("");
expect(renderTemplate(tpl, noFramework)).toBe("");
});
it("matches hyphenated peer ids", () => {
const tpl = '{{#if (eq peers.framework "tanstack-start")}}TS{{/if}}';
expect(renderTemplate(tpl, withTanstack)).toBe("TS");
expect(renderTemplate(tpl, withNext)).toBe("");
});
it("renders an `unless` block when the active peer is absent", () => {
const tpl = "{{#unless peers.framework}}NONE{{/unless}}";
expect(renderTemplate(tpl, noFramework)).toBe("NONE");
expect(renderTemplate(tpl, withNext)).toBe("");
});
it("exposes the raw peer id under {{peers.<category>}}", () => {
expect(renderTemplate("[{{peers.framework}}]", withNext)).toBe("[next]");
expect(renderTemplate("[{{peers.framework}}]", noFramework)).toBe("[]");
});
}); });
}); });
@@ -66,6 +106,19 @@ describe("buildRenderContext", () => {
expect(ctx.packages.auth?.name).toBe("@acme/auth"); expect(ctx.packages.auth?.name).toBe("@acme/auth");
expect(ctx.packages.db?.name).toBe("@acme/db"); expect(ctx.packages.db?.name).toBe("@acme/db");
}); });
it("materializes every PEER_CATEGORIES key under `peers` (undefined when unset)", () => {
const ctx = buildRenderContext({
projectName: "acme",
app: webApp,
packageName: "",
peers: { framework: "next" },
});
expect(ctx.peers.framework).toBe("next");
// styling is a PEER_CATEGORY but not set — the key exists, value is undefined.
expect("styling" in ctx.peers).toBe(true);
expect(ctx.peers.styling).toBeUndefined();
});
}); });
const drizzle: Module = defineModule({ const drizzle: Module = defineModule({
+34 -20
View File
@@ -1,8 +1,10 @@
import Handlebars from "handlebars";
import type { AppSpec } from "./manifest"; import type { AppSpec } from "./manifest";
import { PACKAGE_DIRS } from "./module"; import { type CategoryId, PACKAGE_DIRS, PEER_CATEGORIES } from "./module";
/** /**
* Mustache-style context consumed by {@link renderTemplate}. The shape is the * Handlebars context consumed by {@link renderTemplate}. The shape is the
* public contract template authors write against: * public contract template authors write against:
* *
* {{ project.name }} — the manifest's `name` (a.k.a. the npm scope) * {{ project.name }} — the manifest's `name` (a.k.a. the npm scope)
@@ -13,6 +15,10 @@ import { PACKAGE_DIRS } from "./module";
* Empty string for categories whose `home.kind` isn't "package". * Empty string for categories whose `home.kind` isn't "package".
* {{ packages.<dir>.name }} — any other package by its dir, e.g. {{ packages.db.name }} * {{ packages.<dir>.name }} — any other package by its dir, e.g. {{ packages.db.name }}
* → "@my-app/db". One entry per `PACKAGE_DIRS` member. * → "@my-app/db". One entry per `PACKAGE_DIRS` member.
* {{ peers.<category> }} — id of the active one-cardinality module in that
* category, or undefined. Useful with the `eq`
* helper for conditional blocks:
* {{#if (eq peers.framework "next")}}…{{/if}}
* *
* `app.*` is rebound per target app on every render pass, so a module installed * `app.*` is rebound per target app on every render pass, so a module installed
* into two apps renders correctly in each. * into two apps renders correctly in each.
@@ -22,30 +28,29 @@ export type TemplateContext = {
app: { id: string; dir: string; kind: string }; app: { id: string; dir: string; kind: string };
package: { name: string }; package: { name: string };
packages: Record<string, { name: string }>; packages: Record<string, { name: string }>;
peers: Partial<Record<CategoryId, string>>;
}; };
// Module-singleton instance so registered helpers don't leak into other
// Handlebars consumers (the global is shared otherwise).
const hb = Handlebars.create();
// String-equality helper for matching against a peer id, e.g.
// {{#if (eq peers.framework "next")}}…{{/if}}
hb.registerHelper("eq", (a: unknown, b: unknown) => a === b);
/** /**
* Resolve `{{ a.b.c }}` references against a {@link TemplateContext}. Pure * Render a Handlebars template against a {@link TemplateContext}. Output is
* regex + path walk — no helpers, no logic. Templates that need branching * never HTML-escaped (`noEscape: true`) because templates emit code (JS/TS/
* should live as separate files and the codemod picks which one to render. * JSON), not markup.
* *
* Throws on missing keys so silent stub-outs can't ship to users. The CLI's * Missing keys render as empty strings (Handlebars' default non-strict
* apply path and the web preview share this primitive, so a fresh `{{ ... }}` * behavior). The CLI's apply path and the web preview share this primitive,
* with a typo fails the same way in both. * so substitutions resolve identically in both.
*/ */
export function renderTemplate(source: string, context: TemplateContext): string { export function renderTemplate(source: string, context: TemplateContext): string {
return source.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (_, key: string) => { const template = hb.compile(source, { noEscape: true });
const value = key.split(".").reduce<unknown>((acc, part) => { return template(context);
if (acc !== null && typeof acc === "object" && part in acc) {
return Reflect.get(acc, part);
}
return undefined;
}, context);
if (typeof value !== "string") {
throw new Error(`renderTemplate: missing or non-string key "${key}"`);
}
return value;
});
} }
/** /**
@@ -57,20 +62,29 @@ export function renderTemplate(source: string, context: TemplateContext): string
* Each (module, target-app) iteration in the runner builds a fresh context so * Each (module, target-app) iteration in the runner builds a fresh context so
* `app.*` points at the currently-applying app — that's how the same module's * `app.*` points at the currently-applying app — that's how the same module's
* templates land correctly when shipped into multiple apps. * templates land correctly when shipped into multiple apps.
*
* Every `PEER_CATEGORIES` key is materialized under `peers` (with `undefined`
* when no module is selected) so templates can safely reference any peer slot
* — that lets `{{peers.framework}}` resolve to `undefined` rather than walk
* off the end of a missing path.
*/ */
export function buildRenderContext(opts: { export function buildRenderContext(opts: {
projectName: string; projectName: string;
app: AppSpec; app: AppSpec;
packageName: string; packageName: string;
peers?: Partial<Record<CategoryId, string>>;
}): TemplateContext { }): TemplateContext {
const packages: Record<string, { name: string }> = {}; const packages: Record<string, { name: string }> = {};
for (const dir of PACKAGE_DIRS) { for (const dir of PACKAGE_DIRS) {
packages[dir] = { name: `@${opts.projectName}/${dir}` }; packages[dir] = { name: `@${opts.projectName}/${dir}` };
} }
const peers: Partial<Record<CategoryId, string>> = {};
for (const cat of PEER_CATEGORIES) peers[cat] = opts.peers?.[cat];
return { return {
project: { name: opts.projectName }, project: { name: opts.projectName },
app: { id: opts.app.id, dir: opts.app.dir, kind: opts.app.kind }, app: { id: opts.app.id, dir: opts.app.dir, kind: opts.app.kind },
package: { name: opts.packageName }, package: { name: opts.packageName },
packages, packages,
peers,
}; };
} }
+457 -592
View File
File diff suppressed because it is too large. Load diff
@@ -1,4 +1,12 @@
import { defineModule } from "@stanza/registry"; import { defineModule, type TemplateRef } from "@stanza/registry";
// Same templates regardless of which framework adapter wins. The eslint config
// is a single Handlebars template that branches on `{{peers.framework}}`, so
// pure-TS, Next, and TanStack Start all render from the same source file.
const templates: TemplateRef[] = [
{ src: "eslint.config.mjs", dest: "eslint.config.mjs", scope: "repo", template: true },
{ src: "prettier.config.mjs", dest: "prettier.config.mjs", scope: "repo" },
];
export default defineModule({ export default defineModule({
id: "eslint-prettier", id: "eslint-prettier",
@@ -7,13 +15,14 @@ export default defineModule({
description: "ESLint (flat config) for linting and Prettier for formatting.", description: "ESLint (flat config) for linting and Prettier for formatting.",
version: "0.1.0", version: "0.1.0",
homepage: "https://eslint.org", homepage: "https://eslint.org",
// ESLint config differs per framework, so dispatch on the framework pick. // Framework-agnostic base; adapters layer on framework-specific plugins via
peers: { framework: ["next", "tanstack-start"] }, // Handlebars conditionals in the shared template — same shape as the
// tooling-biome / tooling-oxlint-oxfmt siblings, which also install without
// a framework peer.
devDependencies: { devDependencies: {
eslint: "^9.39.0", eslint: "^9.39.0",
"@eslint/js": "^9.39.0", "@eslint/js": "^9.39.0",
"typescript-eslint": "^8.46.0", "typescript-eslint": "^8.46.0",
"eslint-plugin-react-hooks": "^7.0.0",
prettier: "^3.6.2", prettier: "^3.6.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
}, },
@@ -25,20 +34,25 @@ export default defineModule({
{ {
key: "next", key: "next",
match: { framework: "next" }, match: { framework: "next" },
devDependencies: { "@next/eslint-plugin-next": "^16.2.6" }, devDependencies: {
templates: [ "@next/eslint-plugin-next": "^16.2.6",
{ src: "eslint.config.next.mjs", dest: "eslint.config.mjs", scope: "repo" }, "eslint-plugin-react-hooks": "^7.0.0",
{ src: "prettier.config.mjs", dest: "prettier.config.mjs", scope: "repo" }, },
], templates,
}, },
{ {
key: "tanstack-start", key: "tanstack-start",
match: { framework: "tanstack-start" }, match: { framework: "tanstack-start" },
devDependencies: { "eslint-plugin-react": "^7.37.5" }, devDependencies: {
templates: [ "eslint-plugin-react": "^7.37.5",
{ src: "eslint.config.tanstack.mjs", dest: "eslint.config.mjs", scope: "repo" }, "eslint-plugin-react-hooks": "^7.0.0",
{ src: "prettier.config.mjs", dest: "prettier.config.mjs", scope: "repo" }, },
], templates,
},
{
key: "default",
match: {},
templates,
}, },
], ],
}); });
@@ -0,0 +1,55 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
{{#if (eq peers.framework "next")}}
import next from "@next/eslint-plugin-next";
import reactHooks from "eslint-plugin-react-hooks";
{{/if}}
{{#if (eq peers.framework "tanstack-start")}}
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
{{/if}}
import prettier from "eslint-config-prettier";
export default tseslint.config(
{
ignores: [
"**/node_modules/**",
"**/dist/**",
{{#if (eq peers.framework "next")}}
"**/.next/**",
"**/next-env.d.ts",
{{/if}}
{{#if (eq peers.framework "tanstack-start")}}
"**/.output/**",
"**/.nitro/**",
"**/routeTree.gen.ts",
{{/if}}
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{{#if (eq peers.framework "next")}}
{
plugins: {
"@next/next": next,
"react-hooks": reactHooks,
},
rules: {
...next.configs.recommended.rules,
...next.configs["core-web-vitals"].rules,
...reactHooks.configs.recommended.rules,
},
},
{{/if}}
{{#if (eq peers.framework "tanstack-start")}}
{
plugins: { react, "react-hooks": reactHooks },
settings: { react: { version: "detect" } },
rules: {
...reactHooks.configs.recommended.rules,
"react/react-in-jsx-scope": "off",
},
},
{{/if}}
prettier,
);
@@ -1,24 +0,0 @@
import js from "@eslint/js";
import next from "@next/eslint-plugin-next";
import reactHooks from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";
import prettier from "eslint-config-prettier";
export default tseslint.config(
{ ignores: ["**/.next/**", "**/node_modules/**", "**/next-env.d.ts", "**/dist/**"] },
js.configs.recommended,
...tseslint.configs.recommended,
{
plugins: {
"@next/next": next,
"react-hooks": reactHooks,
},
rules: {
...next.configs.recommended.rules,
...next.configs["core-web-vitals"].rules,
...reactHooks.configs.recommended.rules,
},
},
// Disables stylistic rules that conflict with Prettier — keep last.
prettier,
);
@@ -1,21 +0,0 @@
import js from "@eslint/js";
import react from "eslint-plugin-react";
import reactHooks from "eslint-plugin-react-hooks";
import tseslint from "typescript-eslint";
import prettier from "eslint-config-prettier";
export default tseslint.config(
{ ignores: ["**/dist/**", "**/.output/**", "**/.nitro/**", "**/node_modules/**", "**/routeTree.gen.ts"] },
js.configs.recommended,
...tseslint.configs.recommended,
{
plugins: { react, "react-hooks": reactHooks },
settings: { react: { version: "detect" } },
rules: {
...reactHooks.configs.recommended.rules,
"react/react-in-jsx-scope": "off",
},
},
// Disables stylistic rules that conflict with Prettier — keep last.
prettier,
);