@homepages/template-kit 0.9.0-dev-20260719205406 → 0.9.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 (146) hide show
  1. package/CHANGELOG.md +60 -59
  2. package/README.md +14 -4
  3. package/dist/contracts/fill-treatments.d.ts +23 -0
  4. package/dist/fixtures/sample-property.d.ts +4 -7
  5. package/dist/fixtures/sample-property.js +41 -97
  6. package/dist/fixtures/scenario.d.ts +1 -1
  7. package/dist/fixtures/scenarios/for-rent-condo.js +24 -44
  8. package/dist/fixtures/scenarios/luxury-multi-unit.js +1 -1
  9. package/dist/fixtures/scenarios/sparse-single-family.js +14 -26
  10. package/dist/fixtures.d.ts +2 -2
  11. package/dist/fixtures.js +1 -2
  12. package/dist/index.d.ts +2 -2
  13. package/dist/index.js +2 -2
  14. package/dist/islands/discover.js +1 -1
  15. package/dist/islands/esbuild-plugin.js +1 -1
  16. package/dist/islands/wrap.d.ts +9 -1
  17. package/dist/manifest.json +940 -0
  18. package/dist/media/pack.d.ts +49 -0
  19. package/dist/media/pack.js +69 -0
  20. package/dist/media/resolve-fixture-media.d.ts +22 -0
  21. package/dist/media/resolve-fixture-media.js +73 -0
  22. package/dist/media/resolve-media.js +195 -0
  23. package/dist/package.js +1 -1
  24. package/dist/schema/property-facts.d.ts +2 -2
  25. package/dist/schema/resolve-section-ref.d.ts +4 -0
  26. package/dist/schema/rows.d.ts +9 -0
  27. package/dist/schema/section-nav.d.ts +5 -1
  28. package/dist/schema/slot-types.d.ts +1 -1
  29. package/dist/ssr.d.ts +19 -2
  30. package/dist/ssr.js +32 -13
  31. package/guide/INDEX.md +5 -0
  32. package/guide/check.md +1 -0
  33. package/guide/dev.md +1 -0
  34. package/guide/llms.txt +11 -3
  35. package/guide/recipes/image-slot-crop.md +6 -6
  36. package/guide/recipes/pick-a-scenario.md +13 -5
  37. package/guide/schema-system.md +36 -12
  38. package/guide/upgrading.md +12 -8
  39. package/package.json +4 -16
  40. package/dist/canvas-core/chrome-css.d.ts +0 -24
  41. package/dist/canvas-core/chrome-css.js +0 -403
  42. package/dist/canvas-core/chrome.d.ts +0 -49
  43. package/dist/canvas-core/chrome.js +0 -147
  44. package/dist/canvas-core/frame.d.ts +0 -20
  45. package/dist/canvas-core/frame.js +0 -23
  46. package/dist/canvas-core/hidden-tabs.d.ts +0 -16
  47. package/dist/canvas-core/hidden-tabs.js +0 -84
  48. package/dist/canvas-core/resolve-target.js +0 -55
  49. package/dist/canvas-core/scroll.d.ts +0 -10
  50. package/dist/canvas-core/scroll.js +0 -15
  51. package/dist/canvas-core/section-dom.d.ts +0 -6
  52. package/dist/canvas-core/section-dom.js +0 -43
  53. package/dist/canvas-core/srcdoc.d.ts +0 -8
  54. package/dist/canvas-core/srcdoc.js +0 -77
  55. package/dist/canvas-core/types.d.ts +0 -76
  56. package/dist/canvas-core.d.ts +0 -9
  57. package/dist/canvas-core.js +0 -9
  58. package/dist/cli/check/config.js +0 -41
  59. package/dist/cli/check/css.js +0 -131
  60. package/dist/cli/check/diagnostics.js +0 -37
  61. package/dist/cli/check/index.js +0 -94
  62. package/dist/cli/check/loader.js +0 -149
  63. package/dist/cli/check/paths.js +0 -15
  64. package/dist/cli/check/relativize.js +0 -18
  65. package/dist/cli/check/render-invariants.js +0 -24
  66. package/dist/cli/check/resolve-tool.js +0 -38
  67. package/dist/cli/check/section-assets.js +0 -58
  68. package/dist/cli/check/stages/deps.js +0 -337
  69. package/dist/cli/check/stages/lint.js +0 -46
  70. package/dist/cli/check/stages/render.js +0 -97
  71. package/dist/cli/check/stages/size.js +0 -274
  72. package/dist/cli/check/stages/tree.js +0 -206
  73. package/dist/cli/check/stages/typecheck.js +0 -46
  74. package/dist/cli/check/stages/validate/catalog-exhaustiveness.js +0 -18
  75. package/dist/cli/check/stages/validate/facts.js +0 -19
  76. package/dist/cli/check/stages/validate/formatter-contract.js +0 -14
  77. package/dist/cli/check/stages/validate/group-contract.js +0 -21
  78. package/dist/cli/check/stages/validate/index.js +0 -66
  79. package/dist/cli/check/stages/validate/list-scalar-contract.js +0 -22
  80. package/dist/cli/check/stages/validate/nav-contract.js +0 -92
  81. package/dist/cli/check/stages/validate/nested-slot-contract.js +0 -12
  82. package/dist/cli/check/stages/validate/object-list-contract.js +0 -31
  83. package/dist/cli/check/stages/validate/orchestrator.js +0 -241
  84. package/dist/cli/check/stages/validate/produced-by-contract.js +0 -18
  85. package/dist/cli/check/stages/validate/row-contract.js +0 -31
  86. package/dist/cli/check/stages/validate/select-contract.js +0 -13
  87. package/dist/cli/check/stages/validate/source-contract.js +0 -21
  88. package/dist/cli/check/stages/validate/variant-contract.js +0 -15
  89. package/dist/cli/check/workspace.js +0 -99
  90. package/dist/cli/dev/build-css.js +0 -97
  91. package/dist/cli/dev/catalog.js +0 -35
  92. package/dist/cli/dev/compose-template.js +0 -39
  93. package/dist/cli/dev/content-override.js +0 -58
  94. package/dist/cli/dev/diagnostics.js +0 -68
  95. package/dist/cli/dev/discover.js +0 -35
  96. package/dist/cli/dev/fill-state.js +0 -77
  97. package/dist/cli/dev/index.js +0 -42
  98. package/dist/cli/dev/inspect.js +0 -38
  99. package/dist/cli/dev/island-bootstrap.js +0 -38
  100. package/dist/cli/dev/island-canvas-bootstrap.js +0 -204
  101. package/dist/cli/dev/island-map.js +0 -35
  102. package/dist/cli/dev/island-transform.js +0 -31
  103. package/dist/cli/dev/manifest-instances.js +0 -31
  104. package/dist/cli/dev/render-section.js +0 -24
  105. package/dist/cli/dev/screenshot-target.js +0 -24
  106. package/dist/cli/dev/section-page.js +0 -91
  107. package/dist/cli/dev/server.js +0 -510
  108. package/dist/cli/dev/slot-schema.js +0 -12
  109. package/dist/cli/dev/structure-summary.js +0 -119
  110. package/dist/cli/dev/tailwind.js +0 -32
  111. package/dist/cli/dev/tsconfig-paths-plugin.js +0 -90
  112. package/dist/cli/dev/vite-server.js +0 -39
  113. package/dist/cli/dev/workspace.js +0 -63
  114. package/dist/cli/link/index.js +0 -74
  115. package/dist/cli/link/overlay.js +0 -59
  116. package/dist/cli/link/watch.js +0 -25
  117. package/dist/cli/new/emit.js +0 -48
  118. package/dist/cli/new/index.js +0 -69
  119. package/dist/cli/new/scaffold/section/_Renderer.tsx +0 -20
  120. package/dist/cli/new/scaffold/section/_fill-spec.ts +0 -18
  121. package/dist/cli/new/scaffold/section/_fixtures.ts +0 -13
  122. package/dist/cli/new/scaffold/section/_schema.ts +0 -24
  123. package/dist/cli/new/scaffold/template/_manifest.json +0 -9
  124. package/dist/cli/new/scaffold/template/sections/hero/ExpandableText.tsx +0 -45
  125. package/dist/cli/new/scaffold/template/sections/hero/Renderer.tsx +0 -40
  126. package/dist/cli/new/scaffold/template/sections/hero/components/Feature.tsx +0 -14
  127. package/dist/cli/new/scaffold/template/sections/hero/fill-spec.ts +0 -29
  128. package/dist/cli/new/scaffold/template/sections/hero/fixtures.ts +0 -41
  129. package/dist/cli/new/scaffold/template/sections/hero/schema.ts +0 -60
  130. package/dist/cli/new/scaffold/template/theme.css +0 -24
  131. package/dist/cli/new/scaffold/template/theme.ts +0 -27
  132. package/dist/cli/new/scaffold-assets.js +0 -20
  133. package/dist/cli/pack/collect.js +0 -44
  134. package/dist/cli/pack/guards.js +0 -57
  135. package/dist/cli/pack/index.js +0 -66
  136. package/dist/cli/pack/manifest.js +0 -15
  137. package/dist/cli/pack/zip.js +0 -86
  138. package/dist/cli/theme/generate.js +0 -43
  139. package/dist/cli/theme/index.js +0 -33
  140. package/dist/cli/theme/load-theme.js +0 -69
  141. package/dist/cli.d.ts +0 -1
  142. package/dist/cli.js +0 -138
  143. package/dist/dev-client/assets/index-CJa9G1Na.css +0 -1
  144. package/dist/dev-client/assets/index-C_1atZs4.js +0 -450
  145. package/dist/dev-client/index.html +0 -13
  146. package/dist/tokens.css +0 -21
@@ -1,274 +0,0 @@
1
- import { loadEsbuild } from "../resolve-tool.js";
2
- import { ASSET_LOADERS, ASSET_NAMES, ASSET_PUBLIC_PATH, compileSectionPackageCss, sectionCssFiles } from "../css.js";
3
- import { CHECK_CONFIG } from "../config.js";
4
- import { findSectionIslands } from "../../../islands/discover.js";
5
- import { readFile } from "node:fs/promises";
6
- import { basename, join, relative } from "node:path";
7
- import { existsSync } from "node:fs";
8
- import { gzipSync } from "node:zlib";
9
-
10
- //#region src/cli/check/stages/size.ts
11
- const RESOLVING = Symbol("size-stub:resolving");
12
- /**
13
- * A CSS-only stub, local to this file and NOT `loader.ts`'s `cssStub`: that
14
- * plugin marks *every* bare specifier external (right for evaluating a
15
- * section — it must resolve the workspace's real React and real kit
16
- * primitives, not a second copy), which is exactly wrong for measurement,
17
- * where an npm package's bundled weight is the entire point of the gate.
18
- * Only CSS is stubbed here; everything else is left to the `build()` call's
19
- * own `external` option (see `measureRendererBundle`) plus esbuild's default
20
- * bundling.
21
- *
22
- * Detects a CSS import by resolving the specifier and inspecting the
23
- * *resolved path's* extension — not by pattern-matching the specifier text.
24
- * A CSS-only package need not say so in its specifier (`@fontsource/inter`
25
- * has no `.css` suffix and no `css` path segment; `swiper/css` has no `.css`
26
- * suffix either), so only real resolution tells the two apart. `pluginData`
27
- * is the standard recursion guard for `build.resolve()` re-entering this same
28
- * hook (esbuild docs' own example for this pattern) — mirrors `loader.ts`'s
29
- * `cssStub`, which learned this the hard way.
30
- */
31
- const sizeStub = {
32
- name: "size-stub",
33
- setup(b) {
34
- b.onResolve({ filter: /.*/ }, async (args) => {
35
- if (args.pluginData === RESOLVING) return null;
36
- const resolved = await b.resolve(args.path, {
37
- importer: args.importer,
38
- namespace: args.namespace,
39
- resolveDir: args.resolveDir,
40
- kind: args.kind,
41
- pluginData: RESOLVING
42
- });
43
- if (resolved.errors.length === 0 && resolved.path !== "" && resolved.path.endsWith(".css")) return {
44
- path: resolved.path,
45
- namespace: "size-stub"
46
- };
47
- return null;
48
- });
49
- b.onLoad({
50
- filter: /.*/,
51
- namespace: "size-stub"
52
- }, () => ({
53
- contents: "",
54
- loader: "js"
55
- }));
56
- }
57
- };
58
- /**
59
- * Gzipped bytes of a section's renderer bundle, its own code *and every npm
60
- * package it imports* — the published bundle's actual shape. React alone is
61
- * excluded: it is a peer the platform supplies at render time, never bundled
62
- * into a section. Everything else, including `@homepages/template-kit`
63
- * itself (its primitives are ordinary React components that ship inside the
64
- * section bundle, not a peer), is bundled and counted — that is the entire
65
- * point of this budget. esbuilds `Renderer.tsx` alone (not the other three
66
- * contract files — schema, fill-spec and fixtures never reach the browser).
67
- *
68
- * `external` is deliberately the five React entry points a section can
69
- * actually reach, not `packages: "external"`: the latter would externalize
70
- * every bare specifier — including the icon library or schema library this
71
- * gate exists to catch — reproducing the exact bug this function fixes. Do
72
- * not "simplify" this back to `packages: "external"`.
73
- *
74
- * `platform: "browser"`, not `"neutral"`: `neutral` defaults `mainFields` to
75
- * `[]`, which cannot resolve a CommonJS-only package's `main` field — and
76
- * real sections import such packages (an icon library, for instance). With
77
- * `neutral` the measurement simply fails for those sections; `browser`'s
78
- * default `mainFields` resolves them.
79
- */
80
- async function measureRendererBundle(workspaceRoot, section) {
81
- const { build } = await loadEsbuild(workspaceRoot);
82
- const js = (await build({
83
- entryPoints: [join(section.dir, "Renderer.tsx")],
84
- bundle: true,
85
- minify: true,
86
- format: "esm",
87
- platform: "browser",
88
- jsx: "automatic",
89
- write: false,
90
- outfile: "renderer.js",
91
- external: [
92
- "react",
93
- "react-dom",
94
- "react-dom/server",
95
- "react/jsx-runtime",
96
- "react/jsx-dev-runtime"
97
- ],
98
- plugins: [sizeStub],
99
- absWorkingDir: workspaceRoot,
100
- logLevel: "silent"
101
- })).outputFiles.find((f) => f.path.endsWith(".js"));
102
- if (!js) throw new Error(`esbuild produced no JS output for ${section.dir}`);
103
- return gzipSync(js.contents).byteLength;
104
- }
105
- /**
106
- * Gzipped bytes of a section's stylesheet: its hand-written CSS
107
- * (`sectionCssFiles`, published order) concatenated with the package CSS its
108
- * code pulls in (`compileSectionPackageCss`). A package's `url()`-referenced
109
- * fonts and icons no longer inline into this text — they resolve to
110
- * `/section-assets/` references (see css.ts), and their bytes are measured
111
- * separately by `measureAssetBytes`, the `size-assets` dimension below.
112
- */
113
- async function measureSectionCss(workspaceRoot, section) {
114
- const ownFiles = await sectionCssFiles(section.dir);
115
- const own = (await Promise.all(ownFiles.map((f) => readFile(f, "utf8")))).join("\n");
116
- const packageCss = await compileSectionPackageCss(workspaceRoot, section.dir);
117
- return gzipSync(Buffer.from(`${own}\n${packageCss}`)).byteLength;
118
- }
119
- /**
120
- * Raw bytes of a section's static assets — the fourth size dimension. Fonts and
121
- * images are already compressed, so this is RAW bytes, not gzip. Two esbuild
122
- * passes with the file loader capture every asset the published section emits:
123
- * 1. Renderer.tsx (bundled) — assets imported in JS AND assets url()-referenced
124
- * by the package CSS the renderer pulls in.
125
- * 2. each hand-written stylesheet — its @font-face / url() assets (the author's
126
- * own styles.css is read raw by the CSS budget, never bundled, so its assets
127
- * surface only here).
128
- * Deduped by emitted (content-hashed) filename, so an asset referenced twice or
129
- * from both a stylesheet and JS is counted once.
130
- */
131
- async function measureAssetBytes(workspaceRoot, section) {
132
- const { build } = await loadEsbuild(workspaceRoot);
133
- const byName = /* @__PURE__ */ new Map();
134
- const collect = (outputFiles) => {
135
- for (const f of outputFiles) {
136
- if (/\.(?:js|css|map)$/i.test(f.path)) continue;
137
- byName.set(basename(f.path), f.contents.byteLength);
138
- }
139
- };
140
- collect((await build({
141
- entryPoints: [join(section.dir, "Renderer.tsx")],
142
- bundle: true,
143
- write: false,
144
- format: "esm",
145
- platform: "browser",
146
- jsx: "automatic",
147
- outdir: "dist",
148
- external: [
149
- "react",
150
- "react-dom",
151
- "react-dom/server",
152
- "react/jsx-runtime",
153
- "react/jsx-dev-runtime"
154
- ],
155
- loader: { ...ASSET_LOADERS },
156
- publicPath: "/section-assets",
157
- assetNames: "[name]-[hash]",
158
- absWorkingDir: workspaceRoot,
159
- logLevel: "silent"
160
- })).outputFiles ?? []);
161
- for (const cssFile of await sectionCssFiles(section.dir)) collect((await build({
162
- entryPoints: [cssFile],
163
- bundle: true,
164
- write: false,
165
- outdir: "dist",
166
- loader: {
167
- ".css": "css",
168
- ...ASSET_LOADERS
169
- },
170
- publicPath: "/section-assets",
171
- assetNames: "[name]-[hash]",
172
- absWorkingDir: workspaceRoot,
173
- logLevel: "silent"
174
- })).outputFiles ?? []);
175
- return [...byName.values()].reduce((sum, n) => sum + n, 0);
176
- }
177
- /**
178
- * Gzipped bytes of a section's largest island (browser) bundle. Each "use client"
179
- * component is bundled as its own browser entry, React external (the runtime chunk
180
- * provides it), everything else bundled — the shape ci/static-assets.ts uploads.
181
- * Returns 0 for a section with no islands. Uses `sizeStub` (React-only external),
182
- * NOT the loader's cssStub — see the file header.
183
- */
184
- async function measureIslandBundle(workspaceRoot, section) {
185
- const islands = await findSectionIslands(section.dir);
186
- if (islands.length === 0) return 0;
187
- const { build } = await loadEsbuild(workspaceRoot);
188
- let max = 0;
189
- for (const entry of islands) {
190
- const js = (await build({
191
- entryPoints: [entry],
192
- bundle: true,
193
- minify: true,
194
- format: "esm",
195
- platform: "browser",
196
- jsx: "automatic",
197
- write: false,
198
- outfile: "island.js",
199
- external: [
200
- "react",
201
- "react-dom",
202
- "react-dom/client",
203
- "react/jsx-runtime",
204
- "react/jsx-dev-runtime"
205
- ],
206
- plugins: [sizeStub],
207
- absWorkingDir: workspaceRoot,
208
- logLevel: "silent"
209
- })).outputFiles.find((f) => f.path.endsWith(".js"));
210
- if (js) max = Math.max(max, gzipSync(js.contents).byteLength);
211
- }
212
- return max;
213
- }
214
- function kb(bytes) {
215
- const value = bytes / 1024;
216
- return `${Number.isInteger(value) ? value.toString() : value.toFixed(1)} KB`;
217
- }
218
- function cssDiagnosticFile(workspaceRoot, section) {
219
- const stylesCss = join(section.dir, "styles.css");
220
- return relative(workspaceRoot, existsSync(stylesCss) ? stylesCss : section.dir);
221
- }
222
- async function sizeStage(workspaceRoot, template) {
223
- const diagnostics = [];
224
- for (const section of template.sections) {
225
- let rendererBytes;
226
- let cssBytes;
227
- let islandBytes;
228
- let assetBytes;
229
- try {
230
- rendererBytes = await measureRendererBundle(workspaceRoot, section);
231
- cssBytes = await measureSectionCss(workspaceRoot, section);
232
- islandBytes = await measureIslandBundle(workspaceRoot, section);
233
- assetBytes = await measureAssetBytes(workspaceRoot, section);
234
- } catch {
235
- continue;
236
- }
237
- if (rendererBytes > CHECK_CONFIG.size.rendererBundleGzip) diagnostics.push({
238
- ruleId: "template-kit/size-renderer-bundle",
239
- template: template.key,
240
- section: section.name,
241
- file: `templates/${template.key}/sections/${section.name}/Renderer.tsx`,
242
- message: `renderer bundle is ${kb(rendererBytes)} gzip — over the ${kb(CHECK_CONFIG.size.rendererBundleGzip)} budget`,
243
- 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."
244
- });
245
- if (cssBytes > CHECK_CONFIG.size.sectionCssGzip) diagnostics.push({
246
- ruleId: "template-kit/size-section-css",
247
- template: template.key,
248
- section: section.name,
249
- file: cssDiagnosticFile(workspaceRoot, section),
250
- message: `section stylesheet is ${kb(cssBytes)} gzip — over the ${kb(CHECK_CONFIG.size.sectionCssGzip)} budget`,
251
- fix: "Trim the stylesheet. Pulling in a package's full 'everything' CSS bundle when you use a few of its modules is the usual cause — import only the modules you use. (A url()-referenced font or icon set is measured separately, under size-assets.)"
252
- });
253
- if (islandBytes > CHECK_CONFIG.size.islandBundleGzip) diagnostics.push({
254
- ruleId: "template-kit/size-island-bundle",
255
- template: template.key,
256
- section: section.name,
257
- file: `templates/${template.key}/sections/${section.name}`,
258
- message: `an island bundle is ${kb(islandBytes)} gzip — over the ${kb(CHECK_CONFIG.size.islandBundleGzip)} budget`,
259
- fix: "Trim what the client component pulls in. A whole UI/icon library imported for one widget is the usual cause — import only what you use, or split the island."
260
- });
261
- if (assetBytes > CHECK_CONFIG.size.assets) diagnostics.push({
262
- ruleId: "template-kit/size-assets",
263
- template: template.key,
264
- section: section.name,
265
- file: `templates/${template.key}/sections/${section.name}`,
266
- message: `static assets are ${kb(assetBytes)} — over the ${kb(CHECK_CONFIG.size.assets)} budget`,
267
- fix: "Shrink or subset the asset (a full font family or an unoptimized image is the usual cause), or drop assets the section does not use."
268
- });
269
- }
270
- return diagnostics;
271
- }
272
-
273
- //#endregion
274
- export { measureAssetBytes, measureIslandBundle, measureRendererBundle, measureSectionCss, sizeStage };
@@ -1,206 +0,0 @@
1
- import { ASSET_LOADERS, findImportLine, sectionCssFiles } from "../css.js";
2
- import { CONTRACT_FILES, loadSection } from "../loader.js";
3
- import { flattenLayout } from "../../../schema/rows.js";
4
- import { referencedSectionAssets } from "../section-assets.js";
5
- import { readFile, readdir } from "node:fs/promises";
6
- import { extname, join, relative } from "node:path";
7
-
8
- //#region src/cli/check/stages/tree.ts
9
- const ASSET_EXTENSIONS = new Set(Object.keys(ASSET_LOADERS));
10
- async function isBinaryFile(file) {
11
- return (await readFile(file)).subarray(0, 4096).includes(0);
12
- }
13
- /** Every authored file in the bundle, recursive. `snapshots/` and `node_modules/` are skipped — generated or foreign, never authored. */
14
- async function bundleFiles(dir) {
15
- const out = [];
16
- for (const entry of await readdir(dir, { withFileTypes: true })) {
17
- const full = join(dir, entry.name);
18
- if (entry.isDirectory()) {
19
- if (entry.name === "snapshots" || entry.name === "node_modules") continue;
20
- out.push(...await bundleFiles(full));
21
- } else if (entry.isFile()) out.push(full);
22
- }
23
- return out.sort();
24
- }
25
- /**
26
- * The bundle's authored JSX surface: every `.tsx` except tests. An author may
27
- * put markup anywhere in the folder (components/, hooks/, …), so the marker
28
- * rules scan all of these, not just Renderer.tsx. Tests are excluded
29
- * deliberately — a test's expected-markup assertion must not satisfy a slot
30
- * marker, and fixtures legitimately write raw markup.
31
- */
32
- async function bundleJsxFiles(dir) {
33
- return (await bundleFiles(dir)).filter((f) => f.endsWith(".tsx") && !/\.test\.tsx$/.test(f));
34
- }
35
- function escapeRegExp(s) {
36
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
37
- }
38
- const CSS_REASON = /css-reason:/;
39
- function opensWithCssReason(text) {
40
- const lead = text.match(/^\s*\/\*([\s\S]*?)\*\//);
41
- return lead != null && CSS_REASON.test(lead[1] ?? "");
42
- }
43
- async function checkBundleAnatomy(workspaceRoot, template, section) {
44
- const out = [];
45
- const entries = await readdir(section.dir, { withFileTypes: true });
46
- for (const f of CONTRACT_FILES) {
47
- if (entries.some((entry) => entry.isFile() && entry.name === f)) continue;
48
- out.push({
49
- ruleId: "template-kit/bundle-incomplete",
50
- template: template.key,
51
- section: section.name,
52
- file: relative(workspaceRoot, join(section.dir, f)),
53
- line: 1,
54
- message: `Section "${section.name}" is missing the required contract file \`${f}\`.`,
55
- fix: "Every section needs Renderer.tsx, schema.ts, fill-spec.ts and fixtures.ts. Add the missing file."
56
- });
57
- }
58
- return out;
59
- }
60
- /** A section-folder file that is an asset by extension or by a NUL-byte sniff. */
61
- async function sectionAssetFiles(dir) {
62
- const out = [];
63
- for (const abs of await bundleFiles(dir)) if (ASSET_EXTENSIONS.has(extname(abs).toLowerCase()) || await isBinaryFile(abs)) out.push(abs);
64
- return out;
65
- }
66
- async function checkOrphanAssets(workspaceRoot, template, section) {
67
- const candidates = await sectionAssetFiles(section.dir);
68
- if (candidates.length === 0) return [];
69
- let referenced;
70
- try {
71
- referenced = await referencedSectionAssets(workspaceRoot, section);
72
- } catch {
73
- return [];
74
- }
75
- const out = [];
76
- for (const abs of candidates) {
77
- if (referenced.has(abs)) continue;
78
- const rel = relative(workspaceRoot, abs);
79
- out.push({
80
- ruleId: "template-kit/bundle-binary-asset",
81
- template: template.key,
82
- section: section.name,
83
- file: rel,
84
- line: 1,
85
- message: `Static asset \`${rel}\` sits in the section folder but nothing references it.`,
86
- fix: "Reference it (import it in Renderer.tsx, or url() it from the section's CSS), or delete it. An unreferenced binary bloats the section's content hash and is never served."
87
- });
88
- }
89
- return out;
90
- }
91
- function asSchema(loaded) {
92
- return typeof loaded.schema === "object" && loaded.schema !== null ? loaded.schema : {};
93
- }
94
- function checkSlotMarkers(workspaceRoot, template, section, loaded, jsxFiles) {
95
- const slots = asSchema(loaded).slots ?? {};
96
- const out = [];
97
- const rendererFile = relative(workspaceRoot, join(section.dir, "Renderer.tsx"));
98
- for (const [slot, spec] of Object.entries(slots)) {
99
- if (spec.editable_by_user === false) continue;
100
- const re = new RegExp(`(<Slot\\s+(?:[^>]*\\s+)?id|data-slot-id|slotId)\\s*=\\s*["']${escapeRegExp(slot)}["']`);
101
- if (jsxFiles.some((f) => re.test(f.text))) continue;
102
- out.push({
103
- ruleId: "template-kit/missing-slot-marker",
104
- template: template.key,
105
- section: section.name,
106
- file: rendererFile,
107
- 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.`,
108
- fix: `Mark the slot in Renderer.tsx: <Slot id="${slot}">. The id must be a string literal.`
109
- });
110
- }
111
- return out;
112
- }
113
- function checkSlotGroupMarkers(workspaceRoot, template, section, loaded, jsxFiles) {
114
- const groups = asSchema(loaded).meta?.groups ?? [];
115
- const out = [];
116
- const rendererFile = relative(workspaceRoot, join(section.dir, "Renderer.tsx"));
117
- for (const group of groups) {
118
- if (flattenLayout(group.members).length < 2) continue;
119
- const re = new RegExp(`<SlotGroup\\s+(?:[^>]*\\s+)?id\\s*=\\s*["']${escapeRegExp(group.id)}["']`);
120
- if (jsxFiles.some((f) => re.test(f.text))) continue;
121
- out.push({
122
- ruleId: "template-kit/slot-group-marker",
123
- template: template.key,
124
- section: section.name,
125
- file: rendererFile,
126
- message: `Group "${group.id}" in schema.ts has no matching <SlotGroup id="${group.id}"> anywhere in the bundle.`,
127
- fix: `Wrap the group's slots in <SlotGroup id="${group.id}"> so the editor can present them together.`
128
- });
129
- }
130
- return out;
131
- }
132
- async function checkSectionCss(workspaceRoot, template, section) {
133
- const out = [];
134
- for (const cssFile of await sectionCssFiles(section.dir)) {
135
- const rel = relative(workspaceRoot, cssFile);
136
- const text = await readFile(cssFile, "utf8");
137
- if (!opensWithCssReason(text)) out.push({
138
- ruleId: "template-kit/css-reason",
139
- template: template.key,
140
- section: section.name,
141
- file: rel,
142
- line: 1,
143
- message: `${rel} must open with a \`css-reason:\` comment justifying the hand-CSS.`,
144
- fix: "Open the file with a comment saying why it exists: /* css-reason: … */"
145
- });
146
- for (const m of text.matchAll(/\/\*\s*unlayered:\s*([^*]*)\*\//g)) {
147
- if ((m[1] ?? "").trim() !== "") continue;
148
- out.push({
149
- ruleId: "template-kit/unlayered-fence",
150
- template: template.key,
151
- section: section.name,
152
- file: rel,
153
- line: text.slice(0, m.index).split("\n").length,
154
- message: `${rel}: an \`/* unlayered: */\` fence must state why it escapes the cascade layers.`,
155
- fix: "Give the fence a reason (/* unlayered: … */) and close it with /* endunlayered */."
156
- });
157
- }
158
- const opens = (text.match(/\/\*\s*unlayered:\s*[^*]*\*\//g) ?? []).length;
159
- const closes = (text.match(/\/\*\s*endunlayered\s*\*\//g) ?? []).length;
160
- if (opens !== closes) out.push({
161
- ruleId: "template-kit/unlayered-fence",
162
- template: template.key,
163
- section: section.name,
164
- file: rel,
165
- line: 1,
166
- message: `${rel}: each \`/* unlayered: <reason> */\` needs a matching \`/* endunlayered */\` (found ${String(opens)} open, ${String(closes)} close).`,
167
- fix: "Give the fence a reason (/* unlayered: … */) and close it with /* endunlayered */."
168
- });
169
- const importLine = findImportLine(text);
170
- if (importLine !== void 0) out.push({
171
- ruleId: "template-kit/no-bare-css-import",
172
- template: template.key,
173
- section: section.name,
174
- file: rel,
175
- line: importLine,
176
- message: `${rel}:${String(importLine)} — @import is not allowed in hand-CSS; the browser drops it silently and the rules never reach the page.`,
177
- 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."
178
- });
179
- }
180
- return out;
181
- }
182
- /** The tree-level rules for one template: every static, cross-file section-folder invariant ESLint cannot express. */
183
- async function treeStage(workspaceRoot, template) {
184
- const diagnostics = [];
185
- for (const section of template.sections) {
186
- diagnostics.push(...await checkBundleAnatomy(workspaceRoot, template, section));
187
- diagnostics.push(...await checkOrphanAssets(workspaceRoot, template, section));
188
- let loaded;
189
- try {
190
- loaded = await loadSection(workspaceRoot, section);
191
- } catch {
192
- loaded = void 0;
193
- }
194
- const jsxPaths = await bundleJsxFiles(section.dir);
195
- if (loaded !== void 0 && jsxPaths.length > 0) {
196
- const jsxFiles = await Promise.all(jsxPaths.map(async (abs) => ({ text: await readFile(abs, "utf8") })));
197
- diagnostics.push(...checkSlotMarkers(workspaceRoot, template, section, loaded, jsxFiles));
198
- diagnostics.push(...checkSlotGroupMarkers(workspaceRoot, template, section, loaded, jsxFiles));
199
- }
200
- diagnostics.push(...await checkSectionCss(workspaceRoot, template, section));
201
- }
202
- return diagnostics;
203
- }
204
-
205
- //#endregion
206
- export { treeStage };
@@ -1,46 +0,0 @@
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 };
@@ -1,18 +0,0 @@
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 };
@@ -1,19 +0,0 @@
1
- //#region src/cli/check/stages/validate/facts.ts
2
- const FACTS_ARRAY_FIELDS = /* @__PURE__ */ new Set([
3
- "units",
4
- "photos",
5
- "videos",
6
- "floor_plans",
7
- "pois",
8
- "contacts"
9
- ]);
10
- const FACTS_READABLE = /* @__PURE__ */ new Set([
11
- "extras",
12
- "tours",
13
- "floor_plans",
14
- "photos",
15
- "pois"
16
- ]);
17
-
18
- //#endregion
19
- export { FACTS_ARRAY_FIELDS, FACTS_READABLE };
@@ -1,14 +0,0 @@
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 };
@@ -1,21 +0,0 @@
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 };
@@ -1,66 +0,0 @@
1
- import { MissingContractFile, MissingSectionExport, loadSection } from "../../loader.js";
2
- import { relativizePaths } from "../../relativize.js";
3
- import { validateFixtureModule, validateOneSection, validateTemplateManifest } from "./orchestrator.js";
4
- import { join } from "node:path";
5
- import { existsSync } from "node:fs";
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 };