@homepages/template-kit 0.1.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 (111) hide show
  1. package/CHANGELOG.md +220 -0
  2. package/README.md +70 -16
  3. package/dist/base.css +36 -48
  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 -299
  41. package/dist/design-system/theme.js +112 -83
  42. package/dist/eslint/rules/image-bare-needs-reason.js +6 -1
  43. package/dist/eslint/rules/no-client-directive-in-contract.js +6 -1
  44. package/dist/eslint/rules/no-client-runtime-in-server.js +5 -1
  45. package/dist/eslint/rules/no-css-import-from-render-path.js +8 -2
  46. package/dist/eslint/rules/no-hex.js +84 -7
  47. package/dist/eslint/rules/no-inline-style.js +5 -1
  48. package/dist/eslint/rules/no-nondeterminism.js +5 -1
  49. package/dist/eslint/rules/no-raw-element.js +6 -1
  50. package/dist/eslint/rules/props-from-schema.js +6 -1
  51. package/dist/eslint/rules/serializable-island-props.js +5 -1
  52. package/dist/eslint/rules/slot-marker-literal.js +6 -1
  53. package/dist/eslint.js +1 -1
  54. package/dist/index.d.ts +2 -2
  55. package/dist/index.js +2 -2
  56. package/dist/package.js +1 -1
  57. package/dist/primitives/Image.js +1 -1
  58. package/dist/rules/registry.js +16 -0
  59. package/dist/schema/fill-spec.d.ts +80 -590
  60. package/dist/schema/fixture-schema.d.ts +5 -81
  61. package/dist/schema/manifest.d.ts +39 -475
  62. package/dist/schema/resolve-section-ref.js +10 -0
  63. package/dist/schema/rows.js +10 -0
  64. package/dist/schema/section-nav.js +9 -0
  65. package/dist/schema/section-schema.d.ts +51 -437
  66. package/dist/schema/slot-types.d.ts +12 -16
  67. package/dist/styles.css +31 -88
  68. package/docs/INDEX.md +5 -3
  69. package/docs/check.md +121 -0
  70. package/docs/eslint.md +25 -50
  71. package/docs/llms.txt +26 -11
  72. package/docs/primitives.md +5 -0
  73. package/docs/rules/INDEX.md +65 -0
  74. package/docs/rules/audit-severity.md +91 -0
  75. package/docs/rules/bundle-binary-asset.md +102 -0
  76. package/docs/rules/bundle-incomplete.md +117 -0
  77. package/docs/rules/css-reason.md +94 -0
  78. package/docs/rules/determinism-drift.md +112 -0
  79. package/docs/rules/fixtures-invalid.md +96 -0
  80. package/docs/rules/image-bare-needs-reason.md +117 -0
  81. package/docs/rules/license-denied.md +114 -0
  82. package/docs/rules/lockfile-missing.md +68 -0
  83. package/docs/rules/lockfile-stale.md +88 -0
  84. package/docs/rules/manifest-invalid.md +99 -0
  85. package/docs/rules/missing-slot-marker.md +140 -0
  86. package/docs/rules/no-bare-css-import.md +128 -0
  87. package/docs/rules/no-client-directive-in-contract.md +111 -0
  88. package/docs/rules/no-client-runtime-in-server.md +143 -0
  89. package/docs/rules/no-css-import-from-render-path.md +118 -0
  90. package/docs/rules/no-hex.md +163 -0
  91. package/docs/rules/no-inline-style.md +121 -0
  92. package/docs/rules/no-nondeterminism.md +100 -0
  93. package/docs/rules/no-raw-element.md +100 -0
  94. package/docs/rules/no-templates.md +87 -0
  95. package/docs/rules/parse-error.md +76 -0
  96. package/docs/rules/props-from-schema.md +119 -0
  97. package/docs/rules/render-invariant.md +165 -0
  98. package/docs/rules/schema-invalid.md +96 -0
  99. package/docs/rules/serializable-island-props.md +146 -0
  100. package/docs/rules/server-vs-client.md +105 -0
  101. package/docs/rules/sidebar-order.md +109 -0
  102. package/docs/rules/single-react.md +97 -0
  103. package/docs/rules/size-renderer-bundle.md +109 -0
  104. package/docs/rules/size-section-css.md +95 -0
  105. package/docs/rules/slot-group-marker.md +127 -0
  106. package/docs/rules/slot-marker-literal.md +148 -0
  107. package/docs/rules/typecheck.md +103 -0
  108. package/docs/rules/unlayered-fence.md +113 -0
  109. package/docs/theme-and-css.md +193 -82
  110. package/package.json +4 -9
  111. package/tsconfig.json +1 -1
@@ -0,0 +1,46 @@
1
+ import { resolveFromWorkspace } from "../resolve-tool.js";
2
+ import { sectionOf } from "../paths.js";
3
+ import { relativizePaths } from "../relativize.js";
4
+ import { join, relative } from "node:path";
5
+
6
+ //#region src/cli/check/stages/lint.ts
7
+ const PARSE_ERROR_RULE_ID = "template-kit/parse-error";
8
+ const FIX_HINTS = {
9
+ "template-kit/no-nondeterminism": "Remove the clock or randomness read from server-rendered code, or move this logic into an island (a file marked use client).",
10
+ "template-kit/no-client-runtime-in-server": "Move this into an island (mark the file use client) or drop the browser-only API — a Renderer must be a pure function of its props.",
11
+ "template-kit/serializable-island-props": "Pass only JSON-serializable values to an island: no functions, Date/Map/Set instances, bigints, NaN, or Infinity.",
12
+ "template-kit/no-inline-style": "Replace the style prop with a Tailwind class or a design-system token. Inline style bypasses the cascade and responsive prefixes.",
13
+ "template-kit/no-raw-element": "Use the contract-bearing primitive instead of the raw element: Section for section, Image for img.",
14
+ "template-kit/image-bare-needs-reason": "Add a bare: comment above the Image explaining why the frame is skipped, or remove the bare prop.",
15
+ "template-kit/slot-marker-literal": "Give the marker's id a string literal, not an expression, so the slot-marker completeness check can read it statically.",
16
+ "template-kit/no-hex": "Use a design-system color token instead of a hex literal or CSS color function.",
17
+ "template-kit/no-css-import-from-render-path": "Delete the relative CSS import. The file's own CSS is already collected by path; use a bare package specifier for a third-party stylesheet.",
18
+ "template-kit/props-from-schema": "Derive Props from SectionProps<typeof schema> instead of hand-writing the type.",
19
+ "template-kit/no-client-directive-in-contract": "Remove the use client directive from this contract file. schema.ts, fill-spec.ts, fixtures.ts and Renderer.tsx are read outside the browser."
20
+ };
21
+ async function lintStage(workspaceRoot, template) {
22
+ const { ESLint } = await resolveFromWorkspace(workspaceRoot, "eslint");
23
+ const results = await new ESLint({
24
+ cwd: workspaceRoot,
25
+ errorOnUnmatchedPattern: false
26
+ }).lintFiles([join(template.dir, "**/*.{ts,tsx}")]);
27
+ const out = [];
28
+ for (const result of results) for (const message of result.messages) {
29
+ if (message.severity !== 2) continue;
30
+ const ruleId = message.ruleId ?? PARSE_ERROR_RULE_ID;
31
+ const text = relativizePaths(message.message, workspaceRoot);
32
+ out.push({
33
+ ruleId,
34
+ template: template.key,
35
+ section: sectionOf(template, result.filePath),
36
+ file: relative(workspaceRoot, result.filePath),
37
+ line: message.line,
38
+ message: text,
39
+ fix: FIX_HINTS[ruleId] ?? text
40
+ });
41
+ }
42
+ return out;
43
+ }
44
+
45
+ //#endregion
46
+ export { lintStage };
@@ -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 };