@iterant/site-runtime 3.1.0 → 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.0._
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.0",
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,73 @@
1
+ // @ts-check
2
+ /**
3
+ * Reserved-filename gate: `src/fetch.*`.
4
+ *
5
+ * Astro 7 resolves `srcDir + "fetch"` as the request-pipeline entrypoint
6
+ * (advanced routing): when the module resolves, its default export replaces
7
+ * the default fetch handler for EVERY request, in dev and in production
8
+ * builds alike, and a failure to resolve falls back silently. Resolution
9
+ * goes through Vite, so every resolvable extension counts and a `src/fetch/`
10
+ * directory resolves through its index file. A brand repo must never carry
11
+ * one: an innocently named helper would silently take over the whole request
12
+ * pipeline, and a broken one takes every route down with it. The platform
13
+ * reserves the name for a future runtime-owned shim; until that ships, the
14
+ * file's absence is the contract.
15
+ */
16
+
17
+ import { readdir } from "node:fs/promises";
18
+ import { join, resolve } from "node:path";
19
+ import { pathToFileURL } from "node:url";
20
+
21
+ // Vite's default resolve.extensions, which Astro does not override.
22
+ const RESOLVABLE = /^fetch\.(mjs|js|mts|ts|jsx|tsx|json)$/;
23
+
24
+ /**
25
+ * @param {string} rootDir
26
+ * @returns {Promise<string[]>}
27
+ */
28
+ export async function checkFetchEntrypoint(rootDir) {
29
+ /** @type {import("node:fs").Dirent[]} */
30
+ let entries = [];
31
+ try {
32
+ entries = await readdir(join(rootDir, "src"), { withFileTypes: true });
33
+ } catch {
34
+ // No src/ at all is astro check's problem, not this gate's.
35
+ return [];
36
+ }
37
+ const problems = [];
38
+ for (const entry of entries) {
39
+ if (entry.isDirectory() && entry.name === "fetch") {
40
+ problems.push(
41
+ "src/fetch/ is a reserved name: Astro resolves it (through its index file) " +
42
+ "as the request-pipeline entrypoint for every route, dev and production. " +
43
+ "Rename the directory.",
44
+ );
45
+ continue;
46
+ }
47
+ if (entry.isFile() && RESOLVABLE.test(entry.name)) {
48
+ problems.push(
49
+ `src/${entry.name} is a reserved filename: Astro loads it as the ` +
50
+ "request-pipeline entrypoint for every route, dev and production, " +
51
+ "replacing the platform's default handler. Rename the file.",
52
+ );
53
+ }
54
+ }
55
+ return problems;
56
+ }
57
+
58
+ // Standalone CLI: `node scripts/scan-fetch-entrypoint.mjs [rootDir]`.
59
+ // Prints problems to stderr and exits 1 when any are found.
60
+ if (
61
+ process.argv[1] &&
62
+ import.meta.url === pathToFileURL(resolve(process.argv[1])).href
63
+ ) {
64
+ const problems = await checkFetchEntrypoint(
65
+ process.argv[2] ? resolve(process.argv[2]) : process.cwd(),
66
+ );
67
+ if (problems.length > 0) {
68
+ process.stderr.write(
69
+ `fetch-entrypoint check failed:\n${problems.join("\n")}\n`,
70
+ );
71
+ process.exit(1);
72
+ }
73
+ }
@@ -207,6 +207,19 @@ if (fontProblems.length > 0) {
207
207
  exit(1);
208
208
  }
209
209
 
210
+ // Fetch-entrypoint gate — Astro loads src/fetch.* as the request-pipeline
211
+ // entrypoint for EVERY route, dev and production, so an innocently named
212
+ // helper silently takes over the whole pipeline. The check lives in
213
+ // scan-fetch-entrypoint.mjs so its tests can exercise it on fixtures.
214
+ const { checkFetchEntrypoint } = await import("./scan-fetch-entrypoint.mjs");
215
+ const fetchProblems = await checkFetchEntrypoint(process.cwd());
216
+ if (fetchProblems.length > 0) {
217
+ process.stderr.write(
218
+ `fetch-entrypoint check failed:\n${fetchProblems.join("\n")}\n`,
219
+ );
220
+ exit(1);
221
+ }
222
+
210
223
  // Island-import gate — registered sections never hydrate, so an island-grade
211
224
  // ui primitive imported there ships a dead widget with zero build signal.
212
225
  // The check (direct + barrel + any-depth relative imports; type-only imports
@@ -185,6 +185,12 @@ export function iterantStarter({
185
185
  // Tunnel/preview hostnames are random per boot, so hostname
186
186
  // allow-listing is impossible; access control lives outside the dev server.
187
187
  allowedHosts,
188
+ // Vite 8 turns this on by itself when it sniffs an AI agent in the
189
+ // environment, and a forwarded browser console (any viewer with the
190
+ // preview open) would interleave into the process logs the platform
191
+ // parses for readiness and boot briefs. Pinned so a heuristic never
192
+ // decides the log shape; enabling it is a deliberate platform decision.
193
+ forwardConsole: false,
188
194
  },
189
195
  };
190
196
 
@@ -7,6 +7,7 @@ import {
7
7
  loaderBase,
8
8
  } from "../lib/content-paths";
9
9
  import { entryIdFromFile } from "../lib/locales";
10
+ import { withDevQuarantine } from "./resilience";
10
11
  import { createContentSchemas, type ContentSchemaOptions } from "./schema";
11
12
 
12
13
  // The `pages` + `chrome` collections a brand site runs on. Astro requires the
@@ -53,7 +54,10 @@ export function createCollections({
53
54
  base: loaderBase(pagesDir),
54
55
  generateId: ({ entry }) => entryIdFromFile(entry),
55
56
  }),
56
- schema: pageEntrySchema,
57
+ // Strict at build/verify; in dev an invalid entry is quarantined (logged
58
+ // in full, route degrades to an error stand-in) instead of taking the
59
+ // whole content sync — and with it every route of the preview — down.
60
+ schema: withDevQuarantine(pageEntrySchema),
57
61
  });
58
62
 
59
63
  const chrome = defineCollection({
@@ -0,0 +1,106 @@
1
+ import { z } from "astro/zod";
2
+
3
+ // Dev-server resilience for the pages collection (3.1.1). Astro 7 treats one
4
+ // invalid entry as fatal to the whole content sync, so a single half-finished
5
+ // page took the ENTIRE preview down — every route, plus the save gate's boot.
6
+ // In dev we quarantine instead: the strict schema still decides validity, the
7
+ // violations are logged in full, and the broken entry is replaced by a
8
+ // minimal valid stand-in (chromed page, no sections, error title) so its
9
+ // route degrades while every other page keeps serving. Builds stay strict:
10
+ // verify and publish reject the entry exactly as before, so nothing invalid
11
+ // can ship.
12
+
13
+ const ROUTE_PATTERN = /^\/[a-z0-9\-/]*$/;
14
+
15
+ type AnyPageSchema = z.ZodTypeAny;
16
+
17
+ export function isStrictContentEnv(env: NodeJS.ProcessEnv = process.env) {
18
+ // astro build sets NODE_ENV=production; astro dev sets development.
19
+ // SITE_RUNTIME_STRICT_CONTENT=1 forces build behavior in dev (escape hatch
20
+ // for debugging the strict path itself).
21
+ return (
22
+ env.NODE_ENV === "production" || env.SITE_RUNTIME_STRICT_CONTENT === "1"
23
+ );
24
+ }
25
+
26
+ // Render a ZodError as one line per violation with full paths. Zod 4 already
27
+ // reports the informative branch of a union failure at its deep path (the
28
+ // bare-string wrapper rule lands on e.g. components.1.props.columns.items.1
29
+ // .key directly); genuinely ambiguous unions still collapse to one issue
30
+ // carrying per-branch issue lists in `errors`, so those are walked.
31
+ type UnionBranches = { errors?: z.ZodIssue[][] };
32
+
33
+ function describeIssues(
34
+ issues: z.ZodIssue[],
35
+ base: PropertyKey[] = [],
36
+ ): string[] {
37
+ const lines: string[] = [];
38
+ for (const issue of issues) {
39
+ const path = [...base, ...issue.path];
40
+ const branches = (issue as UnionBranches).errors;
41
+ if (issue.code === "invalid_union" && Array.isArray(branches)) {
42
+ lines.push(...describeIssues(branches.flat(), path));
43
+ continue;
44
+ }
45
+ lines.push(`${path.map(String).join(".")}: ${issue.message}`);
46
+ }
47
+ return [...new Set(lines)];
48
+ }
49
+
50
+ /**
51
+ * Wrap the strict page-entry schema for collection use: pass-through when the
52
+ * entry is valid, quarantine when it is not (dev only). In a strict env
53
+ * (build/verify) and for an entry whose `route` field is itself unusable (it
54
+ * cannot be placed on a route), the original violations surface unchanged.
55
+ */
56
+ export function withDevQuarantine<S extends AnyPageSchema>(
57
+ strict: S,
58
+ env: NodeJS.ProcessEnv = process.env,
59
+ ): z.ZodType<z.output<S>> {
60
+ const strictMode = isStrictContentEnv(env);
61
+ return z.unknown().transform((raw, ctx) => {
62
+ const parsed = strict.safeParse(raw);
63
+ if (parsed.success) return parsed.data as z.output<S>;
64
+
65
+ // Replay the strict schema's own issues through this pipeline — the cast
66
+ // bridges zod 4's public ZodIssue to addIssue's raw-issue input, which
67
+ // are the same objects under different declared types.
68
+ const surface = () => {
69
+ for (const issue of parsed.error.issues) {
70
+ ctx.addIssue(issue as never);
71
+ }
72
+ return z.NEVER;
73
+ };
74
+ if (strictMode) return surface();
75
+
76
+ const issues = describeIssues(parsed.error.issues);
77
+ const route =
78
+ raw !== null &&
79
+ typeof raw === "object" &&
80
+ "route" in raw &&
81
+ typeof raw.route === "string" &&
82
+ ROUTE_PATTERN.test(raw.route)
83
+ ? raw.route
84
+ : null;
85
+ if (route === null) return surface();
86
+
87
+ console.error(
88
+ [
89
+ `[site-runtime] QUARANTINED invalid page entry for route "${route}".`,
90
+ "The dev server keeps serving; this route renders an error stand-in",
91
+ "until the entry is fixed. Publish/verify still reject it. Violations:",
92
+ ...issues.map((line) => ` ${line}`),
93
+ ].join("\n"),
94
+ );
95
+
96
+ return strict.parse({
97
+ route,
98
+ title: "This page has a content error",
99
+ meta: {
100
+ title: "This page has a content error",
101
+ description: `Invalid entry quarantined in dev: ${issues.join("; ").slice(0, 400)}`,
102
+ },
103
+ components: [],
104
+ }) as z.output<S>;
105
+ });
106
+ }
@@ -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
+ }