@homepages/template-kit 0.2.0 → 0.3.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +151 -0
  2. package/README.md +61 -17
  3. package/dist/base.css +9 -0
  4. package/dist/cli/check/config.js +37 -0
  5. package/dist/cli/check/css.js +117 -0
  6. package/dist/cli/check/diagnostics.js +32 -0
  7. package/dist/cli/check/index.js +87 -0
  8. package/dist/cli/check/loader.js +142 -0
  9. package/dist/cli/check/paths.js +15 -0
  10. package/dist/cli/check/relativize.js +18 -0
  11. package/dist/cli/check/render-invariants.js +24 -0
  12. package/dist/cli/check/resolve-tool.js +38 -0
  13. package/dist/cli/check/stages/deps.js +337 -0
  14. package/dist/cli/check/stages/lint.js +46 -0
  15. package/dist/cli/check/stages/render.js +101 -0
  16. package/dist/cli/check/stages/size.js +158 -0
  17. package/dist/cli/check/stages/tree.js +207 -0
  18. package/dist/cli/check/stages/typecheck.js +46 -0
  19. package/dist/cli/check/stages/validate/catalog-exhaustiveness.js +18 -0
  20. package/dist/cli/check/stages/validate/facts.js +18 -0
  21. package/dist/cli/check/stages/validate/formatter-contract.js +14 -0
  22. package/dist/cli/check/stages/validate/group-contract.js +21 -0
  23. package/dist/cli/check/stages/validate/index.js +66 -0
  24. package/dist/cli/check/stages/validate/list-scalar-contract.js +22 -0
  25. package/dist/cli/check/stages/validate/nav-contract.js +93 -0
  26. package/dist/cli/check/stages/validate/nested-slot-contract.js +12 -0
  27. package/dist/cli/check/stages/validate/object-list-contract.js +31 -0
  28. package/dist/cli/check/stages/validate/orchestrator.js +241 -0
  29. package/dist/cli/check/stages/validate/produced-by-contract.js +18 -0
  30. package/dist/cli/check/stages/validate/row-contract.js +31 -0
  31. package/dist/cli/check/stages/validate/select-contract.js +13 -0
  32. package/dist/cli/check/stages/validate/source-contract.js +21 -0
  33. package/dist/cli/check/stages/validate/variant-contract.js +15 -0
  34. package/dist/cli/check/workspace.js +86 -0
  35. package/dist/cli/theme/generate.js +43 -0
  36. package/dist/cli/theme/index.js +33 -0
  37. package/dist/cli/theme/load-theme.js +69 -0
  38. package/dist/cli.js +22 -4
  39. package/dist/contracts/fill-treatments.js +47 -0
  40. package/dist/design-system/theme.d.ts +30 -300
  41. package/dist/design-system/theme.js +112 -90
  42. package/dist/eslint/rules/no-hex.js +78 -6
  43. package/dist/eslint.js +1 -1
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.js +2 -2
  46. package/dist/package.js +1 -1
  47. package/dist/primitives/Image.js +1 -1
  48. package/dist/schema/fill-spec.d.ts +80 -590
  49. package/dist/schema/fixture-schema.d.ts +5 -81
  50. package/dist/schema/manifest.d.ts +39 -475
  51. package/dist/schema/resolve-section-ref.js +10 -0
  52. package/dist/schema/rows.js +10 -0
  53. package/dist/schema/section-nav.js +9 -0
  54. package/dist/schema/section-schema.d.ts +51 -437
  55. package/dist/schema/slot-types.d.ts +12 -16
  56. package/dist/styles.css +31 -88
  57. package/docs/INDEX.md +4 -3
  58. package/docs/check.md +121 -0
  59. package/docs/eslint.md +18 -8
  60. package/docs/llms.txt +21 -9
  61. package/docs/primitives.md +5 -0
  62. package/docs/rules/INDEX.md +11 -4
  63. package/docs/rules/bundle-binary-asset.md +102 -0
  64. package/docs/rules/fixtures-invalid.md +96 -0
  65. package/docs/rules/license-denied.md +11 -3
  66. package/docs/rules/manifest-invalid.md +99 -0
  67. package/docs/rules/no-hex.md +76 -10
  68. package/docs/rules/no-templates.md +87 -0
  69. package/docs/rules/parse-error.md +76 -0
  70. package/docs/rules/schema-invalid.md +96 -0
  71. package/docs/rules/size-section-css.md +2 -2
  72. package/docs/theme-and-css.md +155 -90
  73. package/package.json +4 -9
  74. package/tsconfig.json +1 -1
@@ -0,0 +1,101 @@
1
+ import { FixtureModuleShape, buildInvariantFixtures } from "../../../schema/fixture-schema.js";
2
+ import { loadSection } from "../loader.js";
3
+ import { flattenLayout } from "../../../schema/rows.js";
4
+ import { checkRenderInvariants } from "../render-invariants.js";
5
+ import { createElement } from "react";
6
+ import { renderToStaticMarkup } from "react-dom/server";
7
+
8
+ //#region src/cli/check/stages/render.ts
9
+ function renderSectionHtml(renderer, props) {
10
+ return renderToStaticMarkup(createElement(renderer, props));
11
+ }
12
+ const SLOT_ID_RE = /data-slot-id="([^"]*)"/g;
13
+ function renderSlotOrder(html) {
14
+ const seen = /* @__PURE__ */ new Set();
15
+ const order = [];
16
+ for (const match of html.matchAll(SLOT_ID_RE)) {
17
+ const id = match[1];
18
+ if (seen.has(id)) continue;
19
+ seen.add(id);
20
+ order.push(id);
21
+ }
22
+ return order;
23
+ }
24
+ function checkSidebarOrder(sectionKey, schema, html) {
25
+ const out = [];
26
+ const groupOf = /* @__PURE__ */ new Map();
27
+ for (const group of schema.meta?.groups ?? []) for (const member of flattenLayout(group.members)) groupOf.set(member, group.id);
28
+ const cardOf = (slotId) => groupOf.get(slotId) ?? slotId;
29
+ const slotOrder = renderSlotOrder(html);
30
+ const renderCardOrder = [];
31
+ for (const slotId of slotOrder) {
32
+ const card = cardOf(slotId);
33
+ if (!renderCardOrder.includes(card)) renderCardOrder.push(card);
34
+ }
35
+ const presentCards = new Set(renderCardOrder);
36
+ const schemaCardOrder = [];
37
+ for (const slotId of Object.keys(schema.slots)) {
38
+ const card = cardOf(slotId);
39
+ if (!presentCards.has(card)) continue;
40
+ if (!schemaCardOrder.includes(card)) schemaCardOrder.push(card);
41
+ }
42
+ if (JSON.stringify(schemaCardOrder) !== JSON.stringify(renderCardOrder)) out.push({
43
+ section: sectionKey,
44
+ rule: "sidebar-order",
45
+ message: `Sidebar card order (schema slot declaration order) does not match page render order.\n schema: [${schemaCardOrder.join(", ")}]\n render: [${renderCardOrder.join(", ")}]`
46
+ });
47
+ return out;
48
+ }
49
+ async function renderStage(workspaceRoot, template) {
50
+ const diagnostics = [];
51
+ for (const section of template.sections) {
52
+ const file = `templates/${template.key}/sections/${section.name}/Renderer.tsx`;
53
+ let loaded;
54
+ let fixtures;
55
+ try {
56
+ loaded = await loadSection(workspaceRoot, section);
57
+ fixtures = buildInvariantFixtures({
58
+ schema: loaded.schema,
59
+ module: FixtureModuleShape.parse(loaded.fixtures),
60
+ selfAnchor: section.name
61
+ });
62
+ } catch {
63
+ continue;
64
+ }
65
+ for (const fixture of fixtures) {
66
+ const props = fixture.props;
67
+ const first = renderSectionHtml(loaded.Renderer, props);
68
+ if (first !== renderSectionHtml(loaded.Renderer, props)) {
69
+ diagnostics.push({
70
+ ruleId: "template-kit/determinism-drift",
71
+ template: template.key,
72
+ section: section.name,
73
+ file,
74
+ message: `fixture "${fixture.id}" rendered differently twice from the same props`,
75
+ fix: "Something in the render path is nondeterministic — a clock, a random, or an unstable iteration order. Server-rendered code must be a pure function of its props."
76
+ });
77
+ continue;
78
+ }
79
+ for (const violation of checkRenderInvariants(first)) diagnostics.push({
80
+ ruleId: "template-kit/render-invariant",
81
+ template: template.key,
82
+ section: section.name,
83
+ file,
84
+ message: `fixture "${fixture.id}": ${violation.detail}`,
85
+ fix: violation.rule === "broken-image" ? "Render a placeholder, or nothing, when an image slot is empty — never an <img> with no src." : "A value reached the page without a formatter. Format it, or render nothing when it is absent."
86
+ });
87
+ if (fixture.id === "typical") for (const violation of checkSidebarOrder(section.name, loaded.schema, first)) diagnostics.push({
88
+ ruleId: "template-kit/sidebar-order",
89
+ template: template.key,
90
+ section: section.name,
91
+ file: `templates/${template.key}/sections/${section.name}/schema.ts`,
92
+ message: violation.message,
93
+ fix: "The editor lists slots in schema declaration order. Reorder the schema, or the markup, so the two agree."
94
+ });
95
+ }
96
+ }
97
+ return diagnostics;
98
+ }
99
+
100
+ //#endregion
101
+ export { checkSidebarOrder, renderSectionHtml, renderStage };
@@ -0,0 +1,158 @@
1
+ import { loadEsbuild } from "../resolve-tool.js";
2
+ import { CHECK_CONFIG } from "../config.js";
3
+ import { compileSectionPackageCss, sectionCssFiles } from "../css.js";
4
+ import { existsSync } from "node:fs";
5
+ import { join, relative } from "node:path";
6
+ import { readFile } from "node:fs/promises";
7
+ import { gzipSync } from "node:zlib";
8
+
9
+ //#region src/cli/check/stages/size.ts
10
+ const RESOLVING = Symbol("size-stub:resolving");
11
+ /**
12
+ * A CSS-only stub, local to this file and NOT `loader.ts`'s `cssStub`: that
13
+ * plugin marks *every* bare specifier external (right for evaluating a
14
+ * section — it must resolve the workspace's real React and real kit
15
+ * primitives, not a second copy), which is exactly wrong for measurement,
16
+ * where an npm package's bundled weight is the entire point of the gate.
17
+ * Only CSS is stubbed here; everything else is left to the `build()` call's
18
+ * own `external` option (see `measureRendererBundle`) plus esbuild's default
19
+ * bundling.
20
+ *
21
+ * Detects a CSS import by resolving the specifier and inspecting the
22
+ * *resolved path's* extension — not by pattern-matching the specifier text.
23
+ * A CSS-only package need not say so in its specifier (`@fontsource/inter`
24
+ * has no `.css` suffix and no `css` path segment; `swiper/css` has no `.css`
25
+ * suffix either), so only real resolution tells the two apart. `pluginData`
26
+ * is the standard recursion guard for `build.resolve()` re-entering this same
27
+ * hook (esbuild docs' own example for this pattern) — mirrors `loader.ts`'s
28
+ * `cssStub`, which learned this the hard way.
29
+ */
30
+ const sizeStub = {
31
+ name: "size-stub",
32
+ setup(b) {
33
+ b.onResolve({ filter: /.*/ }, async (args) => {
34
+ if (args.pluginData === RESOLVING) return null;
35
+ const resolved = await b.resolve(args.path, {
36
+ importer: args.importer,
37
+ namespace: args.namespace,
38
+ resolveDir: args.resolveDir,
39
+ kind: args.kind,
40
+ pluginData: RESOLVING
41
+ });
42
+ if (resolved.errors.length === 0 && resolved.path !== "" && resolved.path.endsWith(".css")) return {
43
+ path: resolved.path,
44
+ namespace: "size-stub"
45
+ };
46
+ return null;
47
+ });
48
+ b.onLoad({
49
+ filter: /.*/,
50
+ namespace: "size-stub"
51
+ }, () => ({
52
+ contents: "",
53
+ loader: "js"
54
+ }));
55
+ }
56
+ };
57
+ /**
58
+ * Gzipped bytes of a section's renderer bundle, its own code *and every npm
59
+ * package it imports* — the published bundle's actual shape. React alone is
60
+ * excluded: it is a peer the platform supplies at render time, never bundled
61
+ * into a section. Everything else, including `@homepages/template-kit`
62
+ * itself (its primitives are ordinary React components that ship inside the
63
+ * section bundle, not a peer), is bundled and counted — that is the entire
64
+ * point of this budget. esbuilds `Renderer.tsx` alone (not the other three
65
+ * contract files — schema, fill-spec and fixtures never reach the browser).
66
+ *
67
+ * `external` is deliberately the five React entry points a section can
68
+ * actually reach, not `packages: "external"`: the latter would externalize
69
+ * every bare specifier — including the icon library or schema library this
70
+ * gate exists to catch — reproducing the exact bug this function fixes. Do
71
+ * not "simplify" this back to `packages: "external"`.
72
+ *
73
+ * `platform: "browser"`, not `"neutral"`: `neutral` defaults `mainFields` to
74
+ * `[]`, which cannot resolve a CommonJS-only package's `main` field — and
75
+ * real sections import such packages (an icon library, for instance). With
76
+ * `neutral` the measurement simply fails for those sections; `browser`'s
77
+ * default `mainFields` resolves them.
78
+ */
79
+ async function measureRendererBundle(workspaceRoot, section) {
80
+ const { build } = await loadEsbuild(workspaceRoot);
81
+ const js = (await build({
82
+ entryPoints: [join(section.dir, "Renderer.tsx")],
83
+ bundle: true,
84
+ minify: true,
85
+ format: "esm",
86
+ platform: "browser",
87
+ jsx: "automatic",
88
+ write: false,
89
+ outfile: "renderer.js",
90
+ external: [
91
+ "react",
92
+ "react-dom",
93
+ "react-dom/server",
94
+ "react/jsx-runtime",
95
+ "react/jsx-dev-runtime"
96
+ ],
97
+ plugins: [sizeStub],
98
+ absWorkingDir: workspaceRoot,
99
+ logLevel: "silent"
100
+ })).outputFiles.find((f) => f.path.endsWith(".js"));
101
+ if (!js) throw new Error(`esbuild produced no JS output for ${section.dir}`);
102
+ return gzipSync(js.contents).byteLength;
103
+ }
104
+ /**
105
+ * Gzipped bytes of a section's stylesheet: its hand-written CSS
106
+ * (`sectionCssFiles`, published order) concatenated with the package CSS its
107
+ * code pulls in (`compileSectionPackageCss`). The package-CSS half is the
108
+ * point of this budget — a package's `url()`-referenced fonts and icons
109
+ * inline as `data:` URIs (see css.ts), so a package shipping a 300 KB font
110
+ * shows up here as CSS bytes, and this is the guard against exactly that.
111
+ */
112
+ async function measureSectionCss(workspaceRoot, section) {
113
+ const ownFiles = await sectionCssFiles(section.dir);
114
+ const own = (await Promise.all(ownFiles.map((f) => readFile(f, "utf8")))).join("\n");
115
+ const packageCss = await compileSectionPackageCss(workspaceRoot, section.dir);
116
+ return gzipSync(Buffer.from(`${own}\n${packageCss}`)).byteLength;
117
+ }
118
+ function kb(bytes) {
119
+ const value = bytes / 1024;
120
+ return `${Number.isInteger(value) ? value.toString() : value.toFixed(1)} KB`;
121
+ }
122
+ function cssDiagnosticFile(workspaceRoot, section) {
123
+ const stylesCss = join(section.dir, "styles.css");
124
+ return relative(workspaceRoot, existsSync(stylesCss) ? stylesCss : section.dir);
125
+ }
126
+ async function sizeStage(workspaceRoot, template) {
127
+ const diagnostics = [];
128
+ for (const section of template.sections) {
129
+ let rendererBytes;
130
+ let cssBytes;
131
+ try {
132
+ rendererBytes = await measureRendererBundle(workspaceRoot, section);
133
+ cssBytes = await measureSectionCss(workspaceRoot, section);
134
+ } catch {
135
+ continue;
136
+ }
137
+ if (rendererBytes > CHECK_CONFIG.size.rendererBundleGzip) diagnostics.push({
138
+ ruleId: "template-kit/size-renderer-bundle",
139
+ template: template.key,
140
+ section: section.name,
141
+ file: `templates/${template.key}/sections/${section.name}/Renderer.tsx`,
142
+ message: `renderer bundle is ${kb(rendererBytes)} gzip — over the ${kb(CHECK_CONFIG.size.rendererBundleGzip)} budget`,
143
+ fix: "Trim what the renderer pulls in. A whole icon library imported for two icons is the usual cause — import only the icons you use, or inline them as SVG."
144
+ });
145
+ if (cssBytes > CHECK_CONFIG.size.sectionCssGzip) diagnostics.push({
146
+ ruleId: "template-kit/size-section-css",
147
+ template: template.key,
148
+ section: section.name,
149
+ file: cssDiagnosticFile(workspaceRoot, section),
150
+ message: `section stylesheet is ${kb(cssBytes)} gzip — over the ${kb(CHECK_CONFIG.size.sectionCssGzip)} budget`,
151
+ fix: "Trim the stylesheet. A package that ships a large font or icon set inlines those bytes into your stylesheet — import only what you use."
152
+ });
153
+ }
154
+ return diagnostics;
155
+ }
156
+
157
+ //#endregion
158
+ export { measureRendererBundle, measureSectionCss, sizeStage };
@@ -0,0 +1,207 @@
1
+ import { CONTRACT_FILES, loadSection } from "../loader.js";
2
+ import { flattenLayout } from "../../../schema/rows.js";
3
+ import { findImportLine, sectionCssFiles } from "../css.js";
4
+ import { extname, join, relative } from "node:path";
5
+ import { readFile, readdir } from "node:fs/promises";
6
+
7
+ //#region src/cli/check/stages/tree.ts
8
+ const ASSET_EXTENSIONS = /* @__PURE__ */ new Set([
9
+ ".svg",
10
+ ".png",
11
+ ".jpg",
12
+ ".jpeg",
13
+ ".gif",
14
+ ".webp",
15
+ ".avif",
16
+ ".ico",
17
+ ".woff",
18
+ ".woff2",
19
+ ".ttf",
20
+ ".otf",
21
+ ".eot",
22
+ ".mp4",
23
+ ".webm",
24
+ ".mov",
25
+ ".mp3",
26
+ ".wav",
27
+ ".pdf",
28
+ ".zip"
29
+ ]);
30
+ async function isBinaryFile(file) {
31
+ return (await readFile(file)).subarray(0, 4096).includes(0);
32
+ }
33
+ /** Every authored file in the bundle, recursive. `snapshots/` and `node_modules/` are skipped — generated or foreign, never authored. */
34
+ async function bundleFiles(dir) {
35
+ const out = [];
36
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
37
+ const full = join(dir, entry.name);
38
+ if (entry.isDirectory()) {
39
+ if (entry.name === "snapshots" || entry.name === "node_modules") continue;
40
+ out.push(...await bundleFiles(full));
41
+ } else if (entry.isFile()) out.push(full);
42
+ }
43
+ return out.sort();
44
+ }
45
+ /**
46
+ * The bundle's authored JSX surface: every `.tsx` except tests. An author may
47
+ * put markup anywhere in the folder (components/, hooks/, …), so the marker
48
+ * rules scan all of these, not just Renderer.tsx. Tests are excluded
49
+ * deliberately — a test's expected-markup assertion must not satisfy a slot
50
+ * marker, and fixtures legitimately write raw markup.
51
+ */
52
+ async function bundleJsxFiles(dir) {
53
+ return (await bundleFiles(dir)).filter((f) => f.endsWith(".tsx") && !/\.test\.tsx$/.test(f));
54
+ }
55
+ function escapeRegExp(s) {
56
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
57
+ }
58
+ const CSS_REASON = /css-reason:/;
59
+ function opensWithCssReason(text) {
60
+ const lead = text.match(/^\s*\/\*([\s\S]*?)\*\//);
61
+ return lead != null && CSS_REASON.test(lead[1] ?? "");
62
+ }
63
+ async function checkBundleAnatomy(workspaceRoot, template, section) {
64
+ const out = [];
65
+ const entries = await readdir(section.dir, { withFileTypes: true });
66
+ for (const f of CONTRACT_FILES) {
67
+ if (entries.some((entry) => entry.isFile() && entry.name === f)) continue;
68
+ out.push({
69
+ ruleId: "template-kit/bundle-incomplete",
70
+ template: template.key,
71
+ section: section.name,
72
+ file: relative(workspaceRoot, join(section.dir, f)),
73
+ line: 1,
74
+ message: `Section "${section.name}" is missing the required contract file \`${f}\`.`,
75
+ fix: "Every section needs Renderer.tsx, schema.ts, fill-spec.ts and fixtures.ts. Add the missing file."
76
+ });
77
+ }
78
+ for (const abs of await bundleFiles(section.dir)) {
79
+ const rel = relative(workspaceRoot, abs);
80
+ if (!ASSET_EXTENSIONS.has(extname(abs).toLowerCase()) && !await isBinaryFile(abs)) continue;
81
+ out.push({
82
+ ruleId: "template-kit/bundle-binary-asset",
83
+ template: template.key,
84
+ section: section.name,
85
+ file: rel,
86
+ line: 1,
87
+ message: `Static asset \`${rel}\` has no build meaning in a section bundle yet.`,
88
+ fix: "Binary assets are not supported in a section folder yet. Inline the graphic as SVG markup."
89
+ });
90
+ }
91
+ return out;
92
+ }
93
+ function asSchema(loaded) {
94
+ return typeof loaded.schema === "object" && loaded.schema !== null ? loaded.schema : {};
95
+ }
96
+ function checkSlotMarkers(workspaceRoot, template, section, loaded, jsxFiles) {
97
+ const slots = asSchema(loaded).slots ?? {};
98
+ const out = [];
99
+ const rendererFile = relative(workspaceRoot, join(section.dir, "Renderer.tsx"));
100
+ for (const [slot, spec] of Object.entries(slots)) {
101
+ if (spec.editable_by_user === false) continue;
102
+ const re = new RegExp(`(<Slot\\s+(?:[^>]*\\s+)?id|data-slot-id|slotId)\\s*=\\s*["']${escapeRegExp(slot)}["']`);
103
+ if (jsxFiles.some((f) => re.test(f.text))) continue;
104
+ out.push({
105
+ ruleId: "template-kit/missing-slot-marker",
106
+ template: template.key,
107
+ section: section.name,
108
+ file: rendererFile,
109
+ message: `Slot "${slot}" (editable_by_user: true) declared in schema.ts has no matching \`data-slot-id="${slot}"\` or \`slotId="${slot}"\` marker anywhere in the bundle.`,
110
+ fix: `Mark the slot in Renderer.tsx: <Slot id="${slot}">. The id must be a string literal.`
111
+ });
112
+ }
113
+ return out;
114
+ }
115
+ function checkSlotGroupMarkers(workspaceRoot, template, section, loaded, jsxFiles) {
116
+ const groups = asSchema(loaded).meta?.groups ?? [];
117
+ const out = [];
118
+ const rendererFile = relative(workspaceRoot, join(section.dir, "Renderer.tsx"));
119
+ for (const group of groups) {
120
+ if (flattenLayout(group.members).length < 2) continue;
121
+ const re = new RegExp(`<SlotGroup\\s+(?:[^>]*\\s+)?id\\s*=\\s*["']${escapeRegExp(group.id)}["']`);
122
+ if (jsxFiles.some((f) => re.test(f.text))) continue;
123
+ out.push({
124
+ ruleId: "template-kit/slot-group-marker",
125
+ template: template.key,
126
+ section: section.name,
127
+ file: rendererFile,
128
+ message: `Group "${group.id}" in schema.ts has no matching <SlotGroup id="${group.id}"> anywhere in the bundle.`,
129
+ fix: `Wrap the group's slots in <SlotGroup id="${group.id}"> so the editor can present them together.`
130
+ });
131
+ }
132
+ return out;
133
+ }
134
+ async function checkSectionCss(workspaceRoot, template, section) {
135
+ const out = [];
136
+ for (const cssFile of await sectionCssFiles(section.dir)) {
137
+ const rel = relative(workspaceRoot, cssFile);
138
+ const text = await readFile(cssFile, "utf8");
139
+ if (!opensWithCssReason(text)) out.push({
140
+ ruleId: "template-kit/css-reason",
141
+ template: template.key,
142
+ section: section.name,
143
+ file: rel,
144
+ line: 1,
145
+ message: `${rel} must open with a \`css-reason:\` comment justifying the hand-CSS.`,
146
+ fix: "Open the file with a comment saying why it exists: /* css-reason: … */"
147
+ });
148
+ for (const m of text.matchAll(/\/\*\s*unlayered:\s*([^*]*)\*\//g)) {
149
+ if ((m[1] ?? "").trim() !== "") continue;
150
+ out.push({
151
+ ruleId: "template-kit/unlayered-fence",
152
+ template: template.key,
153
+ section: section.name,
154
+ file: rel,
155
+ line: text.slice(0, m.index).split("\n").length,
156
+ message: `${rel}: an \`/* unlayered: */\` fence must state why it escapes the cascade layers.`,
157
+ fix: "Give the fence a reason (/* unlayered: … */) and close it with /* endunlayered */."
158
+ });
159
+ }
160
+ const opens = (text.match(/\/\*\s*unlayered:\s*[^*]*\*\//g) ?? []).length;
161
+ const closes = (text.match(/\/\*\s*endunlayered\s*\*\//g) ?? []).length;
162
+ if (opens !== closes) out.push({
163
+ ruleId: "template-kit/unlayered-fence",
164
+ template: template.key,
165
+ section: section.name,
166
+ file: rel,
167
+ line: 1,
168
+ message: `${rel}: each \`/* unlayered: <reason> */\` needs a matching \`/* endunlayered */\` (found ${String(opens)} open, ${String(closes)} close).`,
169
+ fix: "Give the fence a reason (/* unlayered: … */) and close it with /* endunlayered */."
170
+ });
171
+ const importLine = findImportLine(text);
172
+ if (importLine !== void 0) out.push({
173
+ ruleId: "template-kit/no-bare-css-import",
174
+ template: template.key,
175
+ section: section.name,
176
+ file: rel,
177
+ line: importLine,
178
+ message: `${rel}:${String(importLine)} — @import is not allowed in hand-CSS; the browser drops it silently and the rules never reach the page.`,
179
+ fix: "Import package CSS from the code that needs it: import \"swiper/css\" in Renderer.tsx, and the build routes it into this layer for you."
180
+ });
181
+ }
182
+ return out;
183
+ }
184
+ /** The tree-level rules for one template: every static, cross-file section-folder invariant ESLint cannot express. */
185
+ async function treeStage(workspaceRoot, template) {
186
+ const diagnostics = [];
187
+ for (const section of template.sections) {
188
+ diagnostics.push(...await checkBundleAnatomy(workspaceRoot, template, section));
189
+ let loaded;
190
+ try {
191
+ loaded = await loadSection(workspaceRoot, section);
192
+ } catch {
193
+ loaded = void 0;
194
+ }
195
+ const jsxPaths = await bundleJsxFiles(section.dir);
196
+ if (loaded !== void 0 && jsxPaths.length > 0) {
197
+ const jsxFiles = await Promise.all(jsxPaths.map(async (abs) => ({ text: await readFile(abs, "utf8") })));
198
+ diagnostics.push(...checkSlotMarkers(workspaceRoot, template, section, loaded, jsxFiles));
199
+ diagnostics.push(...checkSlotGroupMarkers(workspaceRoot, template, section, loaded, jsxFiles));
200
+ }
201
+ diagnostics.push(...await checkSectionCss(workspaceRoot, template, section));
202
+ }
203
+ return diagnostics;
204
+ }
205
+
206
+ //#endregion
207
+ export { treeStage };
@@ -0,0 +1,46 @@
1
+ import { resolveFromWorkspace } from "../resolve-tool.js";
2
+ import { isUnder, sectionOf } from "../paths.js";
3
+ import { relativizePaths } from "../relativize.js";
4
+ import { join, relative } from "node:path";
5
+
6
+ //#region src/cli/check/stages/typecheck.ts
7
+ const CONFIG_FIX_HINT = "Fix tsconfig.json. It must extend the kit preset: \"extends\": \"@homepages/template-kit/tsconfig\".";
8
+ async function typecheckStage(workspaceRoot, template) {
9
+ const ts = await resolveFromWorkspace(workspaceRoot, "typescript");
10
+ const configPath = join(workspaceRoot, "tsconfig.json");
11
+ const configFile = ts.readConfigFile(configPath, (file) => ts.sys.readFile(file));
12
+ if (configFile.error) throw new Error(`could not read ${configPath}: ${ts.flattenDiagnosticMessageText(configFile.error.messageText, " ")}`);
13
+ const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, workspaceRoot);
14
+ const out = [];
15
+ for (const configError of parsed.errors) out.push({
16
+ ruleId: "template-kit/typecheck",
17
+ template: template.key,
18
+ file: relative(workspaceRoot, configPath),
19
+ message: relativizePaths(ts.flattenDiagnosticMessageText(configError.messageText, " "), workspaceRoot),
20
+ fix: CONFIG_FIX_HINT
21
+ });
22
+ const program = ts.createProgram({
23
+ rootNames: parsed.fileNames,
24
+ options: parsed.options,
25
+ configFileParsingDiagnostics: parsed.errors
26
+ });
27
+ const tsDiagnostics = [...program.getSyntacticDiagnostics(), ...program.getSemanticDiagnostics()];
28
+ for (const diagnostic of tsDiagnostics) {
29
+ if (!diagnostic.file) continue;
30
+ const fileName = diagnostic.file.fileName;
31
+ if (!isUnder(template.dir, fileName)) continue;
32
+ out.push({
33
+ ruleId: "template-kit/typecheck",
34
+ template: template.key,
35
+ section: sectionOf(template, fileName),
36
+ file: relative(workspaceRoot, fileName),
37
+ line: diagnostic.start === void 0 ? void 0 : ts.getLineAndCharacterOfPosition(diagnostic.file, diagnostic.start).line + 1,
38
+ message: relativizePaths(ts.flattenDiagnosticMessageText(diagnostic.messageText, " "), workspaceRoot),
39
+ fix: "Fix the type error. Section props are derived from schema.ts — never hand-written."
40
+ });
41
+ }
42
+ return out;
43
+ }
44
+
45
+ //#endregion
46
+ export { typecheckStage };
@@ -0,0 +1,18 @@
1
+ import { SLOT_TYPES } from "../../../../contracts/slot-catalog.js";
2
+ import { SlotDef } from "../../../../schema/slot-types.js";
3
+
4
+ //#region src/cli/check/stages/validate/catalog-exhaustiveness.ts
5
+ function assertCatalogExhaustiveness() {
6
+ const unionTypes = /* @__PURE__ */ new Set();
7
+ const union = SlotDef._def.getter?.() ?? SlotDef;
8
+ for (const option of union.options) {
9
+ const typeSchema = option.shape.type;
10
+ unionTypes.add(typeSchema.value);
11
+ }
12
+ const catalog = new Set(SLOT_TYPES);
13
+ for (const t of catalog) if (!unionTypes.has(t)) throw new Error(`slot-catalog SLOT_TYPES has "${t}" but section-schema SlotDef has no matching Zod variant.`);
14
+ for (const t of unionTypes) if (!catalog.has(t)) throw new Error(`section-schema SlotDef has a "${t}" variant not in slot-catalog SLOT_TYPES.`);
15
+ }
16
+
17
+ //#endregion
18
+ export { assertCatalogExhaustiveness };
@@ -0,0 +1,18 @@
1
+ //#region src/cli/check/stages/validate/facts.ts
2
+ const FACTS_ARRAY_FIELDS = /* @__PURE__ */ new Set([
3
+ "units",
4
+ "photos",
5
+ "floor_plans",
6
+ "pois",
7
+ "contacts"
8
+ ]);
9
+ const FACTS_READABLE = /* @__PURE__ */ new Set([
10
+ "extras",
11
+ "tours",
12
+ "floor_plans",
13
+ "photos",
14
+ "pois"
15
+ ]);
16
+
17
+ //#endregion
18
+ export { FACTS_ARRAY_FIELDS, FACTS_READABLE };
@@ -0,0 +1,14 @@
1
+ import { FORMATTER_NAMES } from "../../../../contracts/slot-catalog.js";
2
+
3
+ //#region src/cli/check/stages/validate/formatter-contract.ts
4
+ function assertFormatterContract(schema) {
5
+ for (const [name, slot] of Object.entries(schema.slots)) {
6
+ const fmt = slot.format;
7
+ if (fmt == null) continue;
8
+ const fn = typeof fmt === "string" ? fmt : fmt.fn;
9
+ if (typeof fn !== "string" || !FORMATTER_NAMES.includes(fn)) throw new Error(`${schema.key}.${name}: unknown formatter "${String(fn)}" (allowed: ${FORMATTER_NAMES.join(", ")})`);
10
+ }
11
+ }
12
+
13
+ //#endregion
14
+ export { assertFormatterContract };
@@ -0,0 +1,21 @@
1
+ import { flattenLayout } from "../../../../schema/rows.js";
2
+
3
+ //#region src/cli/check/stages/validate/group-contract.ts
4
+ function assertGroupContract(schema, sectionKey) {
5
+ const groups = schema.groups;
6
+ if (!groups || groups.length === 0) return;
7
+ const seenIds = /* @__PURE__ */ new Set();
8
+ const seenSlots = /* @__PURE__ */ new Set();
9
+ for (const group of groups) {
10
+ if (seenIds.has(group.id)) throw new Error(`Section "${sectionKey}": duplicate group id "${group.id}".`);
11
+ seenIds.add(group.id);
12
+ for (const member of flattenLayout(group.members)) {
13
+ if (!(member in schema.slots)) throw new Error(`Section "${sectionKey}": group "${group.id}" names unknown slot "${member}".`);
14
+ if (seenSlots.has(member)) throw new Error(`Section "${sectionKey}": slot "${member}" appears in more than one group.`);
15
+ seenSlots.add(member);
16
+ }
17
+ }
18
+ }
19
+
20
+ //#endregion
21
+ export { assertGroupContract };
@@ -0,0 +1,66 @@
1
+ import { MissingContractFile, MissingSectionExport, loadSection } from "../../loader.js";
2
+ import { relativizePaths } from "../../relativize.js";
3
+ import { validateFixtureModule, validateOneSection, validateTemplateManifest } from "./orchestrator.js";
4
+ import { existsSync } from "node:fs";
5
+ import { join } from "node:path";
6
+
7
+ //#region src/cli/check/stages/validate/index.ts
8
+ async function validateStage(workspaceRoot, template) {
9
+ const diagnostics = [];
10
+ for (const section of template.sections) {
11
+ const sectionFile = (name) => `templates/${template.key}/sections/${section.name}/${name}`;
12
+ let loaded;
13
+ try {
14
+ loaded = await loadSection(workspaceRoot, section);
15
+ } catch (cause) {
16
+ if (cause instanceof MissingContractFile) continue;
17
+ diagnostics.push({
18
+ ruleId: "template-kit/schema-invalid",
19
+ template: template.key,
20
+ section: section.name,
21
+ file: cause instanceof MissingSectionExport ? cause.file : sectionFile("schema.ts"),
22
+ message: relativizePaths(cause instanceof Error ? cause.message : String(cause), workspaceRoot),
23
+ fix: "The section's contract files could not be loaded or parsed. Fix the error above."
24
+ });
25
+ continue;
26
+ }
27
+ for (const message of validateOneSection(section.name, loaded)) diagnostics.push({
28
+ ruleId: "template-kit/schema-invalid",
29
+ template: template.key,
30
+ section: section.name,
31
+ file: sectionFile("schema.ts"),
32
+ message,
33
+ fix: "Fix the schema, fill-spec or fixtures declaration this message names."
34
+ });
35
+ for (const message of validateFixtureModule(section.name, loaded)) diagnostics.push({
36
+ ruleId: "template-kit/fixtures-invalid",
37
+ template: template.key,
38
+ section: section.name,
39
+ file: sectionFile("fixtures.ts"),
40
+ message,
41
+ fix: "Fix the fixtures module: it must default-export { base: { slots: { … } } }, plus the optional option_matrix and states blocks."
42
+ });
43
+ }
44
+ if (!existsSync(join(template.dir, "manifest.json"))) {
45
+ diagnostics.push({
46
+ ruleId: "template-kit/manifest-invalid",
47
+ template: template.key,
48
+ file: `templates/${template.key}/manifest.json`,
49
+ line: 1,
50
+ message: `Template "${template.key}" has no manifest.json.`,
51
+ fix: "Add manifest.json to the template folder: a template needs one to declare its section composition — which sections it is built from, in what order."
52
+ });
53
+ return diagnostics;
54
+ }
55
+ for (const message of await validateTemplateManifest(workspaceRoot, template)) diagnostics.push({
56
+ ruleId: "template-kit/manifest-invalid",
57
+ template: template.key,
58
+ file: `templates/${template.key}/manifest.json`,
59
+ message,
60
+ fix: "Fix the manifest entry this message names."
61
+ });
62
+ return diagnostics;
63
+ }
64
+
65
+ //#endregion
66
+ export { validateStage };
@@ -0,0 +1,22 @@
1
+ //#region src/cli/check/stages/validate/list-scalar-contract.ts
2
+ const SCALAR_ELEMENT_TYPES = /* @__PURE__ */ new Set(["text", "number"]);
3
+ function checkListSlot(slot, label, sectionKey) {
4
+ if (slot.element == null) throw new Error(`Section "${sectionKey}": ${label} must declare \`element\` (a scalar Slot, text|number).`);
5
+ if (!SCALAR_ELEMENT_TYPES.has(slot.element.type ?? "")) throw new Error(`Section "${sectionKey}": ${label} element must be a scalar Slot (text|number), got "${slot.element.type}".`);
6
+ }
7
+ function assertListScalarContract(schema, sectionKey) {
8
+ for (const [slotId, slotUnknown] of Object.entries(schema.slots)) {
9
+ const slot = slotUnknown;
10
+ if (slot.type === "list") {
11
+ checkListSlot(slot, `list "${slotId}"`, sectionKey);
12
+ continue;
13
+ }
14
+ if (slot.type === "object_list" && slot.item != null) for (const [childId, child] of Object.entries(slot.item)) {
15
+ if (child.type !== "list") continue;
16
+ checkListSlot(child, `object_list "${slotId}" field "${childId}"`, sectionKey);
17
+ }
18
+ }
19
+ }
20
+
21
+ //#endregion
22
+ export { assertListScalarContract };