@homepages/template-kit 0.0.0 → 0.1.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 +182 -0
- package/README.md +71 -14
- package/dist/base.css +85 -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 +307 -0
- package/dist/design-system/theme.js +95 -0
- package/dist/eslint/is-client-file.js +79 -0
- package/dist/eslint/rules/image-bare-needs-reason.js +38 -0
- package/dist/eslint/rules/no-client-directive-in-contract.js +26 -0
- package/dist/eslint/rules/no-client-runtime-in-server.js +99 -0
- package/dist/eslint/rules/no-css-import-from-render-path.js +23 -0
- package/dist/eslint/rules/no-hex.js +111 -0
- package/dist/eslint/rules/no-inline-style.js +23 -0
- package/dist/eslint/rules/no-nondeterminism.js +44 -0
- package/dist/eslint/rules/no-raw-element.js +32 -0
- package/dist/eslint/rules/props-from-schema.js +32 -0
- package/dist/eslint/rules/serializable-island-props.js +108 -0
- package/dist/eslint/rules/slot-marker-literal.js +33 -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 +37 -13
- 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/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 +6 -4
- package/docs/eslint.md +90 -0
- package/docs/islands.md +150 -0
- package/docs/llms.txt +30 -3
- package/docs/primitives.md +214 -0
- package/docs/schema-system.md +240 -0
- package/docs/theme-and-css.md +179 -0
- package/package.json +30 -6
- package/tsconfig.base.json +16 -0
- package/tsconfig.json +2 -13
- package/dist/src-CrdHXijV.js +0 -23
package/docs/eslint.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
purpose: The authoring-lint preset shipped at `@homepages/template-kit/eslint` — setup, scope, and the eleven rule ids.
|
|
3
|
+
status: living
|
|
4
|
+
related: [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 below |
|
|
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 — not by an import graph or a build transform. A file carrying the
|
|
45
|
+
directive is an island: it hydrates in the browser, so effects, state, event
|
|
46
|
+
handlers, and inline `style` are legal there and the corresponding rules exempt
|
|
47
|
+
it. A rule that does not exempt islands says so explicitly below.
|
|
48
|
+
|
|
49
|
+
## The rules
|
|
50
|
+
|
|
51
|
+
Rules that are properties of one file live here. Rules that are properties of the
|
|
52
|
+
**tree** — a missing contract file, a slot declared in `schema.ts` but unmarked in
|
|
53
|
+
`Renderer.tsx`, a CSS rule — cannot be expressed one-file-at-a-time; they belong to
|
|
54
|
+
`template-kit check` (not yet built) under the same `template-kit/<id>` namespace.
|
|
55
|
+
One namespace, two venues.
|
|
56
|
+
|
|
57
|
+
### Server-rendered code
|
|
58
|
+
|
|
59
|
+
| Rule | What it bans |
|
|
60
|
+
|---|---|
|
|
61
|
+
| `no-nondeterminism` | Clock/randomness reads (`Date.now()`, `new Date()` with no args, `Math.random()`, `crypto.randomUUID()`/`getRandomValues()`, `performance.now()`) — the publisher and the editor must render byte-identical HTML. Exempt inside an island. |
|
|
62
|
+
| `no-client-runtime-in-server` | Effects, state hooks, event handlers, and browser globals (`fetch`, `window`, `document`, …) — a Renderer must be a pure function of its props. Exempt inside an island. |
|
|
63
|
+
| `no-inline-style` | The `style=` JSX prop — it bypasses the cascade and Tailwind's responsive prefixes. Exempt inside an island. |
|
|
64
|
+
| `no-raw-element` | Raw `<section>`/`<img>` — each has a contract-bearing primitive (`Section`/`Image`) that must be used instead. |
|
|
65
|
+
| `image-bare-needs-reason` | `<Image bare>` with no `// bare: <reason>` comment justifying the skipped frame. |
|
|
66
|
+
| `no-css-import-from-render-path` | A `.css` import anywhere in a section's render path — the SSR graph drops it silently. Import CSS from the section's own `styles.css` instead. |
|
|
67
|
+
|
|
68
|
+
### Islands
|
|
69
|
+
|
|
70
|
+
| Rule | What it checks |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `serializable-island-props` | A statically-visible prop passed to an island (a literal function, `new Date()`/`Map`/`Set`/etc., a bigint literal, `NaN`/`Infinity`) that cannot cross the server→browser JSON boundary. It is the static twin of the runtime check — see [islands.md](islands.md#props-must-be-json-serializable). |
|
|
73
|
+
|
|
74
|
+
### Universal — no island exemption
|
|
75
|
+
|
|
76
|
+
| Rule | What it bans |
|
|
77
|
+
|---|---|
|
|
78
|
+
| `no-hex` | Hex color literals and CSS color functions (`oklch`/`oklab`/`rgb`/`rgba`/`hsl`/`hsla`/`color-mix`) in a color context (a Tailwind color-utility bracket, an arbitrary-property color declaration, or an inline `style` value). Token discipline is universal — see [theme-and-css.md](theme-and-css.md#color-roles). |
|
|
79
|
+
|
|
80
|
+
Deliberate asymmetry: `no-inline-style` exempts islands, `no-hex` does not — the
|
|
81
|
+
hydration boundary is not a palette boundary. A section carries no palette of its
|
|
82
|
+
own in either render path.
|
|
83
|
+
|
|
84
|
+
### Markers and schema
|
|
85
|
+
|
|
86
|
+
| Rule | What it checks |
|
|
87
|
+
|---|---|
|
|
88
|
+
| `slot-marker-literal` | A slot-key-bearing marker (`<Slot id>`, `<SlotGroup id>`, `slotId`, `data-slot-id`, `data-slot-group`) must carry a string-literal key, not an expression — the slot-marker completeness check (`template-kit check`) reads these keys statically. `SlotItem` is exempt: its prop is `index: number`, an array position, so there is no schema key to cross-check. |
|
|
89
|
+
| `props-from-schema` | A `Renderer.tsx`'s exported `Props` type must come from `SectionProps<typeof schema>`. A hand-written `Props` shadows that inference and drifts. |
|
|
90
|
+
| `no-client-directive-in-contract` | `"use client"` in a section's four contract files (`Renderer.tsx`, `schema.ts`, `fill-spec.ts`, `fixtures.ts`) — the platform reads them outside a browser; marking one an island takes the section's server-rendered HTML with it. |
|
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,37 @@
|
|
|
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; `TokenTheme` + `compileThemeToCss`; the breakpoint ladder
|
|
31
|
+
(base 350, `bp390` `bp425` `bp512` `md` `lg` `bp1440` `bp1920`); the opt-in
|
|
32
|
+
`content-visibility` recipe for long, image-heavy pages, and why it requires a
|
|
33
|
+
full-screen overlay to be appended to `document.body` rather than rendered inside
|
|
34
|
+
the section.
|
|
13
35
|
- [README](../README.md): install, exports, and the release flow.
|
|
36
|
+
- [Islands](islands.md): interactivity — client components, the serializable-props contract, editor options, and the browser loader.
|
|
37
|
+
- [ESLint preset](eslint.md): `@homepages/template-kit/eslint`, the zero-config flat-config
|
|
38
|
+
preset covering the eleven `template-kit/*` authoring rules — determinism, the
|
|
39
|
+
server/island boundary, serializable island props, the marker and schema contracts,
|
|
40
|
+
and token discipline.
|
|
@@ -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.
|