@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.
- package/CHANGELOG.md +220 -0
- package/README.md +70 -16
- package/dist/base.css +36 -48
- package/dist/cli/check/config.js +37 -0
- package/dist/cli/check/css.js +117 -0
- package/dist/cli/check/diagnostics.js +32 -0
- package/dist/cli/check/index.js +87 -0
- package/dist/cli/check/loader.js +142 -0
- package/dist/cli/check/paths.js +15 -0
- package/dist/cli/check/relativize.js +18 -0
- package/dist/cli/check/render-invariants.js +24 -0
- package/dist/cli/check/resolve-tool.js +38 -0
- package/dist/cli/check/stages/deps.js +337 -0
- package/dist/cli/check/stages/lint.js +46 -0
- package/dist/cli/check/stages/render.js +101 -0
- package/dist/cli/check/stages/size.js +158 -0
- package/dist/cli/check/stages/tree.js +207 -0
- package/dist/cli/check/stages/typecheck.js +46 -0
- package/dist/cli/check/stages/validate/catalog-exhaustiveness.js +18 -0
- package/dist/cli/check/stages/validate/facts.js +18 -0
- package/dist/cli/check/stages/validate/formatter-contract.js +14 -0
- package/dist/cli/check/stages/validate/group-contract.js +21 -0
- package/dist/cli/check/stages/validate/index.js +66 -0
- package/dist/cli/check/stages/validate/list-scalar-contract.js +22 -0
- package/dist/cli/check/stages/validate/nav-contract.js +93 -0
- package/dist/cli/check/stages/validate/nested-slot-contract.js +12 -0
- package/dist/cli/check/stages/validate/object-list-contract.js +31 -0
- package/dist/cli/check/stages/validate/orchestrator.js +241 -0
- package/dist/cli/check/stages/validate/produced-by-contract.js +18 -0
- package/dist/cli/check/stages/validate/row-contract.js +31 -0
- package/dist/cli/check/stages/validate/select-contract.js +13 -0
- package/dist/cli/check/stages/validate/source-contract.js +21 -0
- package/dist/cli/check/stages/validate/variant-contract.js +15 -0
- package/dist/cli/check/workspace.js +86 -0
- package/dist/cli/theme/generate.js +43 -0
- package/dist/cli/theme/index.js +33 -0
- package/dist/cli/theme/load-theme.js +69 -0
- package/dist/cli.js +22 -4
- package/dist/contracts/fill-treatments.js +47 -0
- package/dist/design-system/theme.d.ts +30 -299
- package/dist/design-system/theme.js +112 -83
- package/dist/eslint/rules/image-bare-needs-reason.js +6 -1
- package/dist/eslint/rules/no-client-directive-in-contract.js +6 -1
- package/dist/eslint/rules/no-client-runtime-in-server.js +5 -1
- package/dist/eslint/rules/no-css-import-from-render-path.js +8 -2
- package/dist/eslint/rules/no-hex.js +84 -7
- package/dist/eslint/rules/no-inline-style.js +5 -1
- package/dist/eslint/rules/no-nondeterminism.js +5 -1
- package/dist/eslint/rules/no-raw-element.js +6 -1
- package/dist/eslint/rules/props-from-schema.js +6 -1
- package/dist/eslint/rules/serializable-island-props.js +5 -1
- package/dist/eslint/rules/slot-marker-literal.js +6 -1
- package/dist/eslint.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/package.js +1 -1
- package/dist/primitives/Image.js +1 -1
- package/dist/rules/registry.js +16 -0
- package/dist/schema/fill-spec.d.ts +80 -590
- package/dist/schema/fixture-schema.d.ts +5 -81
- package/dist/schema/manifest.d.ts +39 -475
- package/dist/schema/resolve-section-ref.js +10 -0
- package/dist/schema/rows.js +10 -0
- package/dist/schema/section-nav.js +9 -0
- package/dist/schema/section-schema.d.ts +51 -437
- package/dist/schema/slot-types.d.ts +12 -16
- package/dist/styles.css +31 -88
- package/docs/INDEX.md +5 -3
- package/docs/check.md +121 -0
- package/docs/eslint.md +25 -50
- package/docs/llms.txt +26 -11
- package/docs/primitives.md +5 -0
- package/docs/rules/INDEX.md +65 -0
- package/docs/rules/audit-severity.md +91 -0
- package/docs/rules/bundle-binary-asset.md +102 -0
- package/docs/rules/bundle-incomplete.md +117 -0
- package/docs/rules/css-reason.md +94 -0
- package/docs/rules/determinism-drift.md +112 -0
- package/docs/rules/fixtures-invalid.md +96 -0
- package/docs/rules/image-bare-needs-reason.md +117 -0
- package/docs/rules/license-denied.md +114 -0
- package/docs/rules/lockfile-missing.md +68 -0
- package/docs/rules/lockfile-stale.md +88 -0
- package/docs/rules/manifest-invalid.md +99 -0
- package/docs/rules/missing-slot-marker.md +140 -0
- package/docs/rules/no-bare-css-import.md +128 -0
- package/docs/rules/no-client-directive-in-contract.md +111 -0
- package/docs/rules/no-client-runtime-in-server.md +143 -0
- package/docs/rules/no-css-import-from-render-path.md +118 -0
- package/docs/rules/no-hex.md +163 -0
- package/docs/rules/no-inline-style.md +121 -0
- package/docs/rules/no-nondeterminism.md +100 -0
- package/docs/rules/no-raw-element.md +100 -0
- package/docs/rules/no-templates.md +87 -0
- package/docs/rules/parse-error.md +76 -0
- package/docs/rules/props-from-schema.md +119 -0
- package/docs/rules/render-invariant.md +165 -0
- package/docs/rules/schema-invalid.md +96 -0
- package/docs/rules/serializable-island-props.md +146 -0
- package/docs/rules/server-vs-client.md +105 -0
- package/docs/rules/sidebar-order.md +109 -0
- package/docs/rules/single-react.md +97 -0
- package/docs/rules/size-renderer-bundle.md +109 -0
- package/docs/rules/size-section-css.md +95 -0
- package/docs/rules/slot-group-marker.md +127 -0
- package/docs/rules/slot-marker-literal.md +148 -0
- package/docs/rules/typecheck.md +103 -0
- package/docs/rules/unlayered-fence.md +113 -0
- package/docs/theme-and-css.md +193 -82
- package/package.json +4 -9
- 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.
|