font-lab 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,126 @@
1
+ // generateCatalog — the reusable parity-bundle builder (M5 extraction of gen-catalog's core).
2
+ //
3
+ // Given a project dir and a set of directions (curated OR agent-composed — both validated
4
+ // against the catalog), it self-hosts each font's Google variable woff2, computes next/font's
5
+ // exact adjusted fallback (M0-proven parity), and writes the generated module the dev panel
6
+ // imports plus the woff2 into the project's public/fontlab/. Pure of policy — WHICH directions
7
+ // to build is the caller's choice (the curator default, or the agent's own composition).
8
+
9
+ import { execFileSync } from "node:child_process";
10
+ import { writeFileSync, mkdirSync } from "node:fs";
11
+ import path from "node:path";
12
+ import { get as catalogGet } from "./catalog.mjs";
13
+ import { fontsForDirections } from "./curator.mjs";
14
+
15
+ const UA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36";
16
+ const slug = (family) => family.toLowerCase().replace(/[^a-z0-9]+/g, "-");
17
+ const pct = (n) => `${(Math.abs(n) * 100).toFixed(2)}%`;
18
+ const generic = (cat) => (cat === "serif" ? "serif" : cat === "monospace" ? "monospace" : "sans-serif");
19
+
20
+ const metricsFor = async (s) => (await import("@capsizecss/metrics/" + s)).default;
21
+
22
+ // next/font's adjusted-fallback descriptors for `main` measured against `fallback`.
23
+ function overrides(main, fallback) {
24
+ const sizeAdjust = main.xWidthAvg / main.unitsPerEm / (fallback.xWidthAvg / fallback.unitsPerEm);
25
+ return {
26
+ sizeAdjust: pct(sizeAdjust),
27
+ ascent: pct(main.ascent / (main.unitsPerEm * sizeAdjust)),
28
+ descent: pct(main.descent / (main.unitsPerEm * sizeAdjust)),
29
+ lineGap: pct(main.lineGap / (main.unitsPerEm * sizeAdjust)),
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Build the parity catalog for `directions` into `projectDir`.
35
+ * @param projectDir absolute path to the Next.js project (panel lives at app/_fontlab/)
36
+ * @param directions the directions to render (each role family MUST be a catalog member)
37
+ * @param meta { target, replaces } baked into the generated module (from the analyzer)
38
+ * @param opts { log?: (msg)=>void, fetch?: boolean } fetch:false skips network (test)
39
+ * @returns { fonts, directions, outPath }
40
+ */
41
+ export async function generateCatalog(projectDir, directions, meta = {}, opts = {}) {
42
+ const log = opts.log || (() => {});
43
+ const APP = projectDir.replace(/\/?$/, "/");
44
+ const PUBLIC = APP + "public/fontlab/";
45
+ mkdirSync(PUBLIC, { recursive: true });
46
+
47
+ const arial = await metricsFor("arial");
48
+ const timesNewRoman = await metricsFor("timesNewRoman");
49
+
50
+ const faceCss = [];
51
+ const stacks = {};
52
+ const families = fontsForDirections(directions);
53
+
54
+ for (const family of families) {
55
+ const spec = catalogGet(family); // throws if not a verified catalog member
56
+
57
+ if (opts.fetch !== false) {
58
+ const css = execFileSync("curl", ["-sSL", "-A", UA, `https://fonts.googleapis.com/css2?family=${spec.css2}&display=swap`], { encoding: "utf8" });
59
+ const m = css.match(/\/\* latin \*\/\s*@font-face\s*\{[^}]*?url\((https:[^)]+\.woff2)\)/);
60
+ if (!m) throw new Error(`Could not find latin woff2 for "${family}"`);
61
+ execFileSync("curl", ["-sSL", "-A", UA, "-o", PUBLIC + slug(family) + ".woff2", m[1]]);
62
+ }
63
+
64
+ const main = await metricsFor(spec.capsize);
65
+ const isSerif = main.category === "serif";
66
+ const fb = isSerif ? timesNewRoman : arial;
67
+ const fbName = isSerif ? "Times New Roman" : "Arial";
68
+ const o = overrides(main, fb);
69
+
70
+ faceCss.push(
71
+ `@font-face{font-family:'FL ${family}';font-style:normal;font-weight:100 900;font-display:swap;src:url('/fontlab/${slug(family)}.woff2') format('woff2');}`,
72
+ `@font-face{font-family:'FL ${family} Fallback';src:local('${fbName}');size-adjust:${o.sizeAdjust};ascent-override:${o.ascent};descent-override:${o.descent};line-gap-override:${o.lineGap};}`,
73
+ );
74
+ stacks[family] = `'FL ${family}', 'FL ${family} Fallback', ${generic(main.category)}`;
75
+ log(` ${family.padEnd(20)} -> ${slug(family)}.woff2 (fallback ${fbName}, size-adjust ${o.sizeAdjust})`);
76
+ }
77
+
78
+ const outDirections = directions.map((d) => ({
79
+ id: d.id,
80
+ name: d.name,
81
+ vibe: d.vibe,
82
+ rationale: d.rationale,
83
+ roles: Object.fromEntries(
84
+ Object.entries(d.roles).map(([role, r]) => [role, { family: r.family, source: "google", weights: r.weights, stack: stacks[r.family] }]),
85
+ ),
86
+ }));
87
+
88
+ const ts = `// AUTO-GENERATED by cli/gen-catalog.mjs — do not edit by hand.
89
+ import type { CSSProperties } from "react";
90
+
91
+ export const catalogFontFaceCss = ${JSON.stringify("\n" + faceCss.join("\n") + "\n")};
92
+
93
+ export const target = ${JSON.stringify(meta.target ?? null, null, 2)} as const;
94
+ export const replaces = ${JSON.stringify(meta.replaces ?? null, null, 2)} as const;
95
+
96
+ // Per-role preview swap target (M5/M6): which leaf var to override and on which element. The
97
+ // portable panel reads this so the live swap is honest on any site. null = unswappable role.
98
+ export const wiring = ${JSON.stringify(meta.wiring ?? null, null, 2)} as const;
99
+
100
+ export type Role = "display" | "body" | "mono";
101
+ export type RoleFont = { family: string; source: string; weights: number[]; stack: string };
102
+ export type Direction = {
103
+ id: string;
104
+ name: string;
105
+ vibe: string;
106
+ rationale: string;
107
+ roles: Record<Role, RoleFont>;
108
+ };
109
+
110
+ export const directions: Direction[] = ${JSON.stringify(outDirections, null, 2)};
111
+
112
+ // CSS-variable overrides a direction applies on :root (the swap mechanism, M0-proven).
113
+ export function directionVars(d: Direction): CSSProperties {
114
+ return {
115
+ "--fl-display": d.roles.display.stack,
116
+ "--fl-sans": d.roles.body.stack,
117
+ "--fl-mono": d.roles.mono.stack,
118
+ } as CSSProperties;
119
+ }
120
+ `;
121
+
122
+ const outPath = path.join(APP, "app/_fontlab/catalog.generated.ts");
123
+ mkdirSync(path.dirname(outPath), { recursive: true });
124
+ writeFileSync(outPath, ts);
125
+ return { fonts: families, directions: outDirections, outPath };
126
+ }
package/catalog.mjs ADDED
@@ -0,0 +1,79 @@
1
+ // Font Lab catalog (M4) — the parity asset, not a taste moat (see ARCHITECTURE.md).
2
+ //
3
+ // Each entry is a font we can ship with the WYSIWYG guarantee intact. Membership is gated on
4
+ // two hard requirements, both verified:
5
+ // 1. **Capsize coverage** — `@capsizecss/metrics/<capsize>` resolves, so we can compute
6
+ // next/font's exact adjusted-fallback metrics (CLS-safe, identical to ship). Uncovered
7
+ // fonts throw "Failed to find font override values" at build — a hard gate.
8
+ // 2. **Variable font** — a single latin woff2 spans the whole weight range, so preview and
9
+ // ship use the *same bytes* with no per-weight files and no `weight` arg (which would
10
+ // fork next/font off the variable file and break parity). The `css2` query below was
11
+ // auto-discovered and confirmed to return a weight-RANGE latin face.
12
+ //
13
+ // `roles` = which slots a family is suited to (display / body / mono). `tags` = vibe lookup
14
+ // keys the curator selects on. Pure data; `cli/m4-test.mjs` re-verifies coverage for all.
15
+
16
+ export const catalog = {
17
+ // ── sans — workhorse body / UI (some double as display) ──────────────────
18
+ Inter: { capsize: "inter", css2: "Inter:wght@100..900", roles: ["body", "display"], tags: ["neutral", "ui", "modern"] },
19
+ Geist: { capsize: "geist", css2: "Geist:wght@100..900", roles: ["body", "display"], tags: ["geometric", "minimal", "modern"] },
20
+ Figtree: { capsize: "figtree", css2: "Figtree:wght@300..700", roles: ["body", "display"], tags: ["geometric", "friendly", "modern"] },
21
+ "Hanken Grotesk": { capsize: "hankenGrotesk", css2: "Hanken+Grotesk:wght@100..900", roles: ["body", "display"], tags: ["humanist", "warm", "readable"] },
22
+ "Libre Franklin": { capsize: "libreFranklin", css2: "Libre+Franklin:wght@100..900", roles: ["body"], tags: ["grotesque", "classic", "readable"] },
23
+ "Work Sans": { capsize: "workSans", css2: "Work+Sans:wght@100..900", roles: ["body", "display"], tags: ["grotesque", "neutral", "readable"] },
24
+ "Plus Jakarta Sans": { capsize: "plusJakartaSans", css2: "Plus+Jakarta+Sans:wght@200..800", roles: ["body", "display"], tags: ["geometric", "friendly", "modern"] },
25
+ Manrope: { capsize: "manrope", css2: "Manrope:wght@200..800", roles: ["body", "display"], tags: ["geometric", "minimal", "modern"] },
26
+ "DM Sans": { capsize: "dMSans", css2: "DM+Sans:wght@100..900", roles: ["body", "display"], tags: ["geometric", "minimal", "friendly"] },
27
+ Onest: { capsize: "onest", css2: "Onest:wght@100..900", roles: ["body", "display"], tags: ["neutral", "modern", "readable"] },
28
+ "Source Sans 3": { capsize: "sourceSans3", css2: "Source+Sans+3:wght@200..800", roles: ["body"], tags: ["humanist", "neutral", "readable"] },
29
+ "Public Sans": { capsize: "publicSans", css2: "Public+Sans:wght@100..900", roles: ["body"], tags: ["grotesque", "neutral", "readable"] },
30
+ "Albert Sans": { capsize: "albertSans", css2: "Albert+Sans:wght@100..900", roles: ["body", "display"], tags: ["geometric", "modern", "friendly"] },
31
+ Sora: { capsize: "sora", css2: "Sora:wght@200..800", roles: ["body", "display"], tags: ["geometric", "technical", "modern"] },
32
+ Outfit: { capsize: "outfit", css2: "Outfit:wght@100..900", roles: ["body", "display"], tags: ["geometric", "minimal", "modern"] },
33
+ "Mona Sans": { capsize: "monaSans", css2: "Mona+Sans:wght@200..800", roles: ["body", "display"], tags: ["grotesque", "modern", "technical"] },
34
+ "Instrument Sans": { capsize: "instrumentSans", css2: "Instrument+Sans:wght@400..700", roles: ["body", "display"], tags: ["grotesque", "modern", "neutral"] },
35
+ Epilogue: { capsize: "epilogue", css2: "Epilogue:wght@100..900", roles: ["body", "display"], tags: ["geometric", "modern", "technical"] },
36
+ "Red Hat Display": { capsize: "redHatDisplay", css2: "Red+Hat+Display:wght@300..700", roles: ["display", "body"], tags: ["geometric", "technical", "modern"] },
37
+
38
+ // ── sans — display grotesques (heading-forward) ──────────────────────────
39
+ "Bricolage Grotesque": { capsize: "bricolageGrotesque", css2: "Bricolage+Grotesque:wght@200..800", roles: ["display"], tags: ["grotesque", "characterful", "editorial"] },
40
+ "Space Grotesk": { capsize: "spaceGrotesk", css2: "Space+Grotesk:wght@300..700", roles: ["display"], tags: ["grotesque", "technical", "characterful"] },
41
+ "Familjen Grotesk": { capsize: "familjenGrotesk", css2: "Familjen+Grotesk:wght@400..700", roles: ["display"], tags: ["grotesque", "modern", "characterful"] },
42
+ Archivo: { capsize: "archivo", css2: "Archivo:wght@100..900", roles: ["display", "body"], tags: ["grotesque", "technical", "bold"] },
43
+ Syne: { capsize: "syne", css2: "Syne:wght@400..700", roles: ["display"], tags: ["expressive", "bold", "editorial"] },
44
+ Unbounded: { capsize: "unbounded", css2: "Unbounded:wght@200..800", roles: ["display"], tags: ["expressive", "bold", "funky"] },
45
+ "Darker Grotesque": { capsize: "darkerGrotesque", css2: "Darker+Grotesque:wght@300..700", roles: ["display"], tags: ["expressive", "characterful", "editorial"] },
46
+ Gabarito: { capsize: "gabarito", css2: "Gabarito:wght@400..900", roles: ["display"], tags: ["friendly", "rounded", "bold"] },
47
+
48
+ // ── serif — display + editorial body ─────────────────────────────────────
49
+ Fraunces: { capsize: "fraunces", css2: "Fraunces:opsz,wght@9..40,100..900", roles: ["display", "body"], tags: ["serif", "editorial", "warm", "characterful"] },
50
+ Newsreader: { capsize: "newsreader", css2: "Newsreader:opsz,wght@10..72,300..700", roles: ["body", "display"], tags: ["serif", "editorial", "classic", "readable"] },
51
+ "Source Serif 4": { capsize: "sourceSerif4", css2: "Source+Serif+4:wght@200..800", roles: ["body", "display"], tags: ["serif", "classic", "readable"] },
52
+ Lora: { capsize: "lora", css2: "Lora:wght@400..700", roles: ["body", "display"], tags: ["serif", "warm", "readable", "classic"] },
53
+ "Playfair Display": { capsize: "playfairDisplay", css2: "Playfair+Display:wght@400..900", roles: ["display"], tags: ["serif", "elegant", "editorial", "contrast"] },
54
+ Bitter: { capsize: "bitter", css2: "Bitter:wght@100..900", roles: ["body", "display"], tags: ["serif", "slab", "readable"] },
55
+ "Crimson Pro": { capsize: "crimsonPro", css2: "Crimson+Pro:wght@200..800", roles: ["body", "display"], tags: ["serif", "classic", "editorial", "readable"] },
56
+ Cormorant: { capsize: "cormorant", css2: "Cormorant:wght@300..700", roles: ["display"], tags: ["serif", "elegant", "contrast", "editorial"] },
57
+
58
+ // ── mono — code / labels ─────────────────────────────────────────────────
59
+ "JetBrains Mono": { capsize: "jetBrainsMono", css2: "JetBrains+Mono:wght@200..800", roles: ["mono"], tags: ["mono", "technical", "neutral"] },
60
+ "Geist Mono": { capsize: "geistMono", css2: "Geist+Mono:wght@100..900", roles: ["mono"], tags: ["mono", "minimal", "modern"] },
61
+ "Roboto Mono": { capsize: "robotoMono", css2: "Roboto+Mono:wght@300..700", roles: ["mono"], tags: ["mono", "neutral", "classic"] },
62
+ "Source Code Pro": { capsize: "sourceCodePro", css2: "Source+Code+Pro:wght@200..800", roles: ["mono"], tags: ["mono", "neutral", "readable"] },
63
+ "Fira Code": { capsize: "firaCode", css2: "Fira+Code:wght@300..700", roles: ["mono"], tags: ["mono", "technical", "ligatures"] },
64
+ "Spline Sans Mono": { capsize: "splineSansMono", css2: "Spline+Sans+Mono:wght@300..700", roles: ["mono"], tags: ["mono", "modern", "friendly"] },
65
+ };
66
+
67
+ export const families = Object.keys(catalog);
68
+
69
+ export function inCatalog(family) {
70
+ return Object.prototype.hasOwnProperty.call(catalog, family);
71
+ }
72
+
73
+ // Look up a family, throwing a clear error if it isn't a catalog member (curator authoring
74
+ // guard — a typo'd family must fail loudly, not ship a broken bundle).
75
+ export function get(family) {
76
+ const e = catalog[family];
77
+ if (!e) throw new Error(`"${family}" is not in the Font Lab catalog (no verified parity bundle)`);
78
+ return e;
79
+ }
package/codegen.mjs ADDED
@@ -0,0 +1,399 @@
1
+ // Font Lab codegen (M2 + M3) — turn .font-lab/selection.json into real, reversible
2
+ // next/font + Tailwind edits, with the analyzer (M3) choosing the branch so codegen never
3
+ // guesses. Strategy per SHIP-SPEC.md:
4
+ // • ts-morph for the AST-sensitive bits: merge the next/font import, rewrite the <html>
5
+ // (or <body>) className, and either replace or adopt the consts we're swapping;
6
+ // • fenced markers for the append-only regions (the generated font consts in layout.tsx
7
+ // and the @theme block in globals.css) — trivially find/replace/remove, so re-apply is
8
+ // byte-idempotent and undo is exact;
9
+ // • backup-first undo that needs nothing of the user (no clean tree, no git).
10
+ //
11
+ // Two wiring shapes, both now handled (the analyzer says which applies per role):
12
+ // • ROLE-VAR — a font const lives on a role variable (`--font-sans`) or the role is empty.
13
+ // We replace/create a Font-Lab const on that role var (the M2 path; jack's missing mono).
14
+ // • ADOPT — a font const lives on the project's own variable (`--font-bricolage`) wired to
15
+ // a role through `@theme inline`. We rewrite that const's family IN PLACE, keeping its
16
+ // variable, name, and className token — a minimal diff that leaves the project's own
17
+ // wiring intact (SHIP-SPEC "adopt an existing variable rather than introduce a competitor").
18
+ //
19
+ // Scope gate: App Router + Tailwind v4 + CSS-variable wiring. The analyzer enforces it.
20
+
21
+ import { Project, Node, SyntaxKind, QuoteKind } from "ts-morph";
22
+ import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync } from "node:fs";
23
+ import { createHash } from "node:crypto";
24
+ import { execFileSync } from "node:child_process";
25
+ import path from "node:path";
26
+ import { analyzeProject } from "./analyzer.mjs";
27
+
28
+ const ROLE_VARS = { display: "--font-display", body: "--font-sans", mono: "--font-mono" };
29
+ const ROLE_VAR_SET = new Set(Object.values(ROLE_VARS));
30
+ const cap = (s) => s[0].toUpperCase() + s.slice(1);
31
+ const constName = (role) => "fontLab" + cap(role);
32
+ const importName = (family) => family.replace(/[^A-Za-z0-9]+/g, "_");
33
+ const sha = (buf) => createHash("sha256").update(buf).digest("hex");
34
+
35
+ function resolveTargets(projectDir) {
36
+ const appDir = ["app", "src/app"]
37
+ .map((d) => path.join(projectDir, d))
38
+ .find((d) => existsSync(path.join(d, "layout.tsx")));
39
+ if (!appDir) throw new Error("could not find app/layout.tsx (App Router only for now)");
40
+ const layout = path.join(appDir, "layout.tsx");
41
+ const css = ["globals.css", "global.css"].map((f) => path.join(appDir, f)).find(existsSync);
42
+ if (!css) throw new Error("could not find app/globals.css");
43
+ return { layout, css };
44
+ }
45
+
46
+ // ---- layout.tsx: AST bits (import + className + replace/adopt consts) -------
47
+
48
+ function getStringProp(obj, name) {
49
+ const p = obj.getProperty(name);
50
+ if (!p || !Node.isPropertyAssignment(p)) return null;
51
+ const init = p.getInitializer();
52
+ if (!init) return null;
53
+ if (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init)) return init.getLiteralValue();
54
+ return init.getText().replace(/^["'`]|["'`]$/g, "");
55
+ }
56
+
57
+ // Find the element (preferring the analyzer's choice) that wears the font variables.
58
+ function findClassTarget(sf, preferred) {
59
+ const els = [
60
+ ...sf.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
61
+ ...sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
62
+ ];
63
+ const byTag = (tag) => els.find((e) => e.getTagNameNode().getText() === tag);
64
+ return (preferred && byTag(preferred)) || byTag("html") || byTag("body") || null;
65
+ }
66
+
67
+ function classNameTokens(attr) {
68
+ const dynamic = [];
69
+ const statics = [];
70
+ const init = attr?.getInitializer();
71
+ if (!init) return { dynamic, statics };
72
+ if (Node.isJsxExpression(init)) {
73
+ const expr = init.getExpression();
74
+ if (!expr) return { dynamic, statics };
75
+ if (Node.isTemplateExpression(expr)) {
76
+ statics.push(...expr.getHead().getLiteralText().split(/\s+/).filter(Boolean));
77
+ for (const span of expr.getTemplateSpans()) {
78
+ dynamic.push(span.getExpression().getText());
79
+ statics.push(...span.getLiteral().getLiteralText().split(/\s+/).filter(Boolean));
80
+ }
81
+ } else if (Node.isNoSubstitutionTemplateLiteral(expr) || Node.isStringLiteral(expr)) {
82
+ statics.push(...expr.getLiteralValue().split(/\s+/).filter(Boolean));
83
+ } else {
84
+ dynamic.push(expr.getText());
85
+ }
86
+ } else if (Node.isStringLiteral(init)) {
87
+ statics.push(...init.getLiteralValue().split(/\s+/).filter(Boolean));
88
+ }
89
+ return { dynamic, statics };
90
+ }
91
+
92
+ function buildClassNameInit(dynamic, statics) {
93
+ if (dynamic.length === 0) return `"${statics.join(" ")}"`;
94
+ const dyn = dynamic.map((d) => "${" + d + "}").join(" ");
95
+ const stat = statics.length ? " " + statics.join(" ") : "";
96
+ return "{`" + dyn + stat + "`}";
97
+ }
98
+
99
+ function editLayoutAst(sf, roles, classTarget) {
100
+ // 1) ensure the next/font/google import carries every new family.
101
+ let imp = sf.getImportDeclaration((d) => d.getModuleSpecifierValue() === "next/font/google");
102
+ if (!imp) imp = sf.addImportDeclaration({ moduleSpecifier: "next/font/google", namedImports: [] });
103
+ const named = new Set(imp.getNamedImports().map((n) => n.getName()));
104
+ for (const r of roles)
105
+ if (!named.has(r.importName)) {
106
+ imp.addNamedImport(r.importName);
107
+ named.add(r.importName);
108
+ }
109
+
110
+ const replaced = [];
111
+ const oldImports = new Set();
112
+
113
+ // 2a) ADOPT — rewrite the existing const's family in place, keep its variable/name.
114
+ for (const r of roles.filter((r) => r.mode === "adopt")) {
115
+ const vd = sf.getVariableDeclaration(r.constName);
116
+ const init = vd?.getInitializer();
117
+ if (!init || !Node.isCallExpression(init)) continue;
118
+ const callee = init.getExpression();
119
+ const old = callee.getText();
120
+ if (old !== r.importName) {
121
+ replaced.push({ variable: r.adoptVar, font: old });
122
+ oldImports.add(old);
123
+ callee.replaceWithText(r.importName);
124
+ }
125
+ }
126
+
127
+ // 2b) ROLE-VAR — remove existing non-Font-Lab consts sitting on a role var (the ones we
128
+ // replace). Font Lab's own consts live in a fenced block managed as text — never here.
129
+ const removeNames = [];
130
+ for (const vd of sf.getVariableDeclarations()) {
131
+ if (vd.getName().startsWith("fontLab")) continue;
132
+ const init = vd.getInitializer();
133
+ if (!init || !Node.isCallExpression(init)) continue;
134
+ const obj = init.getArguments()[0];
135
+ const varVal = obj && Node.isObjectLiteralExpression(obj) ? getStringProp(obj, "variable") : null;
136
+ if (varVal && ROLE_VAR_SET.has(varVal)) {
137
+ removeNames.push(vd.getName());
138
+ replaced.push({ variable: varVal, font: init.getExpression().getText() });
139
+ oldImports.add(init.getExpression().getText());
140
+ vd.getVariableStatementOrThrow().remove();
141
+ }
142
+ }
143
+
144
+ // 3) className: drop the removed role-var consts' tokens, add a token for every role-var
145
+ // role. Adopted roles already carry their token and we leave it untouched.
146
+ const el = findClassTarget(sf, classTarget);
147
+ if (!el) throw new Error(`no <${classTarget || "html"}> element in layout.tsx`);
148
+ const attr = el.getAttribute("className");
149
+ const { dynamic, statics } = classNameTokens(attr);
150
+ const kept = dynamic.filter((d) => !removeNames.some((n) => d === n || d.startsWith(n + ".")));
151
+ for (const r of roles.filter((r) => r.mode === "rolevar")) {
152
+ const token = `${r.constName}.variable`;
153
+ if (!kept.includes(token)) kept.push(token);
154
+ }
155
+ const initText = buildClassNameInit(kept, statics);
156
+ if (attr) attr.setInitializer(initText);
157
+ else el.addAttribute({ name: "className", initializer: initText });
158
+
159
+ // 4) drop now-unused imports for replaced/adopted fonts — never one a role still needs.
160
+ const roleImports = new Set(roles.map((r) => r.importName));
161
+ for (const callee of oldImports) {
162
+ if (roleImports.has(callee)) continue;
163
+ const stillUsed = sf
164
+ .getDescendantsOfKind(SyntaxKind.Identifier)
165
+ .some((id) => id.getText() === callee && !Node.isImportSpecifier(id.getParent()));
166
+ if (!stillUsed) imp.getNamedImports().find((n) => n.getName() === callee)?.remove();
167
+ }
168
+
169
+ return { replaced };
170
+ }
171
+
172
+ // ---- layout.tsx: fenced const block (text, idempotent) ---------------------
173
+ // Only role-var roles need a generated const; adopted roles are rewritten above.
174
+
175
+ function setFencedConsts(text, roles) {
176
+ const rv = roles.filter((r) => r.mode === "rolevar");
177
+ const strip = (t) => t.replace(/\n*\/\/ font-lab:start[\s\S]*?\/\/ font-lab:end\n*/g, "\n\n");
178
+ if (!rv.length) return strip(text);
179
+ const lines = [
180
+ "// font-lab:start",
181
+ "// generated — re-run `font-lab apply` to update, `font-lab undo` to revert",
182
+ ...rv.map(
183
+ (r) => `const ${r.constName} = ${r.importName}({ subsets: ["latin"], display: "swap", variable: "${r.varName}" });`,
184
+ ),
185
+ "// font-lab:end",
186
+ ];
187
+ const block = lines.join("\n");
188
+ text = strip(text);
189
+ // Match import statements with or without a trailing semicolon (Prettier `semi: false`
190
+ // projects — e.g. jack-mcgovern.com — write `import x from 'y'` with no `;`).
191
+ const importRe = /^import\s[^\n]*$/gm;
192
+ let last = 0;
193
+ let m;
194
+ while ((m = importRe.exec(text))) last = m.index + m[0].length;
195
+ const before = text.slice(0, last).replace(/\s*$/, "");
196
+ const after = text.slice(last).replace(/^\s*/, "");
197
+ return `${before}\n\n${block}\n\n${after}`;
198
+ }
199
+
200
+ function verifyLayout(sf, roles, classTarget) {
201
+ for (const r of roles) {
202
+ const vd = sf.getVariableDeclaration(r.constName);
203
+ if (!vd) throw new Error(`verify: missing const ${r.constName}`);
204
+ if (r.mode === "adopt") {
205
+ const callee = vd.getInitializer()?.getExpression?.().getText();
206
+ if (callee !== r.importName) throw new Error(`verify: ${r.constName} not rewritten to ${r.importName}`);
207
+ }
208
+ }
209
+ const { dynamic } = classNameTokens(findClassTarget(sf, classTarget)?.getAttribute("className"));
210
+ for (const r of roles) {
211
+ if (!dynamic.includes(`${r.constName}.variable`)) throw new Error(`verify: <${classTarget}> missing ${r.constName}.variable`);
212
+ }
213
+ }
214
+
215
+ // ---- globals.css (fenced markers) ------------------------------------------
216
+ // Only role-var roles need a @theme mapping; adopted roles reuse the project's existing one.
217
+
218
+ function editCss(cssPath, roles) {
219
+ const rv = roles.filter((r) => r.mode === "rolevar");
220
+ let css = readFileSync(cssPath, "utf8");
221
+ const re = /\/\* font-lab:start \*\/[\s\S]*?\/\* font-lab:end \*\//;
222
+ if (!rv.length) {
223
+ if (re.test(css)) writeFileSync(cssPath, css.replace(/\n*\/\* font-lab:start \*\/[\s\S]*?\/\* font-lab:end \*\/\n*/, "\n"));
224
+ return;
225
+ }
226
+ const block = `/* font-lab:start */
227
+ @theme inline {
228
+ ${rv.map((r) => ` ${r.varName}: var(${r.varName});`).join("\n")}
229
+ }
230
+ /* font-lab:end */`;
231
+ if (re.test(css)) css = css.replace(re, block);
232
+ else if (/@import\s+["']tailwindcss["'];/.test(css)) css = css.replace(/(@import\s+["']tailwindcss["'];\n?)/, `$1\n${block}\n`);
233
+ else css = `${block}\n${css}`;
234
+ writeFileSync(cssPath, css);
235
+ }
236
+
237
+ // ---- backups / apply / undo ------------------------------------------------
238
+
239
+ function backup(projectDir, files) {
240
+ const flDir = path.join(projectDir, ".font-lab");
241
+ const runId = new Date().toISOString().replace(/[:.]/g, "-");
242
+ const dir = path.join(flDir, "backups", runId);
243
+ const manifest = { runId, git: null, files: [] };
244
+ for (const f of files) {
245
+ const rel = path.relative(projectDir, f);
246
+ const dest = path.join(dir, rel);
247
+ mkdirSync(path.dirname(dest), { recursive: true });
248
+ copyFileSync(f, dest);
249
+ manifest.files.push({ path: rel, sha256: sha(readFileSync(f)) });
250
+ }
251
+ try {
252
+ manifest.git = execFileSync("git", ["-C", projectDir, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
253
+ } catch {}
254
+ mkdirSync(dir, { recursive: true });
255
+ writeFileSync(path.join(dir, "manifest.json"), JSON.stringify(manifest, null, 2));
256
+ writeFileSync(path.join(flDir, "backups", "latest.txt"), runId);
257
+ return { dir, runId };
258
+ }
259
+
260
+ function restore(projectDir, backupDir) {
261
+ const manifest = JSON.parse(readFileSync(path.join(backupDir, "manifest.json"), "utf8"));
262
+ for (const f of manifest.files) copyFileSync(path.join(backupDir, f.path), path.join(projectDir, f.path));
263
+ }
264
+
265
+ // Decide per-role whether to adopt the project's existing wiring or write a role-var const.
266
+ function planRoles(selection, analysis) {
267
+ return ["display", "body", "mono"].map((role) => {
268
+ const family = selection.roles[role].family;
269
+ const existing = analysis.roles[role];
270
+ const adopt = existing && existing.nextFontVar && !ROLE_VAR_SET.has(existing.nextFontVar);
271
+ return adopt
272
+ ? {
273
+ role,
274
+ family,
275
+ importName: importName(family),
276
+ mode: "adopt",
277
+ constName: existing.constName,
278
+ adoptVar: existing.nextFontVar,
279
+ }
280
+ : {
281
+ role,
282
+ family,
283
+ importName: importName(family),
284
+ mode: "rolevar",
285
+ constName: constName(role),
286
+ varName: ROLE_VARS[role],
287
+ };
288
+ });
289
+ }
290
+
291
+ export function applySelection(projectDir) {
292
+ const selPath = path.join(projectDir, ".font-lab", "selection.json");
293
+ if (!existsSync(selPath)) throw new Error(`no selection at ${selPath} — pick one first`);
294
+ const selection = JSON.parse(readFileSync(selPath, "utf8"));
295
+
296
+ // M3: the analyzer picks the branch; codegen refuses anything it can't ship cleanly.
297
+ const analysis = analyzeProject(projectDir);
298
+ if (!analysis.supported)
299
+ throw new Error(`project not supported by codegen yet: ${analysis.reasons.join("; ")}`);
300
+
301
+ const { layout, css } = resolveTargets(projectDir);
302
+ const classTarget = analysis.classNameTarget || "html";
303
+ const roles = planRoles(selection, analysis);
304
+
305
+ const { dir: backupDir, runId } = backup(projectDir, [layout, css]);
306
+
307
+ // 1) AST edits (import + className + replace/adopt consts), then save.
308
+ const project = new Project({ manipulationSettings: { quoteKind: QuoteKind.Double, useTrailingCommas: false } });
309
+ const sf = project.addSourceFileAtPath(layout);
310
+ const { replaced } = editLayoutAst(sf, roles, classTarget);
311
+ sf.saveSync();
312
+
313
+ // 2) Fenced const block (text) + the CSS @theme block — role-var roles only.
314
+ writeFileSync(layout, setFencedConsts(readFileSync(layout, "utf8"), roles));
315
+ editCss(css, roles);
316
+
317
+ // 3) Verify; on failure restore the backup so the tree is never left half-edited.
318
+ try {
319
+ const vsf = new Project().addSourceFileAtPath(layout);
320
+ verifyLayout(vsf, roles, classTarget);
321
+ } catch (e) {
322
+ restore(projectDir, backupDir);
323
+ throw new Error(`apply aborted (${e.message}); restored from backup`);
324
+ }
325
+
326
+ // 4) Record post-apply hashes so undo can warn if the user edited since.
327
+ const manifestPath = path.join(backupDir, "manifest.json");
328
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
329
+ for (const f of manifest.files) f.appliedSha256 = sha(readFileSync(path.join(projectDir, f.path)));
330
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
331
+
332
+ return {
333
+ runId,
334
+ direction: selection.direction,
335
+ roles: roles.map((r) => ({ role: r.role, family: r.family, mode: r.mode })),
336
+ replaced,
337
+ classTarget,
338
+ edited: [path.relative(projectDir, layout), path.relative(projectDir, css)],
339
+ backupDir: path.relative(projectDir, backupDir),
340
+ };
341
+ }
342
+
343
+ // Rewire dead roles — fix a role the analyzer flags as declared-but-not-rendered. Under
344
+ // Tailwind v4 `@theme inline`, a hand-written `font-family: var(--font-display)` resolves to
345
+ // nothing (the theme var isn't published to :root). The fix: point those raw usages at the
346
+ // PUBLISHED leaf var the next/font const actually sets (e.g. var(--font-bricolage)), which is
347
+ // inherited wherever the font is used. Minimal, backup-first, reversible — and it makes both
348
+ // the live preview and the shipped swap visible on that role. Opt-in (we never auto-edit a
349
+ // user's base styles during a normal apply).
350
+ export function rewireCoverage(projectDir) {
351
+ const analysis = analyzeProject(projectDir);
352
+ const dead = analysis.coverage?.deadRoles || [];
353
+ const { css } = resolveTargets(projectDir);
354
+ if (!dead.length) return { rewired: [], dead, note: "no dead roles to rewire" };
355
+
356
+ // protect @theme blocks (their `--font-display: var(--font-x)` definitions stay as-is)
357
+ let text = readFileSync(css, "utf8");
358
+ const blocks = [];
359
+ let work = text.replace(/@theme(\s+inline)?\s*\{[^}]*\}/g, (m) => `__FLTHEME${blocks.push(m) - 1}__`);
360
+
361
+ const rewired = [];
362
+ for (const role of dead) {
363
+ const roleVar = ROLE_VARS[role];
364
+ const leaf = analysis.roles[role]?.nextFontVar;
365
+ if (!leaf) continue;
366
+ let n = 0;
367
+ work = work.replace(new RegExp(`var\\(\\s*${roleVar}\\s*\\)`, "g"), () => (n++, `var(${leaf})`));
368
+ if (n) rewired.push({ role, from: roleVar, to: leaf, count: n });
369
+ }
370
+ work = work.replace(/__FLTHEME(\d+)__/g, (_, i) => blocks[Number(i)]);
371
+ if (!rewired.length) return { rewired: [], dead, note: "dead roles found, but no raw var() usages to rewire" };
372
+
373
+ const { dir: backupDir, runId } = backup(projectDir, [css]);
374
+ writeFileSync(css, work);
375
+ const manifestPath = path.join(backupDir, "manifest.json");
376
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
377
+ for (const f of manifest.files) f.appliedSha256 = sha(readFileSync(path.join(projectDir, f.path)));
378
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
379
+
380
+ return { runId, rewired, edited: [path.relative(projectDir, css)], backupDir: path.relative(projectDir, backupDir) };
381
+ }
382
+
383
+ export function undo(projectDir) {
384
+ const flDir = path.join(projectDir, ".font-lab");
385
+ const latest = path.join(flDir, "backups", "latest.txt");
386
+ if (!existsSync(latest)) throw new Error("nothing to undo (no backups)");
387
+ const runId = readFileSync(latest, "utf8").trim();
388
+ const dir = path.join(flDir, "backups", runId);
389
+ const manifest = JSON.parse(readFileSync(path.join(dir, "manifest.json"), "utf8"));
390
+ const warnings = [];
391
+ for (const f of manifest.files) {
392
+ const target = path.join(projectDir, f.path);
393
+ if (f.appliedSha256 && existsSync(target) && sha(readFileSync(target)) !== f.appliedSha256) {
394
+ warnings.push(`${f.path} was modified since apply — restoring anyway`);
395
+ }
396
+ }
397
+ restore(projectDir, dir);
398
+ return { runId, restored: manifest.files.map((f) => f.path), warnings };
399
+ }
package/curate.mjs ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ // `font-lab curate` — preview the directions Font Lab would offer a project (M4). Read-only.
3
+ // node cli/curate.mjs [--project <dir>] [--vibe <vibe>] [--count <n>] [--json]
4
+ import path from "node:path";
5
+ import { analyzeProject } from "./analyzer.mjs";
6
+ import { curate } from "./curator.mjs";
7
+
8
+ const arg = (f, d) => {
9
+ const i = process.argv.indexOf(f);
10
+ return i !== -1 && process.argv[i + 1] ? process.argv[i + 1] : d;
11
+ };
12
+ const project = path.resolve(arg("--project", process.cwd()));
13
+ const analysis = analyzeProject(project);
14
+ const dirs = curate(analysis, { vibe: arg("--vibe", undefined), count: Number(arg("--count", "5")) });
15
+
16
+ if (process.argv.includes("--json")) {
17
+ console.log(JSON.stringify(dirs, null, 2));
18
+ } else {
19
+ const cur = analysis.replaces;
20
+ console.log(`Font Lab — ${dirs.length} directions for ${path.relative(process.cwd(), project) || "."}`);
21
+ console.log(` current: ${cur.display ?? "—"} / ${cur.body ?? "—"} / ${cur.mono ?? "—"}\n`);
22
+ for (const d of dirs) {
23
+ console.log(` ${d.name} · ${d.vibe}`);
24
+ console.log(` ${d.roles.display.family} / ${d.roles.body.family} / ${d.roles.mono.family}`);
25
+ console.log(` ${d.rationale}\n`);
26
+ }
27
+ }