@homepages/template-kit 0.1.0 → 0.2.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 +69 -0
- package/README.md +12 -2
- package/dist/base.css +27 -48
- package/dist/design-system/theme.d.ts +2 -1
- package/dist/design-system/theme.js +10 -3
- 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 +6 -1
- 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/package.js +1 -1
- package/dist/rules/registry.js +16 -0
- package/dist/styles.css +1 -1
- package/docs/INDEX.md +2 -1
- package/docs/eslint.md +12 -47
- package/docs/llms.txt +7 -4
- package/docs/rules/INDEX.md +58 -0
- package/docs/rules/audit-severity.md +91 -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/image-bare-needs-reason.md +117 -0
- package/docs/rules/license-denied.md +106 -0
- package/docs/rules/lockfile-missing.md +68 -0
- package/docs/rules/lockfile-stale.md +88 -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 +97 -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/props-from-schema.md +119 -0
- package/docs/rules/render-invariant.md +165 -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 +57 -11
- package/package.json +1 -1
|
@@ -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.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
---
|
|
2
|
+
purpose: The template-kit/serializable-island-props authoring rule — what it enforces, why, and how to fix it.
|
|
3
|
+
status: living
|
|
4
|
+
related: [INDEX.md, server-vs-client.md, ../islands.md, no-client-runtime-in-server.md]
|
|
5
|
+
updated: 2026-07-14
|
|
6
|
+
---
|
|
7
|
+
# template-kit/serializable-island-props
|
|
8
|
+
|
|
9
|
+
> Props handed to an island must be JSON-serializable. This rule is about what a file
|
|
10
|
+
> *hands to* an island, so it has **no island exemption** — it runs everywhere.
|
|
11
|
+
|
|
12
|
+
## Rule
|
|
13
|
+
|
|
14
|
+
The rule looks at every JSX element whose component was imported from a file that is a
|
|
15
|
+
real `"use client"` module on disk. On those elements — and only those — it reports any
|
|
16
|
+
prop value it can statically see to be un-JSON-able:
|
|
17
|
+
|
|
18
|
+
- **a literal function** — an arrow or a `function` expression (`onSelect={() => …}`);
|
|
19
|
+
- **`new Anything()`** — `new Date()`, `new Map()`, `new Set()`, `new URL(…)`,
|
|
20
|
+
`new RegExp(…)`, `new YourClass()`. Everything except `new Object()` and `new Array()`,
|
|
21
|
+
which are plain and cross fine;
|
|
22
|
+
- **a bigint literal** — `10n`;
|
|
23
|
+
- **`NaN` and `Infinity`** — JSON has neither.
|
|
24
|
+
|
|
25
|
+
It recurses into object and array literals, and names the exact path it found:
|
|
26
|
+
`props.nested.deep.cb`, `props.units[0].created`.
|
|
27
|
+
|
|
28
|
+
Carve-outs:
|
|
29
|
+
|
|
30
|
+
- **A getter or setter in an object literal is skipped.** An accessor reads a value back
|
|
31
|
+
out; it never receives one across the boundary.
|
|
32
|
+
- **A value handed over as a plain identifier is invisible.** `onPick={pick}` reports
|
|
33
|
+
nothing — the rule cannot see what `pick` is. That case is deliberately left to the
|
|
34
|
+
runtime check, which throws on it. The lint catches what it can see, early; the runtime
|
|
35
|
+
catches the rest.
|
|
36
|
+
- **This rule stops at the island boundary — the preset does not.**
|
|
37
|
+
`<UnitTable onSelect={() => {}} />` is not reported *by this rule*, because
|
|
38
|
+
`UnitTable` is not an island and no prop crosses a serialization boundary. But inside
|
|
39
|
+
a server-rendered Renderer, that same line is still reported — by
|
|
40
|
+
[`no-client-runtime-in-server`](no-client-runtime-in-server.md), which bans any
|
|
41
|
+
`on*` JSX prop carrying a function on a non-island target, because the handler is
|
|
42
|
+
dropped from the published HTML regardless of what it's attached to. Moving a
|
|
43
|
+
handler off an island and onto an ordinary component doesn't clear the violation —
|
|
44
|
+
it trades this rule's error for that one.
|
|
45
|
+
|
|
46
|
+
## Reason
|
|
47
|
+
|
|
48
|
+
**Props are JSON on the wire.** An island is server-rendered inline with the page, and
|
|
49
|
+
then its props are emitted next to it as a JSON script tag, which the browser parses back
|
|
50
|
+
before hydrating. So a prop is not passed to the island — a *JSON round-trip of* the prop
|
|
51
|
+
is.
|
|
52
|
+
|
|
53
|
+
A function does not survive that trip. Neither does a `Date` (it becomes a string), a
|
|
54
|
+
`Map` (it becomes `{}`), or `NaN` (it becomes `null`). The runtime refuses rather than
|
|
55
|
+
shipping a corrupted prop: it throws `IslandPropsError`, naming the path. This rule says
|
|
56
|
+
the same thing in your editor, in the same words, before the render ever runs.
|
|
57
|
+
|
|
58
|
+
## Fix
|
|
59
|
+
|
|
60
|
+
**Pass data, not behavior.** An island owns its own handlers, so it does not need yours.
|
|
61
|
+
A `Date`/`Map`/`Set`/`RegExp` crosses as its plain-JSON equivalent — an ISO string, an
|
|
62
|
+
array of entries — which you reconstruct inside the island. For `NaN`/`Infinity`, pass a
|
|
63
|
+
finite number, or encode the sentinel as a string prop the island parses.
|
|
64
|
+
|
|
65
|
+
That reconstruction step assumes the value is backed by real data. A `Date` built from a
|
|
66
|
+
literal — `new Date(2020, 0, 1)`, with no slot behind it — has no reason to be a `Date`
|
|
67
|
+
object on the server at all: write the ISO string (or just the year, or whatever the
|
|
68
|
+
island actually needs) as a plain literal prop, and skip the round-trip entirely.
|
|
69
|
+
|
|
70
|
+
### Before
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
// sections/floorplan/Renderer.tsx
|
|
74
|
+
import { Section } from "@homepages/template-kit";
|
|
75
|
+
|
|
76
|
+
import UnitFilter from "./UnitFilter.js";
|
|
77
|
+
import type { Props } from "./schema.js";
|
|
78
|
+
|
|
79
|
+
export function Renderer({ slots }: Props) {
|
|
80
|
+
return (
|
|
81
|
+
<Section>
|
|
82
|
+
<UnitFilter
|
|
83
|
+
units={slots.units}
|
|
84
|
+
onSelect={(id: string) => console.log(id)}
|
|
85
|
+
created={new Date()}
|
|
86
|
+
/>
|
|
87
|
+
</Section>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### After
|
|
93
|
+
|
|
94
|
+
The handler moves inside the island, where it belongs. The date crosses as an ISO string
|
|
95
|
+
and is rebuilt on the other side.
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
// sections/floorplan/Renderer.tsx
|
|
99
|
+
import { Section } from "@homepages/template-kit";
|
|
100
|
+
|
|
101
|
+
import UnitFilter from "./UnitFilter.js";
|
|
102
|
+
import type { Props } from "./schema.js";
|
|
103
|
+
|
|
104
|
+
export function Renderer({ slots }: Props) {
|
|
105
|
+
return (
|
|
106
|
+
<Section>
|
|
107
|
+
<UnitFilter units={slots.units} createdIso={slots.listed_at} />
|
|
108
|
+
</Section>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
// sections/floorplan/UnitFilter.tsx
|
|
115
|
+
"use client";
|
|
116
|
+
|
|
117
|
+
import { useState } from "react";
|
|
118
|
+
|
|
119
|
+
type Unit = { id: string; label: string };
|
|
120
|
+
|
|
121
|
+
export default function UnitFilter({ units, createdIso }: { units: Unit[]; createdIso: string }) {
|
|
122
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
123
|
+
const created = new Date(createdIso); // reconstructed inside the island
|
|
124
|
+
return (
|
|
125
|
+
<div>
|
|
126
|
+
<p>Listed {created.getFullYear()}</p>
|
|
127
|
+
<ul>
|
|
128
|
+
{units.map((u) => (
|
|
129
|
+
<li key={u.id}>
|
|
130
|
+
<button onClick={() => setSelected(u.id)}>{u.label}</button>
|
|
131
|
+
</li>
|
|
132
|
+
))}
|
|
133
|
+
</ul>
|
|
134
|
+
{selected !== null && <p>Selected {selected}</p>}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## See also
|
|
141
|
+
|
|
142
|
+
- [Islands: props must be JSON-serializable](../islands.md#props-must-be-json-serializable) —
|
|
143
|
+
the full allowed/rejected table and the runtime error.
|
|
144
|
+
- [Server vs client](server-vs-client.md) — how a file is scoped.
|
|
145
|
+
- [`no-client-runtime-in-server`](no-client-runtime-in-server.md) — the rule that reports
|
|
146
|
+
an event-handler prop on a non-island target, since this one doesn't.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
---
|
|
2
|
+
purpose: The one concept the server-rendering rules assume — how a file is scoped server or client, and which rules stop at that boundary.
|
|
3
|
+
status: living
|
|
4
|
+
related: [INDEX.md, ../islands.md, ../eslint.md]
|
|
5
|
+
updated: 2026-07-14
|
|
6
|
+
---
|
|
7
|
+
# Server vs client
|
|
8
|
+
|
|
9
|
+
Several rules ban something "in server-rendered code" and say an island is exempt.
|
|
10
|
+
This page is what "server-rendered" and "island" mean, once, so the rule pages do not
|
|
11
|
+
each re-derive it.
|
|
12
|
+
|
|
13
|
+
## Scoping is per file, by the directive
|
|
14
|
+
|
|
15
|
+
A file is a **client module** if and only if its own directive prologue says
|
|
16
|
+
`"use client"`. Nothing else decides it — not what it imports, not what imports *it*,
|
|
17
|
+
not the directory it sits in, not a build transform.
|
|
18
|
+
|
|
19
|
+
The consequence surprises people: **the same code fails in one file and passes in the
|
|
20
|
+
file next to it.**
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
// sections/hero/Renderer.tsx — no directive → server-rendered
|
|
24
|
+
export function Renderer() {
|
|
25
|
+
return <p>{new Date().getFullYear()}</p>; // template-kit/no-nondeterminism
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
// sections/hero/Clock.tsx — directive → island
|
|
31
|
+
"use client";
|
|
32
|
+
|
|
33
|
+
export default function Clock() {
|
|
34
|
+
return <p>{new Date().getFullYear()}</p>; // fine
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Importing an island from a server file does **not** make the server file an island. It
|
|
39
|
+
also does not make the island a server file. Each is scoped on its own.
|
|
40
|
+
|
|
41
|
+
## The prologue
|
|
42
|
+
|
|
43
|
+
The directive prologue is the run of **leading string-literal statements**. Comments and
|
|
44
|
+
blank lines do not end it; **any other statement does**.
|
|
45
|
+
|
|
46
|
+
```tsx
|
|
47
|
+
"use strict";
|
|
48
|
+
"use client"; // still in the prologue → this file IS an island
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
const x = 1;
|
|
53
|
+
"use client"; // real code ended the prologue → this is NOT a directive, and NOT an island
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The second file is the trap. It looks like an island, it is not one, and every
|
|
57
|
+
server-rendering rule still applies to it. If you meant it to be an island, the
|
|
58
|
+
directive must be the first statement in the file.
|
|
59
|
+
|
|
60
|
+
## What each side must be
|
|
61
|
+
|
|
62
|
+
**A server-rendered file** (no directive) runs in the publisher and in the editor, and
|
|
63
|
+
must be a **pure function of its props**: same props in, byte-identical HTML out. No
|
|
64
|
+
clock, no randomness, no effects, no state, no event handlers, no network, no browser
|
|
65
|
+
globals. It has none of those things available, and the two renders must agree.
|
|
66
|
+
|
|
67
|
+
**An island** (with the directive) is server-rendered inline with the rest of the page
|
|
68
|
+
and then **hydrated** in the browser as its own React root. Effects, state, event
|
|
69
|
+
handlers, and inline `style` are all legal there — that is what an island is for. Its
|
|
70
|
+
props cross the server→browser boundary as JSON, which is the one new constraint it
|
|
71
|
+
takes on (see [`serializable-island-props`](serializable-island-props.md)).
|
|
72
|
+
|
|
73
|
+
Interactivity is never banned. It is **relocated**: out of the server-rendered file, into
|
|
74
|
+
a `"use client"` component in the same section folder, rendered from the Renderer like
|
|
75
|
+
any other component.
|
|
76
|
+
|
|
77
|
+
## Exactly three rules exempt islands
|
|
78
|
+
|
|
79
|
+
| Rule | Exempt on a `"use client"` file? |
|
|
80
|
+
|---|---|
|
|
81
|
+
| [`no-nondeterminism`](no-nondeterminism.md) | **yes** — installs no visitors on an island |
|
|
82
|
+
| [`no-client-runtime-in-server`](no-client-runtime-in-server.md) | **yes** |
|
|
83
|
+
| [`no-inline-style`](no-inline-style.md) | **yes** |
|
|
84
|
+
| every other rule | **no** — it runs on island files too |
|
|
85
|
+
|
|
86
|
+
`no-hex` deliberately does **not** exempt islands. The three exemptions above are all
|
|
87
|
+
server-render concerns, and an island runs in the browser. A palette is not a render-path
|
|
88
|
+
concern: **the hydration boundary is not a palette boundary.** A section carries no
|
|
89
|
+
palette of its own in either render path, so `"use client"` plus
|
|
90
|
+
`style={{ color: "#ff0000" }}` passes `no-inline-style` and still fails
|
|
91
|
+
[`no-hex`](no-hex.md).
|
|
92
|
+
|
|
93
|
+
## The one rule scoped by filename
|
|
94
|
+
|
|
95
|
+
[`no-client-directive-in-contract`](no-client-directive-in-contract.md) is governed by
|
|
96
|
+
this boundary but is **not** scoped by the directive — it is scoped by **file glob**. It
|
|
97
|
+
runs on a section's four contract files (`Renderer.tsx`, `schema.ts`, `fill-spec.ts`,
|
|
98
|
+
`fixtures.ts`) and says those files may never carry the directive at all. They are the
|
|
99
|
+
platform's interface to the section and are read outside a browser; making one an island
|
|
100
|
+
would take the section's server-rendered HTML with it.
|
|
101
|
+
|
|
102
|
+
## See also
|
|
103
|
+
|
|
104
|
+
- [Islands](../islands.md) — writing one, its props contract, and its editor options.
|
|
105
|
+
- [Rule index](INDEX.md) — every rule id, in both venues.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
purpose: The template-kit/sidebar-order 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/sidebar-order
|
|
8
|
+
|
|
9
|
+
> The editor sidebar's cards are ordered by the **schema**. The page is ordered by your
|
|
10
|
+
> **JSX**. They must agree — and when they don't, the schema is what moves.
|
|
11
|
+
|
|
12
|
+
## Rule
|
|
13
|
+
|
|
14
|
+
The editor renders one sidebar card per group (and one per ungrouped slot), in the order
|
|
15
|
+
the schema declares them. This check server-renders the section's baseline fixture, reads
|
|
16
|
+
the **first-appearance order of the slot markers** in the resulting HTML, and asserts that
|
|
17
|
+
it matches the schema's declaration order.
|
|
18
|
+
|
|
19
|
+
Three carve-outs, each of which looks like a bug if you don't know about it:
|
|
20
|
+
|
|
21
|
+
- **A slot rendered more than once counts at its first marker.** An address shown in a
|
|
22
|
+
header and again in a footer is ordered by the header.
|
|
23
|
+
- **Order *within* a group is not checked, and neither is contiguity.** A group's members
|
|
24
|
+
may appear in any order on the page, and other slots may sit between them. Responsive
|
|
25
|
+
layouts legitimately do both, and the sidebar collapses the group into one card anyway.
|
|
26
|
+
- **A slot declared but never rendered is excluded.** It has no position on the page, so it
|
|
27
|
+
cannot contradict one.
|
|
28
|
+
|
|
29
|
+
## Reason
|
|
30
|
+
|
|
31
|
+
Two files describe the same section in two different orders, and only one of them is
|
|
32
|
+
visible to the person editing. The failure mode is drift: you reorder the markup — move the
|
|
33
|
+
eyebrow above the headline, promote the price — the schema keeps its original order, and
|
|
34
|
+
the sidebar quietly stops matching the page.
|
|
35
|
+
|
|
36
|
+
Nothing breaks. The section renders, the slots fill, and every card still works. The user
|
|
37
|
+
just finds that the second card edits the first thing they see, and stops trusting the
|
|
38
|
+
panel. This check makes that drift impossible to ship rather than merely unlikely.
|
|
39
|
+
|
|
40
|
+
## Fix
|
|
41
|
+
|
|
42
|
+
**Reorder the schema, not the page.** The page's visual order is the truth — it is what the
|
|
43
|
+
user is looking at — so bring `schema.ts` to it: reorder the `slots` keys, and, if you have
|
|
44
|
+
groups, the `meta.groups` entries, so declaration order matches the page's top-to-bottom
|
|
45
|
+
order.
|
|
46
|
+
|
|
47
|
+
### Before
|
|
48
|
+
|
|
49
|
+
The schema declares `headline` first, the page renders `eyebrow` first:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
// sections/hero/schema.ts — template-kit/sidebar-order
|
|
53
|
+
import type { SectionProps, SectionSchemaShape } from "@homepages/template-kit";
|
|
54
|
+
|
|
55
|
+
export const schema = {
|
|
56
|
+
meta: { displayName: "Hero" },
|
|
57
|
+
slots: {
|
|
58
|
+
headline: { type: "text", size: "medium", required: true, editable_by_user: true },
|
|
59
|
+
eyebrow: { type: "text", size: "short", required: false, editable_by_user: true },
|
|
60
|
+
price: { type: "text", size: "short", required: false, editable_by_user: true },
|
|
61
|
+
},
|
|
62
|
+
} as const satisfies SectionSchemaShape;
|
|
63
|
+
|
|
64
|
+
export type Props = SectionProps<typeof schema>;
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
// sections/hero/Renderer.tsx — the page, top to bottom: eyebrow, headline, price
|
|
69
|
+
import { Section, Slot } from "@homepages/template-kit";
|
|
70
|
+
|
|
71
|
+
import type { Props } from "./schema.js";
|
|
72
|
+
|
|
73
|
+
export function Renderer({ slots }: Props) {
|
|
74
|
+
return (
|
|
75
|
+
<Section>
|
|
76
|
+
<Slot id="eyebrow" textLeaf><p>{slots.eyebrow}</p></Slot>
|
|
77
|
+
<Slot id="headline" as="h1" textLeaf><span>{slots.headline}</span></Slot>
|
|
78
|
+
<Slot id="price" textLeaf><p>{slots.price}</p></Slot>
|
|
79
|
+
</Section>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### After
|
|
85
|
+
|
|
86
|
+
The Renderer is untouched. The schema moves:
|
|
87
|
+
|
|
88
|
+
```ts
|
|
89
|
+
// sections/hero/schema.ts
|
|
90
|
+
import type { SectionProps, SectionSchemaShape } from "@homepages/template-kit";
|
|
91
|
+
|
|
92
|
+
export const schema = {
|
|
93
|
+
meta: { displayName: "Hero" },
|
|
94
|
+
slots: {
|
|
95
|
+
eyebrow: { type: "text", size: "short", required: false, editable_by_user: true },
|
|
96
|
+
headline: { type: "text", size: "medium", required: true, editable_by_user: true },
|
|
97
|
+
price: { type: "text", size: "short", required: false, editable_by_user: true },
|
|
98
|
+
},
|
|
99
|
+
} as const satisfies SectionSchemaShape;
|
|
100
|
+
|
|
101
|
+
export type Props = SectionProps<typeof schema>;
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## See also
|
|
105
|
+
|
|
106
|
+
- [The schema system](../schema-system.md#grouping-slots-into-one-editor-card) — `meta.groups`,
|
|
107
|
+
whose entry order is the other half of the sidebar's card order.
|
|
108
|
+
- [`missing-slot-marker`](missing-slot-marker.md) — the markers this check reads to
|
|
109
|
+
establish the page's order.
|