@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,100 @@
1
+ ---
2
+ purpose: The template-kit/no-nondeterminism authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, server-vs-client.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/no-nondeterminism
8
+
9
+ > No clock reads and no randomness in server-rendered code. Islands are exempt.
10
+
11
+ ## Rule
12
+
13
+ Whether a file is server-rendered or an island is decided per file, by the `"use client"`
14
+ directive — see [server vs client](server-vs-client.md). On an island this rule installs
15
+ no visitors at all.
16
+
17
+ In a server-rendered file, two shapes are banned:
18
+
19
+ - **A read of a nondeterministic source**: `Date.now`, `Math.random`,
20
+ `crypto.randomUUID`, `crypto.getRandomValues`, `performance.now`. The member expression
21
+ alone is enough — `Date.now` reports even without a call, because handing the function
22
+ itself somewhere is the same read one hop later.
23
+ - **`new Date()` with no arguments.** That reads the clock.
24
+
25
+ Two carve-outs:
26
+
27
+ - **`new Date(props.listed_at)` is fine.** It parses a value the section was *given*,
28
+ which is perfectly deterministic. Only the zero-argument form reads the clock.
29
+ - **Your own definitions are yours.** If the object name resolves to a real local
30
+ declaration — `const performance = { now: () => 1 };` — it is not the global, and the
31
+ rule leaves it alone.
32
+
33
+ ## Reason
34
+
35
+ The publisher and the editor both run a section's `Renderer`, and they must produce
36
+ **byte-identical HTML** from the same props. A clock read or a random value makes the two
37
+ renders disagree: the section's output is hashed, so a `Date.now()` in the markup means
38
+ the section looks changed on every single render — a fresh version each publish, and an
39
+ editor canvas that never settles.
40
+
41
+ An island does not have this problem. It runs in the browser, after hydration, and its
42
+ output is nobody's content hash. That is why it is exempt.
43
+
44
+ ## Fix
45
+
46
+ Compute the value **at fill time** and pass it in as a slot value — that is the right
47
+ route for anything that is content. Reach for an island only when the value is genuinely
48
+ live (a countdown, a "time since listed" that must tick).
49
+
50
+ ### Before
51
+
52
+ ```tsx
53
+ import { Section, Slot } from "@homepages/template-kit";
54
+
55
+ import type { Props } from "./schema.js";
56
+
57
+ export function Renderer({ slots }: Props) {
58
+ return (
59
+ <Section>
60
+ <Slot id="headline" as="h1" textLeaf>
61
+ <span>{slots.headline}</span>
62
+ </Slot>
63
+ <p>© {new Date().getFullYear()} — all rights reserved</p>
64
+ </Section>
65
+ );
66
+ }
67
+ ```
68
+
69
+ ### After
70
+
71
+ The year is content, so it becomes a slot the fill pipeline supplies. Declare it in
72
+ `schema.ts`, and the Renderer just renders what it was given:
73
+
74
+ ```tsx
75
+ import { Section, Slot } from "@homepages/template-kit";
76
+
77
+ import type { Props } from "./schema.js";
78
+
79
+ export function Renderer({ slots }: Props) {
80
+ return (
81
+ <Section>
82
+ <Slot id="headline" as="h1" textLeaf>
83
+ <span>{slots.headline}</span>
84
+ </Slot>
85
+ <Slot id="copyright_year" textLeaf>
86
+ <p>© {slots.copyright_year} — all rights reserved</p>
87
+ </Slot>
88
+ </Section>
89
+ );
90
+ }
91
+ ```
92
+
93
+ If the value must be live in the browser rather than baked at fill time, move that
94
+ markup into a `"use client"` component in the section folder instead, and render it from
95
+ the Renderer — the rule does not run there.
96
+
97
+ ## See also
98
+
99
+ - [Server vs client](server-vs-client.md) — how a file is scoped, and what an island may do.
100
+ - [Schema system](../schema-system.md) — declaring the slot the value arrives in.
@@ -0,0 +1,100 @@
1
+ ---
2
+ purpose: The template-kit/no-raw-element authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, ../primitives.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/no-raw-element
8
+
9
+ > **Exactly two** raw elements are banned: `<section>` and `<img>`. Every other tag is
10
+ > yours.
11
+
12
+ ## Rule
13
+
14
+ Two tags, and no others:
15
+
16
+ | Raw tag | Use instead |
17
+ |---|---|
18
+ | `<section>` | `<Section>` |
19
+ | `<img>` | `<Image>` |
20
+
21
+ That is the entire rule. **`<a>`, `<h1>`–`<h6>`, `<div>`, `<ul>`, `<button>`, `<picture>`,
22
+ `<figure>` — every other element is yours to write.** Presentation is the template's own
23
+ JSX; the kit is not trying to own your markup. If you have concluded "the kit bans raw
24
+ HTML", that is exactly backwards.
25
+
26
+ There is no island exemption: the two tags are banned in a `"use client"` file too.
27
+
28
+ ## Reason
29
+
30
+ Each of the two banned tags has a **contract-bearing primitive that must emit it**, and
31
+ writing the raw tag skips the contract.
32
+
33
+ - **`<Section>`** emits `<section class="tr-section">`, which is full-bleed by contract:
34
+ 100% width, zero padding, zero margin, so consecutive sections butt edge-to-edge and a
35
+ page composes as a clean vertical stack. A raw `<section>` lacks that class, so the
36
+ page grows gaps the template never asked for.
37
+ - **`<Image>`** owns the responsive `<picture>` (per-format AVIF/WebP `srcset`, the
38
+ mobile crop, `sizes`), the aspect-ratio box, and the neutral fallback rect that keeps
39
+ the layout box when `src` is empty. A raw `<img>` with a null `src` is a collapsed box
40
+ or a broken-image icon on a real customer's page — and a missing image is a **supported
41
+ state**, not a bug: it happens whenever a listing is thin. `<Image slotId>` also emits
42
+ the slot marker itself, so the image stays selectable in the editor.
43
+
44
+ The two tags are banned because they are the two the platform has to be able to trust.
45
+ Everything else carries no contract, so nothing is gained by policing it.
46
+
47
+ ## Fix
48
+
49
+ Import the primitive from the package root and use it:
50
+
51
+ ```tsx
52
+ import { Section, Image } from "@homepages/template-kit";
53
+ ```
54
+
55
+ ### Before
56
+
57
+ ```tsx
58
+ import type { Props } from "./schema.js";
59
+
60
+ export function Renderer({ slots }: Props) {
61
+ return (
62
+ <section>
63
+ <h2>{slots.headline}</h2>
64
+ <img src={slots.hero_image.url ?? ""} alt={slots.hero_image.alt} />
65
+ </section>
66
+ );
67
+ }
68
+ ```
69
+
70
+ ### After
71
+
72
+ ```tsx
73
+ import { Image, Section } from "@homepages/template-kit";
74
+
75
+ import type { Props } from "./schema.js";
76
+
77
+ export function Renderer({ slots }: Props) {
78
+ return (
79
+ <Section>
80
+ <h2>{slots.headline}</h2>
81
+ <Image
82
+ slotId="hero_image"
83
+ src={slots.hero_image.url}
84
+ alt={slots.hero_image.alt}
85
+ responsive={slots.hero_image.responsive}
86
+ aspectRatio="5 / 3"
87
+ />
88
+ </Section>
89
+ );
90
+ }
91
+ ```
92
+
93
+ The `<h2>` stays exactly as it was. `Image` takes `src`/`alt` directly — a `null` or empty
94
+ `src` is a supported state it renders a fallback rect for, so no `?? ""` guard is needed.
95
+
96
+ ## See also
97
+
98
+ - [Contract primitives](../primitives.md) — all five primitives and the markers they emit.
99
+ - [`image-bare-needs-reason`](image-bare-needs-reason.md) — the escape hatch that drops
100
+ `<Image>`'s frame, and what it costs.
@@ -0,0 +1,87 @@
1
+ ---
2
+ purpose: The template-kit/no-templates authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, manifest-invalid.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/no-templates
8
+
9
+ > A run that gates nothing must never report success. This is what a workspace with no
10
+ > template to check reports instead of a silent pass.
11
+
12
+ ## Rule
13
+
14
+ `templates/` must hold at least one template folder — for the run you asked for:
15
+
16
+ - `template-kit check` (no argument, from the workspace root): `templates/` has no
17
+ folder in it at all.
18
+ - `template-kit check --all`: same — no template folder exists to check.
19
+ - `template-kit check <name>` (or a bare `check` run from inside a template's own
20
+ folder): that one named template does not exist. (A folder that exists but is
21
+ missing its `manifest.json` is a different, more specific failure —
22
+ [`manifest-invalid`](manifest-invalid.md) — because a folder under `templates/` is a
23
+ template whatever else is wrong with it.)
24
+
25
+ This is checked once per run, scoped to the workspace, not to any one template.
26
+
27
+ ## Reason
28
+
29
+ `every()` over an empty set is vacuously true. A `check` run that selected zero templates
30
+ and reported "check passed" would exit 0 having run not one gate — indistinguishable from
31
+ a workspace that passed every rule, when in truth no rule ran at all. Anything that
32
+ re-runs this command on your behalf (an ingest pipeline, a CI step) would accept that
33
+ silence as a clean submission.
34
+
35
+ Reporting a diagnostic instead means "nothing to check" is a failure your run surfaces,
36
+ not a false green light.
37
+
38
+ ## Fix
39
+
40
+ Add a template: `templates/<key>/` with a `manifest.json` declaring its section
41
+ composition, and a `sections/` folder holding the sections it composes. If you meant to
42
+ check a specific template and named it wrong, or ran `check` from a folder that isn't a
43
+ template folder, correct the name or your working directory instead.
44
+
45
+ ### Before
46
+
47
+ ```text
48
+ my-workspace/
49
+ package.json
50
+ package-lock.json
51
+ templates/ ← empty
52
+ ```
53
+
54
+ ```text
55
+ $ template-kit check
56
+ ✗ workspace — 1 problem(s)
57
+ [template-kit/no-templates] This workspace has no template to check: templates/ holds no template folder.
58
+ fix: Add a template: templates/<key>/ with a manifest.json declaring its section composition and a sections/ folder holding the sections it composes.
59
+ ```
60
+
61
+ ### After
62
+
63
+ ```text
64
+ my-workspace/
65
+ package.json
66
+ package-lock.json
67
+ templates/
68
+ acme-modern/
69
+ manifest.json
70
+ sections/
71
+ hero/
72
+ Renderer.tsx
73
+ schema.ts
74
+ fill-spec.ts
75
+ fixtures.ts
76
+ ```
77
+
78
+ ```text
79
+ $ template-kit check
80
+ ✓ acme-modern
81
+ check passed
82
+ ```
83
+
84
+ ## See also
85
+
86
+ - [`manifest-invalid`](manifest-invalid.md) — the check that takes over once a template
87
+ folder exists, on whether its `manifest.json` is present and correct.
@@ -0,0 +1,76 @@
1
+ ---
2
+ purpose: The template-kit/parse-error authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, typecheck.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/parse-error
8
+
9
+ > A file so broken it never became an AST. Every other rule needs one; this is what's
10
+ > reported when there isn't one to have an opinion about.
11
+
12
+ ## Rule
13
+
14
+ Every other lint rule runs against a parsed file — an AST it can walk and ask questions
15
+ of. When a `.ts`/`.tsx` file cannot even be parsed (invalid syntax: an unclosed brace, a
16
+ stray token, a JSX tag that never closes), there is no AST for any rule to run against,
17
+ and no rule id of its own to report. This id is what's reported in that one case instead:
18
+ the parser's own message, relocated to the file and line it names.
19
+
20
+ This is the only diagnostic under this id — it is not a rule you can violate by writing
21
+ working code a particular way, the way `no-hex` or `no-inline-style` are. It exists so
22
+ that "the file didn't parse" is a rule-id and a fix hint, not silence or a raw crash.
23
+
24
+ ## Reason
25
+
26
+ A stage never aborts the run — an author fixing one file's problem must see every other
27
+ file's problems in the same run, not discover them one release at a time. A parser
28
+ exception is the one case where that promise is hardest to keep: there is genuinely
29
+ nothing else to check about a file that doesn't parse. Reporting it under its own id,
30
+ rather than dropping it or letting it throw, keeps that promise intact.
31
+
32
+ You may see the **same file** flagged here and under
33
+ [`typecheck`](typecheck.md): the lint stage and the typecheck stage each parse the
34
+ workspace independently, and a syntax error fails both parsers, so both stages report it,
35
+ each in their own words. Fixing the syntax error clears both.
36
+
37
+ ## Fix
38
+
39
+ Read the message — it is the parser's own error, naming the file and the line. Fix the
40
+ syntax at that location.
41
+
42
+ ### Before
43
+
44
+ ```tsx
45
+ // sections/hero/Renderer.tsx
46
+ export function Renderer() {
47
+ return (
48
+ <Section>
49
+ <h1>{headline}</h1>
50
+ </Section
51
+ );
52
+ }
53
+ ```
54
+
55
+ ```text
56
+ sections/hero/Renderer.tsx:6
57
+ Parsing error: '>' expected.
58
+ ```
59
+
60
+ ### After
61
+
62
+ ```tsx
63
+ // sections/hero/Renderer.tsx
64
+ export function Renderer() {
65
+ return (
66
+ <Section>
67
+ <h1>{headline}</h1>
68
+ </Section>
69
+ );
70
+ }
71
+ ```
72
+
73
+ ## See also
74
+
75
+ - [`typecheck`](typecheck.md) — the sibling failure one layer up: a file that parses but
76
+ does not type-check.
@@ -0,0 +1,119 @@
1
+ ---
2
+ purpose: The template-kit/props-from-schema authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, ../schema-system.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/props-from-schema
8
+
9
+ > A Renderer's props type is **derived** from `schema.ts`, never hand-written in the
10
+ > Renderer.
11
+
12
+ ## Rule
13
+
14
+ The rule runs only on `Renderer.tsx`. It reports an **exported** type alias or interface
15
+ whose name **ends in `Props`** — `Props`, `HeroProps`, `GalleryProps`.
16
+
17
+ Two consequences of that being the whole test:
18
+
19
+ - **It does not read the right-hand side.** Even `export type Props = SectionProps<typeof
20
+ schema>;` reports *if you write it in the Renderer*. The derivation belongs in
21
+ `schema.ts`; the Renderer **imports** it.
22
+ - **A non-exported local type is invisible** to the rule. An internal
23
+ `type Row = { id: string };` used to type a helper's parameter is your business, and is
24
+ not reported.
25
+
26
+ `import type { Props } from "./schema.js";` is the shape the rule is steering you to, and
27
+ it reports nothing.
28
+
29
+ ## Reason
30
+
31
+ The schema is the section's single source of truth, and the prop type is **inferred from
32
+ it** — `SectionProps<typeof schema>` reads the schema literal and resolves each slot to
33
+ its runtime value shape: a required `text` becomes `string`, an optional one
34
+ `string | null`, an `image` becomes `ImageValue`, and so on.
35
+
36
+ A hand-written `Props` shadows that inference, and then **drifts silently**. Add a slot
37
+ to `schema.ts` and forget to render it: with the derived type, reading `slots.subhead` in
38
+ the Renderer is a compile error until the slot exists, and the platform's tooling and
39
+ your renderer agree by construction. With a hand-written `Props`, the Renderer keeps
40
+ compiling happily against the old shape — while the editor, the fill pipeline, and the
41
+ schema all now believe in a slot your markup has never heard of. Nothing fails until a
42
+ user is looking at a page with a missing block in it.
43
+
44
+ It also forces the editor to reason about a second definition of the same thing.
45
+
46
+ ## Fix
47
+
48
+ Declare the derived type once in `schema.ts` and import it in the Renderer:
49
+
50
+ ```ts
51
+ export type Props = SectionProps<typeof schema>;
52
+ ```
53
+
54
+ ```ts
55
+ import type { Props } from "./schema.js";
56
+ ```
57
+
58
+ ### Before
59
+
60
+ ```tsx
61
+ // sections/hero/Renderer.tsx
62
+ import { Section, Slot } from "@homepages/template-kit";
63
+
64
+ export type Props = {
65
+ slots: { headline: string };
66
+ };
67
+
68
+ export function Renderer({ slots }: Props) {
69
+ return (
70
+ <Section>
71
+ <Slot id="headline" as="h1" textLeaf>
72
+ <span>{slots.headline}</span>
73
+ </Slot>
74
+ </Section>
75
+ );
76
+ }
77
+ ```
78
+
79
+ ### After
80
+
81
+ ```ts
82
+ // sections/hero/schema.ts
83
+ import type { SectionProps, SectionSchemaShape } from "@homepages/template-kit";
84
+
85
+ export const schema = {
86
+ meta: { displayName: "Hero" },
87
+ slots: {
88
+ headline: { type: "text", size: "medium", required: true, produced_by: "headline_text" },
89
+ },
90
+ } as const satisfies SectionSchemaShape;
91
+
92
+ export type Props = SectionProps<typeof schema>;
93
+ ```
94
+
95
+ ```tsx
96
+ // sections/hero/Renderer.tsx
97
+ import { Section, Slot } from "@homepages/template-kit";
98
+
99
+ import type { Props } from "./schema.js";
100
+
101
+ export function Renderer({ slots }: Props) {
102
+ return (
103
+ <Section>
104
+ <Slot id="headline" as="h1" textLeaf>
105
+ <span>{slots.headline}</span>
106
+ </Slot>
107
+ </Section>
108
+ );
109
+ }
110
+ ```
111
+
112
+ The `as const satisfies SectionSchemaShape` is load-bearing: without it the literal types
113
+ are widened away and `SectionProps` can no longer tell a required slot from an optional
114
+ one.
115
+
116
+ ## See also
117
+
118
+ - [Schema system](../schema-system.md) — the schema literal, `SectionProps` inference,
119
+ and the four contract files.
@@ -0,0 +1,165 @@
1
+ ---
2
+ purpose: The template-kit/render-invariant authoring rule — what it enforces, why, and how to fix it.
3
+ status: living
4
+ related: [INDEX.md, ../primitives.md, ../schema-system.md]
5
+ updated: 2026-07-14
6
+ ---
7
+ # template-kit/render-invariant
8
+
9
+ > Three things must never reach a published page: `[object Object]`, a bare
10
+ > `null`/`undefined`/`NaN`, and an `<img>` with no source. Every one of them is garbage a
11
+ > real buyer would see.
12
+
13
+ ## Rule
14
+
15
+ Every section is server-rendered against every fixture in its invariant corpus, and the
16
+ output is checked for three defects:
17
+
18
+ 1. **The text `[object Object]`**, anywhere in the rendered HTML.
19
+ 2. **A text leaf that is exactly `null`, `undefined`, or `NaN`.** The match is on the whole
20
+ leaf, so prose that merely contains the word ("null and void") is safe.
21
+ 3. **An `<img>` whose `src` is absent, empty, `"undefined"`, or `"null"`.**
22
+
23
+ The corpus is your `base` fixture, each of your `states` fixtures, and one **synthesized**
24
+ fixture per optional slot with that slot set to its empty value (`null` for a scalar or an
25
+ image, `[]` for a list) — derived from the schema, not declared by you.
26
+
27
+ The check is deliberately biased toward **false negatives**: it would rather miss a bug than
28
+ invent one. Everything it reports is a real defect, so there is nothing here to argue with
29
+ or configure around.
30
+
31
+ ## Reason
32
+
33
+ **Your fixtures are richer than the content a real listing gets filled with.** That is the
34
+ whole story, and almost every violation of this rule is a version of it.
35
+
36
+ You wrote `fixtures.ts` from a property you had in front of you: twelve units, a year
37
+ built, a hero photo, an agent headshot. The fill engine, on a thin listing, bakes in what
38
+ actually exists — which may be no units array at all, no year, no photo. Your preview looks
39
+ perfect, your states look perfect, and the section still renders `Built undefined` on a
40
+ real customer's page, or throws on `slots.units.map` because `units` is `null`.
41
+
42
+ This is precisely why the corpus adds the synthesized nullability fixtures. They are
43
+ adversarial inputs, not designed states: they render your section against exactly the thin
44
+ content your own fixtures never showed you.
45
+
46
+ So the rule to internalize is not "avoid these three strings." It is: **an absent slot is
47
+ routine, not exceptional** — and your markup has to say what happens then.
48
+
49
+ ### Where the three defects actually come from
50
+
51
+ Worth being precise, because the obvious guesses are wrong. Rendering a nullish slot as a
52
+ **child** is safe: React renders `null` and `undefined` as nothing, so `<p>{slots.year_built}</p>`
53
+ on a thin listing is an empty `<p>`, not the text `undefined`. And a list slot is **never
54
+ `null`** — `list`, `object_list`, `poi_list` and `image_collection` always arrive as an array
55
+ (empty when absent), so `.map` cannot throw and `?? []` buys you nothing.
56
+
57
+ The defects come from three narrower places:
58
+
59
+ - **String coercion.** A template literal, `String(x)`, `"" + x`, `.join()`, or **any JSX
60
+ string attribute** (`alt`, `title`, `aria-label`, `src`) turns `null` into `"null"`, an
61
+ absent field into `"undefined"`, and an object into `"[object Object]"`. Coercion is what
62
+ React's own child handling was protecting you from.
63
+ - **Arithmetic on an absent number.** `null * 1.05` is `NaN`, and React renders `NaN` as
64
+ text.
65
+ - **`<Image bare>` with no source.** Framed `<Image>` emits no `<img>` at all when `src` is
66
+ empty — it renders its fallback rect. `bare` forfeits that and ships `<img src="">`.
67
+
68
+ ## Fix
69
+
70
+ - **Never coerce a slot into a string.** Branch on it and render the surrounding text as
71
+ markup, or guard it before it reaches a template literal or an attribute.
72
+ - **Guard arithmetic** behind a `typeof x === "number"` check.
73
+ - **Format an `object_list` row's fields** — they are typed `unknown`, so pick, check, and
74
+ format each one; never interpolate the row.
75
+ - **Let framed `<Image>` handle a missing image**, and reach for `bare` only where the source
76
+ is guaranteed.
77
+
78
+ ### Before
79
+
80
+ ```tsx
81
+ // sections/units/Renderer.tsx
82
+ import { Image, Section, Slot, SlotItem } from "@homepages/template-kit";
83
+
84
+ import type { Props } from "./schema.js";
85
+
86
+ export function Renderer({ slots }: Props) {
87
+ return (
88
+ <Section>
89
+ {/* coerced: an absent year_built lands in the heading as the text "null" */}
90
+ <h2>{`${slots.headline} — built ${slots.year_built}`}</h2>
91
+
92
+ <Slot id="units">
93
+ {slots.units.map((unit, i) => (
94
+ <SlotItem key={String(unit.id)} index={i} as="li">
95
+ {/* coerced: a unit with no price renders "2 Bed · undefined" */}
96
+ <h3>{`${unit.label} · ${unit.price}`}</h3>
97
+ {/* arithmetic on an absent sqft: NaN, rendered as text */}
98
+ <p>{Number(unit.sqft) * 1.05} sq ft heated</p>
99
+ </SlotItem>
100
+ ))}
101
+ </Slot>
102
+
103
+ {/* bare: the card grid already sizes the thumbnail. */}
104
+ <Image bare src={slots.floorplan?.url ?? ""} alt="Floor plan" />
105
+ </Section>
106
+ );
107
+ }
108
+ ```
109
+
110
+ On the golden property this renders perfectly. On a listing with no year, an unpriced unit,
111
+ and no floor plan it ships `built null`, `2 Bed · undefined`, `NaN sq ft heated`, and
112
+ `<img src="">`.
113
+
114
+ ### After
115
+
116
+ ```tsx
117
+ // sections/units/Renderer.tsx
118
+ import { Image, Section, Slot, SlotItem } from "@homepages/template-kit";
119
+
120
+ import type { Props } from "./schema.js";
121
+
122
+ export function Renderer({ slots }: Props) {
123
+ return (
124
+ <Section>
125
+ <h2>
126
+ {slots.headline}
127
+ {slots.year_built ? ` — built ${slots.year_built}` : null}
128
+ </h2>
129
+
130
+ <Slot id="units">
131
+ {slots.units.map((unit, i) => (
132
+ <SlotItem key={typeof unit.id === "string" ? unit.id : i} index={i} as="li">
133
+ <h3>
134
+ {typeof unit.label === "string" ? unit.label : null}
135
+ {typeof unit.price === "number" ? ` · ${unit.price}` : null}
136
+ </h3>
137
+ {typeof unit.sqft === "number" ? (
138
+ <p>{Math.round(unit.sqft * 1.05)} sq ft heated</p>
139
+ ) : null}
140
+ </SlotItem>
141
+ ))}
142
+ </Slot>
143
+
144
+ <Image
145
+ slotId="floorplan"
146
+ src={slots.floorplan?.url ?? null}
147
+ alt={slots.floorplan?.alt ?? ""}
148
+ aspectRatio="4 / 3"
149
+ />
150
+ </Section>
151
+ );
152
+ }
153
+ ```
154
+
155
+ Every coercion now sits behind a check, and the image is framed: a null `src` renders the
156
+ fallback rect, holding the layout box and keeping the slot selectable instead of shipping a
157
+ source-less `<img>`.
158
+
159
+ ## See also
160
+
161
+ - [Contract primitives: `Image`](../primitives.md#image--the-defensive-image-primitive) — what
162
+ the frame does with a missing source, and why `bare` forfeits it.
163
+ - [`typecheck`](typecheck.md) — the same class of bug, caught earlier, for the cases the
164
+ types can see.
165
+ - [`determinism-drift`](determinism-drift.md) — the other check that runs over this corpus.