@homepages/template-kit 0.0.1 → 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 +251 -0
- package/README.md +77 -11
- package/dist/base.css +64 -0
- package/dist/browser.d.ts +26 -0
- package/dist/browser.js +55 -0
- package/dist/cli.js +1 -1
- package/dist/contracts/image-descriptor.d.ts +17 -0
- package/dist/contracts/markers.d.ts +20 -0
- package/dist/contracts/markers.js +21 -0
- package/dist/contracts/slot-catalog.d.ts +237 -0
- package/dist/contracts/slot-catalog.js +247 -0
- package/dist/contracts/transforms.d.ts +108 -0
- package/dist/contracts/transforms.js +117 -0
- package/dist/design-system/breakpoints.d.ts +5 -0
- package/dist/design-system/breakpoints.js +14 -0
- package/dist/design-system/theme.d.ts +308 -0
- package/dist/design-system/theme.js +102 -0
- package/dist/eslint/is-client-file.js +79 -0
- package/dist/eslint/rules/image-bare-needs-reason.js +43 -0
- package/dist/eslint/rules/no-client-directive-in-contract.js +31 -0
- package/dist/eslint/rules/no-client-runtime-in-server.js +103 -0
- package/dist/eslint/rules/no-css-import-from-render-path.js +29 -0
- package/dist/eslint/rules/no-hex.js +116 -0
- package/dist/eslint/rules/no-inline-style.js +27 -0
- package/dist/eslint/rules/no-nondeterminism.js +48 -0
- package/dist/eslint/rules/no-raw-element.js +37 -0
- package/dist/eslint/rules/props-from-schema.js +37 -0
- package/dist/eslint/rules/serializable-island-props.js +112 -0
- package/dist/eslint/rules/slot-marker-literal.js +38 -0
- package/dist/eslint.d.ts +4 -8
- package/dist/eslint.js +75 -11
- package/dist/fixtures/sample-property.d.ts +67 -0
- package/dist/fixtures/sample-property.js +707 -0
- package/dist/fixtures.d.ts +2 -0
- package/dist/fixtures.js +3 -0
- package/dist/index.d.ts +28 -12
- package/dist/index.js +34 -2
- package/dist/island-runtime.d.ts +30 -0
- package/dist/island-runtime.js +73 -0
- package/dist/islands/contract.d.ts +56 -0
- package/dist/islands/contract.js +71 -0
- package/dist/islands/marker.d.ts +16 -0
- package/dist/islands/marker.js +18 -0
- package/dist/package.js +5 -0
- package/dist/primitives/Image.d.ts +49 -0
- package/dist/primitives/Image.js +95 -0
- package/dist/primitives/Section.d.ts +22 -0
- package/dist/primitives/Section.js +26 -0
- package/dist/primitives/Slot.d.ts +50 -0
- package/dist/primitives/Slot.js +39 -0
- package/dist/primitives/SlotGroup.d.ts +38 -0
- package/dist/primitives/SlotGroup.js +24 -0
- package/dist/primitives/SlotItem.d.ts +43 -0
- package/dist/primitives/SlotItem.js +38 -0
- package/dist/primitives/index.d.ts +7 -0
- package/dist/primitives/picture-sources.d.ts +22 -0
- package/dist/primitives/picture-sources.js +52 -0
- package/dist/rules/registry.js +16 -0
- package/dist/schema/fill-spec.d.ts +692 -0
- package/dist/schema/fill-spec.js +98 -0
- package/dist/schema/fixture-schema.d.ts +143 -0
- package/dist/schema/fixture-schema.js +113 -0
- package/dist/schema/index.d.ts +16 -0
- package/dist/schema/manifest.d.ts +527 -0
- package/dist/schema/manifest.js +73 -0
- package/dist/schema/property-facts.d.ts +44 -0
- package/dist/schema/resolve-variants.d.ts +11 -0
- package/dist/schema/resolve-variants.js +15 -0
- package/dist/schema/runtime-values.d.ts +45 -0
- package/dist/schema/section-nav.d.ts +7 -0
- package/dist/schema/section-props.d.ts +69 -0
- package/dist/schema/section-schema.d.ts +533 -0
- package/dist/schema/section-schema.js +72 -0
- package/dist/schema/slot-types.d.ts +117 -0
- package/dist/schema/slot-types.js +181 -0
- package/dist/schema/source.d.ts +13 -0
- package/dist/schema/source.js +12 -0
- package/dist/schema/synthesize-nullable.d.ts +18 -0
- package/dist/schema/synthesize-nullable.js +47 -0
- package/dist/styles.css +131 -9
- package/docs/INDEX.md +7 -4
- package/docs/eslint.md +55 -0
- package/docs/islands.md +150 -0
- package/docs/llms.txt +33 -3
- package/docs/primitives.md +214 -0
- 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/schema-system.md +240 -0
- package/docs/theme-and-css.md +225 -0
- package/package.json +30 -6
- package/tsconfig.base.json +16 -0
- package/tsconfig.json +2 -13
- package/dist/src-dZr5N30y.js +0 -35
package/docs/eslint.md
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
purpose: The authoring-lint preset shipped at `@homepages/template-kit/eslint` — setup and scope. Per-rule reference lives in `rules/`.
|
|
3
|
+
status: living
|
|
4
|
+
related: [INDEX.md, rules/INDEX.md, islands.md, theme-and-css.md, primitives.md]
|
|
5
|
+
updated: 2026-07-14
|
|
6
|
+
---
|
|
7
|
+
# ESLint preset
|
|
8
|
+
|
|
9
|
+
`@homepages/template-kit/eslint` is a flat-config ESLint preset carrying every
|
|
10
|
+
authoring rule the kit enforces one file at a time, under the `template-kit/*`
|
|
11
|
+
rule namespace.
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
// eslint.config.mjs
|
|
17
|
+
import kit from "@homepages/template-kit/eslint";
|
|
18
|
+
export default kit;
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
That is the whole setup — no plugin registration, no parser wiring, no tsconfig.
|
|
22
|
+
The preset ships its own parser (`@typescript-eslint/parser`, a real `dependency`
|
|
23
|
+
of the kit, not a devDependency) and sets `languageOptions` itself, so `npm i -D
|
|
24
|
+
eslint` plus the three lines above is a complete consumer config.
|
|
25
|
+
|
|
26
|
+
The preset is **syntactic only** — it never sets `parserOptions.project` or
|
|
27
|
+
`projectService`. Nothing it enforces requires type information, which is what
|
|
28
|
+
keeps setup to zero extra config; a consumer's own `tsconfig.json` is never
|
|
29
|
+
consulted.
|
|
30
|
+
|
|
31
|
+
## Scope
|
|
32
|
+
|
|
33
|
+
The preset is consumed **once at a workspace root** and lints every section
|
|
34
|
+
nested under `templates/**`; there are no per-template config files. It ships
|
|
35
|
+
three config entries, matched by glob rather than by directory depth:
|
|
36
|
+
|
|
37
|
+
| Entry | Files | Rules |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `template-kit/authoring` | `**/*.ts`, `**/*.tsx` | the nine file-scoped rules — see [`rules/`](rules/INDEX.md) |
|
|
40
|
+
| `template-kit/contract-files` | `**/Renderer.tsx`, `**/schema.ts`, `**/fill-spec.ts`, `**/fixtures.ts` | `no-client-directive-in-contract` |
|
|
41
|
+
| `template-kit/renderer` | `**/Renderer.tsx` | `props-from-schema` |
|
|
42
|
+
|
|
43
|
+
Server-vs-client scoping is decided **per file**, by the `"use client"` directive
|
|
44
|
+
prologue — see [server vs client](rules/server-vs-client.md) for what that means
|
|
45
|
+
and exactly which rules stop at that boundary.
|
|
46
|
+
|
|
47
|
+
## The rules
|
|
48
|
+
|
|
49
|
+
Every rule the preset enforces — what it bans, why, and a before/after fix — is
|
|
50
|
+
documented once, per id, in [`rules/`](rules/INDEX.md). Rules scoped to a single
|
|
51
|
+
file are enforced here, by this preset; rules scoped to the **tree** — a missing
|
|
52
|
+
contract file, a slot declared in `schema.ts` but unmarked in `Renderer.tsx`, a CSS
|
|
53
|
+
budget — cannot be expressed one-file-at-a-time and belong to `template-kit check`
|
|
54
|
+
under the same `template-kit/<id>` namespace. One namespace, two venues: the id
|
|
55
|
+
printed by either failure resolves to exactly one page.
|
package/docs/islands.md
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
---
|
|
2
|
+
purpose: How interactivity works in a template — client components ("islands"), their props contract, and their editor options.
|
|
3
|
+
status: living
|
|
4
|
+
related: [INDEX.md, eslint.md]
|
|
5
|
+
updated: 2026-07-14
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Islands: interactivity in a template
|
|
9
|
+
|
|
10
|
+
A section's `Renderer.tsx` is server-rendered and ships no JavaScript. When part of
|
|
11
|
+
a section needs to be interactive — a filter, a slider, a lightbox — you write an
|
|
12
|
+
ordinary React component in the section folder and mark it `"use client"`. It is
|
|
13
|
+
server-rendered with the rest of the page, then **hydrated** in the browser as its
|
|
14
|
+
own React root. That component is an *island*.
|
|
15
|
+
|
|
16
|
+
If a page contains no islands, it loads no React and no island code — every page
|
|
17
|
+
still ships the small island loader (a few KB, gzipped), but that loader only ever
|
|
18
|
+
reaches React through a dynamic import it takes when an island is actually on the page.
|
|
19
|
+
|
|
20
|
+
## Writing one
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
// sections/floorplan/UnitFilter.tsx
|
|
24
|
+
"use client";
|
|
25
|
+
|
|
26
|
+
import { useState } from "react";
|
|
27
|
+
|
|
28
|
+
export default function UnitFilter({ units }: { units: { id: string; beds: number }[] }) {
|
|
29
|
+
const [beds, setBeds] = useState<number | null>(null);
|
|
30
|
+
const shown = beds === null ? units : units.filter((u) => u.beds === beds);
|
|
31
|
+
return ( /* … */ );
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Use it from the Renderer like any other component. The build wraps it in an island
|
|
36
|
+
marker and hydrates it on the published page:
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
import UnitFilter from "./UnitFilter.js";
|
|
40
|
+
|
|
41
|
+
export function Renderer({ units }: Props) {
|
|
42
|
+
return <UnitFilter units={units} />;
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Rules:
|
|
47
|
+
|
|
48
|
+
- **The component is default-exported.** One island per `"use client"` file.
|
|
49
|
+
- **Effects, state, event handlers, and browser APIs are all fine** inside an island —
|
|
50
|
+
it runs in the browser. They are *not* fine in a Renderer, which must stay a pure
|
|
51
|
+
function of its props.
|
|
52
|
+
- **Third-party client libraries are fine** inside an island (a slider, a map SDK).
|
|
53
|
+
|
|
54
|
+
## Props must be JSON-serializable
|
|
55
|
+
|
|
56
|
+
Everything the Renderer passes to an island crosses a server→browser boundary as JSON:
|
|
57
|
+
|
|
58
|
+
| Allowed | Rejected |
|
|
59
|
+
|---|---|
|
|
60
|
+
| `string`, `number` (finite), `boolean`, `null` | functions, including event handlers |
|
|
61
|
+
| plain objects, arrays, nested combinations of the above | `Date`, `Map`, `Set`, `BigInt`, symbols, class instances |
|
|
62
|
+
| `undefined` values, per JSON: an object property is dropped, an array entry becomes `null` | `NaN`, `Infinity` |
|
|
63
|
+
|
|
64
|
+
A rejected prop fails the render, naming the path:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
IslandPropsError: props.units[0].created is a Date — island props must be
|
|
68
|
+
JSON-serializable (plain objects and arrays only)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Pass an ISO string and parse it inside the island. Pass data, not callbacks — an
|
|
72
|
+
island owns its own handlers.
|
|
73
|
+
|
|
74
|
+
The `template-kit/serializable-island-props` [lint rule](eslint.md#islands) catches a
|
|
75
|
+
statically-visible violation (a literal arrow function, `new Date()`, a bigint literal,
|
|
76
|
+
`NaN`/`Infinity`) in your editor, before the render ever throws — a value assembled at
|
|
77
|
+
runtime and handed over as an identifier is invisible to the rule and stays this
|
|
78
|
+
runtime check's job.
|
|
79
|
+
|
|
80
|
+
## Editor options
|
|
81
|
+
|
|
82
|
+
Templates are edited in a canvas where the section is re-rendered on every keystroke.
|
|
83
|
+
An island can declare how it behaves there:
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
"use client";
|
|
87
|
+
import type { IslandEditorOptions } from "@homepages/template-kit";
|
|
88
|
+
|
|
89
|
+
export default function Accordion({ items, singleOpen }: Props) { /* … */ }
|
|
90
|
+
|
|
91
|
+
export const editor: IslandEditorOptions = {
|
|
92
|
+
live: true, // hydrate in the editor canvas (default: false)
|
|
93
|
+
shieldMode: "fill-parent", // how the editor sizes its selection shield
|
|
94
|
+
liveSurface: (root) => [...root.querySelectorAll(".accordion")], // shield only these
|
|
95
|
+
props: { singleOpen: true }, // editor-only props, merged over the real ones
|
|
96
|
+
};
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Omit `editor` entirely and the island simply renders as static markup in the editor —
|
|
100
|
+
which is the right choice for most islands.
|
|
101
|
+
|
|
102
|
+
## The DOM contract
|
|
103
|
+
|
|
104
|
+
Rarely needed while authoring, but this is what the build emits, and what a host
|
|
105
|
+
hydrates:
|
|
106
|
+
|
|
107
|
+
```html
|
|
108
|
+
<tr-island style="display:contents" data-tr-island="<key>" data-tr-island-id="<id>">
|
|
109
|
+
…server-rendered output…
|
|
110
|
+
</tr-island>
|
|
111
|
+
<script type="application/json" data-tr-island-props="<id>">{"units":[…]}</script>
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
The marker is layout-invisible (`display:contents`), so an island can sit directly
|
|
115
|
+
inside a grid or flex container without adding a box.
|
|
116
|
+
|
|
117
|
+
## Hosting islands yourself
|
|
118
|
+
|
|
119
|
+
`@homepages/template-kit/island-runtime` is the loader. It is not something a template
|
|
120
|
+
author calls — it is what a page (or the editor) calls to bring islands to life:
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { hydrateIslands, unmountIslands } from "@homepages/template-kit/island-runtime";
|
|
124
|
+
|
|
125
|
+
await hydrateIslands({ load: (key) => ISLANDS[key]() }); // ISLANDS: the build's island map
|
|
126
|
+
unmountIslands(sectionEl); // before replacing sectionEl's HTML
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Each island is an independent React root: one island's state, re-renders, and errors
|
|
130
|
+
never touch another's. Hydration is idempotent, so calling `hydrateIslands` again after
|
|
131
|
+
injecting new markup is safe.
|
|
132
|
+
|
|
133
|
+
## Talking to HomePages from an island
|
|
134
|
+
|
|
135
|
+
`@homepages/template-kit/browser` carries the helpers a published page needs to reach the
|
|
136
|
+
HomePages ingest endpoints — reading the site's embedded config and signing requests:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
139
|
+
import { readSiteConfig, signedIngestHeaders } from "@homepages/template-kit/browser";
|
|
140
|
+
|
|
141
|
+
const config = readSiteConfig();
|
|
142
|
+
if (config) {
|
|
143
|
+
const body = JSON.stringify({ name, email });
|
|
144
|
+
await fetch(`${config.ingestBase}/leads/ingest`, {
|
|
145
|
+
method: "POST",
|
|
146
|
+
headers: await signedIngestHeaders(config, body),
|
|
147
|
+
body,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
```
|
package/docs/llms.txt
CHANGED
|
@@ -4,10 +4,40 @@
|
|
|
4
4
|
> inside the npm package so an agent can read it locally, versioned in lockstep
|
|
5
5
|
> with the code it describes.
|
|
6
6
|
|
|
7
|
-
Status: 0.0.x
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
Status: 0.0.x. The schema system, the contract primitives, and the theme tokens are
|
|
8
|
+
complete — a section is authorable end to end against the kit. The `template-kit`
|
|
9
|
+
CLI's commands arrive with the surfaces they drive.
|
|
10
|
+
|
|
11
|
+
Everything an author writes a section against is imported from the package root —
|
|
12
|
+
`@homepages/template-kit`, no subpath imports. The two CSS entries
|
|
13
|
+
(`./styles.css`, `./base.css`) are the deliberate exception.
|
|
10
14
|
|
|
11
15
|
## Docs
|
|
12
16
|
|
|
17
|
+
- [Schema system](schema-system.md): the section authoring contract. A section is
|
|
18
|
+
four files — `schema.ts` (slots + options + `meta.groups`), `fill-spec.ts` (how AI
|
|
19
|
+
fills them), `fixtures.ts` (preview content, built on the kit's golden property
|
|
20
|
+
dataset), `Renderer.tsx` (markup, with props DERIVED from the schema via
|
|
21
|
+
`SectionProps<typeof schema>`). Covers the `as const satisfies` requirement, binding
|
|
22
|
+
slots to property facts with `direct()`/`derived()` over a closed vocabulary, and
|
|
23
|
+
fact-driven variants.
|
|
24
|
+
- [Contract primitives](primitives.md): the only five components the kit ships —
|
|
25
|
+
`Section` (full-bleed section root), `Slot` (`data-slot-id`), `SlotItem` (per-item
|
|
26
|
+
handle in an `object_list` / `image_collection`), `SlotGroup` (`data-slot-group`),
|
|
27
|
+
and `Image` (responsive `<picture>`, empty-state box). Plus the marker-attribute
|
|
28
|
+
contract (`ATTR_SLOT_ID`, `ATTR_SLOT_ITEM`, …). Presentation is the template's own JSX.
|
|
29
|
+
- [Theme and CSS](theme-and-css.md): the two CSS entries and how they load; import
|
|
30
|
+
Tailwind exactly once; `base.css` ships no reset of its own — it requires the
|
|
31
|
+
preflight your Tailwind entry brings; `TokenTheme` + `compileThemeToCss`; the breakpoint ladder
|
|
32
|
+
(base 350, `bp390` `bp425` `bp512` `md` `lg` `bp1440` `bp1920`); the opt-in
|
|
33
|
+
`content-visibility` recipe for long, image-heavy pages, and why it requires a
|
|
34
|
+
full-screen overlay to be appended to `document.body` rather than rendered inside
|
|
35
|
+
the section.
|
|
13
36
|
- [README](../README.md): install, exports, and the release flow.
|
|
37
|
+
- [Islands](islands.md): interactivity — client components, the serializable-props contract, editor options, and the browser loader.
|
|
38
|
+
- [ESLint preset](eslint.md): `@homepages/template-kit/eslint`, the zero-config flat-config
|
|
39
|
+
preset — setup and its three-entry glob scope. Per-rule reference lives in `rules/`.
|
|
40
|
+
- [Rules](rules/INDEX.md): every rule-id a lint or `check` failure can print, under the
|
|
41
|
+
`template-kit/<id>` namespace, one page each at `docs/rules/<id>.md` — Rule / Reason /
|
|
42
|
+
Fix / Before→After. **The loop:** see an id in a failure → read `docs/rules/<id>.md` →
|
|
43
|
+
fix. Nothing else to search for; the id is the filename.
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
---
|
|
2
|
+
purpose: The five contract primitives a section renderer must use, and the marker attributes they emit.
|
|
3
|
+
status: living
|
|
4
|
+
related: [schema-system.md, theme-and-css.md, eslint.md]
|
|
5
|
+
updated: 2026-07-14
|
|
6
|
+
---
|
|
7
|
+
# Contract primitives
|
|
8
|
+
|
|
9
|
+
Everything here is imported from the package root:
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
import { Image, Section, Slot, SlotGroup, SlotItem } from "@homepages/template-kit";
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The kit ships **five components, and nothing else**. That is deliberate: a primitive
|
|
16
|
+
earns its place only by emitting something the platform breaks without — the section
|
|
17
|
+
box model, the editor's marker attributes, the responsive-image machinery.
|
|
18
|
+
Presentation is yours. Headings, links, layout wells, icons, lists, cards: write them
|
|
19
|
+
as JSX in your section, or reach for a package.
|
|
20
|
+
|
|
21
|
+
| Primitive | Emits | Required when |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `Section` | `class="tr-section"` on the section root | always — every renderer roots at exactly one |
|
|
24
|
+
| `Slot` | `data-slot-id` (+ optional `data-slot-text-leaf`) | around every editable slot's outer DOM |
|
|
25
|
+
| `SlotItem` | `data-slot-item` | around each item of an `object_list` / `image_collection` slot |
|
|
26
|
+
| `SlotGroup` | `data-slot-group` | around the members of a group declared in `meta.groups` |
|
|
27
|
+
| `Image` | `<picture>` / `srcset` / the empty-state box | every image — sections never write a raw `<img>` |
|
|
28
|
+
|
|
29
|
+
A slot with no marker in the DOM **cannot be selected or edited**. The markers are the
|
|
30
|
+
only thing connecting the pixels a user clicks to the schema key behind them.
|
|
31
|
+
|
|
32
|
+
## `Section` — the section root
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
export default function Hero({ slots, nav }: Props) {
|
|
36
|
+
return (
|
|
37
|
+
<Section id={nav.selfAnchor} className="bg-surface py-16">
|
|
38
|
+
…
|
|
39
|
+
</Section>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Renders `<section class="tr-section">` and merges any `className` you pass. `as` picks
|
|
45
|
+
a different element from `section | div | article | header | footer | aside`.
|
|
46
|
+
|
|
47
|
+
`.tr-section` (from [`base.css`](theme-and-css.md)) is **full-bleed by contract**: 100%
|
|
48
|
+
width, zero padding, zero margin, so consecutive sections butt edge-to-edge and a page
|
|
49
|
+
composes as a vertical stack with no framework-injected whitespace. Vertical separation
|
|
50
|
+
is the section's own job — a background and padding on this root or a child, never a
|
|
51
|
+
margin that would re-open gaps between sections.
|
|
52
|
+
|
|
53
|
+
A centered, max-width well is plain JSX on a child:
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
<div className="w-full max-w-[var(--tr-container-max)] mx-auto px-[var(--tr-container-pad)]">
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## `Slot` — the editable-slot boundary
|
|
60
|
+
|
|
61
|
+
```tsx
|
|
62
|
+
<Slot id="headline" as="h1" textLeaf className="text-4xl">
|
|
63
|
+
<span>{slots.headline}</span>
|
|
64
|
+
</Slot>
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
| Prop | Meaning |
|
|
68
|
+
|---|---|
|
|
69
|
+
| `id` | The slot key from `schema.ts`, written as a string literal. Emitted as `data-slot-id`. |
|
|
70
|
+
| `as` | Element to render. Default `"div"`. |
|
|
71
|
+
| `textLeaf` | For a single-leaf `text` slot: marks the immediate child element with `data-slot-text-leaf`. |
|
|
72
|
+
| …rest | Any HTML attribute — `className`, `style`, ARIA, `data-*`. |
|
|
73
|
+
|
|
74
|
+
`textLeaf` is what lets the editor write `textContent` on the leaf on every keystroke
|
|
75
|
+
(the optimistic path). It only handles the **single element child** case: with raw text,
|
|
76
|
+
a fragment, or several children it is a silent no-op — the outer `data-slot-id` still
|
|
77
|
+
lands, the editor just skips the optimistic update and converges on the next render. A
|
|
78
|
+
multi-leaf slot (an address with one leaf per line) marks its leaves itself.
|
|
79
|
+
|
|
80
|
+
`Slot` still emits its marker when it has no children. That is what keeps an unfilled
|
|
81
|
+
slot selectable, so a user can click the empty shell and type into it.
|
|
82
|
+
|
|
83
|
+
Not every slot needs a `<Slot>` wrapper: a primitive with a natural single root for its
|
|
84
|
+
slot type takes a `slotId` prop and emits the marker itself — `<Image slotId="hero_image">`
|
|
85
|
+
needs no wrapper. `<Slot>` is for everything else.
|
|
86
|
+
|
|
87
|
+
## `SlotItem` — the per-item handle in a collection
|
|
88
|
+
|
|
89
|
+
A collection slot (`object_list`, `image_collection`) needs **both** markers: `<Slot>`
|
|
90
|
+
marks the whole array, `<SlotItem>` marks one row of it.
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
<Slot id="amenities">
|
|
94
|
+
{slots.amenities.map((item, i) => (
|
|
95
|
+
<SlotItem key={item.id} index={i} as="li" className="border-b">
|
|
96
|
+
<h3>{item.label}</h3>
|
|
97
|
+
</SlotItem>
|
|
98
|
+
))}
|
|
99
|
+
</Slot>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The editor resolves the slot from the ancestor `data-slot-id`, then the specific item
|
|
103
|
+
from the nearest `data-slot-item`. Without `SlotItem` a collection is editable only as a
|
|
104
|
+
whole — no per-item selection, no per-item image cropping, no per-item live surfaces.
|
|
105
|
+
|
|
106
|
+
`index` **must** be the item's zero-based position in the slot's array as rendered. The
|
|
107
|
+
editor writes edits back by that index, so an index that doesn't match its position edits
|
|
108
|
+
the wrong row. `index` is an array position, not a schema key, so unlike `Slot`'s and
|
|
109
|
+
`SlotGroup`'s `id` it is exempt from the [`template-kit/slot-marker-literal`](eslint.md#markers-and-schema)
|
|
110
|
+
lint rule.
|
|
111
|
+
|
|
112
|
+
## `SlotGroup` — slots edited as one unit
|
|
113
|
+
|
|
114
|
+
Grouping is authored in two places that must agree.
|
|
115
|
+
|
|
116
|
+
`schema.ts` declares the group in `meta.groups`, which collapses its members into one
|
|
117
|
+
card in the editor sidebar:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
meta: {
|
|
121
|
+
displayName: "Contact",
|
|
122
|
+
groups: [
|
|
123
|
+
{ id: "agent", label: "Agent", members: ["agent_name", ["agent_email", "agent_phone"]] },
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`Renderer.tsx` wraps the members' shared container, which makes the canvas outline and
|
|
129
|
+
select them as the single unit matching that one card:
|
|
130
|
+
|
|
131
|
+
```tsx
|
|
132
|
+
<SlotGroup id="agent" className="flex flex-col gap-2">
|
|
133
|
+
<Slot id="agent_name" textLeaf><h3>{slots.agent_name}</h3></Slot>
|
|
134
|
+
<Slot id="agent_email" textLeaf><span>{slots.agent_email}</span></Slot>
|
|
135
|
+
<Slot id="agent_phone" textLeaf><span>{slots.agent_phone}</span></Slot>
|
|
136
|
+
</SlotGroup>
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The primitive's `id` **must equal** the group's `id` in `meta.groups`. `SlotGroup` must
|
|
140
|
+
be an **ancestor** of every member's `data-slot-id` element — the editor walks up with
|
|
141
|
+
`closest("[data-slot-group]")`, so a marker that isn't an ancestor is never found.
|
|
142
|
+
|
|
143
|
+
## `Image` — the defensive image primitive
|
|
144
|
+
|
|
145
|
+
Sections never write a raw `<img>`. `Image` owns the responsive `<picture>`, the
|
|
146
|
+
empty-state box, and the layout box that survives a missing source.
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
<Image
|
|
150
|
+
slotId="hero_image"
|
|
151
|
+
src={slots.hero_image.url}
|
|
152
|
+
alt={slots.hero_image.alt}
|
|
153
|
+
responsive={slots.hero_image.responsive}
|
|
154
|
+
sizes="(min-width: 1024px) 1200px, 100vw"
|
|
155
|
+
aspectRatio="5 / 3"
|
|
156
|
+
/>
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
| Prop | Meaning |
|
|
160
|
+
|---|---|
|
|
161
|
+
| `src` | Image URL. Required prop; `null`/empty is a supported state, not a bug. |
|
|
162
|
+
| `alt` | Required. Empty string for a decorative image. |
|
|
163
|
+
| `slotId` | Emits `data-slot-id` on the wrapper — an image slot needs no `<Slot>` around it. |
|
|
164
|
+
| `aspectRatio` | CSS `aspect-ratio` reserved on the wrapper. Survives a missing `src`. |
|
|
165
|
+
| `responsive` | The platform's `ResponsiveImage` descriptor (`ImageValue.responsive`). |
|
|
166
|
+
| `responsiveMobile` | Mobile-crop descriptor (`ImageValue.mobile.responsive`); needs `mobileBreakpoint`. |
|
|
167
|
+
| `mobileBreakpoint` | Desktop↔mobile switch width in px. The mobile `<source>` gets `max-width: breakpoint − 1`. |
|
|
168
|
+
| `sizes` | The slot's `sizes` attribute, applied to every srcset candidate. |
|
|
169
|
+
| `maxRungWidth` | Per-section srcset ceiling in px. Wider rungs are dropped (the smallest is always kept). |
|
|
170
|
+
| `bare` | Escape hatch: a single `<img>`, no wrapper. |
|
|
171
|
+
|
|
172
|
+
**Framed (the default).** A wrapper `div` carries the slot marker, holds the aspect-ratio
|
|
173
|
+
box, and absolutely-positions the inner `<img>` with `object-cover`. Cropping happens
|
|
174
|
+
upstream — the platform delivers a pre-cropped asset — so the image is simply centered.
|
|
175
|
+
When `src` is empty the wrapper renders alone as a neutral `role="img"` rect, keeping the
|
|
176
|
+
layout box and the slot selectable. **That deterministic empty state is the reason the
|
|
177
|
+
frame exists**, so pass `aspectRatio` rather than sizing the image from its own content.
|
|
178
|
+
|
|
179
|
+
**Responsive.** Given a `responsive` descriptor the `<img>` is wrapped in `<picture>` with
|
|
180
|
+
per-format AVIF/WebP `<source srcset>` (the base raster stays on the `<img>`), plus a
|
|
181
|
+
media-gated mobile crop when `responsiveMobile` + `mobileBreakpoint` are set, and carries
|
|
182
|
+
`sizes` and intrinsic `width`/`height`. With no descriptor it degrades to a plain single
|
|
183
|
+
`<img src>` — no `srcset`, no `<picture>`.
|
|
184
|
+
|
|
185
|
+
**Bare.** `bare` renders one `<img class="w-full">` with no wrapper, for chrome images
|
|
186
|
+
whose parent already establishes the layout box (a logo you want `object-contain`, a slide
|
|
187
|
+
inside its own aspect-ratio parent). It deliberately sets no `object-fit`, so an
|
|
188
|
+
`object-contain` you pass isn't fighting a built-in `object-cover`. It forfeits the
|
|
189
|
+
empty-state box — reach for it only when the parent guarantees the box.
|
|
190
|
+
|
|
191
|
+
## The marker contract
|
|
192
|
+
|
|
193
|
+
The primitives are the sanctioned way to produce these attributes. The constants are
|
|
194
|
+
exported so a section's own browser-side code can query the DOM without hardcoding a
|
|
195
|
+
magic string.
|
|
196
|
+
|
|
197
|
+
| Constant | Attribute | On |
|
|
198
|
+
|---|---|---|
|
|
199
|
+
| `ATTR_SLOT_ID` | `data-slot-id` | the `Slot` wrapper / `Image`'s framed wrapper |
|
|
200
|
+
| `ATTR_SLOT_TEXT_LEAF` | `data-slot-text-leaf` | the text leaf inside a text slot |
|
|
201
|
+
| `ATTR_SLOT_ITEM` | `data-slot-item` | one item of a collection slot |
|
|
202
|
+
| `ATTR_SLOT_GROUP` | `data-slot-group` | the container wrapping a group's members |
|
|
203
|
+
| `ATTR_SECTION_INSTANCE_ID` | `data-section-instance-id` | the section root — **written by the platform**, not by a primitive |
|
|
204
|
+
| `ATTR_TR_ENHANCE` | `data-tr-enhance` | opt-in hook telling an enhancer to attach to this element |
|
|
205
|
+
|
|
206
|
+
Changing what an attribute means is a platform contract break, so these names are stable
|
|
207
|
+
across a major version of the kit.
|
|
208
|
+
|
|
209
|
+
## Renderers stay pure
|
|
210
|
+
|
|
211
|
+
Renderers are pure functions of props: no fetches, no globals, no `useEffect` for data,
|
|
212
|
+
no `Date.now()` / `Math.random()`. They are rendered to static markup by the publisher
|
|
213
|
+
and re-mounted constantly by the editor. Interactivity that needs the browser attaches
|
|
214
|
+
through `data-tr-enhance`, not through render-time side effects.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
purpose: Router for the rule corpus — every template-kit/<id> an author can hit, and the page that explains it.
|
|
3
|
+
status: living
|
|
4
|
+
related: [server-vs-client.md, ../eslint.md, ../INDEX.md]
|
|
5
|
+
updated: 2026-07-14
|
|
6
|
+
---
|
|
7
|
+
# Rules
|
|
8
|
+
|
|
9
|
+
Every authoring rule the kit enforces is printed under one namespace, `template-kit/<id>`,
|
|
10
|
+
and resolves to exactly one page here: `docs/rules/<id>.md`.
|
|
11
|
+
|
|
12
|
+
**One namespace, two venues.** Some rules are properties of **one file** (a `Date.now()`
|
|
13
|
+
in a Renderer) and are enforced by the ESLint preset. Others are properties of the
|
|
14
|
+
**tree** (a slot declared in `schema.ts` but never marked in the JSX, a stylesheet, a
|
|
15
|
+
dependency, a byte budget) and cannot be expressed one file at a time; those are enforced
|
|
16
|
+
by `template-kit check`. You never need to know which engine produced a violation — only
|
|
17
|
+
the id, which names its page either way.
|
|
18
|
+
|
|
19
|
+
Read [server vs client](server-vs-client.md) first if you are here for any rule that
|
|
20
|
+
mentions "server-rendered code" or an island. It is the one concept those rules assume,
|
|
21
|
+
and it is explained there rather than on each of them.
|
|
22
|
+
|
|
23
|
+
| Rule | Venue | What it enforces |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| [`no-nondeterminism`](no-nondeterminism.md) | eslint | No clock or randomness reads in server-rendered code. |
|
|
26
|
+
| [`no-client-runtime-in-server`](no-client-runtime-in-server.md) | eslint | No effects, state, event handlers, network, or browser globals in server-rendered code. |
|
|
27
|
+
| [`serializable-island-props`](serializable-island-props.md) | eslint | Props handed to an island must be JSON-serializable. |
|
|
28
|
+
| [`no-client-directive-in-contract`](no-client-directive-in-contract.md) | eslint | No "use client" in a section's four contract files. |
|
|
29
|
+
| [`props-from-schema`](props-from-schema.md) | eslint | A Renderer's Props type must be derived from schema.ts, not hand-written. |
|
|
30
|
+
| [`no-inline-style`](no-inline-style.md) | eslint | No inline style= in server-rendered JSX. Islands are exempt. |
|
|
31
|
+
| [`no-raw-element`](no-raw-element.md) | eslint | No raw `<section>` or `<img>` — use `<Section>` and `<Image>`. |
|
|
32
|
+
| [`image-bare-needs-reason`](image-bare-needs-reason.md) | eslint | `<Image bare>` needs a `// bare:` comment saying why it skips the frame. |
|
|
33
|
+
| [`slot-marker-literal`](slot-marker-literal.md) | eslint | A slot-marker key must be a string literal, not an expression. |
|
|
34
|
+
| [`no-hex`](no-hex.md) | eslint | No hard-coded colours. Islands are NOT exempt. |
|
|
35
|
+
| [`no-css-import-from-render-path`](no-css-import-from-render-path.md) | eslint | No relative/absolute .css import from a section's render path — a package's is the sanctioned channel. |
|
|
36
|
+
| [`bundle-incomplete`](bundle-incomplete.md) | check | A section folder is missing one of its four required files. |
|
|
37
|
+
| [`missing-slot-marker`](missing-slot-marker.md) | check | An editable slot in schema.ts has no marker in the section's JSX. |
|
|
38
|
+
| [`slot-group-marker`](slot-group-marker.md) | check | A group with two or more members has no `<SlotGroup>` wrapper. |
|
|
39
|
+
| [`css-reason`](css-reason.md) | check | A hand-written CSS file must open with a `css-reason:` comment. |
|
|
40
|
+
| [`no-bare-css-import`](no-bare-css-import.md) | check | No @import in hand-written CSS — the browser drops it silently. |
|
|
41
|
+
| [`unlayered-fence`](unlayered-fence.md) | check | An `/* unlayered: */` escape hatch must be balanced and give a reason. |
|
|
42
|
+
| [`sidebar-order`](sidebar-order.md) | check | Schema slot order must match the order the page renders them in. |
|
|
43
|
+
| [`typecheck`](typecheck.md) | check | The template must compile under TypeScript. |
|
|
44
|
+
| [`determinism-drift`](determinism-drift.md) | check | Rendering the same fixture twice must produce byte-identical HTML. |
|
|
45
|
+
| [`render-invariant`](render-invariant.md) | check | Rendered output must carry no [object Object], bare null/undefined/NaN leaf, or src-less `<img>`. |
|
|
46
|
+
| [`lockfile-missing`](lockfile-missing.md) | check | The workspace has no package-lock.json. |
|
|
47
|
+
| [`lockfile-stale`](lockfile-stale.md) | check | The lockfile does not satisfy package.json — an `npm ci` would fail. |
|
|
48
|
+
| [`single-react`](single-react.md) | check | The installed tree carries more than one copy of react or react-dom. |
|
|
49
|
+
| [`audit-severity`](audit-severity.md) | check | A dependency carries a high or critical security advisory. |
|
|
50
|
+
| [`license-denied`](license-denied.md) | check | A production dependency's license is not on the allowlist. |
|
|
51
|
+
| [`size-renderer-bundle`](size-renderer-bundle.md) | check | A section's renderer bundle exceeds its byte budget. |
|
|
52
|
+
| [`size-section-css`](size-section-css.md) | check | A section's compiled CSS exceeds its byte budget. |
|
|
53
|
+
|
|
54
|
+
## See also
|
|
55
|
+
|
|
56
|
+
- [Server vs client](server-vs-client.md) — how a file is scoped, and which rules stop at
|
|
57
|
+
the hydration boundary.
|
|
58
|
+
- [ESLint preset](../eslint.md) — setting the preset up in a template workspace.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
---
|
|
2
|
+
purpose: The template-kit/audit-severity authoring rule — what it enforces, why, and how to fix it.
|
|
3
|
+
status: living
|
|
4
|
+
related: [INDEX.md, license-denied.md]
|
|
5
|
+
updated: 2026-07-14
|
|
6
|
+
---
|
|
7
|
+
# template-kit/audit-severity
|
|
8
|
+
|
|
9
|
+
> A dependency with a **high** or **critical** advisory fails. Low and moderate pass, on
|
|
10
|
+
> purpose. There is no way to suppress it.
|
|
11
|
+
|
|
12
|
+
## Rule
|
|
13
|
+
|
|
14
|
+
`npm audit` runs over your workspace, and a violation is reported for each advisory at
|
|
15
|
+
severity **high** or **critical**.
|
|
16
|
+
|
|
17
|
+
**Low and moderate advisories are a deliberate pass.** They are not "not yet enforced" —
|
|
18
|
+
they are not enforced. Nobody will ask you to clear them.
|
|
19
|
+
|
|
20
|
+
**There is no suppression mechanism.** No inline waiver, no ignore file, no severity
|
|
21
|
+
override. If you are looking for the flag, there isn't one, and that is the design.
|
|
22
|
+
|
|
23
|
+
## Reason
|
|
24
|
+
|
|
25
|
+
The kit lets you reach for third-party packages — a carousel, a map SDK, a date formatter —
|
|
26
|
+
because a section that has to hand-write everything is a section nobody finishes. The
|
|
27
|
+
consequence is that your dependency tree ships to a published page we host for a customer,
|
|
28
|
+
so the supply chain is part of the submission.
|
|
29
|
+
|
|
30
|
+
This gate is the **floor**, not a review. Cutting at high/critical is what keeps it a floor:
|
|
31
|
+
a rule that fired on every transitive moderate would be a rule everyone learned to route
|
|
32
|
+
around, and the exception would swallow the check. Two severities, no exceptions, and a
|
|
33
|
+
clear answer to "can I ship this."
|
|
34
|
+
|
|
35
|
+
## Fix
|
|
36
|
+
|
|
37
|
+
In order of preference:
|
|
38
|
+
|
|
39
|
+
1. **`npm audit fix`** — resolves most advisories by moving a transitive dependency inside
|
|
40
|
+
its existing range.
|
|
41
|
+
2. **Upgrade the offending package.** If the fix is in a new major, `npm audit fix --force`
|
|
42
|
+
will take it, but read what it changes first.
|
|
43
|
+
3. **Drop the package.** An advisory with no fix available is a package with no fix
|
|
44
|
+
available. Replace it, or hand-write the part of it you actually use.
|
|
45
|
+
|
|
46
|
+
Then re-run `npm audit` and commit the lockfile.
|
|
47
|
+
|
|
48
|
+
### Before
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
$ npm audit
|
|
52
|
+
# npm audit report
|
|
53
|
+
|
|
54
|
+
marked <4.0.10
|
|
55
|
+
Severity: high
|
|
56
|
+
Inefficient Regular Expression Complexity in marked
|
|
57
|
+
fix available via `npm audit fix --force`
|
|
58
|
+
|
|
59
|
+
1 high severity vulnerability
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```jsonc
|
|
63
|
+
// package.json
|
|
64
|
+
{
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"marked": "^3.0.8"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### After
|
|
72
|
+
|
|
73
|
+
```jsonc
|
|
74
|
+
// package.json
|
|
75
|
+
{
|
|
76
|
+
"dependencies": {
|
|
77
|
+
"marked": "^14.1.2"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
```text
|
|
83
|
+
$ npm audit
|
|
84
|
+
found 0 vulnerabilities
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## See also
|
|
88
|
+
|
|
89
|
+
- [`license-denied`](license-denied.md) — the other supply-chain gate on your production
|
|
90
|
+
dependencies.
|
|
91
|
+
- [`lockfile-stale`](lockfile-stale.md) — commit the lockfile the fix regenerated.
|