@iterant/site-runtime 3.1.2 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.3.0._
54
54
 
55
55
  **Toolchain** (this package owns the version; do NOT declare these):
56
56
 
@@ -68,18 +68,19 @@ _Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.1.2._
68
68
 
69
69
  **Curated kit** (import directly; declare only to override):
70
70
 
71
- | Package | Version | What it is |
72
- | -------------------------- | ------- | ------------------------------------------- |
73
- | `lucide-react` | 1.31.0 | icons |
74
- | `motion` | 13.0.0 | animation |
75
- | `radix-ui` | 1.6.7 | unstyled accessible primitives |
76
- | `@radix-ui/react-slot` | 1.3.3 | prop forwarding for composable components |
77
- | `embla-carousel-react` | 8.6.0 | carousels |
78
- | `clsx` | 2.1.1 | conditional class names |
79
- | `tailwind-merge` | 3.6.0 | conflicting-utility resolution |
80
- | `class-variance-authority` | 0.7.1 | component variants |
81
- | `tw-animate-css` | 1.4.0 | the animation utilities Tailwind v4 dropped |
82
- | `github-slugger` | 2.0.0 | heading and anchor slugs |
71
+ | Package | Version | What it is |
72
+ | -------------------------- | ------- | ----------------------------------------------------- |
73
+ | `lucide-react` | 1.31.0 | icons |
74
+ | `motion` | 13.0.0 | animation |
75
+ | `radix-ui` | 1.6.7 | unstyled accessible primitives |
76
+ | `@radix-ui/react-slot` | 1.3.3 | prop forwarding for composable components |
77
+ | `embla-carousel-react` | 8.6.0 | carousels |
78
+ | `clsx` | 2.1.1 | conditional class names |
79
+ | `tailwind-merge` | 3.6.0 | conflicting-utility resolution |
80
+ | `class-variance-authority` | 0.7.1 | component variants |
81
+ | `tw-animate-css` | 1.4.0 | the animation utilities Tailwind v4 dropped |
82
+ | `github-slugger` | 2.0.0 | heading and anchor slugs |
83
+ | `micromark` | 4.0.2 | the CommonMark compiler markdown wrappers render with |
83
84
 
84
85
  <!-- /generated -->
85
86
 
@@ -99,9 +100,15 @@ The grammar (`content-values.ts`, schema-enforced):
99
100
 
100
101
  - Copy is **wrapped**: `{"type":"text","value":"…"}`,
101
102
  `{"type":"link","text":"…","href":"…"}`,
102
- `{"type":"image","src":"…","alt":"…"}`, `{"type":"svg","markup":"…"}`,
103
+ `{"type":"image","src":"…","alt":"…"}`,
104
+ `{"type":"video","src":"…","poster":"…"}`, `{"type":"svg","markup":"…"}`,
103
105
  `{"type":"color","value":"…"}`. Repeated content is
104
- `{"type":"array","items":[{…}]}`.
106
+ `{"type":"array","items":[{…}]}`. Flowing body copy that needs inline
107
+ structure is `{"type":"markdown","value":"…"}` (3.3.0, see the markdown
108
+ section below). A link's `text` is optional in bespoke
109
+ content (an anchor can wrap its label); authored sections and chrome
110
+ require it (`labelledLinkSchema`). Videos carry no copy and are never
111
+ translated.
105
112
  - Non-copy config stays bare: numbers, booleans, short lowercase tokens
106
113
  (`"zap"`, `"center"`, `"inverse"`). Bare strings with uppercase letters or
107
114
  spaces are rejected by the schema, so wrap them.
@@ -112,7 +119,8 @@ The grammar (`content-values.ts`, schema-enforced):
112
119
  - Components render bindings so tools can find the copy in the DOM:
113
120
  `data-component={id}` plus `data-component-type` on the section root;
114
121
  `data-editable="<field>"`, `data-path="props.<field>"` and
115
- `data-edit-type="text|link|image|color"` on each copy-bearing element. Paths
122
+ `data-edit-type="text|link|image|color|markdown"` on each copy-bearing
123
+ element. Paths
116
124
  are wrapper-level (`props.heading`, `props.features.0.title`), never
117
125
  leaf-level. Array containers carry `data-array-container="<field>"`, items
118
126
  `data-array-item={index}`.
@@ -123,6 +131,75 @@ The grammar (`content-values.ts`, schema-enforced):
123
131
  - `site-runtime scan-copy` finds hardcoded copy in component code. Like every
124
132
  gate, never edit or weaken it.
125
133
 
134
+ ### Markdown bodies (3.3.0)
135
+
136
+ A `{"type":"text"}` value is an opaque string, so a link mid-sentence was
137
+ inexpressible before this wrapper existed: a link was always its own leaf (a
138
+ CTA, a nav item, a card). `{"type":"markdown","value":"…"}` holds flowing body
139
+ copy with inline structure (links, emphasis, headings, lists, images) as
140
+ CommonMark, stored as written.
141
+
142
+ - **One wrapper is one body.** The whole value is a single edit and
143
+ translation target. Never split a body into per-paragraph wrappers to
144
+ imitate the old grammar; the point is that a sentence's link lives inside
145
+ the sentence that reads around it.
146
+ - **Raw HTML is refused by the schema.** Write CommonMark. HTML that should
147
+ DISPLAY as copy (a technical article showing `<div>`) goes in a code span or
148
+ fence, which the schema skips. The compiler additionally renders with raw
149
+ HTML disabled, so anything that predates the schema displays as literal
150
+ text instead of becoming elements.
151
+ - **Render with the runtime's compiler**, never your own:
152
+
153
+ ```tsx
154
+ import { renderMarkdown } from "@iterant/site-runtime/markdown";
155
+ import { readMarkdown } from "@iterant/site-runtime/content-values";
156
+
157
+ const body = readMarkdown(data.body); // raw CommonMark | null
158
+ <div
159
+ class="prose"
160
+ data-editable="body"
161
+ data-path="props.body"
162
+ data-edit-type="markdown"
163
+ set:html={body === null ? "" : renderMarkdown(body)}
164
+ />;
165
+ ```
166
+
167
+ - **Headings start at `##`.** The body renders below the page's own h1; the
168
+ compiler does not demote levels for you.
169
+ - Hrefs inside the value follow the same rules as link wrappers: root-relative
170
+ internal routes, absolute URLs for external targets.
171
+
172
+ ### Reading leaves in section code
173
+
174
+ A section prop typed `ContentLeaf` is a union, and reading a variant field off
175
+ the union (`leaf.value`, `leaf.href`) fails `astro check` even though it
176
+ renders. Read leaves through the exported accessors, one per kind, and never
177
+ hand-roll narrowing helpers:
178
+
179
+ ```tsx
180
+ import {
181
+ readImage,
182
+ readItems,
183
+ readLink,
184
+ readText,
185
+ } from "@iterant/site-runtime/content-values";
186
+
187
+ <h2>{readText(data.heading)}</h2>;
188
+ {
189
+ readItems(data.features).map((item, index) => (
190
+ <li key={index}>{readText(item.title)}</li>
191
+ ));
192
+ }
193
+ const cta = readLink(data.cta); // { text?, href, target? } | null
194
+ const hero = readImage(data.hero); // { src, alt, srcset? } | null
195
+ const reel = readVideo(data.reel); // { src, poster? } | null
196
+ ```
197
+
198
+ They never throw and degrade to empty (`""`, `null`, `[]`) on the wrong kind,
199
+ so a section renders with partial content instead of taking the route down.
200
+ Type guards (`isTextContent`, `isLinkContent`, …) are exported for the rare
201
+ case that needs manual narrowing.
202
+
126
203
  ### The collection schemas
127
204
 
128
205
  The repo's `src/content.config.ts` is a shim over `createCollections`, which
@@ -145,6 +222,67 @@ shape, id rules, uniqueness, the registry check on `mode: "registry"` pages, the
145
222
  chrome mount contract, locale entry ids and `_translation` all live in the
146
223
  package and change with a version bump.
147
224
 
225
+ ### The links manifest (3.3.0)
226
+
227
+ `createCollections` also returns a third `links` collection reading the
228
+ machine-owned `src/content/links.json`: the internal-links manifest the
229
+ platform writes and nothing else may. Never hand-edit it, never let the agent
230
+ edit it; link corrections happen upstream in the platform's link graph. The
231
+ wire shape:
232
+
233
+ ```json
234
+ {
235
+ "version": 1,
236
+ "routes": {
237
+ "/pricing": {
238
+ "label": "Related",
239
+ "links": [
240
+ { "href": "/guide", "text": "The guide", "placement": "related_aside" }
241
+ ]
242
+ }
243
+ }
244
+ }
245
+ ```
246
+
247
+ `LayoutCore` looks the current route up in `routes` and mounts the slice as a
248
+ `<nav data-iterant-links>` inside the shell's slot, below the page content and
249
+ above the shell-rendered footer. An absent file, an empty `routes` record, or
250
+ a route with no slice all render zero bytes for the block, and nothing enters
251
+ the shared CSS bundle, so apart from the two version metas the built pages
252
+ match a repo pinned before 3.3.0: the file ships dark until the platform
253
+ writes it. Each slice
254
+ carries its own already-localized `label` (a locale sibling's route key is the
255
+ full prefixed route, `/es/pricing`, with no fallback to the base route), so
256
+ the package holds no copy and performs no localization. The schema tolerates
257
+ unknown keys at every level (`placement` is written but unread), so an
258
+ additive platform-side field can never fail an already-pinned repo's build.
259
+ "The one `@source` line" below does not grow: the component ships no utility
260
+ classes, only a scoped style block on the data attribute.
261
+
262
+ ### Choosing a shell (3.2.0)
263
+
264
+ A repo may own more than one frame. A replicated site whose second page carried
265
+ different chrome keeps that page's whole shell beside the site's, under a scope
266
+ suffix (`shell-frame-pricing.tsx`), and `components/layout/shells.tsx` GLOBS
267
+ them, so a frame registers by existing rather than by being listed.
268
+
269
+ A page entry names the one it wants:
270
+
271
+ ```json
272
+ { "route": "/pricing", "shell": "pricing", "meta": { "title": "Pricing" } }
273
+ ```
274
+
275
+ The layout reads it off the entry and threads it to `shell.astro` as `shellId`,
276
+ so a route states it once in content instead of every `.astro` restating it. A
277
+ route may still pass `shell` directly, and the prop wins over the entry, the
278
+ same rule the structured-data props follow.
279
+
280
+ Omit it and nothing changes: the seam falls back to the route's own scope and
281
+ then to the site's frame, which is what every page written before 3.2.0 does.
282
+ The name is not validated against the registry, because the shells a repo owns
283
+ are a build-time glob that no schema can see; a name matching nothing renders
284
+ the site's frame rather than failing the build.
285
+
148
286
  ## Pages: entry-driven vs bespoke
149
287
 
150
288
  Every page has a JSON entry holding its route, SEO meta and content. What
@@ -233,8 +371,8 @@ import { SITE_SHELL } from "@/site-shell";
233
371
  Every shell keeps importing `../layouts/Layout.astro` unchanged. The core owns
234
372
  the head (charset, viewport, favicon, generator, version meta, SEO, JSON-LD,
235
373
  hreflang, the `head` slot), resolves locale-aware chrome and the page entry, and
236
- renders the brand shell around the page slot with the `chrome`, `navbar` and
237
- `footer` props. It never imports a stylesheet and never imports chrome
374
+ renders the brand shell around the page slot with the `chrome`, `navbar`,
375
+ `footer` and `shellId` props. It never imports a stylesheet and never imports chrome
238
376
  components: `shell.astro` mounts those, which is what keeps their `client:load`
239
377
  directive traceable.
240
378
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iterant/site-runtime",
3
- "version": "3.1.2",
3
+ "version": "3.3.0",
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": {
@@ -10,6 +10,7 @@
10
10
  "kit-table": "node scripts/generate-kit-table.mjs",
11
11
  "lint": "eslint . --max-warnings 0",
12
12
  "lint:fix": "eslint --fix .",
13
+ "prepublishOnly": "node scripts/check-packed.mjs",
13
14
  "preversion": "node scripts/generate-kit-table.mjs",
14
15
  "test": "vitest run && node scripts/check-fixture.mjs",
15
16
  "test:fixture": "node scripts/check-fixture.mjs",
@@ -37,6 +38,7 @@
37
38
  "./content": "./src/content/collections.ts",
38
39
  "./content/schema": "./src/content/schema.ts",
39
40
  "./content-values": "./src/lib/content-values.ts",
41
+ "./markdown": "./src/lib/markdown.ts",
40
42
  "./locales": "./src/lib/locales.ts",
41
43
  "./hreflang": "./src/lib/hreflang.ts",
42
44
  "./chrome": "./src/lib/chrome.ts",
@@ -46,6 +48,8 @@
46
48
  "./seo": "./src/components/seo.tsx",
47
49
  "./seo-json": "./src/components/seo-json.tsx",
48
50
  "./layout": "./src/layouts/LayoutCore.astro",
51
+ "./layout-core": "./src/layouts/layout-core.ts",
52
+ "./layout-contract": "./src/layouts/layout-contract.ts",
49
53
  "./under-construction": "./src/routes/UnderConstruction.astro",
50
54
  "./routes": "./src/routes/index.ts",
51
55
  "./config": "./src/config/preset.ts",
@@ -74,7 +78,8 @@
74
78
  "tailwind-merge",
75
79
  "class-variance-authority",
76
80
  "tw-animate-css",
77
- "github-slugger"
81
+ "github-slugger",
82
+ "micromark"
78
83
  ]
79
84
  },
80
85
  "dependencies": {
@@ -90,6 +95,7 @@
90
95
  "embla-carousel-react": "8.6.0",
91
96
  "github-slugger": "2.0.0",
92
97
  "lucide-react": "1.31.0",
98
+ "micromark": "4.0.2",
93
99
  "motion": "13.0.0",
94
100
  "radix-ui": "1.6.7",
95
101
  "react": "19.2.8",
@@ -0,0 +1,78 @@
1
+ ---
2
+ // The machine-fed related-links block (ILV-6). Props are one route's slice of
3
+ // the machine-owned src/content/links.json manifest; the manifest carries
4
+ // every string, the heading included, so this file holds no copy of its own
5
+ // and never re-sorts the array (ordering is the builder's contract).
6
+ //
7
+ // The wrapper is a <nav> ON PURPOSE: the canonical-corpus scrape excludes nav
8
+ // and footer tags, which is what keeps machine-placed links from ever being
9
+ // re-ingested as their own ranking evidence. The element choice is
10
+ // load-bearing; related-links.test.ts pins it.
11
+ //
12
+ // No class attribute anywhere: tailwind-surface.test.ts pins exactly one
13
+ // shipped file with utility classes, and this must not become a second.
14
+ // Styling is an inline block INSIDE the conditional, every selector rooted at
15
+ // the data attribute, colors from currentColor and custom-property fallbacks
16
+ // only, so the block inherits each brand's own cascade. Inline on purpose: a
17
+ // hoisted scoped style would enter the shared CSS bundle (and move its hash)
18
+ // even on a brand with no manifest, breaking the absent-file byte-identity
19
+ // that makes the renderer safe to ship ahead of any written manifest.
20
+
21
+ interface Props {
22
+ links?: { href: string; text: string }[];
23
+ label?: string;
24
+ }
25
+
26
+ const { links, label } = Astro.props;
27
+
28
+ // set:html, not a style-tag expression: Astro treats style content as raw
29
+ // text, so an interpolation inside the tag would ship its braces literally.
30
+ const RELATED_LINKS_CSS = `
31
+ [data-iterant-links] {
32
+ margin: 3rem auto 0;
33
+ padding: 1.5rem 1rem 2rem;
34
+ max-width: var(--it-content-max-width, 72rem);
35
+ }
36
+ [data-iterant-links] h2 {
37
+ margin: 0 0 0.75rem;
38
+ font-size: 0.875rem;
39
+ font-weight: 600;
40
+ letter-spacing: 0.05em;
41
+ text-transform: uppercase;
42
+ opacity: 0.7;
43
+ }
44
+ [data-iterant-links] ul {
45
+ margin: 0;
46
+ padding: 0;
47
+ list-style: none;
48
+ display: flex;
49
+ flex-wrap: wrap;
50
+ gap: 0.5rem 1.5rem;
51
+ }
52
+ [data-iterant-links] a {
53
+ color: currentColor;
54
+ text-decoration: underline;
55
+ text-underline-offset: 0.2em;
56
+ text-decoration-color: var(--it-link-underline, currentColor);
57
+ }
58
+ [data-iterant-links] a:hover {
59
+ opacity: 0.75;
60
+ }
61
+ `;
62
+ ---
63
+
64
+ {
65
+ links && links.length > 0 && (
66
+ <nav data-iterant-links aria-label={label}>
67
+ {label && <h2>{label}</h2>}
68
+ <ul>
69
+ {links.map((link) => (
70
+ <li>
71
+ <a href={link.href}>{link.text}</a>
72
+ </li>
73
+ ))}
74
+ </ul>
75
+ <style is:inline set:html={RELATED_LINKS_CSS} />
76
+ </nav>
77
+ )
78
+ }
@@ -8,7 +8,11 @@ import {
8
8
  } from "../lib/content-paths";
9
9
  import { entryIdFromFile } from "../lib/locales";
10
10
  import { withDevQuarantine } from "./resilience";
11
- import { createContentSchemas, type ContentSchemaOptions } from "./schema";
11
+ import {
12
+ createContentSchemas,
13
+ linksManifestSchema,
14
+ type ContentSchemaOptions,
15
+ } from "./schema";
12
16
 
13
17
  // The `pages` + `chrome` collections a brand site runs on. Astro requires the
14
18
  // collection definition to live in the repo's own src/content.config.ts, so
@@ -71,5 +75,20 @@ export function createCollections({
71
75
  schema: chromeEntrySchema,
72
76
  });
73
77
 
74
- return { pages, chrome };
78
+ const links = defineCollection({
79
+ // The machine-owned links.json manifest (ILV-6), written only by the
80
+ // platform's sync channel. An absent file is a first-class valid state
81
+ // (the loader matches nothing and the layout renders nothing), the same
82
+ // ships-dark discipline chrome.json follows. NOT withDevQuarantine: the
83
+ // quarantine stand-in is page-shaped, and an invalid manifest is a
84
+ // platform bug that should fail verify loudly.
85
+ loader: glob({
86
+ pattern: "links.json",
87
+ base: loaderBase(chromeDir),
88
+ generateId: ({ entry }) => entryIdFromFile(entry),
89
+ }),
90
+ schema: linksManifestSchema,
91
+ });
92
+
93
+ return { pages, chrome, links };
75
94
  }
@@ -135,6 +135,26 @@ export function createContentSchemas({
135
135
  // design ships its own nav/footer (a full-design import) so the layout
136
136
  // mounts no chrome. Additive — existing entries omit it and keep chrome.
137
137
  chrome: z.boolean().default(true),
138
+ // Which SHELL this page renders in (site-runtime 3.2.0). A replicated
139
+ // site can carry more than one frame: a page whose chrome differed from
140
+ // the site's kept its own under a scope suffix, and the shell registry
141
+ // globs whatever sits beside it rather than listing them, so a shell
142
+ // registers by existing. Naming one here is how a page WRITTEN LATER
143
+ // picks from what the site already has; a replicated page needs no name,
144
+ // because its own shell is the one its route resolves to.
145
+ //
146
+ // Additive and unvalidated against the registry ON PURPOSE: the shells a
147
+ // repo owns are a glob resolved at build time, so a schema cannot know
148
+ // them, and a name that matches nothing falls back to the site's shell
149
+ // rather than failing a brand's build. Existing entries omit it and
150
+ // render exactly as they do today.
151
+ shell: z
152
+ .string()
153
+ .regex(
154
+ /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
155
+ "shell must be a lowercase scope name (letters, digits, single hyphens)",
156
+ )
157
+ .optional(),
138
158
  meta: z
139
159
  .object({
140
160
  title: z.string(),
@@ -237,3 +257,44 @@ export function createContentSchemas({
237
257
  }
238
258
 
239
259
  export type ContentSchemas = ReturnType<typeof createContentSchemas>;
260
+
261
+ // The machine-owned src/content/links.json manifest (ILV-6): the renderer-side
262
+ // mirror of the platform's document contract (the Django builder is the
263
+ // authority; a key rename happens in both places together or not at all).
264
+ // Every object level is deliberately NON-strict: pins move brand by brand
265
+ // while the platform writes one manifest shape fleet-wide, so an additive
266
+ // field must be invisible to an older runtime, never a build failure delivered
267
+ // through a content file. A breaking shape change still fails parse loudly.
268
+ // `placement` is serialized by the builder but unread here, which is why it
269
+ // does not appear below.
270
+ export const linksManifestSchema = z.object({
271
+ version: z.number().int().positive(),
272
+ routes: z.record(
273
+ z.string(),
274
+ z.object({
275
+ // Always emitted by the builder, already locale-resolved per route; the
276
+ // renderer never localizes and holds no fallback copy.
277
+ label: z
278
+ .string()
279
+ .regex(/\S/, { message: "label cannot be blank" })
280
+ .optional(),
281
+ links: z.array(
282
+ z.object({
283
+ // Page targets are root-relative routes (Page.url_path); KB and
284
+ // trust targets keep their absolute crawled URL. The lookahead
285
+ // rejects protocol-relative //host hrefs, which would render an
286
+ // external link disguised as a same-site route.
287
+ href: z.string().regex(/^(\/(?!\/)|https?:\/\/)/i, {
288
+ message:
289
+ "expected a root-relative route or an absolute http(s) URL",
290
+ }),
291
+ text: z
292
+ .string()
293
+ .regex(/\S/, { message: "link text cannot be blank" }),
294
+ }),
295
+ ),
296
+ }),
297
+ ),
298
+ });
299
+
300
+ export type LinksManifest = z.infer<typeof linksManifestSchema>;
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  import { getCollection, getEntry } from "astro:content";
3
3
  import type { AstroComponentFactory } from "astro/runtime/server/index.js";
4
+ import RelatedLinks from "../components/RelatedLinks.astro";
4
5
  import { SEO, type PageType } from "../components/seo";
5
6
  import type { SeoJsonSchema } from "../components/seo-json";
6
7
  import type { HreflangAlternate } from "../lib/hreflang";
@@ -65,6 +66,12 @@ interface Props {
65
66
  pageType?: PageType;
66
67
  datePublished?: string;
67
68
  dateModified?: string;
69
+ // Which shell to render this page in (site-runtime 3.2.0). Read from the
70
+ // page entry's `shell` when a shell does not pass one, the same
71
+ // prop-wins-over-entry rule the structured-data props above follow. A
72
+ // replicated site can own several frames; this is what lets a page written
73
+ // later choose one instead of always taking the site's.
74
+ shell?: string;
68
75
  }
69
76
 
70
77
  const {
@@ -86,6 +93,7 @@ const {
86
93
  pageType,
87
94
  datePublished,
88
95
  dateModified,
96
+ shell,
89
97
  } = Astro.props;
90
98
 
91
99
  const site = Astro.site;
@@ -117,16 +125,32 @@ const { navbar, footer } = pickChromeComponents(chromeEntry?.data.components);
117
125
  // article dates reach the JSON-LD graph without every bespoke shell having
118
126
  // to thread them. Shells that pass the props explicitly still win. Routes
119
127
  // without an entry (404, under-construction) fall back to a plain WebPage.
120
- const pageEntry = findPageEntryByRoute(
121
- await getCollection("pages"),
122
- routePathFromPathname(Astro.url.pathname),
123
- { includeDrafts: !import.meta.env.PROD },
124
- );
128
+ const routePath = routePathFromPathname(Astro.url.pathname);
129
+ const pageEntry = findPageEntryByRoute(await getCollection("pages"), routePath, {
130
+ includeDrafts: !import.meta.env.PROD,
131
+ });
125
132
  const structuredData = resolveStructuredData(
126
133
  { pageType, datePublished, dateModified },
127
134
  pageEntry?.data.meta,
128
135
  );
129
136
 
137
+ // The shell this page renders in, resolved from the same entry, so a page names
138
+ // its shell once in content rather than every route restating it. Undefined
139
+ // leaves the seam to fall back the way it already does: the route's own scope,
140
+ // then the site's shell. A repo whose shell.astro predates this ignores the
141
+ // prop, which is what makes the forward safe to ship ahead of any emit.
142
+ const shellId = shell ?? pageEntry?.data.shell;
143
+
144
+ // Machine-fed related links (ILV-6): the machine-owned src/content/links.json
145
+ // manifest, resolved by this page's own route key. A locale sibling looks up
146
+ // its full prefixed route; there is no fallback to the base route, because the
147
+ // builder computes each sibling's slice from its own edges and drops
148
+ // cross-language targets. The slice's label arrives already locale-resolved,
149
+ // so the `locale` above is not consulted. Absent file, absent route, or an
150
+ // empty slice all render nothing, the chrome.json ships-dark discipline.
151
+ const linksEntry = await getEntry("links", "links");
152
+ const linksSlice = linksEntry?.data.routes[routePath];
153
+
130
154
  // Version identity: the installed package version IS the site's runtime
131
155
  // version. `it-astro-starter-version` keeps emitting the same value while the
132
156
  // plugin loader and the platform's page indexer still read that name, and
@@ -175,8 +199,8 @@ const structuredData = resolveStructuredData(
175
199
  <slot name="head" />
176
200
  </head>
177
201
  <body class={siteShell.bodyClass} style={siteShell.bodyStyle}>
178
- <Shell chrome={chrome} navbar={navbar} footer={footer}>
179
- <slot />
202
+ <Shell chrome={chrome} navbar={navbar} footer={footer} shellId={shellId}>
203
+ <slot /><RelatedLinks links={linksSlice?.links} label={linksSlice?.label} />
180
204
  </Shell>
181
205
  </body>
182
206
  </html>
@@ -0,0 +1,33 @@
1
+ /** The layout surface GENERATORS write against, machine-readable (3.2.0).
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
+ "shell",
28
+ ],
29
+ /** Injected by the repo's Layout shim; never passed by a route. */
30
+ shimInjected: ["siteConfig", "siteShell", "Shell"],
31
+ /** Named slots the layout renders. Anything else is dropped. */
32
+ slots: ["head"],
33
+ } 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,
@@ -9,8 +9,10 @@ import { z } from "astro/zod";
9
9
  // hrefs, srcs, ids, or config values.
10
10
  //
11
11
  // { "type": "text", "value": "Visible prose" }
12
+ // { "type": "markdown", "value": "Prose with [inline links](/pricing)." }
12
13
  // { "type": "link", "text": "Start free", "href": "/signup" }
13
14
  // { "type": "image", "src": "/images/x.webp", "alt": "Description" }
15
+ // { "type": "video", "src": "/media/x.mp4", "poster": "/images/x.webp" }
14
16
  // { "type": "svg", "markup": "<svg…>", "label": "Accessible name" }
15
17
  // { "type": "color", "value": "#0ea5e9" }
16
18
  // { "type": "array", "items": [ { …wrapped fields per item… } ] }
@@ -25,9 +27,27 @@ export interface TextContent {
25
27
  value: string;
26
28
  }
27
29
 
30
+ /** Flowing body copy that needs inline structure a text wrapper cannot hold:
31
+ * links mid-sentence, emphasis, headings, lists, images. The value is
32
+ * CommonMark, stored as written; the renderer (lib/markdown.ts) compiles it
33
+ * at build time with raw HTML disabled, so markup in the value displays as
34
+ * literal text rather than becoming elements. One wrapper is one body: the
35
+ * whole value is a single edit/translation target, which is what keeps a
36
+ * sentence's link inside the sentence that reads around it. */
37
+ export interface MarkdownContent {
38
+ type: "markdown";
39
+ value: string;
40
+ }
41
+
28
42
  export interface LinkContent {
29
43
  type: "link";
30
- text: string;
44
+ /** The link's own label. Optional, because a link does not always have one:
45
+ * an anchor wrapping an icon, a card, or several elements has its label in
46
+ * the leaves beneath it, and those are separately addressable. A link with
47
+ * no text of its own renders none, and its destination stays editable,
48
+ * which is the point of the wrapper. Authored sections and chrome require
49
+ * one anyway: see labelledLinkSchema. */
50
+ text?: string;
31
51
  href: string;
32
52
  target?: "_blank" | "_self";
33
53
  }
@@ -38,6 +58,12 @@ export interface ImageCandidate {
38
58
  src: string;
39
59
  assetId?: string;
40
60
  descriptor: string;
61
+ /** The <source> encoding this candidate composes ("image/webp"), absent for
62
+ * the <img>'s own candidates. A <picture> is one image the browser fetches
63
+ * in whichever format it supports, so its formats ride one wrapper: a swap
64
+ * that clears the candidates drops every <source> at once and hands the
65
+ * picture back to the img it just rewrote. */
66
+ type?: string;
41
67
  }
42
68
 
43
69
  export interface ImageContent {
@@ -50,6 +76,18 @@ export interface ImageContent {
50
76
  srcset?: ImageCandidate[];
51
77
  }
52
78
 
79
+ /** A video the page plays. Like an image it is a resource the visitor sees and
80
+ * an owner replaces, and unlike an image it carries no copy: there is nothing
81
+ * in it to translate, which is why it has no text field and never appears in a
82
+ * translation pass. `poster` is the still frame shown before playback. */
83
+ export interface VideoContent {
84
+ type: "video";
85
+ src: string;
86
+ assetId?: string;
87
+ poster?: string;
88
+ posterAssetId?: string;
89
+ }
90
+
53
91
  export interface SvgContent {
54
92
  type: "svg";
55
93
  markup: string;
@@ -68,8 +106,10 @@ export interface ArrayContent {
68
106
 
69
107
  export type ContentValue =
70
108
  | TextContent
109
+ | MarkdownContent
71
110
  | LinkContent
72
111
  | ImageContent
112
+ | VideoContent
73
113
  | SvgContent
74
114
  | ColorContent
75
115
  | ArrayContent;
@@ -88,24 +128,94 @@ export const textContentSchema = z
88
128
  // would try to rewrite it and the editor would offer a text input for
89
129
  // a destination. Links live in link wrappers ({type:"link",text,href});
90
130
  // image sources in image wrappers.
131
+ //
132
+ // The tail is RFC 3986's path characters, not "any non-space" (3.1.3). A
133
+ // value the spec's own grammar cannot read as a url is not a destination,
134
+ // whatever it starts with: `/>` out of a syntax-highlighted code sample is
135
+ // visible copy, and refusing it left it baked into a component instead.
136
+ // Genuinely ambiguous cases stay refused: `/h` is both a price unit and a
137
+ // valid path, and no predicate over the STRING can tell them apart.
91
138
  value: z
92
139
  .string()
93
140
  .refine(
94
- (value) => !/^(?:\/|#|https?:\/\/)\S*$/.test(value.trim()),
141
+ (value) =>
142
+ !/^(?:\/|#|https?:\/\/)[A-Za-z0-9\-._~%!$&'()*+,;=:@/?#[\]]*$/.test(
143
+ value.trim(),
144
+ ),
95
145
  'this looks like a URL or path — use {"type":"link","text":…,"href":…} (or image src), not a text wrapper',
96
146
  ),
97
147
  })
98
148
  .strict();
99
149
 
150
+ // Raw HTML is refused at the schema level, not silently neutralized at render
151
+ // time: an entry carrying `<script>` or `<div>` is a modeling error the author
152
+ // should see at build, and the renderer's escaping is the backstop, not the
153
+ // contract.
154
+ //
155
+ // The gate matches ELEMENT NAMES HTML actually defines, not every `<word>`.
156
+ // CommonMark reads `<int>`, `Promise<T>` and `x<y and a>b` as raw html too,
157
+ // and the safe renderer escapes each one back into exactly the words the
158
+ // author typed, so refusing them rejected correct prose while claiming the
159
+ // author had written markup. A name HTML does not define cannot be a mistaken
160
+ // attempt to render an element, which is the only thing worth refusing.
161
+ //
162
+ // Code is stripped before the check, so markup shown AS copy stays legal,
163
+ // which is what the error message promises: fenced (``` and ~~~), indented,
164
+ // and inline spans, whose closing run must match the opening run (a ``-span
165
+ // legitimately contains a lone backtick). Autolinks (`<https://…>`) never
166
+ // match because a tag name cannot contain `:`.
167
+ const _HTML_ELEMENTS = new Set(
168
+ `a abbr address area article aside audio b base bdi bdo blockquote body br
169
+ button canvas caption cite code col colgroup data datalist dd del details
170
+ dfn dialog div dl dt em embed fieldset figcaption figure footer form h1 h2
171
+ h3 h4 h5 h6 head header hgroup hr html i iframe img input ins kbd label
172
+ legend li link main map mark menu meta meter nav noscript object ol optgroup
173
+ option output p param picture pre progress q rp rt ruby s samp script search
174
+ section select slot small source span strong style sub summary sup table
175
+ tbody td template textarea tfoot th thead time title tr track u ul var video
176
+ wbr svg path circle rect g defs use`.split(/\s+/),
177
+ );
178
+ const _CODE = /(`+)[\s\S]*?\1|~~~[\s\S]*?~~~|(?:^|\n)(?: {4}|\t)[^\n]*/g;
179
+ const _TAG = /<\/?([a-zA-Z][a-zA-Z0-9-]*)(?:[\s/>]|$)/g;
180
+
181
+ export function carriesRawHtml(value: string): boolean {
182
+ const prose = value.replace(_CODE, " ");
183
+ for (const [, name] of prose.matchAll(_TAG)) {
184
+ if (_HTML_ELEMENTS.has(name.toLowerCase())) return true;
185
+ }
186
+ return false;
187
+ }
188
+
189
+ export const markdownContentSchema = z
190
+ .object({
191
+ type: z.literal("markdown"),
192
+ value: z
193
+ .string()
194
+ .refine(
195
+ (value) => !carriesRawHtml(value),
196
+ "raw HTML is not part of the markdown grammar: write CommonMark (links, emphasis, headings, lists, images); HTML to display as copy belongs in a code span",
197
+ ),
198
+ })
199
+ .strict();
200
+
100
201
  export const linkContentSchema = z
101
202
  .object({
102
203
  type: z.literal("link"),
103
- text: z.string(),
204
+ text: z.string().optional(),
104
205
  href: z.string(),
105
206
  target: z.enum(["_blank", "_self"]).optional(),
106
207
  })
107
208
  .strict();
108
209
 
210
+ /** A link that must carry a label: every one an AUTHOR writes. A hero CTA or a
211
+ * nav item with no text is a button nobody can read, so the components that
212
+ * render one from a fixed schema hold the stricter contract. The loose
213
+ * wrapper above is for bespoke pages, where an anchor's label can live in the
214
+ * markup beneath it. */
215
+ export const labelledLinkSchema = linkContentSchema.extend({
216
+ text: z.string(),
217
+ });
218
+
109
219
  // A srcset candidate descriptor is a width ("400w") or pixel density ("2x").
110
220
  const descriptorSchema = z
111
221
  .string()
@@ -119,6 +229,7 @@ export const imageCandidateSchema = z
119
229
  src: z.string(),
120
230
  assetId: z.string().optional(),
121
231
  descriptor: descriptorSchema,
232
+ type: z.string().optional(),
122
233
  })
123
234
  .strict();
124
235
 
@@ -134,6 +245,16 @@ export const imageContentSchema = z
134
245
  })
135
246
  .strict();
136
247
 
248
+ export const videoContentSchema = z
249
+ .object({
250
+ type: z.literal("video"),
251
+ src: z.string(),
252
+ assetId: z.string().optional(),
253
+ poster: z.string().optional(),
254
+ posterAssetId: z.string().optional(),
255
+ })
256
+ .strict();
257
+
137
258
  export const svgContentSchema = z
138
259
  .object({
139
260
  type: z.literal("svg"),
@@ -168,8 +289,10 @@ const propKeySchema = z
168
289
  export const contentLeafSchema: z.ZodType<ContentLeaf> = z.lazy(() =>
169
290
  z.union([
170
291
  textContentSchema,
292
+ markdownContentSchema,
171
293
  linkContentSchema,
172
294
  imageContentSchema,
295
+ videoContentSchema,
173
296
  svgContentSchema,
174
297
  colorContentSchema,
175
298
  arrayContentSchema,
@@ -199,3 +322,120 @@ export const arrayOf = <T extends z.ZodRawShape>(shape: T) =>
199
322
  items: z.array(z.object(shape).strict()),
200
323
  })
201
324
  .strict();
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // Leaf accessors. Section code receives ContentLeaf, an eight-way union, and
328
+ // the natural-but-wrong move is reading a variant's field off the union
329
+ // (`leaf.value` renders fine and fails astro check). These are the one
330
+ // documented way to read a leaf: narrow-and-extract, never throw, degrade to
331
+ // empty so a section renders with partial content instead of taking the
332
+ // route down. Use them instead of hand-rolled narrowing helpers.
333
+
334
+ export function isTextContent(leaf: ContentLeaf): leaf is TextContent {
335
+ return typeof leaf === "object" && leaf !== null && leaf.type === "text";
336
+ }
337
+
338
+ export function isMarkdownContent(leaf: ContentLeaf): leaf is MarkdownContent {
339
+ return typeof leaf === "object" && leaf !== null && leaf.type === "markdown";
340
+ }
341
+
342
+ export function isLinkContent(leaf: ContentLeaf): leaf is LinkContent {
343
+ return typeof leaf === "object" && leaf !== null && leaf.type === "link";
344
+ }
345
+
346
+ export function isImageContent(leaf: ContentLeaf): leaf is ImageContent {
347
+ return typeof leaf === "object" && leaf !== null && leaf.type === "image";
348
+ }
349
+
350
+ export function isVideoContent(leaf: ContentLeaf): leaf is VideoContent {
351
+ return typeof leaf === "object" && leaf !== null && leaf.type === "video";
352
+ }
353
+
354
+ export function isSvgContent(leaf: ContentLeaf): leaf is SvgContent {
355
+ return typeof leaf === "object" && leaf !== null && leaf.type === "svg";
356
+ }
357
+
358
+ export function isColorContent(leaf: ContentLeaf): leaf is ColorContent {
359
+ return typeof leaf === "object" && leaf !== null && leaf.type === "color";
360
+ }
361
+
362
+ export function isArrayContent(leaf: ContentLeaf): leaf is ArrayContent {
363
+ return typeof leaf === "object" && leaf !== null && leaf.type === "array";
364
+ }
365
+
366
+ /**
367
+ * The renderable text of a leaf: a text wrapper's value, or a bare config
368
+ * scalar stringified (a count or token an entry legitimately interpolates
369
+ * into copy). Structured wrappers (markdown, link, image, video, svg, color,
370
+ * array)
371
+ * have no text reading and come back empty — read them with their own
372
+ * accessor.
373
+ */
374
+ export function readText(leaf: ContentLeaf | null | undefined): string {
375
+ if (leaf === null || leaf === undefined) return "";
376
+ if (isTextContent(leaf)) return leaf.value;
377
+ if (typeof leaf === "object") return "";
378
+ return String(leaf);
379
+ }
380
+
381
+ /** The markdown wrapper's raw CommonMark source, or null. Rendering it is
382
+ * lib/markdown.ts's `renderMarkdown`; the accessor hands back source so
383
+ * non-rendering callers (search text, excerpts) are not forced through the
384
+ * compiler. */
385
+ export function readMarkdown(
386
+ leaf: ContentLeaf | null | undefined,
387
+ ): string | null {
388
+ return leaf !== null && leaf !== undefined && isMarkdownContent(leaf)
389
+ ? leaf.value
390
+ : null;
391
+ }
392
+
393
+ /** The link wrapper (text, href, optional target), or null. */
394
+ export function readLink(
395
+ leaf: ContentLeaf | null | undefined,
396
+ ): LinkContent | null {
397
+ return leaf !== null && leaf !== undefined && isLinkContent(leaf)
398
+ ? leaf
399
+ : null;
400
+ }
401
+
402
+ /** The image wrapper (src, alt, optional srcset candidates), or null. */
403
+ export function readImage(
404
+ leaf: ContentLeaf | null | undefined,
405
+ ): ImageContent | null {
406
+ return leaf !== null && leaf !== undefined && isImageContent(leaf)
407
+ ? leaf
408
+ : null;
409
+ }
410
+
411
+ /** The video wrapper (src, optional poster), or null. */
412
+ export function readVideo(
413
+ leaf: ContentLeaf | null | undefined,
414
+ ): VideoContent | null {
415
+ return leaf !== null && leaf !== undefined && isVideoContent(leaf)
416
+ ? leaf
417
+ : null;
418
+ }
419
+
420
+ /** The svg wrapper (markup, optional label), or null. */
421
+ export function readSvg(
422
+ leaf: ContentLeaf | null | undefined,
423
+ ): SvgContent | null {
424
+ return leaf !== null && leaf !== undefined && isSvgContent(leaf)
425
+ ? leaf
426
+ : null;
427
+ }
428
+
429
+ /** A color wrapper's CSS value, or null. */
430
+ export function readColor(leaf: ContentLeaf | null | undefined): string | null {
431
+ return leaf !== null && leaf !== undefined && isColorContent(leaf)
432
+ ? leaf.value
433
+ : null;
434
+ }
435
+
436
+ /** An array wrapper's items, or an empty list. */
437
+ export function readItems(leaf: ContentLeaf | null | undefined): ContentItem[] {
438
+ return leaf !== null && leaf !== undefined && isArrayContent(leaf)
439
+ ? leaf.items
440
+ : [];
441
+ }
@@ -0,0 +1,17 @@
1
+ import { micromark } from "micromark";
2
+
3
+ // The one compiler for {type:"markdown"} content values (content-values.ts).
4
+ // Runs at build time inside Astro's static render, so the browser ships HTML,
5
+ // never a markdown runtime. micromark's default is the safety contract the
6
+ // grammar depends on: raw HTML in the source is character-encoded into
7
+ // visible text, not parsed into elements — the schema refuses element-like
8
+ // tags up front, and this default is the backstop for anything that predates
9
+ // or evades the schema. Do not pass allowDangerousHtml here, ever.
10
+ //
11
+ // Headings: a markdown body renders inside a section, below the page's own
12
+ // h1, so authors start at `##`. The compiler does not rewrite heading levels;
13
+ // a body that opens with `#` is an authoring error the review pass catches,
14
+ // not something to silently demote.
15
+ export function renderMarkdown(value: string): string {
16
+ return micromark(value);
17
+ }