@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,204 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ /**
4
+ * Scan component code for hardcoded visible copy.
5
+ *
6
+ * The project convention (AGENTS.md "Content") is that no visible string
7
+ * lives in TSX — copy comes from JSON entries via typed props. This scanner
8
+ * finds violations with the TypeScript AST (not grep — class strings,
9
+ * imports and icon names would drown a text search):
10
+ *
11
+ * - JSX text nodes containing letters: <h1>Pricing plans</h1>
12
+ * - String-literal JSX expression children: <h1>{"Pricing plans"}</h1>
13
+ * - Prose-looking string attributes: aria-label="Close menu"
14
+ * (multi-word values starting with an uppercase letter; code-bearing
15
+ * attributes like className/href/src are exempt)
16
+ * - Literal search terms in string transforms:
17
+ * heading.replace("agentssss", "agents")
18
+ * A transform's operands come from entry fields, never TSX literals —
19
+ * a lettered literal here rewrites copy at render time, so the entry,
20
+ * the editor, and the rendered page disagree (typo masking, hardcoded
21
+ * highlight targets). Letterless separators (.replace(" ", "<br/>"),
22
+ * .replace(/\s+/g, "-")) stay legal.
23
+ *
24
+ * Output: one `file:line: "text"` per finding, empty on a clean tree.
25
+ * Always exits 0 — the caller decides severity (the platform save gate
26
+ * reports findings as warnings).
27
+ */
28
+
29
+ import { readdirSync, readFileSync } from "node:fs";
30
+ import { join } from "node:path";
31
+ import ts from "typescript";
32
+
33
+ const ROOTS = ["src/components", "src/pages"];
34
+ const MAX_FINDINGS = 20;
35
+
36
+ // Attributes whose values are code/config, never copy.
37
+ const CODE_ATTRIBUTES = new Set([
38
+ "className",
39
+ "class",
40
+ "id",
41
+ "key",
42
+ "href",
43
+ "src",
44
+ "srcSet",
45
+ "sizes",
46
+ "target",
47
+ "rel",
48
+ "type",
49
+ "name",
50
+ "value",
51
+ "loading",
52
+ "decoding",
53
+ "width",
54
+ "height",
55
+ "viewBox",
56
+ "fill",
57
+ "stroke",
58
+ "strokeWidth",
59
+ "strokeLinecap",
60
+ "strokeLinejoin",
61
+ "d",
62
+ "points",
63
+ "xmlns",
64
+ "style",
65
+ "lang",
66
+ "dir",
67
+ "role",
68
+ "method",
69
+ "action",
70
+ "autoComplete",
71
+ "inputMode",
72
+ ]);
73
+
74
+ /** @param {string} dir @returns {string[]} */
75
+ function tsxFiles(dir) {
76
+ /** @type {string[]} */
77
+ const out = [];
78
+ let entries;
79
+ try {
80
+ entries = readdirSync(dir, { withFileTypes: true });
81
+ } catch {
82
+ return out;
83
+ }
84
+ for (const entry of entries) {
85
+ const path = join(dir, entry.name);
86
+ if (entry.isDirectory()) out.push(...tsxFiles(path));
87
+ else if (entry.name.endsWith(".tsx")) out.push(path);
88
+ }
89
+ return out;
90
+ }
91
+
92
+ /** @param {string} text */
93
+ function hasLetters(text) {
94
+ // HTML entities (&ldquo; &amp;) are punctuation, not copy.
95
+ return /[a-zA-Z]{2,}/.test(text.replace(/&#?[a-zA-Z0-9]+;/g, ""));
96
+ }
97
+
98
+ /** Prose heuristic for attribute values: multi-word, uppercase start. */
99
+ /** @param {string} text */
100
+ function looksLikeProse(text) {
101
+ return /^[A-Z].*\s\S/.test(text.trim());
102
+ }
103
+
104
+ /**
105
+ * Lettered literal search operand of .replace()/.replaceAll(). Legitimate
106
+ * operands are entry-field references (identifiers, member access) — a
107
+ * lettered literal rewrites copy at render time. Returns the literal text,
108
+ * or undefined when the argument is not one.
109
+ * @param {ts.Node} arg @returns {string | undefined}
110
+ */
111
+ function literalSearchTerm(arg) {
112
+ if (ts.isStringLiteral(arg) || ts.isNoSubstitutionTemplateLiteral(arg)) {
113
+ return hasLetters(arg.text) ? arg.text : undefined;
114
+ }
115
+ if (ts.isRegularExpressionLiteral(arg)) {
116
+ // /pattern/flags — flags are single letters; strip before testing.
117
+ const pattern = arg.text.replace(/\/[a-z]*$/, "").slice(1);
118
+ return hasLetters(pattern) ? pattern : undefined;
119
+ }
120
+ return undefined;
121
+ }
122
+
123
+ /** @type {string[]} */
124
+ const findings = [];
125
+
126
+ /** @param {ts.SourceFile} source @param {ts.Node} node @param {string} text */
127
+ function report(source, node, text) {
128
+ const { line } = source.getLineAndCharacterOfPosition(node.getStart(source));
129
+ const preview = text.trim().replace(/\s+/g, " ").slice(0, 60);
130
+ findings.push(`${source.fileName}:${line + 1}: "${preview}"`);
131
+ }
132
+
133
+ /** @param {ts.SourceFile} source */
134
+ function scan(source) {
135
+ /** @param {ts.Node} node */
136
+ const visit = (node) => {
137
+ if (ts.isJsxText(node)) {
138
+ if (hasLetters(node.text)) report(source, node, node.text);
139
+ } else if (
140
+ ts.isJsxExpression(node) &&
141
+ node.expression &&
142
+ (ts.isStringLiteral(node.expression) ||
143
+ ts.isNoSubstitutionTemplateLiteral(node.expression)) &&
144
+ node.parent &&
145
+ (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent)) &&
146
+ hasLetters(node.expression.text)
147
+ ) {
148
+ report(source, node, node.expression.text);
149
+ } else if (
150
+ ts.isJsxAttribute(node) &&
151
+ node.initializer &&
152
+ ts.isStringLiteral(node.initializer) &&
153
+ !CODE_ATTRIBUTES.has(node.name.getText(source)) &&
154
+ !node.name.getText(source).startsWith("data-") &&
155
+ !node.name.getText(source).startsWith("aria-hidden") &&
156
+ looksLikeProse(node.initializer.text)
157
+ ) {
158
+ report(source, node, node.initializer.text);
159
+ } else if (
160
+ ts.isCallExpression(node) &&
161
+ ts.isPropertyAccessExpression(node.expression) &&
162
+ /^replace(All)?$/.test(node.expression.name.text) &&
163
+ node.arguments.length > 0
164
+ ) {
165
+ const term = literalSearchTerm(node.arguments[0]);
166
+ if (term !== undefined) {
167
+ const shown = term.length > 12 ? `${term.slice(0, 12)}…` : term;
168
+ report(
169
+ source,
170
+ node.arguments[0],
171
+ `.replace("${shown}") — term must be an entry field`,
172
+ );
173
+ }
174
+ }
175
+ ts.forEachChild(node, visit);
176
+ };
177
+ visit(source);
178
+ }
179
+
180
+ // With file arguments, scan exactly those files (the platform's builder
181
+ // gate scans one just-written component); with none, walk ROOTS.
182
+ const argFiles = process.argv.slice(2);
183
+ const files =
184
+ argFiles.length > 0 ? argFiles : ROOTS.flatMap((root) => tsxFiles(root));
185
+
186
+ for (const file of files) {
187
+ {
188
+ const source = ts.createSourceFile(
189
+ file,
190
+ readFileSync(file, "utf8"),
191
+ ts.ScriptTarget.Latest,
192
+ true,
193
+ ts.ScriptKind.TSX,
194
+ );
195
+ scan(source);
196
+ }
197
+ }
198
+
199
+ if (findings.length > 0) {
200
+ for (const finding of findings.slice(0, MAX_FINDINGS)) console.log(finding);
201
+ if (findings.length > MAX_FINDINGS) {
202
+ console.log(`… and ${findings.length - MAX_FINDINGS} more`);
203
+ }
204
+ }
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ /**
4
+ * Island-import gate. Registered sections are server-rendered, never
5
+ * hydrated — the registry map cannot trace client:* directives, so an
6
+ * island-grade ui primitive imported from src/components/sections/ renders
7
+ * a DEAD widget with zero build signal. The grade of each primitive lives
8
+ * in src/components/ui/index.ts (UI_PRIMITIVE_GRADES); this gate fails the
9
+ * build when any file under src/components/sections/ imports an
10
+ * island-grade module from src/components/ui/.
11
+ *
12
+ * Caught (verify fails):
13
+ * - direct module imports, alias or relative at any depth:
14
+ * "@/components/ui/tabs", "../ui/carousel", "../../ui/tabs"
15
+ * - barrel imports that name an island primitive:
16
+ * import { Tabs } from "@/components/ui" (the barrel re-exports them;
17
+ * the import is resolved to names, so { Accordion } via barrel passes)
18
+ * - namespace/star imports of the barrel or an island module
19
+ * (import * as UI / export * — can't prove islands go unused)
20
+ *
21
+ * Ignored (no false positive):
22
+ * - `import type { ... }` and specifier lists that are all `type X`
23
+ * (erased at build — no runtime dependency, no dead widget)
24
+ *
25
+ * KNOWN LIMITATION — transitive imports are out of scope: a section that
26
+ * imports a helper module which itself imports an island primitive is NOT
27
+ * caught. Tracing the full module graph belongs to a bundler, not a regex
28
+ * gate; the helper indirection is rare and reviewable. If it bites, the
29
+ * fix is the same as for a direct import: move the section to a bespoke
30
+ * page with a page-private component.
31
+ *
32
+ * Runs inside `bun run verify` (imported by scripts/verify.mjs) and
33
+ * standalone: `node scripts/scan-island-imports.mjs [rootDir]`.
34
+ */
35
+
36
+ import { readdir, readFile } from "node:fs/promises";
37
+ import { basename, dirname, join, resolve } from "node:path";
38
+ import { pathToFileURL } from "node:url";
39
+
40
+ /**
41
+ * import/export-from statements, single or multi line:
42
+ * import <clause> from "<spec>" export <clause> from "<spec>"
43
+ * m[1] keyword, m[2] clause (may start with `type`), m[3] spec.
44
+ */
45
+ const IMPORT_FROM_PATTERN =
46
+ /(?:^|[\n;])\s*(import|export)\s+([\s\S]*?)\s*from\s+["']([^"']+)["']/g;
47
+
48
+ /**
49
+ * What a clause actually binds at runtime after TS erasure.
50
+ *
51
+ * @param {string} clause
52
+ * @returns {{ erased: boolean; namespace: boolean; names: string[] }}
53
+ */
54
+ function parseClause(clause) {
55
+ const trimmed = clause.trim();
56
+ // `import type { ... }` / `export type { ... }` / `import type * as ns`
57
+ if (/^type[\s{]/.test(trimmed)) {
58
+ return { erased: true, namespace: false, names: [] };
59
+ }
60
+ const namespace = trimmed.includes("*");
61
+ const braces = trimmed.match(/\{([^}]*)\}/);
62
+ const names = braces
63
+ ? braces[1]
64
+ .split(",")
65
+ .map((s) => s.trim())
66
+ .filter(Boolean)
67
+ .filter((s) => !/^type\s/.test(s))
68
+ .map((s) => s.split(/\s+as\s+/)[0].trim())
69
+ : [];
70
+ const hasDefault = /^[A-Za-z_$][\w$]*\s*(,|$)/.test(trimmed);
71
+ // `import { type Foo } from ...` with no value bindings is erased too.
72
+ const erased =
73
+ !namespace && !hasDefault && braces !== null && names.length === 0;
74
+ return { erased, namespace, names };
75
+ }
76
+
77
+ /**
78
+ * Resolve an import specifier to an absolute, extensionless path — or null
79
+ * for bare package specifiers.
80
+ *
81
+ * @param {string} spec
82
+ * @param {string} fromFile absolute path of the importing file
83
+ * @param {string} srcDir absolute path of <root>/src (the `@/` alias target)
84
+ */
85
+ function resolveSpec(spec, fromFile, srcDir) {
86
+ let abs = null;
87
+ if (spec.startsWith("@/")) abs = join(srcDir, spec.slice(2));
88
+ else if (spec.startsWith(".")) abs = resolve(dirname(fromFile), spec);
89
+ if (abs === null) return null;
90
+ return abs.replace(/\.(tsx|ts|jsx|js|mjs)$/, "");
91
+ }
92
+
93
+ /**
94
+ * @param {string} rootDir project root (contains src/)
95
+ * @returns {Promise<string[]>} human-readable problems, empty when clean
96
+ */
97
+ export async function checkIslandImports(rootDir = process.cwd()) {
98
+ /** @type {string[]} */
99
+ const problems = [];
100
+ const srcDir = join(rootDir, "src");
101
+ const uiDir = join(srcDir, "components", "ui");
102
+
103
+ // Parse the grade manifest ("<name>: \"island\"") from the ui barrel.
104
+ let manifest = "";
105
+ try {
106
+ manifest = await readFile(join(uiDir, "index.ts"), "utf8");
107
+ } catch {
108
+ return problems; // no ui barrel — nothing to gate
109
+ }
110
+ const islands = new Set(
111
+ [...manifest.matchAll(/(\w+):\s*"island"/g)].map((m) => m[1]),
112
+ );
113
+ if (islands.size === 0) return problems;
114
+
115
+ // Resolve which of the barrel's re-exported NAMES are island-grade, so a
116
+ // barrel import is judged by what it binds, not by the path alone.
117
+ const islandNames = new Set();
118
+ for (const m of manifest.matchAll(
119
+ /export\s+(type\s+)?\{([^}]*)\}\s+from\s+["']\.\/([\w-]+)["']/g,
120
+ )) {
121
+ if (m[1] || !islands.has(m[3])) continue;
122
+ for (const spec of m[2].split(",")) {
123
+ const s = spec.trim();
124
+ if (!s || /^type\s/.test(s)) continue;
125
+ // exported name is the alias when present: `X as Y` exports Y
126
+ const parts = s.split(/\s+as\s+/);
127
+ islandNames.add(parts[parts.length - 1].trim());
128
+ }
129
+ }
130
+
131
+ const fix =
132
+ `Registered sections are server-rendered and never hydrate, so this widget would be dead on the page. ` +
133
+ `Move the section to a bespoke page ("mode": "bespoke" in the entry) with a page-private component under src/components/pages/<page>/, or use an ssr-grade primitive.`;
134
+
135
+ const sectionsDir = join(srcDir, "components", "sections");
136
+ /** @param {string} dir */
137
+ async function walk(dir) {
138
+ /** @type {import("node:fs").Dirent[]} */
139
+ let entries = [];
140
+ try {
141
+ entries = await readdir(dir, { withFileTypes: true });
142
+ } catch {
143
+ return;
144
+ }
145
+ for (const entry of entries) {
146
+ const path = join(dir, entry.name);
147
+ if (entry.isDirectory()) {
148
+ await walk(path);
149
+ continue;
150
+ }
151
+ if (!/\.(tsx|ts)$/.test(entry.name)) continue;
152
+ const content = await readFile(path, "utf8");
153
+ for (const match of content.matchAll(IMPORT_FROM_PATTERN)) {
154
+ const clause = parseClause(match[2]);
155
+ if (clause.erased) continue; // type-only: gone at build time
156
+ const resolved = resolveSpec(match[3], path, srcDir);
157
+ if (resolved === null) continue;
158
+
159
+ if (resolved === uiDir || resolved === join(uiDir, "index")) {
160
+ // Barrel import: judge by the bound names.
161
+ if (clause.namespace) {
162
+ problems.push(
163
+ `${path}: namespace-imports the ui barrel ("${match[3]}"), which re-exports island-grade primitives. ` +
164
+ `Import the ssr-grade primitives you need by name instead. ${fix}`,
165
+ );
166
+ continue;
167
+ }
168
+ const bad = clause.names.filter((n) => islandNames.has(n));
169
+ if (bad.length > 0) {
170
+ problems.push(
171
+ `${path}: imports the island-grade ui primitive(s) ${bad.map((n) => `"${n}"`).join(", ")} via the ui barrel ("${match[3]}"). ${fix}`,
172
+ );
173
+ }
174
+ continue;
175
+ }
176
+
177
+ if (dirname(resolved) === uiDir) {
178
+ const module = basename(resolved);
179
+ if (islands.has(module)) {
180
+ problems.push(
181
+ `${path}: imports the island-grade ui primitive "${module}". ${fix}`,
182
+ );
183
+ }
184
+ }
185
+ }
186
+ }
187
+ }
188
+ await walk(sectionsDir);
189
+ return problems;
190
+ }
191
+
192
+ // Standalone CLI: `node scripts/scan-island-imports.mjs [rootDir]`.
193
+ // Prints problems to stderr and exits 1 when any are found.
194
+ if (
195
+ process.argv[1] &&
196
+ import.meta.url === pathToFileURL(resolve(process.argv[1])).href
197
+ ) {
198
+ const problems = await checkIslandImports(
199
+ process.argv[2] ? resolve(process.argv[2]) : process.cwd(),
200
+ );
201
+ if (problems.length > 0) {
202
+ process.stderr.write(
203
+ `island-import check failed:\n${problems.join("\n")}\n`,
204
+ );
205
+ process.exit(1);
206
+ }
207
+ }
@@ -0,0 +1,283 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ /**
4
+ * Verify the project compiles and builds cleanly.
5
+ *
6
+ * Runs `astro check` followed by `astro build`. Designed to be called by
7
+ * agents, CI, or humans — the output rules are the same either way:
8
+ *
9
+ * - No ANSI escape codes in the final output. We set NO_COLOR=1 and
10
+ * FORCE_COLOR=0 so anything that honors those (astro CLI, vite, tsc,
11
+ * rollup) emits plain text. We *also* strip ANSI post-hoc from the
12
+ * captured buffer, because @astrojs/check's diagnostic formatter
13
+ * bypasses NO_COLOR and TTY detection and writes raw SGR codes
14
+ * regardless. Belt-and-suspenders on purpose.
15
+ *
16
+ * - stdout and stderr are captured into one buffer, roughly interleaved
17
+ * as they arrive. Astro writes diagnostics to stderr and progress to
18
+ * stdout; a caller who only reads one stream gets half the story and
19
+ * has to retry with `2>&1`.
20
+ *
21
+ * - Silent on success, verbose on failure. A successful verify exits 0
22
+ * and prints nothing (not even a trailing newline). A failed verify
23
+ * prints the full captured output of the step that failed, then
24
+ * exits with that step's exit code. Saves tokens in agent tool
25
+ * results and puts the useful output front-and-center.
26
+ *
27
+ * Run from the repo root via `bun run verify` / `npm run verify` (which map to
28
+ * `site-runtime verify`). Every path below resolves against process.cwd(), the
29
+ * repo being verified — the gates themselves live in node_modules.
30
+ */
31
+
32
+ import { spawn } from "node:child_process";
33
+ import { delimiter, join } from "node:path";
34
+ import { exit } from "node:process";
35
+ import { fileURLToPath } from "node:url";
36
+
37
+ /**
38
+ * A sibling gate's absolute path, wherever the package is installed.
39
+ * @param {string} name
40
+ */
41
+ const gate = (name) => fileURLToPath(new URL(name, import.meta.url));
42
+
43
+ const env = {
44
+ ...process.env,
45
+ // The repo's own binaries (astro, eslint) resolve by name. Package-manager
46
+ // `run` scripts already put node_modules/.bin on PATH; prepending it here
47
+ // means a direct `site-runtime verify` invocation works the same way.
48
+ PATH: [
49
+ join(process.cwd(), "node_modules", ".bin"),
50
+ process.env.PATH ?? "",
51
+ ].join(delimiter),
52
+ // Disable ANSI at the source for anything that honors these. astro CLI,
53
+ // vite, tsc, rollup all do. @astrojs/check's diagnostic formatter does
54
+ // not — that's handled by stripAnsi() below.
55
+ NO_COLOR: "1",
56
+ FORCE_COLOR: "0",
57
+ // CI=1 makes some tools pick their non-interactive, non-TTY code paths
58
+ // (no spinners, no progress bars, no prompts).
59
+ CI: "1",
60
+ };
61
+
62
+ // CSI + simple ESC sequences. Covers SGR colors (\x1b[...m), cursor moves,
63
+ // and OSC hyperlinks — enough to clean any terminal output that leaks
64
+ // through despite NO_COLOR.
65
+ const ANSI_PATTERN =
66
+ // eslint-disable-next-line no-control-regex
67
+ /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07\x1B]*(?:\x07|\x1B\\))/g;
68
+
69
+ /** @param {string} text */
70
+ function stripAnsi(text) {
71
+ return text.replace(ANSI_PATTERN, "");
72
+ }
73
+
74
+ /**
75
+ * Spawn a command, capture stdout+stderr together, resolve with exit code
76
+ * and combined buffer. Preserves interleaving as best as Node's stream
77
+ * events allow — good enough for human and LLM reading.
78
+ *
79
+ * @param {string} cmd
80
+ * @param {string[]} args
81
+ * @returns {Promise<{ code: number; output: string }>}
82
+ */
83
+ function run(cmd, args) {
84
+ return new Promise((resolve) => {
85
+ const proc = spawn(cmd, args, {
86
+ env,
87
+ stdio: ["ignore", "pipe", "pipe"],
88
+ });
89
+
90
+ let output = "";
91
+ proc.stdout.on("data", (chunk) => {
92
+ output += chunk.toString();
93
+ });
94
+ proc.stderr.on("data", (chunk) => {
95
+ output += chunk.toString();
96
+ });
97
+
98
+ proc.on("error", (err) => {
99
+ resolve({ code: 1, output: `${output}\n${err.message}` });
100
+ });
101
+ proc.on("close", (code) => {
102
+ resolve({ code: code ?? 1, output });
103
+ });
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Font integrity gate. Corrupt font binaries fail SILENTLY at runtime:
109
+ * the build is green, but the browser's OTS sanitizer rejects the file and
110
+ * every affected element falls back to the default serif — the site ships
111
+ * looking broken with zero build signal. (Learned the hard way: a woff2
112
+ * scraped off a live site declared 61375 bytes in its header but was 90034
113
+ * bytes on disk.) Fonts must come from @fontsource packages, never copied
114
+ * from crawled sites.
115
+ *
116
+ * Checks every public/fonts/*.woff2: WOFF2 magic + header totalSize ===
117
+ * actual byte length. Also verifies each local url(...) in a globals.css
118
+ * @font-face resolves to a file under public/.
119
+ */
120
+ async function checkFonts() {
121
+ const { readdir, readFile, stat } = await import("node:fs/promises");
122
+ const { join } = await import("node:path");
123
+ const problems = [];
124
+
125
+ const fontsDir = join(process.cwd(), "public", "fonts");
126
+ /** @type {string[]} */
127
+ let entries = [];
128
+ try {
129
+ entries = await readdir(fontsDir);
130
+ } catch {
131
+ /* no fonts dir — nothing to check */
132
+ }
133
+ for (const name of entries.filter((n) => n.endsWith(".woff2"))) {
134
+ const path = join(fontsDir, name);
135
+ const buf = await readFile(path);
136
+ if (buf.length < 12 || buf.toString("latin1", 0, 4) !== "wOF2") {
137
+ problems.push(`public/fonts/${name}: not a WOFF2 file`);
138
+ continue;
139
+ }
140
+ const declared = buf.readUInt32BE(8);
141
+ if (declared !== buf.length) {
142
+ problems.push(
143
+ `public/fonts/${name}: corrupt — WOFF2 header declares ${declared} bytes, file is ${buf.length}. ` +
144
+ `Browsers will reject it and text falls back to default serif. ` +
145
+ `Replace it with the same font from an @fontsource package.`,
146
+ );
147
+ }
148
+ }
149
+
150
+ try {
151
+ const css = await readFile(
152
+ join(process.cwd(), "src", "styles", "globals.css"),
153
+ "utf8",
154
+ );
155
+ for (const match of css.matchAll(/url\("(\/[^")]+\.woff2?)"\)/g)) {
156
+ const rel = match[1];
157
+ const target = join(process.cwd(), "public", rel.slice(1));
158
+ const exists = await stat(target).then(
159
+ () => true,
160
+ () => false,
161
+ );
162
+ if (!exists) {
163
+ problems.push(
164
+ `globals.css references ${rel} but public${rel} does not exist`,
165
+ );
166
+ }
167
+ }
168
+ } catch {
169
+ /* globals.css missing is astro check's problem, not ours */
170
+ }
171
+
172
+ return problems;
173
+ }
174
+
175
+ const fontProblems = await checkFonts();
176
+ if (fontProblems.length > 0) {
177
+ process.stderr.write(
178
+ `font integrity check failed:\n${fontProblems.join("\n")}\n`,
179
+ );
180
+ exit(1);
181
+ }
182
+
183
+ // Island-import gate — registered sections never hydrate, so an island-grade
184
+ // ui primitive imported there ships a dead widget with zero build signal.
185
+ // The check (direct + barrel + any-depth relative imports; type-only imports
186
+ // exempt; transitive imports a documented limitation) lives in
187
+ // scan-island-imports.mjs so its tests can exercise it on fixtures.
188
+ const { checkIslandImports } = await import("./scan-island-imports.mjs");
189
+ const islandProblems = await checkIslandImports(process.cwd());
190
+ if (islandProblems.length > 0) {
191
+ process.stderr.write(
192
+ `island-import check failed:\n${islandProblems.join("\n")}\n`,
193
+ );
194
+ exit(1);
195
+ }
196
+
197
+ /** @type {Array<[string, string[]]>} */
198
+ const steps = [
199
+ ["eslint", ["."]],
200
+ ["astro", ["check"]],
201
+ // The repo's own unit tests (bun:test). The content grammar's own suite ships
202
+ // with this package; what runs here is whatever the brand repo keeps under
203
+ // src/. bun is the project runner (see CI + bun.lock).
204
+ ["bun", ["test", "src"]],
205
+ ["astro", ["build"]],
206
+ // Bespoke-sibling hydration gate (2.10.0): asserts, against the fresh dist/,
207
+ // that every non-draft bespoke page satisfies the two conventions its
208
+ // locale siblings hydrate through, and pins the Astro runtime directive
209
+ // contract they rely on. Both failure modes are silent at runtime.
210
+ [process.execPath, [gate("scan-bespoke-siblings.mjs")]],
211
+ ];
212
+
213
+ for (const [cmd, args] of steps) {
214
+ const { code, output } = await run(cmd, args);
215
+ if (code !== 0) {
216
+ // Failure: write the buffer (minus ANSI) to STDERR, then exit with
217
+ // the step's code. stderr is the right channel for diagnostics, and
218
+ // it's also the stream that `bun run` / sandbox bash executors tend
219
+ // to surface on non-zero exit — so the caller actually sees it.
220
+ process.stderr.write(stripAnsi(output));
221
+ exit(code);
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Editor-bytes gate. The visual editor is dev-only — injected by
227
+ * integrations/visual-editor-dev.mjs under `astro dev`, with the vendored
228
+ * bundle living in vendor/ (NOT public/) — so a production build must
229
+ * contain ZERO editor bytes. Markers cover the bundle name, its serving
230
+ * URL, the runtime's log prefix, and its CSS class namespace.
231
+ */
232
+ async function checkEditorBytes() {
233
+ const { readdir, readFile } = await import("node:fs/promises");
234
+ const { join } = await import("node:path");
235
+ const markers = [
236
+ "visual-editor.iife",
237
+ "__visual-editor",
238
+ "[VisualEditor]",
239
+ "__ve-",
240
+ ];
241
+ /** @type {string[]} */
242
+ const problems = [];
243
+
244
+ /** @param {string} dir */
245
+ async function walk(dir) {
246
+ /** @type {import("node:fs").Dirent[]} */
247
+ let entries = [];
248
+ try {
249
+ entries = await readdir(dir, { withFileTypes: true });
250
+ } catch {
251
+ return;
252
+ }
253
+ for (const entry of entries) {
254
+ const path = join(dir, entry.name);
255
+ if (entry.isDirectory()) {
256
+ await walk(path);
257
+ continue;
258
+ }
259
+ const content = await readFile(path, "latin1");
260
+ for (const marker of markers) {
261
+ if (content.includes(marker)) {
262
+ problems.push(
263
+ `${path}: contains editor marker "${marker}" — the visual editor is dev-only and must never ship in a build`,
264
+ );
265
+ }
266
+ }
267
+ }
268
+ }
269
+
270
+ await walk(join(process.cwd(), "dist"));
271
+ return problems;
272
+ }
273
+
274
+ const editorProblems = await checkEditorBytes();
275
+ if (editorProblems.length > 0) {
276
+ process.stderr.write(
277
+ `editor-bytes check failed:\n${editorProblems.join("\n")}\n`,
278
+ );
279
+ exit(1);
280
+ }
281
+
282
+ // Success: exit silently. Nothing to print.
283
+ exit(0);