mirror of
https://github.com/jakejarvis/stanza.git
synced 2026-10-16 06:15:43 -04:00
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:
19 files changed
+858
-697
No files matched your search
@@ -48,6 +48,7 @@
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.4.0",
|
||||
"citty": "^0.2.2",
|
||||
"handlebars": "^4.7.9",
|
||||
"picocolors": "^1.1.1",
|
||||
"semver": "^7.8.1",
|
||||
"ts-morph": "^28.0.0",
|
||||
|
||||
@@ -330,3 +330,91 @@ describe("tooling slot (single-choice, repo-scoped)", () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
TemplateRef,
|
||||
} from "@stanza/registry";
|
||||
import {
|
||||
activePeerIds,
|
||||
buildRenderContext,
|
||||
categoryHome,
|
||||
installPackageJsonTargets,
|
||||
@@ -122,11 +123,14 @@ export async function applyModule(args: {
|
||||
// the active target so a module shipped into multiple apps renders correctly
|
||||
// per app. `seedApp` covers repo/package-scoped templates — they don't
|
||||
// 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 =>
|
||||
buildRenderContext({
|
||||
projectName: manifest.name,
|
||||
app,
|
||||
packageName,
|
||||
peers: activePeerIds(manifest, app.id),
|
||||
});
|
||||
const seedApp = targetApps[0]!;
|
||||
|
||||
@@ -593,6 +597,7 @@ export async function revertCodemods(args: {
|
||||
projectName: manifest.name,
|
||||
app,
|
||||
packageName,
|
||||
peers: activePeerIds(manifest, app.id),
|
||||
});
|
||||
const project = lazyProject(appRoot);
|
||||
const ctx = buildContext({
|
||||
|
||||
@@ -20,20 +20,21 @@
|
||||
"@stanza/registry": "workspace:*",
|
||||
"@tabler/icons-react": "^3.44.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-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-ssr-query": "^1.167.0",
|
||||
"@tanstack/react-start": "^1.168.10",
|
||||
"@tanstack/router-plugin": "^1.168.10",
|
||||
"@tanstack/react-start": "^1.168.13",
|
||||
"@tanstack/router-plugin": "^1.168.11",
|
||||
"@vercel/functions": "^3.6.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"fumadocs-core": "^16.9.1",
|
||||
"fumadocs-mdx": "^15.0.8",
|
||||
"fumadocs-ui": "npm:@fumadocs/base-ui@^16.9.1",
|
||||
"handlebars": "^4.7.9",
|
||||
"lru-cache": "^11.5.0",
|
||||
"nitro": "^3.0.260522-beta",
|
||||
"posthog-js": "^1.376.0",
|
||||
|
||||
@@ -93,6 +93,21 @@ function pendingPeers(
|
||||
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
|
||||
* peers — the same logic the CLI uses. Modules whose peers aren't satisfied are
|
||||
|
||||
@@ -7,7 +7,12 @@ import {
|
||||
} from "@stanza/registry";
|
||||
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 { Preview } from "@/server/highlighter";
|
||||
import { getHighlighter, renderPreview } from "@/server/highlighter.server";
|
||||
@@ -47,6 +52,7 @@ export const getBuilderState = createServerFn({ method: "GET" })
|
||||
|
||||
const { name, pm, selections } = parseSelections(data);
|
||||
const resolved = resolveSelected(modules, selections);
|
||||
const peers = selectedPeerIds(selections);
|
||||
|
||||
// Templates carry their own content; package.json, stanza.json, and
|
||||
// .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, {
|
||||
name,
|
||||
apps,
|
||||
peers,
|
||||
}).map((tpl) => ({ path: tpl.path, content: tpl.content }));
|
||||
if (hasSelection) {
|
||||
const pkgJsons = synthesizePackageJsons(resolved, { name, apps, packageManager: pm });
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@changesets/cli": "^2.31.0",
|
||||
"@stanza/registry": "workspace:*",
|
||||
"@types/node": "^25.9.1",
|
||||
"jiti": "^2.7.0",
|
||||
"typescript": "^6.0.3",
|
||||
"vite-plus": "catalog:"
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"./resolver": "./src/resolver.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"handlebars": "^4.7.9",
|
||||
"validate-npm-package-name": "^8.0.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
|
||||
@@ -54,7 +54,7 @@ export {
|
||||
} from "./manifest";
|
||||
|
||||
export type { ResolveContext, ResolveResult, ResolveError } from "./resolver";
|
||||
export { resolveAdapter, isCompatible, categoryOrder } from "./resolver";
|
||||
export { resolveAdapter, isCompatible, categoryOrder, activePeerIds } from "./resolver";
|
||||
|
||||
export type {
|
||||
PackageManager,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { assert, describe, expect, it } from "vite-plus/test";
|
||||
|
||||
import { emptyManifest } from "./manifest";
|
||||
import { defineModule, type Module } from "./module";
|
||||
import { resolveAdapter } from "./resolver";
|
||||
import { activePeerIds, resolveAdapter } from "./resolver";
|
||||
|
||||
const drizzle: Module = defineModule({
|
||||
id: "drizzle",
|
||||
@@ -156,3 +156,69 @@ describe("resolveAdapter — add-ons", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -45,7 +45,7 @@ export type ResolveResult =
|
||||
* always wins on tiebreak when no peers are required.
|
||||
*/
|
||||
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).
|
||||
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
|
||||
* iterate `PEER_CATEGORIES` and read the single installed/pending pick. When
|
||||
* `targetAppId` is set, the installed lookup is scoped to that app so e.g.
|
||||
* web's styling adapter peer-matches against web's framework, not native's.
|
||||
* Active peer ids from the manifest alone — only `cardinality: "one"`
|
||||
* categories can be peers, so we iterate `PEER_CATEGORIES` and read the
|
||||
* single installed pick. When `targetAppId` is set, the lookup is scoped to
|
||||
* 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
|
||||
* resolution and only one app is being processed at a time.
|
||||
*/
|
||||
function activePeerIds(context: ResolveContext): Partial<Record<CategoryId, string>> {
|
||||
const out: Partial<Record<CategoryId, string>> = {};
|
||||
function activePeerIdsForContext(context: ResolveContext): Partial<Record<CategoryId, string>> {
|
||||
const installed = activePeerIds(context.manifest, context.targetAppId);
|
||||
const out: Partial<Record<CategoryId, string>> = { ...installed };
|
||||
for (const category of PEER_CATEGORIES) {
|
||||
const pending = context.pending[category]?.id;
|
||||
const installed = selectedOne(context.manifest, category, context.targetAppId)?.id;
|
||||
const chosen = pending ?? installed;
|
||||
if (chosen) out[category] = chosen;
|
||||
if (pending) out[category] = pending;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -146,7 +146,7 @@ export type SynthesizedTemplate = {
|
||||
*/
|
||||
export function synthesizeTemplates(
|
||||
resolved: Partial<Record<CategoryId, SynthesizeEntry[]>>,
|
||||
opts: { name: string; apps?: AppSpec[] },
|
||||
opts: { name: string; apps?: AppSpec[]; peers?: Partial<Record<CategoryId, string>> },
|
||||
): SynthesizedTemplate[] {
|
||||
const apps = opts.apps && opts.apps.length > 0 ? opts.apps : [defaultWebApp()];
|
||||
const out: SynthesizedTemplate[] = [];
|
||||
@@ -170,6 +170,7 @@ export function synthesizeTemplates(
|
||||
projectName: opts.name,
|
||||
app,
|
||||
packageName,
|
||||
peers: opts.peers,
|
||||
});
|
||||
|
||||
for (const tpl of entry.adapter.templates ?? []) {
|
||||
|
||||
@@ -40,18 +40,58 @@ describe("renderTemplate", () => {
|
||||
expect(renderTemplate("{{ project.name }}", ctx)).toBe("acme");
|
||||
});
|
||||
|
||||
it("throws on a missing top-level key", () => {
|
||||
expect(() => renderTemplate("{{missing}}", ctx)).toThrow(/missing or non-string key "missing"/);
|
||||
it("renders missing keys as empty strings (non-strict Handlebars)", () => {
|
||||
expect(renderTemplate("[{{missing}}]", ctx)).toBe("[]");
|
||||
expect(renderTemplate("[{{packages.nope.name}}]", ctx)).toBe("[]");
|
||||
});
|
||||
|
||||
it("throws on a missing nested key", () => {
|
||||
expect(() => renderTemplate("{{packages.nope.name}}", ctx)).toThrow(
|
||||
/missing or non-string key "packages.nope.name"/,
|
||||
);
|
||||
it("does not HTML-escape values (noEscape=true)", () => {
|
||||
// Without noEscape, the "/" in package names would become "/".
|
||||
expect(renderTemplate("{{package.name}}", ctx)).toBe("@acme/auth");
|
||||
});
|
||||
|
||||
it("throws when a path resolves to an object instead of a string", () => {
|
||||
expect(() => renderTemplate("{{project}}", ctx)).toThrow(/missing or non-string key "project"/);
|
||||
describe("peers + conditionals", () => {
|
||||
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.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({
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import Handlebars from "handlebars";
|
||||
|
||||
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:
|
||||
*
|
||||
* {{ 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".
|
||||
* {{ packages.<dir>.name }} — any other package by its dir, e.g. {{ packages.db.name }}
|
||||
* → "@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
|
||||
* into two apps renders correctly in each.
|
||||
@@ -22,30 +28,29 @@ export type TemplateContext = {
|
||||
app: { id: string; dir: string; kind: string };
|
||||
package: { 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
|
||||
* regex + path walk — no helpers, no logic. Templates that need branching
|
||||
* should live as separate files and the codemod picks which one to render.
|
||||
* Render a Handlebars template against a {@link TemplateContext}. Output is
|
||||
* never HTML-escaped (`noEscape: true`) because templates emit code (JS/TS/
|
||||
* JSON), not markup.
|
||||
*
|
||||
* Throws on missing keys so silent stub-outs can't ship to users. The CLI's
|
||||
* apply path and the web preview share this primitive, so a fresh `{{ ... }}`
|
||||
* with a typo fails the same way in both.
|
||||
* Missing keys render as empty strings (Handlebars' default non-strict
|
||||
* behavior). The CLI's apply path and the web preview share this primitive,
|
||||
* so substitutions resolve identically in both.
|
||||
*/
|
||||
export function renderTemplate(source: string, context: TemplateContext): string {
|
||||
return source.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, (_, key: string) => {
|
||||
const value = key.split(".").reduce<unknown>((acc, part) => {
|
||||
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;
|
||||
});
|
||||
const template = hb.compile(source, { noEscape: true });
|
||||
return template(context);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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
|
||||
* `app.*` points at the currently-applying app — that's how the same module's
|
||||
* 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: {
|
||||
projectName: string;
|
||||
app: AppSpec;
|
||||
packageName: string;
|
||||
peers?: Partial<Record<CategoryId, string>>;
|
||||
}): TemplateContext {
|
||||
const packages: Record<string, { name: string }> = {};
|
||||
for (const dir of PACKAGE_DIRS) {
|
||||
packages[dir] = { name: `@${opts.projectName}/${dir}` };
|
||||
}
|
||||
const peers: Partial<Record<CategoryId, string>> = {};
|
||||
for (const cat of PEER_CATEGORIES) peers[cat] = opts.peers?.[cat];
|
||||
return {
|
||||
project: { name: opts.projectName },
|
||||
app: { id: opts.app.id, dir: opts.app.dir, kind: opts.app.kind },
|
||||
package: { name: opts.packageName },
|
||||
packages,
|
||||
peers,
|
||||
};
|
||||
}
|
||||
Generated
+457
-592
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({
|
||||
id: "eslint-prettier",
|
||||
@@ -7,13 +15,14 @@ export default defineModule({
|
||||
description: "ESLint (flat config) for linting and Prettier for formatting.",
|
||||
version: "0.1.0",
|
||||
homepage: "https://eslint.org",
|
||||
// ESLint config differs per framework, so dispatch on the framework pick.
|
||||
peers: { framework: ["next", "tanstack-start"] },
|
||||
// Framework-agnostic base; adapters layer on framework-specific plugins via
|
||||
// Handlebars conditionals in the shared template — same shape as the
|
||||
// tooling-biome / tooling-oxlint-oxfmt siblings, which also install without
|
||||
// a framework peer.
|
||||
devDependencies: {
|
||||
eslint: "^9.39.0",
|
||||
"@eslint/js": "^9.39.0",
|
||||
"typescript-eslint": "^8.46.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.0",
|
||||
prettier: "^3.6.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
},
|
||||
@@ -25,20 +34,25 @@ export default defineModule({
|
||||
{
|
||||
key: "next",
|
||||
match: { framework: "next" },
|
||||
devDependencies: { "@next/eslint-plugin-next": "^16.2.6" },
|
||||
templates: [
|
||||
{ src: "eslint.config.next.mjs", dest: "eslint.config.mjs", scope: "repo" },
|
||||
{ src: "prettier.config.mjs", dest: "prettier.config.mjs", scope: "repo" },
|
||||
],
|
||||
devDependencies: {
|
||||
"@next/eslint-plugin-next": "^16.2.6",
|
||||
"eslint-plugin-react-hooks": "^7.0.0",
|
||||
},
|
||||
templates,
|
||||
},
|
||||
{
|
||||
key: "tanstack-start",
|
||||
match: { framework: "tanstack-start" },
|
||||
devDependencies: { "eslint-plugin-react": "^7.37.5" },
|
||||
templates: [
|
||||
{ src: "eslint.config.tanstack.mjs", dest: "eslint.config.mjs", scope: "repo" },
|
||||
{ src: "prettier.config.mjs", dest: "prettier.config.mjs", scope: "repo" },
|
||||
],
|
||||
devDependencies: {
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.0",
|
||||
},
|
||||
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,
|
||||
);
|
||||
Reference in new issue
Block a user