@iterant/site-runtime 3.1.0 → 3.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/runtime-contract.md +38 -3
- package/package.json +3 -1
- package/scripts/scan-fetch-entrypoint.mjs +73 -0
- package/scripts/verify.mjs +13 -0
- package/src/config/preset.ts +6 -0
- package/src/content/collections.ts +5 -1
- package/src/content/resilience.ts +106 -0
- package/src/layouts/layout-contract.ts +32 -0
- package/src/layouts/layout-core.ts +2 -0
- package/src/lib/chrome-schemas.ts +33 -2
- package/src/lib/content-values.ts +160 -3
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.1.
|
|
53
|
+
_Generated from package.json by scripts/generate-kit-table.mjs. Runtime 3.1.3._
|
|
54
54
|
|
|
55
55
|
**Toolchain** (this package owns the version; do NOT declare these):
|
|
56
56
|
|
|
@@ -99,9 +99,13 @@ The grammar (`content-values.ts`, schema-enforced):
|
|
|
99
99
|
|
|
100
100
|
- Copy is **wrapped**: `{"type":"text","value":"…"}`,
|
|
101
101
|
`{"type":"link","text":"…","href":"…"}`,
|
|
102
|
-
`{"type":"image","src":"…","alt":"…"}`,
|
|
102
|
+
`{"type":"image","src":"…","alt":"…"}`,
|
|
103
|
+
`{"type":"video","src":"…","poster":"…"}`, `{"type":"svg","markup":"…"}`,
|
|
103
104
|
`{"type":"color","value":"…"}`. Repeated content is
|
|
104
|
-
`{"type":"array","items":[{…}]}`.
|
|
105
|
+
`{"type":"array","items":[{…}]}`. A link's `text` is optional in bespoke
|
|
106
|
+
content (an anchor can wrap its label); authored sections and chrome
|
|
107
|
+
require it (`labelledLinkSchema`). Videos carry no copy and are never
|
|
108
|
+
translated.
|
|
105
109
|
- Non-copy config stays bare: numbers, booleans, short lowercase tokens
|
|
106
110
|
(`"zap"`, `"center"`, `"inverse"`). Bare strings with uppercase letters or
|
|
107
111
|
spaces are rejected by the schema, so wrap them.
|
|
@@ -123,6 +127,37 @@ The grammar (`content-values.ts`, schema-enforced):
|
|
|
123
127
|
- `site-runtime scan-copy` finds hardcoded copy in component code. Like every
|
|
124
128
|
gate, never edit or weaken it.
|
|
125
129
|
|
|
130
|
+
### Reading leaves in section code
|
|
131
|
+
|
|
132
|
+
A section prop typed `ContentLeaf` is a union, and reading a variant field off
|
|
133
|
+
the union (`leaf.value`, `leaf.href`) fails `astro check` even though it
|
|
134
|
+
renders. Read leaves through the exported accessors, one per kind, and never
|
|
135
|
+
hand-roll narrowing helpers:
|
|
136
|
+
|
|
137
|
+
```tsx
|
|
138
|
+
import {
|
|
139
|
+
readImage,
|
|
140
|
+
readItems,
|
|
141
|
+
readLink,
|
|
142
|
+
readText,
|
|
143
|
+
} from "@iterant/site-runtime/content-values";
|
|
144
|
+
|
|
145
|
+
<h2>{readText(data.heading)}</h2>;
|
|
146
|
+
{
|
|
147
|
+
readItems(data.features).map((item, index) => (
|
|
148
|
+
<li key={index}>{readText(item.title)}</li>
|
|
149
|
+
));
|
|
150
|
+
}
|
|
151
|
+
const cta = readLink(data.cta); // { text?, href, target? } | null
|
|
152
|
+
const hero = readImage(data.hero); // { src, alt, srcset? } | null
|
|
153
|
+
const reel = readVideo(data.reel); // { src, poster? } | null
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
They never throw and degrade to empty (`""`, `null`, `[]`) on the wrong kind,
|
|
157
|
+
so a section renders with partial content instead of taking the route down.
|
|
158
|
+
Type guards (`isTextContent`, `isLinkContent`, …) are exported for the rare
|
|
159
|
+
case that needs manual narrowing.
|
|
160
|
+
|
|
126
161
|
### The collection schemas
|
|
127
162
|
|
|
128
163
|
The repo's `src/content.config.ts` is a shim over `createCollections`, which
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iterant/site-runtime",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The platform layer every Iterant brand site runs on: content grammar, collection schemas, SEO head and JSON-LD, layout core, Astro config preset, dev integrations and the verify gates.",
|
|
6
6
|
"scripts": {
|
|
@@ -46,6 +46,8 @@
|
|
|
46
46
|
"./seo": "./src/components/seo.tsx",
|
|
47
47
|
"./seo-json": "./src/components/seo-json.tsx",
|
|
48
48
|
"./layout": "./src/layouts/LayoutCore.astro",
|
|
49
|
+
"./layout-core": "./src/layouts/layout-core.ts",
|
|
50
|
+
"./layout-contract": "./src/layouts/layout-contract.ts",
|
|
49
51
|
"./under-construction": "./src/routes/UnderConstruction.astro",
|
|
50
52
|
"./routes": "./src/routes/index.ts",
|
|
51
53
|
"./config": "./src/config/preset.ts",
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Reserved-filename gate: `src/fetch.*`.
|
|
4
|
+
*
|
|
5
|
+
* Astro 7 resolves `srcDir + "fetch"` as the request-pipeline entrypoint
|
|
6
|
+
* (advanced routing): when the module resolves, its default export replaces
|
|
7
|
+
* the default fetch handler for EVERY request, in dev and in production
|
|
8
|
+
* builds alike, and a failure to resolve falls back silently. Resolution
|
|
9
|
+
* goes through Vite, so every resolvable extension counts and a `src/fetch/`
|
|
10
|
+
* directory resolves through its index file. A brand repo must never carry
|
|
11
|
+
* one: an innocently named helper would silently take over the whole request
|
|
12
|
+
* pipeline, and a broken one takes every route down with it. The platform
|
|
13
|
+
* reserves the name for a future runtime-owned shim; until that ships, the
|
|
14
|
+
* file's absence is the contract.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readdir } from "node:fs/promises";
|
|
18
|
+
import { join, resolve } from "node:path";
|
|
19
|
+
import { pathToFileURL } from "node:url";
|
|
20
|
+
|
|
21
|
+
// Vite's default resolve.extensions, which Astro does not override.
|
|
22
|
+
const RESOLVABLE = /^fetch\.(mjs|js|mts|ts|jsx|tsx|json)$/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} rootDir
|
|
26
|
+
* @returns {Promise<string[]>}
|
|
27
|
+
*/
|
|
28
|
+
export async function checkFetchEntrypoint(rootDir) {
|
|
29
|
+
/** @type {import("node:fs").Dirent[]} */
|
|
30
|
+
let entries = [];
|
|
31
|
+
try {
|
|
32
|
+
entries = await readdir(join(rootDir, "src"), { withFileTypes: true });
|
|
33
|
+
} catch {
|
|
34
|
+
// No src/ at all is astro check's problem, not this gate's.
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const problems = [];
|
|
38
|
+
for (const entry of entries) {
|
|
39
|
+
if (entry.isDirectory() && entry.name === "fetch") {
|
|
40
|
+
problems.push(
|
|
41
|
+
"src/fetch/ is a reserved name: Astro resolves it (through its index file) " +
|
|
42
|
+
"as the request-pipeline entrypoint for every route, dev and production. " +
|
|
43
|
+
"Rename the directory.",
|
|
44
|
+
);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (entry.isFile() && RESOLVABLE.test(entry.name)) {
|
|
48
|
+
problems.push(
|
|
49
|
+
`src/${entry.name} is a reserved filename: Astro loads it as the ` +
|
|
50
|
+
"request-pipeline entrypoint for every route, dev and production, " +
|
|
51
|
+
"replacing the platform's default handler. Rename the file.",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return problems;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Standalone CLI: `node scripts/scan-fetch-entrypoint.mjs [rootDir]`.
|
|
59
|
+
// Prints problems to stderr and exits 1 when any are found.
|
|
60
|
+
if (
|
|
61
|
+
process.argv[1] &&
|
|
62
|
+
import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
|
63
|
+
) {
|
|
64
|
+
const problems = await checkFetchEntrypoint(
|
|
65
|
+
process.argv[2] ? resolve(process.argv[2]) : process.cwd(),
|
|
66
|
+
);
|
|
67
|
+
if (problems.length > 0) {
|
|
68
|
+
process.stderr.write(
|
|
69
|
+
`fetch-entrypoint check failed:\n${problems.join("\n")}\n`,
|
|
70
|
+
);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
}
|
package/scripts/verify.mjs
CHANGED
|
@@ -207,6 +207,19 @@ if (fontProblems.length > 0) {
|
|
|
207
207
|
exit(1);
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
// Fetch-entrypoint gate — Astro loads src/fetch.* as the request-pipeline
|
|
211
|
+
// entrypoint for EVERY route, dev and production, so an innocently named
|
|
212
|
+
// helper silently takes over the whole pipeline. The check lives in
|
|
213
|
+
// scan-fetch-entrypoint.mjs so its tests can exercise it on fixtures.
|
|
214
|
+
const { checkFetchEntrypoint } = await import("./scan-fetch-entrypoint.mjs");
|
|
215
|
+
const fetchProblems = await checkFetchEntrypoint(process.cwd());
|
|
216
|
+
if (fetchProblems.length > 0) {
|
|
217
|
+
process.stderr.write(
|
|
218
|
+
`fetch-entrypoint check failed:\n${fetchProblems.join("\n")}\n`,
|
|
219
|
+
);
|
|
220
|
+
exit(1);
|
|
221
|
+
}
|
|
222
|
+
|
|
210
223
|
// Island-import gate — registered sections never hydrate, so an island-grade
|
|
211
224
|
// ui primitive imported there ships a dead widget with zero build signal.
|
|
212
225
|
// The check (direct + barrel + any-depth relative imports; type-only imports
|
package/src/config/preset.ts
CHANGED
|
@@ -185,6 +185,12 @@ export function iterantStarter({
|
|
|
185
185
|
// Tunnel/preview hostnames are random per boot, so hostname
|
|
186
186
|
// allow-listing is impossible; access control lives outside the dev server.
|
|
187
187
|
allowedHosts,
|
|
188
|
+
// Vite 8 turns this on by itself when it sniffs an AI agent in the
|
|
189
|
+
// environment, and a forwarded browser console (any viewer with the
|
|
190
|
+
// preview open) would interleave into the process logs the platform
|
|
191
|
+
// parses for readiness and boot briefs. Pinned so a heuristic never
|
|
192
|
+
// decides the log shape; enabling it is a deliberate platform decision.
|
|
193
|
+
forwardConsole: false,
|
|
188
194
|
},
|
|
189
195
|
};
|
|
190
196
|
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
loaderBase,
|
|
8
8
|
} from "../lib/content-paths";
|
|
9
9
|
import { entryIdFromFile } from "../lib/locales";
|
|
10
|
+
import { withDevQuarantine } from "./resilience";
|
|
10
11
|
import { createContentSchemas, type ContentSchemaOptions } from "./schema";
|
|
11
12
|
|
|
12
13
|
// The `pages` + `chrome` collections a brand site runs on. Astro requires the
|
|
@@ -53,7 +54,10 @@ export function createCollections({
|
|
|
53
54
|
base: loaderBase(pagesDir),
|
|
54
55
|
generateId: ({ entry }) => entryIdFromFile(entry),
|
|
55
56
|
}),
|
|
56
|
-
|
|
57
|
+
// Strict at build/verify; in dev an invalid entry is quarantined (logged
|
|
58
|
+
// in full, route degrades to an error stand-in) instead of taking the
|
|
59
|
+
// whole content sync — and with it every route of the preview — down.
|
|
60
|
+
schema: withDevQuarantine(pageEntrySchema),
|
|
57
61
|
});
|
|
58
62
|
|
|
59
63
|
const chrome = defineCollection({
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { z } from "astro/zod";
|
|
2
|
+
|
|
3
|
+
// Dev-server resilience for the pages collection (3.1.1). Astro 7 treats one
|
|
4
|
+
// invalid entry as fatal to the whole content sync, so a single half-finished
|
|
5
|
+
// page took the ENTIRE preview down — every route, plus the save gate's boot.
|
|
6
|
+
// In dev we quarantine instead: the strict schema still decides validity, the
|
|
7
|
+
// violations are logged in full, and the broken entry is replaced by a
|
|
8
|
+
// minimal valid stand-in (chromed page, no sections, error title) so its
|
|
9
|
+
// route degrades while every other page keeps serving. Builds stay strict:
|
|
10
|
+
// verify and publish reject the entry exactly as before, so nothing invalid
|
|
11
|
+
// can ship.
|
|
12
|
+
|
|
13
|
+
const ROUTE_PATTERN = /^\/[a-z0-9\-/]*$/;
|
|
14
|
+
|
|
15
|
+
type AnyPageSchema = z.ZodTypeAny;
|
|
16
|
+
|
|
17
|
+
export function isStrictContentEnv(env: NodeJS.ProcessEnv = process.env) {
|
|
18
|
+
// astro build sets NODE_ENV=production; astro dev sets development.
|
|
19
|
+
// SITE_RUNTIME_STRICT_CONTENT=1 forces build behavior in dev (escape hatch
|
|
20
|
+
// for debugging the strict path itself).
|
|
21
|
+
return (
|
|
22
|
+
env.NODE_ENV === "production" || env.SITE_RUNTIME_STRICT_CONTENT === "1"
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Render a ZodError as one line per violation with full paths. Zod 4 already
|
|
27
|
+
// reports the informative branch of a union failure at its deep path (the
|
|
28
|
+
// bare-string wrapper rule lands on e.g. components.1.props.columns.items.1
|
|
29
|
+
// .key directly); genuinely ambiguous unions still collapse to one issue
|
|
30
|
+
// carrying per-branch issue lists in `errors`, so those are walked.
|
|
31
|
+
type UnionBranches = { errors?: z.ZodIssue[][] };
|
|
32
|
+
|
|
33
|
+
function describeIssues(
|
|
34
|
+
issues: z.ZodIssue[],
|
|
35
|
+
base: PropertyKey[] = [],
|
|
36
|
+
): string[] {
|
|
37
|
+
const lines: string[] = [];
|
|
38
|
+
for (const issue of issues) {
|
|
39
|
+
const path = [...base, ...issue.path];
|
|
40
|
+
const branches = (issue as UnionBranches).errors;
|
|
41
|
+
if (issue.code === "invalid_union" && Array.isArray(branches)) {
|
|
42
|
+
lines.push(...describeIssues(branches.flat(), path));
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
lines.push(`${path.map(String).join(".")}: ${issue.message}`);
|
|
46
|
+
}
|
|
47
|
+
return [...new Set(lines)];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Wrap the strict page-entry schema for collection use: pass-through when the
|
|
52
|
+
* entry is valid, quarantine when it is not (dev only). In a strict env
|
|
53
|
+
* (build/verify) and for an entry whose `route` field is itself unusable (it
|
|
54
|
+
* cannot be placed on a route), the original violations surface unchanged.
|
|
55
|
+
*/
|
|
56
|
+
export function withDevQuarantine<S extends AnyPageSchema>(
|
|
57
|
+
strict: S,
|
|
58
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
59
|
+
): z.ZodType<z.output<S>> {
|
|
60
|
+
const strictMode = isStrictContentEnv(env);
|
|
61
|
+
return z.unknown().transform((raw, ctx) => {
|
|
62
|
+
const parsed = strict.safeParse(raw);
|
|
63
|
+
if (parsed.success) return parsed.data as z.output<S>;
|
|
64
|
+
|
|
65
|
+
// Replay the strict schema's own issues through this pipeline — the cast
|
|
66
|
+
// bridges zod 4's public ZodIssue to addIssue's raw-issue input, which
|
|
67
|
+
// are the same objects under different declared types.
|
|
68
|
+
const surface = () => {
|
|
69
|
+
for (const issue of parsed.error.issues) {
|
|
70
|
+
ctx.addIssue(issue as never);
|
|
71
|
+
}
|
|
72
|
+
return z.NEVER;
|
|
73
|
+
};
|
|
74
|
+
if (strictMode) return surface();
|
|
75
|
+
|
|
76
|
+
const issues = describeIssues(parsed.error.issues);
|
|
77
|
+
const route =
|
|
78
|
+
raw !== null &&
|
|
79
|
+
typeof raw === "object" &&
|
|
80
|
+
"route" in raw &&
|
|
81
|
+
typeof raw.route === "string" &&
|
|
82
|
+
ROUTE_PATTERN.test(raw.route)
|
|
83
|
+
? raw.route
|
|
84
|
+
: null;
|
|
85
|
+
if (route === null) return surface();
|
|
86
|
+
|
|
87
|
+
console.error(
|
|
88
|
+
[
|
|
89
|
+
`[site-runtime] QUARANTINED invalid page entry for route "${route}".`,
|
|
90
|
+
"The dev server keeps serving; this route renders an error stand-in",
|
|
91
|
+
"until the entry is fixed. Publish/verify still reject it. Violations:",
|
|
92
|
+
...issues.map((line) => ` ${line}`),
|
|
93
|
+
].join("\n"),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
return strict.parse({
|
|
97
|
+
route,
|
|
98
|
+
title: "This page has a content error",
|
|
99
|
+
meta: {
|
|
100
|
+
title: "This page has a content error",
|
|
101
|
+
description: `Invalid entry quarantined in dev: ${issues.join("; ").slice(0, 400)}`,
|
|
102
|
+
},
|
|
103
|
+
components: [],
|
|
104
|
+
}) as z.output<S>;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** The layout surface GENERATORS write against, machine-readable (3.1.3).
|
|
2
|
+
* Platform code that emits brand-repo routes imports this and tests its
|
|
3
|
+
* emissions against it: a prop or slot an emission uses that is not listed
|
|
4
|
+
* here is dropped silently at render, which is exactly how the locale-twin
|
|
5
|
+
* incident escaped notice for two contract generations. The repo's Layout
|
|
6
|
+
* shim injects the brand-context trio itself, so emitted routes never pass
|
|
7
|
+
* those. The package's own contract test pins this list against
|
|
8
|
+
* LayoutCore.astro, so the two cannot drift apart. */
|
|
9
|
+
export const LAYOUT_CONTRACT = {
|
|
10
|
+
/** Props an emitted route may pass through the repo's Layout shim. */
|
|
11
|
+
props: [
|
|
12
|
+
"title",
|
|
13
|
+
"description",
|
|
14
|
+
"canonical",
|
|
15
|
+
"image",
|
|
16
|
+
"imageAlt",
|
|
17
|
+
"noindex",
|
|
18
|
+
"type",
|
|
19
|
+
"siteName",
|
|
20
|
+
"jsonLd",
|
|
21
|
+
"lang",
|
|
22
|
+
"hreflang",
|
|
23
|
+
"chrome",
|
|
24
|
+
"pageType",
|
|
25
|
+
"datePublished",
|
|
26
|
+
"dateModified",
|
|
27
|
+
],
|
|
28
|
+
/** Injected by the repo's Layout shim; never passed by a route. */
|
|
29
|
+
shimInjected: ["siteConfig", "siteShell", "Shell"],
|
|
30
|
+
/** Named slots the layout renders. Anything else is dropped. */
|
|
31
|
+
slots: ["head"],
|
|
32
|
+
} as const;
|
|
@@ -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,
|
|
@@ -11,6 +11,7 @@ import { z } from "astro/zod";
|
|
|
11
11
|
// { "type": "text", "value": "Visible prose" }
|
|
12
12
|
// { "type": "link", "text": "Start free", "href": "/signup" }
|
|
13
13
|
// { "type": "image", "src": "/images/x.webp", "alt": "Description" }
|
|
14
|
+
// { "type": "video", "src": "/media/x.mp4", "poster": "/images/x.webp" }
|
|
14
15
|
// { "type": "svg", "markup": "<svg…>", "label": "Accessible name" }
|
|
15
16
|
// { "type": "color", "value": "#0ea5e9" }
|
|
16
17
|
// { "type": "array", "items": [ { …wrapped fields per item… } ] }
|
|
@@ -27,7 +28,13 @@ export interface TextContent {
|
|
|
27
28
|
|
|
28
29
|
export interface LinkContent {
|
|
29
30
|
type: "link";
|
|
30
|
-
|
|
31
|
+
/** The link's own label. Optional, because a link does not always have one:
|
|
32
|
+
* an anchor wrapping an icon, a card, or several elements has its label in
|
|
33
|
+
* the leaves beneath it, and those are separately addressable. A link with
|
|
34
|
+
* no text of its own renders none, and its destination stays editable,
|
|
35
|
+
* which is the point of the wrapper. Authored sections and chrome require
|
|
36
|
+
* one anyway: see labelledLinkSchema. */
|
|
37
|
+
text?: string;
|
|
31
38
|
href: string;
|
|
32
39
|
target?: "_blank" | "_self";
|
|
33
40
|
}
|
|
@@ -38,6 +45,12 @@ export interface ImageCandidate {
|
|
|
38
45
|
src: string;
|
|
39
46
|
assetId?: string;
|
|
40
47
|
descriptor: string;
|
|
48
|
+
/** The <source> encoding this candidate composes ("image/webp"), absent for
|
|
49
|
+
* the <img>'s own candidates. A <picture> is one image the browser fetches
|
|
50
|
+
* in whichever format it supports, so its formats ride one wrapper: a swap
|
|
51
|
+
* that clears the candidates drops every <source> at once and hands the
|
|
52
|
+
* picture back to the img it just rewrote. */
|
|
53
|
+
type?: string;
|
|
41
54
|
}
|
|
42
55
|
|
|
43
56
|
export interface ImageContent {
|
|
@@ -50,6 +63,18 @@ export interface ImageContent {
|
|
|
50
63
|
srcset?: ImageCandidate[];
|
|
51
64
|
}
|
|
52
65
|
|
|
66
|
+
/** A video the page plays. Like an image it is a resource the visitor sees and
|
|
67
|
+
* an owner replaces, and unlike an image it carries no copy: there is nothing
|
|
68
|
+
* in it to translate, which is why it has no text field and never appears in a
|
|
69
|
+
* translation pass. `poster` is the still frame shown before playback. */
|
|
70
|
+
export interface VideoContent {
|
|
71
|
+
type: "video";
|
|
72
|
+
src: string;
|
|
73
|
+
assetId?: string;
|
|
74
|
+
poster?: string;
|
|
75
|
+
posterAssetId?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
53
78
|
export interface SvgContent {
|
|
54
79
|
type: "svg";
|
|
55
80
|
markup: string;
|
|
@@ -70,6 +95,7 @@ export type ContentValue =
|
|
|
70
95
|
| TextContent
|
|
71
96
|
| LinkContent
|
|
72
97
|
| ImageContent
|
|
98
|
+
| VideoContent
|
|
73
99
|
| SvgContent
|
|
74
100
|
| ColorContent
|
|
75
101
|
| ArrayContent;
|
|
@@ -88,10 +114,20 @@ export const textContentSchema = z
|
|
|
88
114
|
// would try to rewrite it and the editor would offer a text input for
|
|
89
115
|
// a destination. Links live in link wrappers ({type:"link",text,href});
|
|
90
116
|
// image sources in image wrappers.
|
|
117
|
+
//
|
|
118
|
+
// The tail is RFC 3986's path characters, not "any non-space" (3.1.3). A
|
|
119
|
+
// value the spec's own grammar cannot read as a url is not a destination,
|
|
120
|
+
// whatever it starts with: `/>` out of a syntax-highlighted code sample is
|
|
121
|
+
// visible copy, and refusing it left it baked into a component instead.
|
|
122
|
+
// Genuinely ambiguous cases stay refused: `/h` is both a price unit and a
|
|
123
|
+
// valid path, and no predicate over the STRING can tell them apart.
|
|
91
124
|
value: z
|
|
92
125
|
.string()
|
|
93
126
|
.refine(
|
|
94
|
-
(value) =>
|
|
127
|
+
(value) =>
|
|
128
|
+
!/^(?:\/|#|https?:\/\/)[A-Za-z0-9\-._~%!$&'()*+,;=:@/?#[\]]*$/.test(
|
|
129
|
+
value.trim(),
|
|
130
|
+
),
|
|
95
131
|
'this looks like a URL or path — use {"type":"link","text":…,"href":…} (or image src), not a text wrapper',
|
|
96
132
|
),
|
|
97
133
|
})
|
|
@@ -100,12 +136,21 @@ export const textContentSchema = z
|
|
|
100
136
|
export const linkContentSchema = z
|
|
101
137
|
.object({
|
|
102
138
|
type: z.literal("link"),
|
|
103
|
-
text: z.string(),
|
|
139
|
+
text: z.string().optional(),
|
|
104
140
|
href: z.string(),
|
|
105
141
|
target: z.enum(["_blank", "_self"]).optional(),
|
|
106
142
|
})
|
|
107
143
|
.strict();
|
|
108
144
|
|
|
145
|
+
/** A link that must carry a label: every one an AUTHOR writes. A hero CTA or a
|
|
146
|
+
* nav item with no text is a button nobody can read, so the components that
|
|
147
|
+
* render one from a fixed schema hold the stricter contract. The loose
|
|
148
|
+
* wrapper above is for bespoke pages, where an anchor's label can live in the
|
|
149
|
+
* markup beneath it. */
|
|
150
|
+
export const labelledLinkSchema = linkContentSchema.extend({
|
|
151
|
+
text: z.string(),
|
|
152
|
+
});
|
|
153
|
+
|
|
109
154
|
// A srcset candidate descriptor is a width ("400w") or pixel density ("2x").
|
|
110
155
|
const descriptorSchema = z
|
|
111
156
|
.string()
|
|
@@ -119,6 +164,7 @@ export const imageCandidateSchema = z
|
|
|
119
164
|
src: z.string(),
|
|
120
165
|
assetId: z.string().optional(),
|
|
121
166
|
descriptor: descriptorSchema,
|
|
167
|
+
type: z.string().optional(),
|
|
122
168
|
})
|
|
123
169
|
.strict();
|
|
124
170
|
|
|
@@ -134,6 +180,16 @@ export const imageContentSchema = z
|
|
|
134
180
|
})
|
|
135
181
|
.strict();
|
|
136
182
|
|
|
183
|
+
export const videoContentSchema = z
|
|
184
|
+
.object({
|
|
185
|
+
type: z.literal("video"),
|
|
186
|
+
src: z.string(),
|
|
187
|
+
assetId: z.string().optional(),
|
|
188
|
+
poster: z.string().optional(),
|
|
189
|
+
posterAssetId: z.string().optional(),
|
|
190
|
+
})
|
|
191
|
+
.strict();
|
|
192
|
+
|
|
137
193
|
export const svgContentSchema = z
|
|
138
194
|
.object({
|
|
139
195
|
type: z.literal("svg"),
|
|
@@ -170,6 +226,7 @@ export const contentLeafSchema: z.ZodType<ContentLeaf> = z.lazy(() =>
|
|
|
170
226
|
textContentSchema,
|
|
171
227
|
linkContentSchema,
|
|
172
228
|
imageContentSchema,
|
|
229
|
+
videoContentSchema,
|
|
173
230
|
svgContentSchema,
|
|
174
231
|
colorContentSchema,
|
|
175
232
|
arrayContentSchema,
|
|
@@ -199,3 +256,103 @@ export const arrayOf = <T extends z.ZodRawShape>(shape: T) =>
|
|
|
199
256
|
items: z.array(z.object(shape).strict()),
|
|
200
257
|
})
|
|
201
258
|
.strict();
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Leaf accessors. Section code receives ContentLeaf, a seven-way union, and
|
|
262
|
+
// the natural-but-wrong move is reading a variant's field off the union
|
|
263
|
+
// (`leaf.value` renders fine and fails astro check). These are the one
|
|
264
|
+
// documented way to read a leaf: narrow-and-extract, never throw, degrade to
|
|
265
|
+
// empty so a section renders with partial content instead of taking the
|
|
266
|
+
// route down. Use them instead of hand-rolled narrowing helpers.
|
|
267
|
+
|
|
268
|
+
export function isTextContent(leaf: ContentLeaf): leaf is TextContent {
|
|
269
|
+
return typeof leaf === "object" && leaf !== null && leaf.type === "text";
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function isLinkContent(leaf: ContentLeaf): leaf is LinkContent {
|
|
273
|
+
return typeof leaf === "object" && leaf !== null && leaf.type === "link";
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function isImageContent(leaf: ContentLeaf): leaf is ImageContent {
|
|
277
|
+
return typeof leaf === "object" && leaf !== null && leaf.type === "image";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function isVideoContent(leaf: ContentLeaf): leaf is VideoContent {
|
|
281
|
+
return typeof leaf === "object" && leaf !== null && leaf.type === "video";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export function isSvgContent(leaf: ContentLeaf): leaf is SvgContent {
|
|
285
|
+
return typeof leaf === "object" && leaf !== null && leaf.type === "svg";
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function isColorContent(leaf: ContentLeaf): leaf is ColorContent {
|
|
289
|
+
return typeof leaf === "object" && leaf !== null && leaf.type === "color";
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function isArrayContent(leaf: ContentLeaf): leaf is ArrayContent {
|
|
293
|
+
return typeof leaf === "object" && leaf !== null && leaf.type === "array";
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The renderable text of a leaf: a text wrapper's value, or a bare config
|
|
298
|
+
* scalar stringified (a count or token an entry legitimately interpolates
|
|
299
|
+
* into copy). Structured wrappers (link, image, video, svg, color, array)
|
|
300
|
+
* have no text reading and come back empty — read them with their own
|
|
301
|
+
* accessor.
|
|
302
|
+
*/
|
|
303
|
+
export function readText(leaf: ContentLeaf | null | undefined): string {
|
|
304
|
+
if (leaf === null || leaf === undefined) return "";
|
|
305
|
+
if (isTextContent(leaf)) return leaf.value;
|
|
306
|
+
if (typeof leaf === "object") return "";
|
|
307
|
+
return String(leaf);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The link wrapper (text, href, optional target), or null. */
|
|
311
|
+
export function readLink(
|
|
312
|
+
leaf: ContentLeaf | null | undefined,
|
|
313
|
+
): LinkContent | null {
|
|
314
|
+
return leaf !== null && leaf !== undefined && isLinkContent(leaf)
|
|
315
|
+
? leaf
|
|
316
|
+
: null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The image wrapper (src, alt, optional srcset candidates), or null. */
|
|
320
|
+
export function readImage(
|
|
321
|
+
leaf: ContentLeaf | null | undefined,
|
|
322
|
+
): ImageContent | null {
|
|
323
|
+
return leaf !== null && leaf !== undefined && isImageContent(leaf)
|
|
324
|
+
? leaf
|
|
325
|
+
: null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The video wrapper (src, optional poster), or null. */
|
|
329
|
+
export function readVideo(
|
|
330
|
+
leaf: ContentLeaf | null | undefined,
|
|
331
|
+
): VideoContent | null {
|
|
332
|
+
return leaf !== null && leaf !== undefined && isVideoContent(leaf)
|
|
333
|
+
? leaf
|
|
334
|
+
: null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The svg wrapper (markup, optional label), or null. */
|
|
338
|
+
export function readSvg(
|
|
339
|
+
leaf: ContentLeaf | null | undefined,
|
|
340
|
+
): SvgContent | null {
|
|
341
|
+
return leaf !== null && leaf !== undefined && isSvgContent(leaf)
|
|
342
|
+
? leaf
|
|
343
|
+
: null;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** A color wrapper's CSS value, or null. */
|
|
347
|
+
export function readColor(leaf: ContentLeaf | null | undefined): string | null {
|
|
348
|
+
return leaf !== null && leaf !== undefined && isColorContent(leaf)
|
|
349
|
+
? leaf.value
|
|
350
|
+
: null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** An array wrapper's items, or an empty list. */
|
|
354
|
+
export function readItems(leaf: ContentLeaf | null | undefined): ContentItem[] {
|
|
355
|
+
return leaf !== null && leaf !== undefined && isArrayContent(leaf)
|
|
356
|
+
? leaf.items
|
|
357
|
+
: [];
|
|
358
|
+
}
|