@iterant/site-runtime 3.1.3 → 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.3._
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.3._
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
 
@@ -102,7 +103,9 @@ The grammar (`content-values.ts`, schema-enforced):
102
103
  `{"type":"image","src":"…","alt":"…"}`,
103
104
  `{"type":"video","src":"…","poster":"…"}`, `{"type":"svg","markup":"…"}`,
104
105
  `{"type":"color","value":"…"}`. Repeated content is
105
- `{"type":"array","items":[{…}]}`. A link's `text` is optional in bespoke
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
106
109
  content (an anchor can wrap its label); authored sections and chrome
107
110
  require it (`labelledLinkSchema`). Videos carry no copy and are never
108
111
  translated.
@@ -116,7 +119,8 @@ The grammar (`content-values.ts`, schema-enforced):
116
119
  - Components render bindings so tools can find the copy in the DOM:
117
120
  `data-component={id}` plus `data-component-type` on the section root;
118
121
  `data-editable="<field>"`, `data-path="props.<field>"` and
119
- `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
120
124
  are wrapper-level (`props.heading`, `props.features.0.title`), never
121
125
  leaf-level. Array containers carry `data-array-container="<field>"`, items
122
126
  `data-array-item={index}`.
@@ -127,6 +131,44 @@ The grammar (`content-values.ts`, schema-enforced):
127
131
  - `site-runtime scan-copy` finds hardcoded copy in component code. Like every
128
132
  gate, never edit or weaken it.
129
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
+
130
172
  ### Reading leaves in section code
131
173
 
132
174
  A section prop typed `ContentLeaf` is a union, and reading a variant field off
@@ -180,6 +222,67 @@ shape, id rules, uniqueness, the registry check on `mode: "registry"` pages, the
180
222
  chrome mount contract, locale entry ids and `_translation` all live in the
181
223
  package and change with a version bump.
182
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
+
183
286
  ## Pages: entry-driven vs bespoke
184
287
 
185
288
  Every page has a JSON entry holding its route, SEO meta and content. What
@@ -268,8 +371,8 @@ import { SITE_SHELL } from "@/site-shell";
268
371
  Every shell keeps importing `../layouts/Layout.astro` unchanged. The core owns
269
372
  the head (charset, viewport, favicon, generator, version meta, SEO, JSON-LD,
270
373
  hreflang, the `head` slot), resolves locale-aware chrome and the page entry, and
271
- renders the brand shell around the page slot with the `chrome`, `navbar` and
272
- `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
273
376
  components: `shell.astro` mounts those, which is what keeps their `client:load`
274
377
  directive traceable.
275
378
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iterant/site-runtime",
3
- "version": "3.1.3",
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",
@@ -76,7 +78,8 @@
76
78
  "tailwind-merge",
77
79
  "class-variance-authority",
78
80
  "tw-animate-css",
79
- "github-slugger"
81
+ "github-slugger",
82
+ "micromark"
80
83
  ]
81
84
  },
82
85
  "dependencies": {
@@ -92,6 +95,7 @@
92
95
  "embla-carousel-react": "8.6.0",
93
96
  "github-slugger": "2.0.0",
94
97
  "lucide-react": "1.31.0",
98
+ "micromark": "4.0.2",
95
99
  "motion": "13.0.0",
96
100
  "radix-ui": "1.6.7",
97
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>
@@ -1,4 +1,4 @@
1
- /** The layout surface GENERATORS write against, machine-readable (3.1.3).
1
+ /** The layout surface GENERATORS write against, machine-readable (3.2.0).
2
2
  * Platform code that emits brand-repo routes imports this and tests its
3
3
  * emissions against it: a prop or slot an emission uses that is not listed
4
4
  * here is dropped silently at render, which is exactly how the locale-twin
@@ -24,6 +24,7 @@ export const LAYOUT_CONTRACT = {
24
24
  "pageType",
25
25
  "datePublished",
26
26
  "dateModified",
27
+ "shell",
27
28
  ],
28
29
  /** Injected by the repo's Layout shim; never passed by a route. */
29
30
  shimInjected: ["siteConfig", "siteShell", "Shell"],
@@ -9,6 +9,7 @@ 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" }
14
15
  // { "type": "video", "src": "/media/x.mp4", "poster": "/images/x.webp" }
@@ -26,6 +27,18 @@ export interface TextContent {
26
27
  value: string;
27
28
  }
28
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
+
29
42
  export interface LinkContent {
30
43
  type: "link";
31
44
  /** The link's own label. Optional, because a link does not always have one:
@@ -93,6 +106,7 @@ export interface ArrayContent {
93
106
 
94
107
  export type ContentValue =
95
108
  | TextContent
109
+ | MarkdownContent
96
110
  | LinkContent
97
111
  | ImageContent
98
112
  | VideoContent
@@ -133,6 +147,57 @@ export const textContentSchema = z
133
147
  })
134
148
  .strict();
135
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
+
136
201
  export const linkContentSchema = z
137
202
  .object({
138
203
  type: z.literal("link"),
@@ -224,6 +289,7 @@ const propKeySchema = z
224
289
  export const contentLeafSchema: z.ZodType<ContentLeaf> = z.lazy(() =>
225
290
  z.union([
226
291
  textContentSchema,
292
+ markdownContentSchema,
227
293
  linkContentSchema,
228
294
  imageContentSchema,
229
295
  videoContentSchema,
@@ -258,7 +324,7 @@ export const arrayOf = <T extends z.ZodRawShape>(shape: T) =>
258
324
  .strict();
259
325
 
260
326
  // ---------------------------------------------------------------------------
261
- // Leaf accessors. Section code receives ContentLeaf, a seven-way union, and
327
+ // Leaf accessors. Section code receives ContentLeaf, an eight-way union, and
262
328
  // the natural-but-wrong move is reading a variant's field off the union
263
329
  // (`leaf.value` renders fine and fails astro check). These are the one
264
330
  // documented way to read a leaf: narrow-and-extract, never throw, degrade to
@@ -269,6 +335,10 @@ export function isTextContent(leaf: ContentLeaf): leaf is TextContent {
269
335
  return typeof leaf === "object" && leaf !== null && leaf.type === "text";
270
336
  }
271
337
 
338
+ export function isMarkdownContent(leaf: ContentLeaf): leaf is MarkdownContent {
339
+ return typeof leaf === "object" && leaf !== null && leaf.type === "markdown";
340
+ }
341
+
272
342
  export function isLinkContent(leaf: ContentLeaf): leaf is LinkContent {
273
343
  return typeof leaf === "object" && leaf !== null && leaf.type === "link";
274
344
  }
@@ -296,7 +366,8 @@ export function isArrayContent(leaf: ContentLeaf): leaf is ArrayContent {
296
366
  /**
297
367
  * The renderable text of a leaf: a text wrapper's value, or a bare config
298
368
  * scalar stringified (a count or token an entry legitimately interpolates
299
- * into copy). Structured wrappers (link, image, video, svg, color, array)
369
+ * into copy). Structured wrappers (markdown, link, image, video, svg, color,
370
+ * array)
300
371
  * have no text reading and come back empty — read them with their own
301
372
  * accessor.
302
373
  */
@@ -307,6 +378,18 @@ export function readText(leaf: ContentLeaf | null | undefined): string {
307
378
  return String(leaf);
308
379
  }
309
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
+
310
393
  /** The link wrapper (text, href, optional target), or null. */
311
394
  export function readLink(
312
395
  leaf: ContentLeaf | null | undefined,
@@ -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
+ }