@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.
- package/docs/runtime-contract.md +156 -18
- package/package.json +8 -2
- package/src/components/RelatedLinks.astro +78 -0
- package/src/content/collections.ts +21 -2
- package/src/content/schema.ts +61 -0
- package/src/layouts/LayoutCore.astro +31 -7
- package/src/layouts/layout-contract.ts +33 -0
- package/src/layouts/layout-core.ts +2 -0
- package/src/lib/chrome-schemas.ts +33 -2
- package/src/lib/content-values.ts +243 -3
- package/src/lib/markdown.ts +17 -0
package/docs/runtime-contract.md
CHANGED
|
@@ -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.
|
|
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":"…"}`,
|
|
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
|
|
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
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
}
|
package/src/content/schema.ts
CHANGED
|
@@ -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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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;
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
arrayOf,
|
|
5
5
|
configTokenSchema,
|
|
6
6
|
contentLeafSchema,
|
|
7
|
-
|
|
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 =
|
|
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
|
-
|
|
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) =>
|
|
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
|
+
}
|