@iterant/site-runtime 3.0.2

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.
Files changed (36) hide show
  1. package/README.md +30 -0
  2. package/bin/site-runtime.mjs +46 -0
  3. package/docs/runtime-contract.md +324 -0
  4. package/package.json +84 -0
  5. package/scripts/scan-bespoke-siblings.mjs +191 -0
  6. package/scripts/scan-copy.mjs +204 -0
  7. package/scripts/scan-island-imports.mjs +207 -0
  8. package/scripts/verify.mjs +283 -0
  9. package/src/components/seo-json.tsx +157 -0
  10. package/src/components/seo.tsx +294 -0
  11. package/src/config/preset.ts +198 -0
  12. package/src/content/collections.ts +71 -0
  13. package/src/content/schema.ts +239 -0
  14. package/src/index.ts +22 -0
  15. package/src/integrations/iterant-plugins.mjs +83 -0
  16. package/src/integrations/new-file-reload.mjs +95 -0
  17. package/src/integrations/preview-error-shell.mjs +145 -0
  18. package/src/layouts/LayoutCore.astro +182 -0
  19. package/src/layouts/layout-core.ts +141 -0
  20. package/src/lib/bespoke-pages.ts +60 -0
  21. package/src/lib/chrome-schemas.ts +266 -0
  22. package/src/lib/chrome.ts +23 -0
  23. package/src/lib/content-paths.ts +16 -0
  24. package/src/lib/content-values.ts +201 -0
  25. package/src/lib/hreflang.ts +123 -0
  26. package/src/lib/locales.ts +92 -0
  27. package/src/lib/sitemap/get-sitemap-paths.ts +65 -0
  28. package/src/lib/sitemap/index.ts +19 -0
  29. package/src/lib/sitemap/routes.ts +141 -0
  30. package/src/lib/sitemap/shared.ts +95 -0
  31. package/src/lib/sitemap/sitemap-with-custom-pages-plugin.ts +74 -0
  32. package/src/routes/UnderConstruction.astro +43 -0
  33. package/src/routes/index.ts +11 -0
  34. package/src/routes/llms-txt.ts +68 -0
  35. package/src/routes/robots-txt.ts +56 -0
  36. package/src/version.ts +9 -0
@@ -0,0 +1,182 @@
1
+ ---
2
+ import { getCollection, getEntry } from "astro:content";
3
+ import type { AstroComponentFactory } from "astro/runtime/server/index.js";
4
+ import { SEO, type PageType } from "../components/seo";
5
+ import type { SeoJsonSchema } from "../components/seo-json";
6
+ import type { HreflangAlternate } from "../lib/hreflang";
7
+ import { DEFAULT_LOCALE, localeFromPath } from "../lib/locales";
8
+ import { SITE_RUNTIME_VERSION } from "../version";
9
+ import {
10
+ canonicalUrlFor,
11
+ findPageEntryByRoute,
12
+ pickChromeComponents,
13
+ resolveAbsoluteUrl,
14
+ resolveStructuredData,
15
+ routePathFromPathname,
16
+ type LayoutSiteConfig,
17
+ type SiteShell,
18
+ } from "./layout-core";
19
+
20
+ // The platform's HTML shell: head, SEO + structured data, hreflang, locale-aware
21
+ // chrome resolution, and the brand shell around the page. Brand context arrives
22
+ // as props (siteConfig / siteShell / Shell) instead of repo imports, so this
23
+ // file carries no brand-specific knowledge and rides a package bump.
24
+ //
25
+ // The repo keeps src/layouts/Layout.astro as a shim that supplies those three
26
+ // props and imports globals.css; every bespoke shell keeps importing
27
+ // ../layouts/Layout.astro unchanged, and a brand that genuinely needs different
28
+ // behavior ejects by editing the shim.
29
+
30
+ interface Props {
31
+ // ---- brand context, injected by the repo's Layout.astro shim ----
32
+ /** The repo's SITE_CONFIG: name/description defaults + Organization data. */
33
+ siteConfig: LayoutSiteConfig;
34
+ /** The repo's SITE_SHELL: `<html>` and `<body>` attributes. */
35
+ siteShell: SiteShell;
36
+ /** The repo's src/components/layout/shell.astro, rendered around the page. */
37
+ Shell: AstroComponentFactory;
38
+
39
+ // ---- page props (unchanged from the starter's Layout) ----
40
+ title?: string;
41
+ description?: string;
42
+ canonical?: string | URL;
43
+ image?: string | URL;
44
+ imageAlt?: string;
45
+ noindex?: boolean;
46
+ type?: "website" | "article";
47
+ siteName?: string;
48
+ jsonLd?: SeoJsonSchema | SeoJsonSchema[];
49
+ // `<html lang>` in canonical BCP-47 casing (starter 2.9.0). Base pages are
50
+ // the brand default; locale siblings pass their own (`pt-BR`). Callers that
51
+ // don't localize (bespoke shells) get the default — a base-only page.
52
+ lang?: string;
53
+ // hreflang alternates for this page's locale group. Empty (the default) when
54
+ // a page has no siblings — a lone page emits no hreflang.
55
+ hreflang?: HreflangAlternate[];
56
+ // Site chrome (navbar/footer) mount toggle (starter 2.13.0). Default true;
57
+ // set false on a page that ships its own nav/footer (e.g. a full-design
58
+ // import) so the layout mounts no site-level chrome. Threaded from the page
59
+ // entry's `chrome` flag.
60
+ chrome?: boolean;
61
+ // Structured-data overrides (starter 2.17.0). The layout resolves the page
62
+ // entry by route and reads meta.pageType / meta.datePublished /
63
+ // meta.dateModified itself, so shells need not thread these; the props
64
+ // exist for the rare shell whose route has no entry. Prop wins over entry.
65
+ pageType?: PageType;
66
+ datePublished?: string;
67
+ dateModified?: string;
68
+ }
69
+
70
+ const {
71
+ siteConfig,
72
+ siteShell,
73
+ Shell,
74
+ title = siteConfig.name,
75
+ description = siteConfig.description,
76
+ canonical,
77
+ image,
78
+ imageAlt,
79
+ noindex = false,
80
+ type = "website",
81
+ siteName = siteConfig.name,
82
+ jsonLd,
83
+ lang = DEFAULT_LOCALE,
84
+ hreflang = [],
85
+ chrome = true,
86
+ pageType,
87
+ datePublished,
88
+ dateModified,
89
+ } = Astro.props;
90
+
91
+ const site = Astro.site;
92
+
93
+ const canonicalUrl = canonicalUrlFor({
94
+ canonical,
95
+ pathname: Astro.url.pathname,
96
+ site,
97
+ });
98
+ const imageUrl = resolveAbsoluteUrl(image, site);
99
+
100
+ // Site-level chrome (src/content/chrome.json). Resolved by component id so an
101
+ // absent navbar/footer renders NO chrome — the template ships chrome.json
102
+ // empty, so this is a byte-identical no-op until a brand populates it.
103
+ // getEntry (not getChromeProps) because we need to distinguish an absent
104
+ // component from empty props to drive the conditional mount.
105
+ // Locale-aware (starter 2.8.0): a request under a locale prefix (/es/…)
106
+ // mounts chrome.<locale>.json when that sibling exists, falling back to the
107
+ // base chrome — pages translate before chrome does, so a missing sibling
108
+ // must never strip the nav.
109
+ const locale = localeFromPath(Astro.url.pathname);
110
+ const chromeEntry =
111
+ (locale ? await getEntry("chrome", `chrome.${locale}`) : undefined) ??
112
+ (await getEntry("chrome", "chrome"));
113
+ const { navbar, footer } = pickChromeComponents(chromeEntry?.data.components);
114
+
115
+ // Structured data (starter 2.17.0): resolve the current page's entry by
116
+ // route, the same match the catch-all makes, so meta.pageType and the
117
+ // article dates reach the JSON-LD graph without every bespoke shell having
118
+ // to thread them. Shells that pass the props explicitly still win. Routes
119
+ // 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
+ );
125
+ const structuredData = resolveStructuredData(
126
+ { pageType, datePublished, dateModified },
127
+ pageEntry?.data.meta,
128
+ );
129
+
130
+ // Version identity: the installed package version IS the site's runtime
131
+ // version. `it-astro-starter-version` keeps emitting the same value while the
132
+ // plugin loader and the platform's page indexer still read that name, and
133
+ // retires once both read `it-site-runtime`.
134
+ //
135
+ // No utility-class word belongs in prose anywhere under src/: Tailwind's
136
+ // content scan reads whatever a brand's `@source` line points at, and a word
137
+ // in a comment becomes a real rule in every page's stylesheet. The scanned
138
+ // surface is pinned by layout-core.contract.test.ts.
139
+ ---
140
+
141
+ <!doctype html>
142
+ <html
143
+ lang={lang}
144
+ class={siteShell.htmlClass}
145
+ style={siteShell.htmlStyle}
146
+ data-theme="light"
147
+ >
148
+ <head>
149
+ <meta charset="UTF-8" />
150
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
151
+ <link rel="icon" type="image/x-icon" href="/favicon.ico" />
152
+ <meta name="generator" content={Astro.generator} />
153
+ <meta name="it-site-runtime" content={SITE_RUNTIME_VERSION} />
154
+ <meta name="it-astro-starter-version" content={SITE_RUNTIME_VERSION} />
155
+ <SEO
156
+ title={title}
157
+ description={description}
158
+ canonicalUrl={canonicalUrl}
159
+ imageUrl={imageUrl}
160
+ imageAlt={imageAlt}
161
+ noindex={noindex}
162
+ type={type}
163
+ pageType={structuredData.pageType}
164
+ datePublished={structuredData.datePublished}
165
+ dateModified={structuredData.dateModified}
166
+ siteName={siteName}
167
+ organization={{ logo: siteConfig.logo, sameAs: siteConfig.sameAs }}
168
+ jsonLd={jsonLd}
169
+ />
170
+ {
171
+ hreflang.map((alt) => (
172
+ <link rel="alternate" hreflang={alt.hreflang} href={alt.href} />
173
+ ))
174
+ }
175
+ <slot name="head" />
176
+ </head>
177
+ <body class={siteShell.bodyClass} style={siteShell.bodyStyle}>
178
+ <Shell chrome={chrome} navbar={navbar} footer={footer}>
179
+ <slot />
180
+ </Shell>
181
+ </body>
182
+ </html>
@@ -0,0 +1,141 @@
1
+ import type { PageType } from "../components/seo";
2
+ import type { ContentProps } from "../lib/content-values";
3
+
4
+ // The pure half of LayoutCore.astro: URL resolution, chrome lookup, and the
5
+ // entry-driven structured-data resolution. Everything here is a function of
6
+ // its arguments so it can be tested without an Astro render.
7
+
8
+ /**
9
+ * The brand identity the head needs: a repo's `SITE_CONFIG`. `logo` must be an
10
+ * absolute, publicly fetchable URL (crawlers 403 on signed S3 links) and
11
+ * `sameAs` holds canonical profile URLs for the brand; both enrich the
12
+ * Organization node.
13
+ */
14
+ export interface LayoutSiteConfig {
15
+ name: string;
16
+ description: string;
17
+ logo?: string;
18
+ sameAs?: string[];
19
+ }
20
+
21
+ /**
22
+ * The site's `<html>` and `<body>` attributes: a repo's `SITE_SHELL`. They sit
23
+ * outside every component, so the brand shell cannot reach them and the layout
24
+ * takes them as data instead.
25
+ */
26
+ export interface SiteShell {
27
+ htmlClass: string;
28
+ htmlStyle: string;
29
+ bodyClass: string;
30
+ bodyStyle: string;
31
+ }
32
+
33
+ /** A chrome component as the chrome collection stores it. */
34
+ export interface ChromeComponent {
35
+ id: string;
36
+ type: string;
37
+ props: ContentProps;
38
+ }
39
+
40
+ /** The structured-data fields the layout reads off an entry's `meta`. */
41
+ export interface StructuredDataFields {
42
+ pageType?: PageType;
43
+ datePublished?: string;
44
+ dateModified?: string;
45
+ }
46
+
47
+ /** The minimal page-entry shape route resolution and structured data need. */
48
+ export interface RoutedPageEntry {
49
+ data: {
50
+ route: string;
51
+ draft: boolean;
52
+ meta: StructuredDataFields;
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Absolute form of a caller-supplied URL: a `URL` passes through, a string is
58
+ * resolved against `site` when one is configured, and a bare string survives
59
+ * unchanged in a dev server with no `site`.
60
+ */
61
+ export function resolveAbsoluteUrl(
62
+ value: string | URL | undefined,
63
+ site: URL | string | undefined,
64
+ ): string | undefined {
65
+ if (!value) return undefined;
66
+ if (value instanceof URL) return value.toString();
67
+ if (site) return new URL(value, site).toString();
68
+ return value;
69
+ }
70
+
71
+ /**
72
+ * The page's canonical URL: an explicit `canonical` prop wins, otherwise the
73
+ * current path against `site`. Undefined without a configured `site`, which is
74
+ * what makes the SEO graph degrade to a link-free shape in local dev.
75
+ */
76
+ export function canonicalUrlFor(params: {
77
+ canonical?: string | URL;
78
+ pathname: string;
79
+ site: URL | string | undefined;
80
+ }): string | undefined {
81
+ return (
82
+ resolveAbsoluteUrl(params.canonical, params.site) ??
83
+ (params.site ? new URL(params.pathname, params.site).toString() : undefined)
84
+ );
85
+ }
86
+
87
+ /**
88
+ * The route value an entry would carry for this request path: the pathname
89
+ * with trailing slashes trimmed (`/pricing/` → `/pricing`), root left alone.
90
+ * The same match the catch-all makes.
91
+ */
92
+ export function routePathFromPathname(pathname: string): string {
93
+ return pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
94
+ }
95
+
96
+ /**
97
+ * The navbar/footer components of a chrome entry, by id. An ABSENT component
98
+ * (not merely empty props) is what drives the conditional mount, so this
99
+ * returns undefined per slot rather than a default.
100
+ */
101
+ export function pickChromeComponents<T extends { id: string }>(
102
+ components: T[] | undefined,
103
+ ): { navbar?: T; footer?: T } {
104
+ return {
105
+ navbar: components?.find((component) => component.id === "navbar"),
106
+ footer: components?.find((component) => component.id === "footer"),
107
+ };
108
+ }
109
+
110
+ /**
111
+ * The page entry serving a route, the same match the catch-all makes. Drafts
112
+ * resolve in dev only, so `includeDrafts` is the caller's `!import.meta.env.PROD`.
113
+ */
114
+ export function findPageEntryByRoute<T extends RoutedPageEntry>(
115
+ entries: T[],
116
+ routePath: string,
117
+ options: { includeDrafts: boolean },
118
+ ): T | undefined {
119
+ return entries.find(
120
+ (entry) =>
121
+ entry.data.route === routePath &&
122
+ (!entry.data.draft || options.includeDrafts),
123
+ );
124
+ }
125
+
126
+ /**
127
+ * Structured-data fields for the page: the entry's `meta` supplies them so no
128
+ * shell has to thread them, and an explicit prop still wins (the rare shell
129
+ * whose route has no entry). Routes without an entry fall back to a plain
130
+ * WebPage.
131
+ */
132
+ export function resolveStructuredData(
133
+ props: StructuredDataFields,
134
+ entryMeta?: StructuredDataFields,
135
+ ): StructuredDataFields {
136
+ return {
137
+ pageType: props.pageType ?? entryMeta?.pageType,
138
+ datePublished: props.datePublished ?? entryMeta?.datePublished,
139
+ dateModified: props.dateModified ?? entryMeta?.dateModified,
140
+ };
141
+ }
@@ -0,0 +1,60 @@
1
+ // Bespoke locale siblings (starter 2.10.0): a bespoke page's locale sibling
2
+ // (`home.es.json`, route `/es`) has no hand-written shell — the catch-all
3
+ // renders it through the SAME React page component as its base
4
+ // (`src/components/pages/<base>/page.tsx`), resolved via an import.meta.glob
5
+ // registry, with the sibling's translated `components`. These are the pure
6
+ // pieces of that lookup; the glob itself must stay a literal inside
7
+ // src/pages/[...slug].astro (Vite resolves glob patterns relative to the
8
+ // importing module).
9
+
10
+ /** The loose component shape the registry hands the catch-all. */
11
+ export type BespokePageComponent = (props: {
12
+ components: unknown[];
13
+ }) => unknown;
14
+
15
+ /**
16
+ * The base page name a registry module path belongs to:
17
+ * `../components/pages/home/page.tsx` → `home`. Undefined for anything that
18
+ * isn't a `pages/<base>/page.tsx` module.
19
+ */
20
+ export function basePageFromModulePath(path: string): string | undefined {
21
+ return /\/components\/pages\/([^/]+)\/page\.tsx$/.exec(path)?.[1];
22
+ }
23
+
24
+ /**
25
+ * The import specifier bespoke shells use for their page component. In a
26
+ * production build this exact string is the manifest key hydration resolves
27
+ * the island's component-url through: the base shell's static
28
+ * `<Page client:load />` is what puts the component in the client bundle,
29
+ * keyed by its import specifier — the sibling's island points at the same
30
+ * bundle by passing the same specifier as `client:component-path`.
31
+ * Load-bearing: shells import their page component as
32
+ * `@/components/pages/<base>/page` and hydrate it with `client:load` (the
33
+ * documented bespoke pattern) or their locale siblings cannot hydrate.
34
+ */
35
+ export function pageComponentSpecifier(base: string): string {
36
+ return `@/components/pages/${base}/page`;
37
+ }
38
+
39
+ /**
40
+ * Pick the page component out of a `page.tsx` module: the default export when
41
+ * present, else the module's single function export (the documented pattern
42
+ * is one named export, e.g. `HomePage`). Undefined when nothing usable is
43
+ * exported — the caller treats that like a registry miss.
44
+ */
45
+ export function pickPageExport(
46
+ mod: Record<string, unknown>,
47
+ ): { exportName: string; Component: BespokePageComponent } | undefined {
48
+ if (typeof mod.default === "function") {
49
+ return {
50
+ exportName: "default",
51
+ Component: mod.default as BespokePageComponent,
52
+ };
53
+ }
54
+ const fns = Object.entries(mod).filter(
55
+ ([, value]) => typeof value === "function",
56
+ );
57
+ if (fns.length !== 1) return undefined;
58
+ const [exportName, Component] = fns[0];
59
+ return { exportName, Component: Component as BespokePageComponent };
60
+ }
@@ -0,0 +1,266 @@
1
+ import { z } from "astro/zod";
2
+
3
+ import {
4
+ arrayOf,
5
+ configTokenSchema,
6
+ contentLeafSchema,
7
+ linkContentSchema,
8
+ textContentSchema,
9
+ } from "./content-values";
10
+
11
+ // Props schemas for the prebuilt chrome components (navbar / footer). Chrome
12
+ // copy lives in src/content/chrome.json under the same content-value grammar
13
+ // as page copy; these schemas lock the exact shape the chrome extractor must
14
+ // emit and the exact shape navbar.tsx / footer.tsx render. Unlike registered
15
+ // SECTIONS, chrome types are NOT added to REGISTERED_SECTION_PROPS — that
16
+ // would make them page-insertable — so the chrome collection validates them
17
+ // through CHROME_COMPONENT_PROPS instead (../content/schema.ts). The brand
18
+ // repo passes this map into createCollections; the shapes are platform-owned.
19
+
20
+ const text = textContentSchema;
21
+ const link = linkContentSchema;
22
+
23
+ // A nav link is a link wrapper plus an optional trigger slug. When menuRef is
24
+ // present the item opens a dropdown whose contents live in the top-level field
25
+ // menuFieldKey(menuRef). The slug is a config token (lowercase), so it survives
26
+ // configTokenSchema; the derivation guarantees the field key is propKey-safe.
27
+ const navLinkItemSchema = z
28
+ .object({
29
+ label: link,
30
+ menuRef: configTokenSchema.optional(),
31
+ icon: configTokenSchema.optional(),
32
+ })
33
+ .strict();
34
+
35
+ // A dropdown group's items: a link plus optional supporting description.
36
+ const menuItemSchema = { label: link, description: text.optional() };
37
+
38
+ // A footer column / legal item: just a link.
39
+ const linkItemSchema = { label: link };
40
+
41
+ // A social item: a link plus an icon token (configToken → lucide map).
42
+ const socialItemSchema = { label: link, icon: configTokenSchema };
43
+
44
+ // Derive the dropdown field key for a trigger slug. The lowercase slug
45
+ // (`products`, `for-teams`) maps to a camelCase top-level field
46
+ // (`menuProducts`, `menuForTeams`). The `menu` prefix guarantees a leading
47
+ // letter, so the result always satisfies propKeySchema. Both the emitter
48
+ // (CLONE-CHR-2) and navbar.tsx call this — one derivation, no drift.
49
+ export function menuFieldKey(slug: string): string {
50
+ const pascal = slug
51
+ .split(/[^a-z0-9]+/)
52
+ .filter(Boolean)
53
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
54
+ .join("");
55
+ return `menu${pascal}`;
56
+ }
57
+
58
+ const MENU_FIELD = /^menu[A-Z0-9]/;
59
+ const COLUMN_FIELD = /^column[A-Z0-9]/;
60
+ const COLUMN_TITLE_FIELD = /^column[A-Z0-9][A-Za-z0-9]*Title$/;
61
+
62
+ // Fixed navbar fields. Dynamic menu* dropdown fields arrive via .catchall and
63
+ // are validated in the superRefine below (a base .object cannot express
64
+ // "unknown keys must match a pattern AND parse as this item schema").
65
+ const navbarBaseSchema = z
66
+ .object({
67
+ logo: contentLeafSchema.optional(),
68
+ menuToggleLabel: text,
69
+ links: arrayOf(navLinkItemSchema.shape),
70
+ ctas: arrayOf(linkItemSchema).optional(),
71
+ collapse: z.enum(["sm", "md", "lg", "xl", "always"]).optional(),
72
+ sticky: z.boolean().optional(),
73
+ background: z.enum(["solid", "transparent", "solid-on-scroll"]).optional(),
74
+ searchAction: link.optional(),
75
+ searchPlaceholder: text.optional(),
76
+ })
77
+ .catchall(contentLeafSchema);
78
+
79
+ const NAVBAR_FIXED_KEYS = new Set([
80
+ "logo",
81
+ "menuToggleLabel",
82
+ "links",
83
+ "ctas",
84
+ "collapse",
85
+ "sticky",
86
+ "background",
87
+ "searchAction",
88
+ "searchPlaceholder",
89
+ ]);
90
+
91
+ export const navbarPropsSchema = navbarBaseSchema.superRefine((props, ctx) => {
92
+ const menuFields = new Set<string>();
93
+
94
+ for (const key of Object.keys(props)) {
95
+ if (NAVBAR_FIXED_KEYS.has(key)) continue;
96
+ if (!MENU_FIELD.test(key)) {
97
+ ctx.addIssue({
98
+ code: "custom",
99
+ path: [key],
100
+ message: `unknown navbar prop "${key}" — dropdown fields must match /^menu[A-Z0-9]/ (see menuFieldKey)`,
101
+ });
102
+ continue;
103
+ }
104
+ const result = arrayOf(menuItemSchema).safeParse(props[key]);
105
+ if (!result.success) {
106
+ for (const issue of result.error.issues) {
107
+ ctx.addIssue({ ...issue, path: [key, ...issue.path] });
108
+ }
109
+ }
110
+ menuFields.add(key);
111
+ }
112
+
113
+ // Every menuRef must resolve to a derived dropdown field. This is what locks
114
+ // the emitted shape: zod rejects a link that references a dropdown that
115
+ // isn't there.
116
+ const links = props.links;
117
+ if (links && typeof links === "object" && "items" in links) {
118
+ (links.items as Array<{ menuRef?: string }>).forEach((item, index) => {
119
+ if (item.menuRef === undefined) return;
120
+ const field = menuFieldKey(item.menuRef);
121
+ if (!menuFields.has(field)) {
122
+ ctx.addIssue({
123
+ code: "custom",
124
+ path: ["links", "items", index, "menuRef"],
125
+ message: `menuRef "${item.menuRef}" has no matching dropdown field "${field}" — add a top-level "${field}" array`,
126
+ });
127
+ }
128
+ });
129
+ }
130
+ });
131
+
132
+ // Fixed footer fields. Dynamic column* arrays and column*Title text fields
133
+ // arrive via .catchall and are validated in the superRefine.
134
+ const footerBaseSchema = z
135
+ .object({
136
+ logo: contentLeafSchema.optional(),
137
+ blurb: text.optional(),
138
+ copyright: text.optional(),
139
+ band: z.enum(["default", "inverse", "accent"]).optional(),
140
+ legal: arrayOf(linkItemSchema).optional(),
141
+ social: arrayOf(socialItemSchema).optional(),
142
+ newsletterHeading: text.optional(),
143
+ newsletterPlaceholder: text.optional(),
144
+ newsletterCta: link.optional(),
145
+ })
146
+ .catchall(contentLeafSchema);
147
+
148
+ const FOOTER_FIXED_KEYS = new Set([
149
+ "logo",
150
+ "blurb",
151
+ "copyright",
152
+ "band",
153
+ "legal",
154
+ "social",
155
+ "newsletterHeading",
156
+ "newsletterPlaceholder",
157
+ "newsletterCta",
158
+ ]);
159
+
160
+ export const footerPropsSchema = footerBaseSchema.superRefine((props, ctx) => {
161
+ for (const key of Object.keys(props)) {
162
+ if (FOOTER_FIXED_KEYS.has(key)) continue;
163
+ if (COLUMN_TITLE_FIELD.test(key)) {
164
+ const result = text.safeParse(props[key]);
165
+ if (!result.success) {
166
+ for (const issue of result.error.issues) {
167
+ ctx.addIssue({ ...issue, path: [key, ...issue.path] });
168
+ }
169
+ }
170
+ // A <field>Title with no matching <field> column is a dangling title.
171
+ const column = key.slice(0, -"Title".length);
172
+ if (!(column in props)) {
173
+ ctx.addIssue({
174
+ code: "custom",
175
+ path: [key],
176
+ message: `"${key}" has no matching column field "${column}"`,
177
+ });
178
+ }
179
+ continue;
180
+ }
181
+ if (!COLUMN_FIELD.test(key)) {
182
+ ctx.addIssue({
183
+ code: "custom",
184
+ path: [key],
185
+ message: `unknown footer prop "${key}" — column fields must match /^column[A-Z0-9]/`,
186
+ });
187
+ continue;
188
+ }
189
+ const result = arrayOf(linkItemSchema).safeParse(props[key]);
190
+ if (!result.success) {
191
+ for (const issue of result.error.issues) {
192
+ ctx.addIssue({ ...issue, path: [key, ...issue.path] });
193
+ }
194
+ }
195
+ }
196
+ });
197
+
198
+ // Chrome id → props schema. The chrome collection (../content/schema.ts)
199
+ // validates navbar/footer components against these. A chrome component whose id is not one
200
+ // of these is a hard schema error (chromeIdIssues below) — the layout mounts
201
+ // ONLY these two ids, so any other entry would validate but render nothing.
202
+ export const CHROME_COMPONENT_PROPS = {
203
+ navbar: navbarPropsSchema,
204
+ footer: footerPropsSchema,
205
+ } as const;
206
+
207
+ export type ChromeComponentType = keyof typeof CHROME_COMPONENT_PROPS;
208
+
209
+ export const isChromeComponent = (type: string): type is ChromeComponentType =>
210
+ type in CHROME_COMPONENT_PROPS;
211
+
212
+ // The only ids the layout mounts (LayoutCore resolves chrome by id and mounts
213
+ // navbar + footer). Anything else in chrome.json validates-but-renders-nothing,
214
+ // so it must be a loud error, not a silent no-op.
215
+ export const KNOWN_CHROME_IDS = ["navbar", "footer"] as const;
216
+
217
+ // A replicated chrome component (clone emit): the navbar/footer copy lives in
218
+ // bespoke content-value props (validated by componentSchema like any bespoke
219
+ // page component), not the prebuilt navbar/footer prop grammar. It still mounts
220
+ // by id, so its id must be a known chrome id — but its type is "replicated",
221
+ // not "navbar"/"footer", and it is exempt from the CHROME_COMPONENT_PROPS shape.
222
+ export const REPLICATED_CHROME_TYPE = "replicated";
223
+
224
+ // Validate a chrome.json components[] list's ids against the mount contract:
225
+ // every id must be navbar|footer, and a known id's `type` must equal its `id`
226
+ // (an {id:"navbar", type:"footer"} cross-wire validates the wrong props and
227
+ // still renders nothing) OR be the replicated type (clone-emitted chrome whose
228
+ // copy rides bespoke props). Returns actionable issues steering extra bands to
229
+ // page-adjacent sections; pure, so it is unit-testable without the astro
230
+ // collection (collections.ts imports astro virtual modules and cannot be).
231
+ export function chromeIdIssues(
232
+ components: { id: string; type: string }[],
233
+ ): { index: number; id: string; message: string }[] {
234
+ const issues: { index: number; id: string; message: string }[] = [];
235
+ const known = new Set<string>(KNOWN_CHROME_IDS);
236
+ components.forEach((component, index) => {
237
+ if (!known.has(component.id)) {
238
+ issues.push({
239
+ index,
240
+ id: component.id,
241
+ message: `chrome.json renders only the "navbar" and "footer" components — "${component.id}" would validate but never mount. Put an announcement/utility bar or any extra band in a page section adjacent to the chrome (see AGENTS.md, "Chrome-adjunct convention").`,
242
+ });
243
+ return;
244
+ }
245
+ if (
246
+ component.type !== component.id &&
247
+ component.type !== REPLICATED_CHROME_TYPE
248
+ ) {
249
+ issues.push({
250
+ index,
251
+ id: component.id,
252
+ message: `chrome component "${component.id}" must have type "${component.id}" (or "${REPLICATED_CHROME_TYPE}" for clone-emitted chrome), not "${component.type}" — a mismatched type validates the wrong props and still renders nothing.`,
253
+ });
254
+ }
255
+ });
256
+ return issues;
257
+ }
258
+
259
+ // The ordered column fields on a footer's props, in entry key order (JS object
260
+ // insertion order, preserved through zod). footer.tsx iterates these; the
261
+ // paired <field>Title (if present) supplies the column heading.
262
+ export function footerColumnKeys(props: Record<string, unknown>): string[] {
263
+ return Object.keys(props).filter(
264
+ (key) => COLUMN_FIELD.test(key) && !COLUMN_TITLE_FIELD.test(key),
265
+ );
266
+ }
@@ -0,0 +1,23 @@
1
+ import { getEntry } from "astro:content";
2
+
3
+ import type { ContentProps } from "./content-values";
4
+
5
+ // Typed access to a site-level chrome component's props
6
+ // (src/content/chrome.json). Chrome copy (nav labels, hrefs, footer text)
7
+ // lives there so it stays editable and translatable like all other copy.
8
+ //
9
+ // LayoutCore mounts the brand's navbar/footer with getEntry (not this
10
+ // helper) so it can tell an ABSENT component from empty props and skip the
11
+ // mount. Use getChromeProps in a BESPOKE .astro page that renders chrome
12
+ // outside Layout:
13
+ //
14
+ // const navbar = await getChromeProps("navbar");
15
+ // <Navbar data={navbar} client:load />
16
+ //
17
+ // It returns {} for a missing component, so it cannot drive a conditional
18
+ // mount — pass a present component's props.
19
+ export async function getChromeProps(id: string): Promise<ContentProps> {
20
+ const chrome = await getEntry("chrome", "chrome");
21
+ const component = chrome?.data.components.find((c) => c.id === id);
22
+ return component?.props ?? {};
23
+ }
@@ -0,0 +1,16 @@
1
+ // Where brand content lives, relative to the project root. One pair of defaults
2
+ // shared by every consumer of them: the collection loaders (../content), the
3
+ // sitemap's path source (./sitemap) and the config preset (../config). A repo
4
+ // that moves its content passes the same `pagesDir` to both, so the collection
5
+ // and the sitemap can never disagree about where the entries are.
6
+
7
+ /** Page entry directory (`<page>.json`, `<page>.<locale>.json`). */
8
+ export const DEFAULT_PAGES_DIR = "src/content/pages";
9
+
10
+ /** Directory holding chrome.json and its locale siblings. */
11
+ export const DEFAULT_CHROME_DIR = "src/content";
12
+
13
+ /** A root-relative content dir in the form Astro's glob loader `base` takes. */
14
+ export function loaderBase(dir: string): string {
15
+ return dir.startsWith(".") || dir.startsWith("/") ? dir : `./${dir}`;
16
+ }