@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.
- package/README.md +30 -0
- package/bin/site-runtime.mjs +46 -0
- package/docs/runtime-contract.md +324 -0
- package/package.json +84 -0
- package/scripts/scan-bespoke-siblings.mjs +191 -0
- package/scripts/scan-copy.mjs +204 -0
- package/scripts/scan-island-imports.mjs +207 -0
- package/scripts/verify.mjs +283 -0
- package/src/components/seo-json.tsx +157 -0
- package/src/components/seo.tsx +294 -0
- package/src/config/preset.ts +198 -0
- package/src/content/collections.ts +71 -0
- package/src/content/schema.ts +239 -0
- package/src/index.ts +22 -0
- package/src/integrations/iterant-plugins.mjs +83 -0
- package/src/integrations/new-file-reload.mjs +95 -0
- package/src/integrations/preview-error-shell.mjs +145 -0
- package/src/layouts/LayoutCore.astro +182 -0
- package/src/layouts/layout-core.ts +141 -0
- package/src/lib/bespoke-pages.ts +60 -0
- package/src/lib/chrome-schemas.ts +266 -0
- package/src/lib/chrome.ts +23 -0
- package/src/lib/content-paths.ts +16 -0
- package/src/lib/content-values.ts +201 -0
- package/src/lib/hreflang.ts +123 -0
- package/src/lib/locales.ts +92 -0
- package/src/lib/sitemap/get-sitemap-paths.ts +65 -0
- package/src/lib/sitemap/index.ts +19 -0
- package/src/lib/sitemap/routes.ts +141 -0
- package/src/lib/sitemap/shared.ts +95 -0
- package/src/lib/sitemap/sitemap-with-custom-pages-plugin.ts +74 -0
- package/src/routes/UnderConstruction.astro +43 -0
- package/src/routes/index.ts +11 -0
- package/src/routes/llms-txt.ts +68 -0
- package/src/routes/robots-txt.ts +56 -0
- 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
|
+
}
|