@iterant/site-runtime 3.1.2 → 3.1.3

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.
@@ -50,7 +50,7 @@ runtime and says so.
50
50
 
51
51
  <!-- generated: available libraries -->
52
52
 
53
- _Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.1.2._
53
+ _Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.1.3._
54
54
 
55
55
  **Toolchain** (this package owns the version; do NOT declare these):
56
56
 
@@ -99,9 +99,13 @@ The grammar (`content-values.ts`, schema-enforced):
99
99
 
100
100
  - Copy is **wrapped**: `{"type":"text","value":"…"}`,
101
101
  `{"type":"link","text":"…","href":"…"}`,
102
- `{"type":"image","src":"…","alt":"…"}`, `{"type":"svg","markup":"…"}`,
102
+ `{"type":"image","src":"…","alt":"…"}`,
103
+ `{"type":"video","src":"…","poster":"…"}`, `{"type":"svg","markup":"…"}`,
103
104
  `{"type":"color","value":"…"}`. Repeated content is
104
- `{"type":"array","items":[{…}]}`.
105
+ `{"type":"array","items":[{…}]}`. A link's `text` is optional in bespoke
106
+ content (an anchor can wrap its label); authored sections and chrome
107
+ require it (`labelledLinkSchema`). Videos carry no copy and are never
108
+ translated.
105
109
  - Non-copy config stays bare: numbers, booleans, short lowercase tokens
106
110
  (`"zap"`, `"center"`, `"inverse"`). Bare strings with uppercase letters or
107
111
  spaces are rejected by the schema, so wrap them.
@@ -123,6 +127,37 @@ The grammar (`content-values.ts`, schema-enforced):
123
127
  - `site-runtime scan-copy` finds hardcoded copy in component code. Like every
124
128
  gate, never edit or weaken it.
125
129
 
130
+ ### Reading leaves in section code
131
+
132
+ A section prop typed `ContentLeaf` is a union, and reading a variant field off
133
+ the union (`leaf.value`, `leaf.href`) fails `astro check` even though it
134
+ renders. Read leaves through the exported accessors, one per kind, and never
135
+ hand-roll narrowing helpers:
136
+
137
+ ```tsx
138
+ import {
139
+ readImage,
140
+ readItems,
141
+ readLink,
142
+ readText,
143
+ } from "@iterant/site-runtime/content-values";
144
+
145
+ <h2>{readText(data.heading)}</h2>;
146
+ {
147
+ readItems(data.features).map((item, index) => (
148
+ <li key={index}>{readText(item.title)}</li>
149
+ ));
150
+ }
151
+ const cta = readLink(data.cta); // { text?, href, target? } | null
152
+ const hero = readImage(data.hero); // { src, alt, srcset? } | null
153
+ const reel = readVideo(data.reel); // { src, poster? } | null
154
+ ```
155
+
156
+ They never throw and degrade to empty (`""`, `null`, `[]`) on the wrong kind,
157
+ so a section renders with partial content instead of taking the route down.
158
+ Type guards (`isTextContent`, `isLinkContent`, …) are exported for the rare
159
+ case that needs manual narrowing.
160
+
126
161
  ### The collection schemas
127
162
 
128
163
  The repo's `src/content.config.ts` is a shim over `createCollections`, which
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iterant/site-runtime",
3
- "version": "3.1.2",
3
+ "version": "3.1.3",
4
4
  "type": "module",
5
5
  "description": "The platform layer every Iterant brand site runs on: content grammar, collection schemas, SEO head and JSON-LD, layout core, Astro config preset, dev integrations and the verify gates.",
6
6
  "scripts": {
@@ -46,6 +46,8 @@
46
46
  "./seo": "./src/components/seo.tsx",
47
47
  "./seo-json": "./src/components/seo-json.tsx",
48
48
  "./layout": "./src/layouts/LayoutCore.astro",
49
+ "./layout-core": "./src/layouts/layout-core.ts",
50
+ "./layout-contract": "./src/layouts/layout-contract.ts",
49
51
  "./under-construction": "./src/routes/UnderConstruction.astro",
50
52
  "./routes": "./src/routes/index.ts",
51
53
  "./config": "./src/config/preset.ts",
@@ -0,0 +1,32 @@
1
+ /** The layout surface GENERATORS write against, machine-readable (3.1.3).
2
+ * Platform code that emits brand-repo routes imports this and tests its
3
+ * emissions against it: a prop or slot an emission uses that is not listed
4
+ * here is dropped silently at render, which is exactly how the locale-twin
5
+ * incident escaped notice for two contract generations. The repo's Layout
6
+ * shim injects the brand-context trio itself, so emitted routes never pass
7
+ * those. The package's own contract test pins this list against
8
+ * LayoutCore.astro, so the two cannot drift apart. */
9
+ export const LAYOUT_CONTRACT = {
10
+ /** Props an emitted route may pass through the repo's Layout shim. */
11
+ props: [
12
+ "title",
13
+ "description",
14
+ "canonical",
15
+ "image",
16
+ "imageAlt",
17
+ "noindex",
18
+ "type",
19
+ "siteName",
20
+ "jsonLd",
21
+ "lang",
22
+ "hreflang",
23
+ "chrome",
24
+ "pageType",
25
+ "datePublished",
26
+ "dateModified",
27
+ ],
28
+ /** Injected by the repo's Layout shim; never passed by a route. */
29
+ shimInjected: ["siteConfig", "siteShell", "Shell"],
30
+ /** Named slots the layout renders. Anything else is dropped. */
31
+ slots: ["head"],
32
+ } as const;
@@ -139,3 +139,5 @@ export function resolveStructuredData(
139
139
  dateModified: props.dateModified ?? entryMeta?.dateModified,
140
140
  };
141
141
  }
142
+
143
+ export { LAYOUT_CONTRACT } from "./layout-contract";
@@ -4,7 +4,7 @@ import {
4
4
  arrayOf,
5
5
  configTokenSchema,
6
6
  contentLeafSchema,
7
- linkContentSchema,
7
+ labelledLinkSchema,
8
8
  textContentSchema,
9
9
  } from "./content-values";
10
10
 
@@ -18,7 +18,7 @@ import {
18
18
  // repo passes this map into createCollections; the shapes are platform-owned.
19
19
 
20
20
  const text = textContentSchema;
21
- const link = linkContentSchema;
21
+ const link = labelledLinkSchema;
22
22
 
23
23
  // A nav link is a link wrapper plus an optional trigger slug. When menuRef is
24
24
  // present the item opens a dropdown whose contents live in the top-level field
@@ -214,6 +214,14 @@ export const isChromeComponent = (type: string): type is ChromeComponentType =>
214
214
  // so it must be a loud error, not a silent no-op.
215
215
  export const KNOWN_CHROME_IDS = ["navbar", "footer"] as const;
216
216
 
217
+ // …and site FURNITURE, which only a replicated shell mounts. A copyright strip
218
+ // or a utility bar rides the frame rather than the navbar, so it renders on
219
+ // every page and its copy belongs to the SITE rather than to any one page. The
220
+ // id says who mounts it: this shell renders `frame-*` rows by id exactly as it
221
+ // renders navbar and footer, and the starter's own shell never emits one, so a
222
+ // `frame-*` row cannot appear in a repo with nothing to mount it.
223
+ const FURNITURE_ID = /^frame-[a-z0-9][a-z0-9-]*$/;
224
+
217
225
  // A replicated chrome component (clone emit): the navbar/footer copy lives in
218
226
  // bespoke content-value props (validated by componentSchema like any bespoke
219
227
  // page component), not the prebuilt navbar/footer prop grammar. It still mounts
@@ -221,6 +229,22 @@ export const KNOWN_CHROME_IDS = ["navbar", "footer"] as const;
221
229
  // not "navbar"/"footer", and it is exempt from the CHROME_COMPONENT_PROPS shape.
222
230
  export const REPLICATED_CHROME_TYPE = "replicated";
223
231
 
232
+ // …and a PER-PAGE chrome row (3.1.3): a known role with a scope suffix, e.g.
233
+ // "navbar-pricing". A site can have more than one header, and which one a page
234
+ // uses belongs to the page.
235
+ //
236
+ // It exists because a replicated page whose header differs from the site's had
237
+ // only two outcomes, and both are wrong: the site's header wins and the page
238
+ // loses its own, or the page's rides its own frame, is re-homed when that frame
239
+ // is withheld, and renders BESIDE the site's — the same header twice. A row of
240
+ // its own is the missing third.
241
+ //
242
+ // The role PREFIX is the mount contract, unchanged: the suffix says which
243
+ // variant, and the prefix still says which mount can render it, so a row that
244
+ // nothing could mount is still the loud error it has always been. A scoped row
245
+ // is clone-emitted, so its type is "replicated" like the rest.
246
+ const SCOPED_CHROME_ID = /^(?:navbar|footer)-[a-z0-9][a-z0-9-]*$/;
247
+
224
248
  // Validate a chrome.json components[] list's ids against the mount contract:
225
249
  // every id must be navbar|footer, and a known id's `type` must equal its `id`
226
250
  // (an {id:"navbar", type:"footer"} cross-wire validates the wrong props and
@@ -234,6 +258,13 @@ export function chromeIdIssues(
234
258
  const issues: { index: number; id: string; message: string }[] = [];
235
259
  const known = new Set<string>(KNOWN_CHROME_IDS);
236
260
  components.forEach((component, index) => {
261
+ if (
262
+ (FURNITURE_ID.test(component.id) ||
263
+ SCOPED_CHROME_ID.test(component.id)) &&
264
+ component.type === REPLICATED_CHROME_TYPE
265
+ ) {
266
+ return;
267
+ }
237
268
  if (!known.has(component.id)) {
238
269
  issues.push({
239
270
  index,
@@ -11,6 +11,7 @@ import { z } from "astro/zod";
11
11
  // { "type": "text", "value": "Visible prose" }
12
12
  // { "type": "link", "text": "Start free", "href": "/signup" }
13
13
  // { "type": "image", "src": "/images/x.webp", "alt": "Description" }
14
+ // { "type": "video", "src": "/media/x.mp4", "poster": "/images/x.webp" }
14
15
  // { "type": "svg", "markup": "<svg…>", "label": "Accessible name" }
15
16
  // { "type": "color", "value": "#0ea5e9" }
16
17
  // { "type": "array", "items": [ { …wrapped fields per item… } ] }
@@ -27,7 +28,13 @@ export interface TextContent {
27
28
 
28
29
  export interface LinkContent {
29
30
  type: "link";
30
- text: string;
31
+ /** The link's own label. Optional, because a link does not always have one:
32
+ * an anchor wrapping an icon, a card, or several elements has its label in
33
+ * the leaves beneath it, and those are separately addressable. A link with
34
+ * no text of its own renders none, and its destination stays editable,
35
+ * which is the point of the wrapper. Authored sections and chrome require
36
+ * one anyway: see labelledLinkSchema. */
37
+ text?: string;
31
38
  href: string;
32
39
  target?: "_blank" | "_self";
33
40
  }
@@ -38,6 +45,12 @@ export interface ImageCandidate {
38
45
  src: string;
39
46
  assetId?: string;
40
47
  descriptor: string;
48
+ /** The <source> encoding this candidate composes ("image/webp"), absent for
49
+ * the <img>'s own candidates. A <picture> is one image the browser fetches
50
+ * in whichever format it supports, so its formats ride one wrapper: a swap
51
+ * that clears the candidates drops every <source> at once and hands the
52
+ * picture back to the img it just rewrote. */
53
+ type?: string;
41
54
  }
42
55
 
43
56
  export interface ImageContent {
@@ -50,6 +63,18 @@ export interface ImageContent {
50
63
  srcset?: ImageCandidate[];
51
64
  }
52
65
 
66
+ /** A video the page plays. Like an image it is a resource the visitor sees and
67
+ * an owner replaces, and unlike an image it carries no copy: there is nothing
68
+ * in it to translate, which is why it has no text field and never appears in a
69
+ * translation pass. `poster` is the still frame shown before playback. */
70
+ export interface VideoContent {
71
+ type: "video";
72
+ src: string;
73
+ assetId?: string;
74
+ poster?: string;
75
+ posterAssetId?: string;
76
+ }
77
+
53
78
  export interface SvgContent {
54
79
  type: "svg";
55
80
  markup: string;
@@ -70,6 +95,7 @@ export type ContentValue =
70
95
  | TextContent
71
96
  | LinkContent
72
97
  | ImageContent
98
+ | VideoContent
73
99
  | SvgContent
74
100
  | ColorContent
75
101
  | ArrayContent;
@@ -88,10 +114,20 @@ export const textContentSchema = z
88
114
  // would try to rewrite it and the editor would offer a text input for
89
115
  // a destination. Links live in link wrappers ({type:"link",text,href});
90
116
  // image sources in image wrappers.
117
+ //
118
+ // The tail is RFC 3986's path characters, not "any non-space" (3.1.3). A
119
+ // value the spec's own grammar cannot read as a url is not a destination,
120
+ // whatever it starts with: `/>` out of a syntax-highlighted code sample is
121
+ // visible copy, and refusing it left it baked into a component instead.
122
+ // Genuinely ambiguous cases stay refused: `/h` is both a price unit and a
123
+ // valid path, and no predicate over the STRING can tell them apart.
91
124
  value: z
92
125
  .string()
93
126
  .refine(
94
- (value) => !/^(?:\/|#|https?:\/\/)\S*$/.test(value.trim()),
127
+ (value) =>
128
+ !/^(?:\/|#|https?:\/\/)[A-Za-z0-9\-._~%!$&'()*+,;=:@/?#[\]]*$/.test(
129
+ value.trim(),
130
+ ),
95
131
  'this looks like a URL or path — use {"type":"link","text":…,"href":…} (or image src), not a text wrapper',
96
132
  ),
97
133
  })
@@ -100,12 +136,21 @@ export const textContentSchema = z
100
136
  export const linkContentSchema = z
101
137
  .object({
102
138
  type: z.literal("link"),
103
- text: z.string(),
139
+ text: z.string().optional(),
104
140
  href: z.string(),
105
141
  target: z.enum(["_blank", "_self"]).optional(),
106
142
  })
107
143
  .strict();
108
144
 
145
+ /** A link that must carry a label: every one an AUTHOR writes. A hero CTA or a
146
+ * nav item with no text is a button nobody can read, so the components that
147
+ * render one from a fixed schema hold the stricter contract. The loose
148
+ * wrapper above is for bespoke pages, where an anchor's label can live in the
149
+ * markup beneath it. */
150
+ export const labelledLinkSchema = linkContentSchema.extend({
151
+ text: z.string(),
152
+ });
153
+
109
154
  // A srcset candidate descriptor is a width ("400w") or pixel density ("2x").
110
155
  const descriptorSchema = z
111
156
  .string()
@@ -119,6 +164,7 @@ export const imageCandidateSchema = z
119
164
  src: z.string(),
120
165
  assetId: z.string().optional(),
121
166
  descriptor: descriptorSchema,
167
+ type: z.string().optional(),
122
168
  })
123
169
  .strict();
124
170
 
@@ -134,6 +180,16 @@ export const imageContentSchema = z
134
180
  })
135
181
  .strict();
136
182
 
183
+ export const videoContentSchema = z
184
+ .object({
185
+ type: z.literal("video"),
186
+ src: z.string(),
187
+ assetId: z.string().optional(),
188
+ poster: z.string().optional(),
189
+ posterAssetId: z.string().optional(),
190
+ })
191
+ .strict();
192
+
137
193
  export const svgContentSchema = z
138
194
  .object({
139
195
  type: z.literal("svg"),
@@ -170,6 +226,7 @@ export const contentLeafSchema: z.ZodType<ContentLeaf> = z.lazy(() =>
170
226
  textContentSchema,
171
227
  linkContentSchema,
172
228
  imageContentSchema,
229
+ videoContentSchema,
173
230
  svgContentSchema,
174
231
  colorContentSchema,
175
232
  arrayContentSchema,
@@ -199,3 +256,103 @@ export const arrayOf = <T extends z.ZodRawShape>(shape: T) =>
199
256
  items: z.array(z.object(shape).strict()),
200
257
  })
201
258
  .strict();
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Leaf accessors. Section code receives ContentLeaf, a seven-way union, and
262
+ // the natural-but-wrong move is reading a variant's field off the union
263
+ // (`leaf.value` renders fine and fails astro check). These are the one
264
+ // documented way to read a leaf: narrow-and-extract, never throw, degrade to
265
+ // empty so a section renders with partial content instead of taking the
266
+ // route down. Use them instead of hand-rolled narrowing helpers.
267
+
268
+ export function isTextContent(leaf: ContentLeaf): leaf is TextContent {
269
+ return typeof leaf === "object" && leaf !== null && leaf.type === "text";
270
+ }
271
+
272
+ export function isLinkContent(leaf: ContentLeaf): leaf is LinkContent {
273
+ return typeof leaf === "object" && leaf !== null && leaf.type === "link";
274
+ }
275
+
276
+ export function isImageContent(leaf: ContentLeaf): leaf is ImageContent {
277
+ return typeof leaf === "object" && leaf !== null && leaf.type === "image";
278
+ }
279
+
280
+ export function isVideoContent(leaf: ContentLeaf): leaf is VideoContent {
281
+ return typeof leaf === "object" && leaf !== null && leaf.type === "video";
282
+ }
283
+
284
+ export function isSvgContent(leaf: ContentLeaf): leaf is SvgContent {
285
+ return typeof leaf === "object" && leaf !== null && leaf.type === "svg";
286
+ }
287
+
288
+ export function isColorContent(leaf: ContentLeaf): leaf is ColorContent {
289
+ return typeof leaf === "object" && leaf !== null && leaf.type === "color";
290
+ }
291
+
292
+ export function isArrayContent(leaf: ContentLeaf): leaf is ArrayContent {
293
+ return typeof leaf === "object" && leaf !== null && leaf.type === "array";
294
+ }
295
+
296
+ /**
297
+ * The renderable text of a leaf: a text wrapper's value, or a bare config
298
+ * scalar stringified (a count or token an entry legitimately interpolates
299
+ * into copy). Structured wrappers (link, image, video, svg, color, array)
300
+ * have no text reading and come back empty — read them with their own
301
+ * accessor.
302
+ */
303
+ export function readText(leaf: ContentLeaf | null | undefined): string {
304
+ if (leaf === null || leaf === undefined) return "";
305
+ if (isTextContent(leaf)) return leaf.value;
306
+ if (typeof leaf === "object") return "";
307
+ return String(leaf);
308
+ }
309
+
310
+ /** The link wrapper (text, href, optional target), or null. */
311
+ export function readLink(
312
+ leaf: ContentLeaf | null | undefined,
313
+ ): LinkContent | null {
314
+ return leaf !== null && leaf !== undefined && isLinkContent(leaf)
315
+ ? leaf
316
+ : null;
317
+ }
318
+
319
+ /** The image wrapper (src, alt, optional srcset candidates), or null. */
320
+ export function readImage(
321
+ leaf: ContentLeaf | null | undefined,
322
+ ): ImageContent | null {
323
+ return leaf !== null && leaf !== undefined && isImageContent(leaf)
324
+ ? leaf
325
+ : null;
326
+ }
327
+
328
+ /** The video wrapper (src, optional poster), or null. */
329
+ export function readVideo(
330
+ leaf: ContentLeaf | null | undefined,
331
+ ): VideoContent | null {
332
+ return leaf !== null && leaf !== undefined && isVideoContent(leaf)
333
+ ? leaf
334
+ : null;
335
+ }
336
+
337
+ /** The svg wrapper (markup, optional label), or null. */
338
+ export function readSvg(
339
+ leaf: ContentLeaf | null | undefined,
340
+ ): SvgContent | null {
341
+ return leaf !== null && leaf !== undefined && isSvgContent(leaf)
342
+ ? leaf
343
+ : null;
344
+ }
345
+
346
+ /** A color wrapper's CSS value, or null. */
347
+ export function readColor(leaf: ContentLeaf | null | undefined): string | null {
348
+ return leaf !== null && leaf !== undefined && isColorContent(leaf)
349
+ ? leaf.value
350
+ : null;
351
+ }
352
+
353
+ /** An array wrapper's items, or an empty list. */
354
+ export function readItems(leaf: ContentLeaf | null | undefined): ContentItem[] {
355
+ return leaf !== null && leaf !== undefined && isArrayContent(leaf)
356
+ ? leaf.items
357
+ : [];
358
+ }