@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,96 @@
|
|
|
1
|
+
---
|
|
2
|
+
purpose: The template-kit/schema-invalid authoring rule — what it enforces, why, and how to fix it.
|
|
3
|
+
status: living
|
|
4
|
+
related: [INDEX.md, ../schema-system.md, fixtures-invalid.md, manifest-invalid.md]
|
|
5
|
+
updated: 2026-07-14
|
|
6
|
+
---
|
|
7
|
+
# template-kit/schema-invalid
|
|
8
|
+
|
|
9
|
+
> `schema.ts` and `fill-spec.ts` are a matched pair. This is every check that makes sure
|
|
10
|
+
> they actually agree.
|
|
11
|
+
|
|
12
|
+
## Rule
|
|
13
|
+
|
|
14
|
+
A section's `schema.ts` and `fill-spec.ts` must, together:
|
|
15
|
+
|
|
16
|
+
- **parse** — each is `as const satisfies` its shape (`SectionSchemaShape`,
|
|
17
|
+
`FillSpecShape`); a value that doesn't satisfy it fails here with the parse error;
|
|
18
|
+
- **cross-reference correctly** — every `fill-spec.ts` decision produces a slot that
|
|
19
|
+
actually exists in `schema.ts`, every `reads` entry names a real, author-declarable
|
|
20
|
+
facts key, every option's `affects_decisions`/`affects_slots` names a real decision or
|
|
21
|
+
slot;
|
|
22
|
+
- **satisfy the closed per-type contracts** — an `object_list` slot's `item_order`
|
|
23
|
+
covers every field, a `select` slot's cases match its transform, a group's members are
|
|
24
|
+
slots that exist, and the rest of the per-slot-type rules the schema system documents.
|
|
25
|
+
|
|
26
|
+
One violation is one diagnostic; a section with several wrong things reports all of them
|
|
27
|
+
in the same run, not one per fix.
|
|
28
|
+
|
|
29
|
+
This is the id you also see when a section **cannot be built or parsed at all** — a
|
|
30
|
+
syntax error in `schema.ts`, a missing export, an import that doesn't resolve. The
|
|
31
|
+
message in that case is the underlying build error, relocated to the file it points at.
|
|
32
|
+
|
|
33
|
+
## Reason
|
|
34
|
+
|
|
35
|
+
`fill-spec.ts` is not free-standing — every decision it declares exists to fill a slot
|
|
36
|
+
`schema.ts` declared, and every fact it reads must be one the platform actually exposes.
|
|
37
|
+
A decision that produces a slot no schema declares, or reads a fact that doesn't exist,
|
|
38
|
+
is not a style problem: the fill pipeline would either drop the output on the floor or
|
|
39
|
+
crash trying to read a fact key that isn't there. Catching the mismatch here, at author
|
|
40
|
+
time, is the difference between a red check and a listing that silently fails to fill.
|
|
41
|
+
|
|
42
|
+
## Fix
|
|
43
|
+
|
|
44
|
+
Read the message: it names the section, the decision or slot in question, and what's
|
|
45
|
+
wrong with it. Fix whichever side is actually stale — the schema, or the fill-spec.
|
|
46
|
+
|
|
47
|
+
### Before
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// sections/facts/schema.ts
|
|
51
|
+
export const schema = {
|
|
52
|
+
meta: { displayName: "Facts" },
|
|
53
|
+
slots: {
|
|
54
|
+
year_built: { type: "text", required: false, produced_by: "year_built_text" },
|
|
55
|
+
},
|
|
56
|
+
options: {},
|
|
57
|
+
} as const satisfies SectionSchemaShape;
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// sections/facts/fill-spec.ts
|
|
62
|
+
export const fillSpec = {
|
|
63
|
+
reads: [],
|
|
64
|
+
decisions: [
|
|
65
|
+
{ id: "year_built_text", type: "text-block", produces: ["year_built_summary"], length_cap: 20 },
|
|
66
|
+
// ^ typo — schema.ts declares "year_built"
|
|
67
|
+
],
|
|
68
|
+
} as const satisfies FillSpecShape;
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
Section "facts": fill-spec decision "year_built_text" produces unknown slot "year_built_summary".
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### After
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
// sections/facts/fill-spec.ts
|
|
79
|
+
export const fillSpec = {
|
|
80
|
+
reads: [],
|
|
81
|
+
decisions: [
|
|
82
|
+
{ id: "year_built_text", type: "text-block", produces: ["year_built"], length_cap: 20 },
|
|
83
|
+
],
|
|
84
|
+
} as const satisfies FillSpecShape;
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## See also
|
|
88
|
+
|
|
89
|
+
- [The schema system](../schema-system.md) — the full authoring contract for `schema.ts`
|
|
90
|
+
and `fill-spec.ts`.
|
|
91
|
+
- [`fixtures-invalid`](fixtures-invalid.md) — the sibling gate on the third contract
|
|
92
|
+
file, `fixtures.ts`.
|
|
93
|
+
- [`manifest-invalid`](manifest-invalid.md) — the same kind of cross-reference check, one
|
|
94
|
+
level up, on a template composing several sections.
|
|
95
|
+
- [`bundle-incomplete`](bundle-incomplete.md) — reported instead of this id when a
|
|
96
|
+
contract file is missing outright, rather than present and wrong.
|
|
@@ -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.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
---
|
|
2
|
+
purpose: The template-kit/single-react authoring rule — what it enforces, why, and how to fix it.
|
|
3
|
+
status: living
|
|
4
|
+
related: [INDEX.md, ../islands.md]
|
|
5
|
+
updated: 2026-07-14
|
|
6
|
+
---
|
|
7
|
+
# template-kit/single-react
|
|
8
|
+
|
|
9
|
+
> Exactly one copy of `react` and one of `react-dom` in the installed tree. A second copy
|
|
10
|
+
> breaks hydration **only in production** — never in your preview.
|
|
11
|
+
|
|
12
|
+
## Rule
|
|
13
|
+
|
|
14
|
+
The installed tree may contain **one** `react` and **one** `react-dom`. A nested duplicate
|
|
15
|
+
anywhere under `node_modules` is a violation, whoever pulled it in.
|
|
16
|
+
|
|
17
|
+
React is a **peer** dependency of the kit, not a bundled one: the kit ships against the
|
|
18
|
+
React your workspace installs. So do your islands, and so does every React package you add.
|
|
19
|
+
They all have to be the same React.
|
|
20
|
+
|
|
21
|
+
## Reason
|
|
22
|
+
|
|
23
|
+
React's hooks are not self-contained. `useState` does not hold your state — it dispatches
|
|
24
|
+
through a **module-level** dispatcher that the currently-rendering React sets. Two copies of
|
|
25
|
+
React on a page means two dispatchers, and an island rendered by one while hydrated by the
|
|
26
|
+
other reads a dispatcher that is null or foreign. Hydration throws (`Invalid hook call` is
|
|
27
|
+
the usual spelling), the island never comes alive, and whatever it was — the filter, the
|
|
28
|
+
carousel, the lightbox — is inert on the page.
|
|
29
|
+
|
|
30
|
+
**What makes this the worst kind of bug is where it shows up.** Islands are hydrated on the
|
|
31
|
+
**published page**. In the editor, an island renders as static markup unless it opts into
|
|
32
|
+
`live: true`, so it is never hydrated and the two Reacts never meet. Your preview is
|
|
33
|
+
perfect. Your submission builds. The section is dead on the customer's site, and nowhere
|
|
34
|
+
else.
|
|
35
|
+
|
|
36
|
+
## Fix
|
|
37
|
+
|
|
38
|
+
Get the tree back to one copy:
|
|
39
|
+
|
|
40
|
+
1. **`npm dedupe`** — if the versions are compatible, npm hoists them to a single copy and
|
|
41
|
+
you are done.
|
|
42
|
+
2. **Align your own range.** The kit's peer range is `react@^19` / `react-dom@^19`; if your
|
|
43
|
+
`package.json` pins something older, npm has no choice but to install two.
|
|
44
|
+
3. **Deal with the package that pinned its own.** A dependency that depends on a React
|
|
45
|
+
*version range your React does not satisfy* will always get its own nested copy. Widen
|
|
46
|
+
its range if it has a newer release, or replace the package. There is no way to make two
|
|
47
|
+
Reacts coexist.
|
|
48
|
+
|
|
49
|
+
Confirm with `npm ls react react-dom` — one entry each, no `deduped`-less nested copies.
|
|
50
|
+
|
|
51
|
+
### Before
|
|
52
|
+
|
|
53
|
+
```jsonc
|
|
54
|
+
// package.json
|
|
55
|
+
{
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"react": "^19.0.0",
|
|
58
|
+
"react-dom": "^19.0.0",
|
|
59
|
+
"legacy-carousel": "^2.1.0" // depends on react@^18
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
$ npm ls react
|
|
66
|
+
template-workspace
|
|
67
|
+
├─┬ legacy-carousel@2.1.0
|
|
68
|
+
│ └── react@18.3.1 ← a second React, nested
|
|
69
|
+
└── react@19.1.0
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### After
|
|
73
|
+
|
|
74
|
+
`legacy-carousel` is replaced with a package whose peer range accepts React 19:
|
|
75
|
+
|
|
76
|
+
```jsonc
|
|
77
|
+
// package.json
|
|
78
|
+
{
|
|
79
|
+
"dependencies": {
|
|
80
|
+
"react": "^19.0.0",
|
|
81
|
+
"react-dom": "^19.0.0",
|
|
82
|
+
"swiper": "^12.0.0"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
$ npm ls react
|
|
89
|
+
template-workspace
|
|
90
|
+
└── react@19.1.0
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## See also
|
|
94
|
+
|
|
95
|
+
- [Islands](../islands.md) — what gets hydrated on the published page, and what does not get
|
|
96
|
+
hydrated in the editor.
|
|
97
|
+
- [`lockfile-stale`](lockfile-stale.md) — the other check that reads your installed tree.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
purpose: The template-kit/size-renderer-bundle authoring rule — what it enforces, why, and how to fix it.
|
|
3
|
+
status: living
|
|
4
|
+
related: [INDEX.md, size-section-css.md]
|
|
5
|
+
updated: 2026-07-14
|
|
6
|
+
---
|
|
7
|
+
# template-kit/size-renderer-bundle
|
|
8
|
+
|
|
9
|
+
> A section's renderer bundle must be **≤ 50 KB gzip**. The budget is not
|
|
10
|
+
> author-overridable.
|
|
11
|
+
|
|
12
|
+
## Rule
|
|
13
|
+
|
|
14
|
+
Each section's renderer is bundled with esbuild and minified, with **React treated as
|
|
15
|
+
external** (it is a peer, shared by the whole page, and is not counted against any one
|
|
16
|
+
section). The gzipped result must be **50 KB or less**.
|
|
17
|
+
|
|
18
|
+
The budget is **visible in `template-kit check`'s configuration and is not
|
|
19
|
+
author-overridable**. A template workspace cannot raise it, and there is no per-section
|
|
20
|
+
exemption. If you go looking for the flag, this is the answer: there isn't one.
|
|
21
|
+
|
|
22
|
+
## Reason
|
|
23
|
+
|
|
24
|
+
The output is a real page for a real buyer, standing in a driveway on a phone. Bytes are the
|
|
25
|
+
one cost the section cannot delegate.
|
|
26
|
+
|
|
27
|
+
The number is set to be generous — roughly **twice** the largest section shipping today — so
|
|
28
|
+
it does not fight ordinary authoring. You will not hit it by writing a rich section. You hit
|
|
29
|
+
it when something **runaway** gets pulled in:
|
|
30
|
+
|
|
31
|
+
- **a barrel import of an icon set**, which drags every icon into the bundle so you can use
|
|
32
|
+
three;
|
|
33
|
+
- **a package that brings a font, a locale table, or a polyfill along with it**, none of
|
|
34
|
+
which you asked for.
|
|
35
|
+
|
|
36
|
+
That is the shape of essentially every violation: not a section that grew, but a single
|
|
37
|
+
import that was more expensive than it looked.
|
|
38
|
+
|
|
39
|
+
## Fix
|
|
40
|
+
|
|
41
|
+
Find the import that is not paying for itself.
|
|
42
|
+
|
|
43
|
+
- **Import named symbols, never a namespace.** `import { ArrowRight } from "…"` is
|
|
44
|
+
tree-shakeable; `import * as Icons from "…"` is not — a namespace object has to contain
|
|
45
|
+
everything, so nothing can be dropped.
|
|
46
|
+
- **Prefer the package's narrow entry** if it offers one.
|
|
47
|
+
- **Hand-write the residue.** If you use one function out of a large utility package, copy
|
|
48
|
+
the function.
|
|
49
|
+
|
|
50
|
+
**Do not try to hoist code out of the section to dodge the budget.** Each template owns its
|
|
51
|
+
own presentation code; there is no shared library above a section to hoist it into, and the
|
|
52
|
+
bytes would still land on the same page.
|
|
53
|
+
|
|
54
|
+
### Before
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
// sections/amenities/Renderer.tsx
|
|
58
|
+
import { Section, Slot, SlotItem } from "@homepages/template-kit";
|
|
59
|
+
import * as Icons from "lucide-react"; // ← the whole set: every icon, in the bundle
|
|
60
|
+
|
|
61
|
+
import type { Props } from "./schema.js";
|
|
62
|
+
|
|
63
|
+
export function Renderer({ slots }: Props) {
|
|
64
|
+
return (
|
|
65
|
+
<Section>
|
|
66
|
+
<Slot id="amenities">
|
|
67
|
+
{slots.amenities.map((item, i) => (
|
|
68
|
+
<SlotItem key={typeof item.id === "string" ? item.id : i} index={i} as="li">
|
|
69
|
+
<Icons.Check aria-hidden />
|
|
70
|
+
<h3>{typeof item.label === "string" ? item.label : null}</h3>
|
|
71
|
+
</SlotItem>
|
|
72
|
+
))}
|
|
73
|
+
</Slot>
|
|
74
|
+
</Section>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### After
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
// sections/amenities/Renderer.tsx
|
|
83
|
+
import { Section, Slot, SlotItem } from "@homepages/template-kit";
|
|
84
|
+
import { Check } from "lucide-react"; // ← one icon, and the bundler can drop the rest
|
|
85
|
+
|
|
86
|
+
import type { Props } from "./schema.js";
|
|
87
|
+
|
|
88
|
+
export function Renderer({ slots }: Props) {
|
|
89
|
+
return (
|
|
90
|
+
<Section>
|
|
91
|
+
<Slot id="amenities">
|
|
92
|
+
{slots.amenities.map((item, i) => (
|
|
93
|
+
<SlotItem key={typeof item.id === "string" ? item.id : i} index={i} as="li">
|
|
94
|
+
<Check aria-hidden />
|
|
95
|
+
<h3>{typeof item.label === "string" ? item.label : null}</h3>
|
|
96
|
+
</SlotItem>
|
|
97
|
+
))}
|
|
98
|
+
</Slot>
|
|
99
|
+
</Section>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## See also
|
|
105
|
+
|
|
106
|
+
- [`size-section-css`](size-section-css.md) — the same budget on the other half of what the
|
|
107
|
+
section ships.
|
|
108
|
+
- [`license-denied`](license-denied.md) — the other reason to look hard at a dependency
|
|
109
|
+
before you take it.
|