@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,95 @@
|
|
|
1
|
+
---
|
|
2
|
+
purpose: The template-kit/size-section-css authoring rule — what it enforces, why, and how to fix it.
|
|
3
|
+
status: living
|
|
4
|
+
related: [INDEX.md, size-renderer-bundle.md, ../theme-and-css.md]
|
|
5
|
+
updated: 2026-07-14
|
|
6
|
+
---
|
|
7
|
+
# template-kit/size-section-css
|
|
8
|
+
|
|
9
|
+
> A section's compiled CSS must be **≤ 50 KB gzip** — **including the package CSS its module
|
|
10
|
+
> graph imports.** That inclusion is the whole point of the rule.
|
|
11
|
+
|
|
12
|
+
## Rule
|
|
13
|
+
|
|
14
|
+
A section's compiled cascade layer must gzip to **50 KB or less**. What is measured is
|
|
15
|
+
everything that lands in the layer:
|
|
16
|
+
|
|
17
|
+
- the section's hand-written CSS, and
|
|
18
|
+
- **every package stylesheet its module graph imports** — the `import "swiper/css"` in a
|
|
19
|
+
component, an island, or a marked enhancer.
|
|
20
|
+
|
|
21
|
+
The budget is **visible in `template-kit check`'s configuration and is not
|
|
22
|
+
author-overridable**. A workspace cannot raise it, and there is no per-section exemption.
|
|
23
|
+
|
|
24
|
+
## Reason
|
|
25
|
+
|
|
26
|
+
Same page, same phone, same buyer as
|
|
27
|
+
[`size-renderer-bundle`](size-renderer-bundle.md) — and here the bytes are worse, because
|
|
28
|
+
CSS blocks the first paint.
|
|
29
|
+
|
|
30
|
+
"Including package CSS" is not a technicality; it is the failure this rule exists to catch.
|
|
31
|
+
Your own stylesheet is almost never the problem — it is a few kilobytes of overrides. The
|
|
32
|
+
package's is. And the reason it is invisible to you is the mechanism: a package stylesheet's
|
|
33
|
+
`url()` references — its icon sprites, its webfont — are **inlined as `data:` URIs** into the
|
|
34
|
+
compiled stylesheet. A 300 KB font file arrives in your published CSS without you ever
|
|
35
|
+
having seen a 300 KB file. Nothing in your source looks large. Nothing in the package's
|
|
36
|
+
`dist` listing looks alarming. The compiled layer is 400 KB.
|
|
37
|
+
|
|
38
|
+
So the number this check reports is frequently the first time anyone finds out what a
|
|
39
|
+
one-line CSS import actually cost.
|
|
40
|
+
|
|
41
|
+
## Fix
|
|
42
|
+
|
|
43
|
+
Three moves, in order of how often they work:
|
|
44
|
+
|
|
45
|
+
1. **Import the slimmest entry the package offers.** Most CSS-shipping packages have a core
|
|
46
|
+
stylesheet plus opt-in modules, *and* a fat "everything" bundle that is the one you get
|
|
47
|
+
from copying the quickstart. Take the core, plus only the modules you use.
|
|
48
|
+
2. **Cut the webfont.** If the package's stylesheet drags in a font for its icons, and you
|
|
49
|
+
are using three of those icons, replace them with inline SVG — or with a family the
|
|
50
|
+
template's theme already declares in `fonts` (`--tr-font-<name>`), which costs nothing
|
|
51
|
+
because it is already loaded.
|
|
52
|
+
3. **Drop the package and hand-write the residue.** If you use one component from a large
|
|
53
|
+
widget library, its stylesheet is paying for components you never render.
|
|
54
|
+
|
|
55
|
+
### Before
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
// sections/gallery/Carousel.tsx
|
|
59
|
+
"use client";
|
|
60
|
+
|
|
61
|
+
import Swiper from "swiper";
|
|
62
|
+
import "swiper/swiper-bundle.css"; // ← every module Swiper ships, plus its icon font
|
|
63
|
+
|
|
64
|
+
export default function Carousel({ slides }: { slides: { id: string; url: string }[] }) {
|
|
65
|
+
// …uses navigation arrows and pagination dots, and nothing else
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### After
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
// sections/gallery/Carousel.tsx
|
|
73
|
+
"use client";
|
|
74
|
+
|
|
75
|
+
import Swiper from "swiper";
|
|
76
|
+
import "swiper/css"; // ← the core layout only
|
|
77
|
+
import "swiper/css/navigation"; // ← the two modules this carousel actually uses
|
|
78
|
+
import "swiper/css/pagination";
|
|
79
|
+
|
|
80
|
+
export default function Carousel({ slides }: { slides: { id: string; url: string }[] }) {
|
|
81
|
+
// …
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The arrows and dots that came from the bundle's icon font are drawn as inline SVG in the
|
|
86
|
+
component instead, so nothing pulls a webfont into the layer.
|
|
87
|
+
|
|
88
|
+
## See also
|
|
89
|
+
|
|
90
|
+
- [`size-renderer-bundle`](size-renderer-bundle.md) — the same budget on the section's
|
|
91
|
+
JavaScript.
|
|
92
|
+
- [`no-bare-css-import`](no-bare-css-import.md) — why a package's stylesheet is imported from
|
|
93
|
+
code and never `@import`ed from CSS.
|
|
94
|
+
- [Theme and CSS](../theme-and-css.md) — the fonts and tokens the page already loads, which
|
|
95
|
+
cost you nothing to reuse.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
---
|
|
2
|
+
purpose: The template-kit/slot-group-marker 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/slot-group-marker
|
|
8
|
+
|
|
9
|
+
> A group declared in `meta.groups` with **two or more members** must be wrapped in
|
|
10
|
+
> `<SlotGroup id="…">` in the JSX. Grouping is authored in two places, and they must agree.
|
|
11
|
+
|
|
12
|
+
## Rule
|
|
13
|
+
|
|
14
|
+
For every entry in `schema.ts`'s `meta.groups` whose flattened member list has **≥ 2
|
|
15
|
+
members**, the section's JSX must contain `<SlotGroup id="<that group's id>">` wrapping a
|
|
16
|
+
container that is a **shared ancestor** of every member's marker.
|
|
17
|
+
|
|
18
|
+
"Flattened" is literal: `members: ["agent_name", ["agent_email", "agent_phone"]]` is three
|
|
19
|
+
members, not two — a `[a, b]` tuple is a 2-up row, not a single member.
|
|
20
|
+
|
|
21
|
+
Membership is read from the typed `meta.groups` block. The check never guesses a group by
|
|
22
|
+
scanning your markup for things that look related.
|
|
23
|
+
|
|
24
|
+
Carve-outs:
|
|
25
|
+
|
|
26
|
+
- **A 1-member group needs no wrapper.** One slot, one card — the group adds no grouping
|
|
27
|
+
the marker isn't already doing.
|
|
28
|
+
- **A single slot rendered more than once is not a group.** An address shown in both a
|
|
29
|
+
desktop and a mobile layout is one slot in two places; it needs no `meta.groups` entry
|
|
30
|
+
and no `<SlotGroup>`.
|
|
31
|
+
|
|
32
|
+
## Reason
|
|
33
|
+
|
|
34
|
+
`meta.groups` makes the editor collapse several slots into **one accordion card** in the
|
|
35
|
+
sidebar. The `<SlotGroup>` wrapper is the other half of that promise: the canvas outlines
|
|
36
|
+
and selects the group as **one unit**, because its hover/selection chrome targets the
|
|
37
|
+
`data-slot-group` container.
|
|
38
|
+
|
|
39
|
+
Declare the group and omit the wrapper and the two halves disagree. The sidebar shows one
|
|
40
|
+
card, and the canvas — with no group container to find — falls back to outlining the
|
|
41
|
+
members one at a time. The user sees a single "Agent" card that highlights three separate
|
|
42
|
+
boxes on the page.
|
|
43
|
+
|
|
44
|
+
`SlotGroup` must be an **ancestor** of every member's `data-slot-id` element, because the
|
|
45
|
+
editor resolves the group with `closest("[data-slot-group]")` from the clicked slot. A
|
|
46
|
+
marker that sits beside the members rather than around them is never found.
|
|
47
|
+
|
|
48
|
+
## Fix
|
|
49
|
+
|
|
50
|
+
Wrap the members' shared container in `<SlotGroup id="…">`, using the **same id** as the
|
|
51
|
+
`meta.groups` entry. If the slots do not actually edit as a unit, the other honest fix is
|
|
52
|
+
to delete the group from `meta.groups` — then each slot is its own card and no wrapper is
|
|
53
|
+
wanted.
|
|
54
|
+
|
|
55
|
+
### Before
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
// sections/contact/schema.ts
|
|
59
|
+
import type { SectionProps, SectionSchemaShape } from "@homepages/template-kit";
|
|
60
|
+
|
|
61
|
+
export const schema = {
|
|
62
|
+
meta: {
|
|
63
|
+
displayName: "Contact",
|
|
64
|
+
groups: [
|
|
65
|
+
{ id: "agent", label: "Agent", members: ["agent_name", ["agent_email", "agent_phone"]] },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
slots: {
|
|
69
|
+
agent_name: { type: "text", size: "short", required: true, editable_by_user: true },
|
|
70
|
+
agent_email: { type: "text", size: "short", required: false, editable_by_user: true },
|
|
71
|
+
agent_phone: { type: "text", size: "short", required: false, editable_by_user: true },
|
|
72
|
+
},
|
|
73
|
+
} as const satisfies SectionSchemaShape;
|
|
74
|
+
|
|
75
|
+
export type Props = SectionProps<typeof schema>;
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
```tsx
|
|
79
|
+
// sections/contact/Renderer.tsx — template-kit/slot-group-marker: agent
|
|
80
|
+
import { Section, Slot } from "@homepages/template-kit";
|
|
81
|
+
|
|
82
|
+
import type { Props } from "./schema.js";
|
|
83
|
+
|
|
84
|
+
export function Renderer({ slots }: Props) {
|
|
85
|
+
return (
|
|
86
|
+
<Section>
|
|
87
|
+
<div className="flex flex-col gap-2">
|
|
88
|
+
<Slot id="agent_name" textLeaf><h3>{slots.agent_name}</h3></Slot>
|
|
89
|
+
<Slot id="agent_email" textLeaf><span>{slots.agent_email}</span></Slot>
|
|
90
|
+
<Slot id="agent_phone" textLeaf><span>{slots.agent_phone}</span></Slot>
|
|
91
|
+
</div>
|
|
92
|
+
</Section>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### After
|
|
98
|
+
|
|
99
|
+
The container was already there — it just needed to be the group:
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
// sections/contact/Renderer.tsx
|
|
103
|
+
import { Section, Slot, SlotGroup } from "@homepages/template-kit";
|
|
104
|
+
|
|
105
|
+
import type { Props } from "./schema.js";
|
|
106
|
+
|
|
107
|
+
export function Renderer({ slots }: Props) {
|
|
108
|
+
return (
|
|
109
|
+
<Section>
|
|
110
|
+
<SlotGroup id="agent" className="flex flex-col gap-2">
|
|
111
|
+
<Slot id="agent_name" textLeaf><h3>{slots.agent_name}</h3></Slot>
|
|
112
|
+
<Slot id="agent_email" textLeaf><span>{slots.agent_email}</span></Slot>
|
|
113
|
+
<Slot id="agent_phone" textLeaf><span>{slots.agent_phone}</span></Slot>
|
|
114
|
+
</SlotGroup>
|
|
115
|
+
</Section>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## See also
|
|
121
|
+
|
|
122
|
+
- [Contract primitives: `SlotGroup`](../primitives.md#slotgroup--slots-edited-as-one-unit) —
|
|
123
|
+
the ancestor requirement and what the marker does.
|
|
124
|
+
- [The schema system](../schema-system.md#grouping-slots-into-one-editor-card) — grouping is
|
|
125
|
+
declared in `meta.groups`; there is no per-slot `group` field.
|
|
126
|
+
- [`missing-slot-marker`](missing-slot-marker.md) — the per-slot marker each member still
|
|
127
|
+
needs.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
---
|
|
2
|
+
purpose: The template-kit/slot-marker-literal 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/slot-marker-literal
|
|
8
|
+
|
|
9
|
+
> A slot key must be written as a string literal wherever it is marked. `SlotItem` is
|
|
10
|
+
> exempt.
|
|
11
|
+
|
|
12
|
+
## Rule
|
|
13
|
+
|
|
14
|
+
These four places carry a slot key, and each must be a **string literal**:
|
|
15
|
+
|
|
16
|
+
| Where | Example |
|
|
17
|
+
|---|---|
|
|
18
|
+
| `<Slot id>` | `<Slot id="headline">` |
|
|
19
|
+
| `<SlotGroup id>` | `<SlotGroup id="agent">` |
|
|
20
|
+
| `slotId` on any element | `<Image slotId="hero_image" …>` |
|
|
21
|
+
| `data-slot-id` / `data-slot-group` on any element | `<div data-slot-id="headline">` |
|
|
22
|
+
|
|
23
|
+
Anything that is not a plain string literal reports: an expression (`<Slot id={key}>`), a
|
|
24
|
+
template literal (`` <Slot id={`slot_${n}`}> ``), and a valueless attribute
|
|
25
|
+
(`<div data-slot-id>`).
|
|
26
|
+
|
|
27
|
+
**`SlotItem` is deliberately exempt.** Its prop is `index: number` — an array position,
|
|
28
|
+
written `index={i}` inside a `.map()`, not a schema key. There is nothing to cross-check
|
|
29
|
+
it against, so an expression there is correct and expected.
|
|
30
|
+
|
|
31
|
+
## Reason
|
|
32
|
+
|
|
33
|
+
This is not style. A separate, cross-file completeness check verifies that **every slot
|
|
34
|
+
declared in `schema.ts` has a marker in the section's JSX** — and it reads these keys
|
|
35
|
+
**statically**, from the source text, without running your Renderer.
|
|
36
|
+
|
|
37
|
+
A key written as an expression is a key it cannot resolve. So the check cannot see the
|
|
38
|
+
slot as marked, and — worse — the marker that actually reaches the browser is whatever
|
|
39
|
+
your expression happened to evaluate to. The slot **silently loses editor selection**: it
|
|
40
|
+
still fills, it still renders, and a user clicking on it in the editor selects nothing.
|
|
41
|
+
Nothing throws. The failure is invisible until someone tries to edit that block.
|
|
42
|
+
|
|
43
|
+
This rule exists **so that** [`missing-slot-marker`](missing-slot-marker.md) can work. A
|
|
44
|
+
`.map()`-generated slot key defeats both at once: the completeness check reports the slot
|
|
45
|
+
as unmarked, and no lint tells you why.
|
|
46
|
+
|
|
47
|
+
## Fix
|
|
48
|
+
|
|
49
|
+
This shows up in two different shapes, and the fix depends on which one you're in.
|
|
50
|
+
|
|
51
|
+
**A key that is static but not literal** — held in a `const`, or assembled with a
|
|
52
|
+
template literal — isn't generating anything: there is exactly one possible value, so
|
|
53
|
+
unwrap it back into a plain inline string literal.
|
|
54
|
+
|
|
55
|
+
**A key generated inside a `.map()` over an array** is a genuine collection: declare it
|
|
56
|
+
as one slot in `schema.ts`, mark the array **once** with `<Slot>`, and mark each row with
|
|
57
|
+
`<SlotItem index={i}>`.
|
|
58
|
+
|
|
59
|
+
### Before
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
import { Section, Slot } from "@homepages/template-kit";
|
|
63
|
+
|
|
64
|
+
import type { Props } from "./schema.js";
|
|
65
|
+
|
|
66
|
+
const FIELD = "headline";
|
|
67
|
+
|
|
68
|
+
export function Renderer({ slots }: Props) {
|
|
69
|
+
return (
|
|
70
|
+
<Section>
|
|
71
|
+
<Slot id={FIELD} as="h1" textLeaf>
|
|
72
|
+
<span>{slots.headline}</span>
|
|
73
|
+
</Slot>
|
|
74
|
+
<Slot id={`sub${"title"}`} textLeaf>
|
|
75
|
+
<p>{slots.subtitle}</p>
|
|
76
|
+
</Slot>
|
|
77
|
+
</Section>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Neither `FIELD` nor the template literal is doing any work — both spell out their whole
|
|
83
|
+
value at the call site. That's the common case: a constant or a template literal standing
|
|
84
|
+
in for a string that was always going to be one string.
|
|
85
|
+
|
|
86
|
+
### After
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
import { Section, Slot } from "@homepages/template-kit";
|
|
90
|
+
|
|
91
|
+
import type { Props } from "./schema.js";
|
|
92
|
+
|
|
93
|
+
export function Renderer({ slots }: Props) {
|
|
94
|
+
return (
|
|
95
|
+
<Section>
|
|
96
|
+
<Slot id="headline" as="h1" textLeaf>
|
|
97
|
+
<span>{slots.headline}</span>
|
|
98
|
+
</Slot>
|
|
99
|
+
<Slot id="subtitle" textLeaf>
|
|
100
|
+
<p>{slots.subtitle}</p>
|
|
101
|
+
</Slot>
|
|
102
|
+
</Section>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
The other shape is different: a key actually produced by iterating an array is modelling
|
|
108
|
+
a real collection, and the fix is structural, not a matter of unwrapping.
|
|
109
|
+
|
|
110
|
+
```tsx
|
|
111
|
+
// Before — `.map()` produces a different key each iteration; none of them is a literal.
|
|
112
|
+
const FIELDS = ["headline", "subhead"] as const;
|
|
113
|
+
|
|
114
|
+
export function Renderer({ slots }: Props) {
|
|
115
|
+
return (
|
|
116
|
+
<Section>
|
|
117
|
+
{FIELDS.map((key) => (
|
|
118
|
+
<Slot key={key} id={key} textLeaf>
|
|
119
|
+
<p>{slots[key]}</p>
|
|
120
|
+
</Slot>
|
|
121
|
+
))}
|
|
122
|
+
</Section>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
A genuine collection keeps its loop — the literal key is on the `<Slot>`, and the loop
|
|
128
|
+
variable stays on `SlotItem`'s `index`, which is exempt:
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
<Slot id="amenities">
|
|
132
|
+
{slots.amenities.map((item, i) => (
|
|
133
|
+
<SlotItem key={item.id} index={i} as="li">
|
|
134
|
+
<h3>{item.label}</h3>
|
|
135
|
+
</SlotItem>
|
|
136
|
+
))}
|
|
137
|
+
</Slot>
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Don't reach for this structural rewrite for the first case above — a named constant or a
|
|
141
|
+
template literal is not a collection, and modelling it as one is solving a problem you
|
|
142
|
+
don't have.
|
|
143
|
+
|
|
144
|
+
## See also
|
|
145
|
+
|
|
146
|
+
- [Contract primitives](../primitives.md#the-marker-contract) — the marker attributes and
|
|
147
|
+
which primitive emits each.
|
|
148
|
+
- [Schema system](../schema-system.md) — where slot keys are declared.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
purpose: The template-kit/typecheck 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/typecheck
|
|
8
|
+
|
|
9
|
+
> The template must compile. Every type error is one violation.
|
|
10
|
+
|
|
11
|
+
## Rule
|
|
12
|
+
|
|
13
|
+
The template's TypeScript must compile cleanly. The check resolves `typescript` from **your
|
|
14
|
+
workspace's** `node_modules` — the compiler that runs is the one you installed, at the
|
|
15
|
+
version you pinned, against your own `tsconfig.json`.
|
|
16
|
+
|
|
17
|
+
If `typescript` is not installed, that is itself the diagnostic (with the fix), not a crash.
|
|
18
|
+
|
|
19
|
+
## Reason
|
|
20
|
+
|
|
21
|
+
The schema system is the section contract, and it is enforced **through the type system**.
|
|
22
|
+
`SectionProps<typeof schema>` is not a convenience — it is the mechanism that keeps
|
|
23
|
+
`schema.ts` and `Renderer.tsx` in agreement: a slot you add to the schema and forget to
|
|
24
|
+
render, a slot you render under the wrong name, an optional slot you treat as always
|
|
25
|
+
present, a slot whose type you assume wrong. Each of those is a compile error rather than a
|
|
26
|
+
surprise on a stranger's published page.
|
|
27
|
+
|
|
28
|
+
A template that does not typecheck has opted out of the only thing checking that the two
|
|
29
|
+
files still describe the same section.
|
|
30
|
+
|
|
31
|
+
## Fix
|
|
32
|
+
|
|
33
|
+
Fix the type error. If the compiler itself is missing, install it and point your
|
|
34
|
+
`tsconfig.json` at the kit's base config, which sets the module resolution and JSX settings
|
|
35
|
+
the kit's types expect:
|
|
36
|
+
|
|
37
|
+
```jsonc
|
|
38
|
+
// tsconfig.json
|
|
39
|
+
{ "extends": "@homepages/template-kit/tsconfig", "include": ["sections", "theme.ts"] }
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install --save-dev typescript
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Before
|
|
47
|
+
|
|
48
|
+
`year_built` is declared `required: false`, so `SectionProps` resolves it to
|
|
49
|
+
`string | null` — and the Renderer treats it as a `string`:
|
|
50
|
+
|
|
51
|
+
```tsx
|
|
52
|
+
// sections/facts/Renderer.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
|
+
{/* error TS18047: 'slots.year_built' is possibly 'null'. */}
|
|
61
|
+
<Slot id="year_built" textLeaf>
|
|
62
|
+
<p>Built {slots.year_built.slice(0, 4)}</p>
|
|
63
|
+
</Slot>
|
|
64
|
+
</Section>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
That is not a pedantic complaint. `required: false` means a thin listing really does arrive
|
|
70
|
+
with no year, and this line really does throw on it.
|
|
71
|
+
|
|
72
|
+
### After
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
// sections/facts/Renderer.tsx
|
|
76
|
+
import { Section, Slot } from "@homepages/template-kit";
|
|
77
|
+
|
|
78
|
+
import type { Props } from "./schema.js";
|
|
79
|
+
|
|
80
|
+
export function Renderer({ slots }: Props) {
|
|
81
|
+
return (
|
|
82
|
+
<Section>
|
|
83
|
+
{slots.year_built ? (
|
|
84
|
+
<Slot id="year_built" textLeaf>
|
|
85
|
+
<p>Built {slots.year_built.slice(0, 4)}</p>
|
|
86
|
+
</Slot>
|
|
87
|
+
) : null}
|
|
88
|
+
</Section>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
If the slot should never be absent, the other correct fix is in the schema — declare it
|
|
94
|
+
`required: true`, and the type narrows to `string` everywhere.
|
|
95
|
+
|
|
96
|
+
## See also
|
|
97
|
+
|
|
98
|
+
- [The schema system](../schema-system.md#renderertsx--props-derived-never-hand-written) —
|
|
99
|
+
how `SectionProps` resolves each slot to its runtime type.
|
|
100
|
+
- [`props-from-schema`](props-from-schema.md) — the rule that stops a hand-written `Props`
|
|
101
|
+
from disabling this inference in the first place.
|
|
102
|
+
- [`render-invariant`](render-invariant.md) — the same class of bug caught at render time,
|
|
103
|
+
for the cases the types cannot see.
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
purpose: The template-kit/unlayered-fence authoring rule — what it enforces, why, and how to fix it.
|
|
3
|
+
status: living
|
|
4
|
+
related: [INDEX.md, ../theme-and-css.md]
|
|
5
|
+
updated: 2026-07-14
|
|
6
|
+
---
|
|
7
|
+
# template-kit/unlayered-fence
|
|
8
|
+
|
|
9
|
+
> **This is not "you forgot to layer your CSS."** Everything is layered by default — the
|
|
10
|
+
> build does it for you. This rule governs the *escape hatch* from layering: an
|
|
11
|
+
> `/* unlayered: … */` fence must give a reason and must be closed.
|
|
12
|
+
|
|
13
|
+
## Rule
|
|
14
|
+
|
|
15
|
+
Your hand-written CSS is wrapped in a cascade layer by the build. You do not write
|
|
16
|
+
`@layer` yourself, and there is nothing to forget.
|
|
17
|
+
|
|
18
|
+
The **fence** opts a run of rules *out* of that wrapping, hoisting them above every layer:
|
|
19
|
+
|
|
20
|
+
```css
|
|
21
|
+
/* unlayered: <reason> */
|
|
22
|
+
…rules that must beat every layer…
|
|
23
|
+
/* endunlayered */
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Two things are checked, both under this id:
|
|
27
|
+
|
|
28
|
+
1. **Every opener carries a non-empty reason.** A bare `/* unlayered: */` is a violation.
|
|
29
|
+
2. **Openers and closers balance.** An unterminated fence is a violation — it would swallow
|
|
30
|
+
the whole rest of the file out of the layer, which is never what anyone meant.
|
|
31
|
+
|
|
32
|
+
## Reason
|
|
33
|
+
|
|
34
|
+
Cascade layers have one rule that decides everything here: **an unlayered rule beats a
|
|
35
|
+
layered one, regardless of specificity.** Not "usually" — always. That is what layers are
|
|
36
|
+
for, and it is why the platform layers your CSS: inside the layer order (`theme, base,
|
|
37
|
+
components, sections, template, utilities`) a Tailwind utility can always override a
|
|
38
|
+
section's stylesheet, and a section's stylesheet can never leak past its layer to beat
|
|
39
|
+
someone else's utility.
|
|
40
|
+
|
|
41
|
+
So the hatch exists for exactly one situation: **a third-party library injects an unlayered
|
|
42
|
+
rule into the document at runtime** — a map SDK writing a `<style>` element when it mounts,
|
|
43
|
+
say — and that rule beats your layered override no matter how specific you make it. The
|
|
44
|
+
only thing that can beat it is a rule that is also unlayered. Nothing else qualifies. If
|
|
45
|
+
your rule merely needs to beat *your own* CSS, or a Tailwind utility, it belongs in a layer
|
|
46
|
+
and you are reaching for the wrong tool.
|
|
47
|
+
|
|
48
|
+
Which is why the reason is mandatory. A fence with no reason is a rule that outranks every
|
|
49
|
+
layer on the page for reasons the next author cannot reconstruct — they cannot tell whether
|
|
50
|
+
it is load-bearing or whether someone was fighting a specificity problem with a hammer. And
|
|
51
|
+
an unbalanced fence silently promotes every rule below it, so a stylesheet that behaved
|
|
52
|
+
yesterday starts beating the utilities that were supposed to override it.
|
|
53
|
+
|
|
54
|
+
## Fix
|
|
55
|
+
|
|
56
|
+
Close every fence with `/* endunlayered */`, and give every opener a reason that **names the
|
|
57
|
+
runtime-injected rule it has to beat**. A short phrase is enough — it only has to be
|
|
58
|
+
non-empty and specific about which rule it's beating, not a full sentence. If it does not
|
|
59
|
+
have to beat one, delete the fence: layered is the default, and the default is right.
|
|
60
|
+
|
|
61
|
+
The Before/After below both open with a `/* css-reason: … */` comment. That line has
|
|
62
|
+
nothing to do with fencing — it satisfies a separate rule, [`css-reason`](css-reason.md),
|
|
63
|
+
which every hand-written stylesheet must open with. It's shown here because real section
|
|
64
|
+
CSS carries both; this rule's own grammar starts at `/* unlayered: */`.
|
|
65
|
+
|
|
66
|
+
### Before
|
|
67
|
+
|
|
68
|
+
```css
|
|
69
|
+
/* css-reason: overrides the map SDK's injected control chrome. */
|
|
70
|
+
|
|
71
|
+
/* unlayered: */
|
|
72
|
+
.maplibregl-ctrl-group button {
|
|
73
|
+
background: var(--tr-color-surface);
|
|
74
|
+
border-radius: var(--tr-radius-sm);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.tr-map-legend {
|
|
78
|
+
padding: 0.5rem;
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Two violations at once: the reason is empty, and the fence is never closed — so
|
|
83
|
+
`.tr-map-legend` is hoisted out of the layer too, where it now outranks every utility a
|
|
84
|
+
future author might try to override it with.
|
|
85
|
+
|
|
86
|
+
### After
|
|
87
|
+
|
|
88
|
+
```css
|
|
89
|
+
/* css-reason: overrides the map SDK's control chrome — the SDK builds those nodes after
|
|
90
|
+
hydration, so Tailwind's scanner never sees a class to compile. */
|
|
91
|
+
|
|
92
|
+
/* unlayered: the map SDK injects its control styles as an unlayered <style> element at
|
|
93
|
+
runtime; a layered rule cannot beat it at any specificity. */
|
|
94
|
+
.maplibregl-ctrl-group button {
|
|
95
|
+
background: var(--tr-color-surface);
|
|
96
|
+
border-radius: var(--tr-radius-sm);
|
|
97
|
+
}
|
|
98
|
+
/* endunlayered */
|
|
99
|
+
|
|
100
|
+
/* Our own DOM — layered, like everything else. */
|
|
101
|
+
.tr-map-legend {
|
|
102
|
+
padding: 0.5rem;
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The fence now wraps exactly the rules that need to escape, and `.tr-map-legend` is back in
|
|
107
|
+
the layer where a utility can still override it.
|
|
108
|
+
|
|
109
|
+
## See also
|
|
110
|
+
|
|
111
|
+
- [Theme and CSS](../theme-and-css.md) — the layer order, and why utilities come last.
|
|
112
|
+
- [`css-reason`](css-reason.md) — the comment every hand-written stylesheet opens with; the
|
|
113
|
+
fence reason is the same discipline, one level in.
|