@jxsuite/compiler 1.5.0 → 2.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.
@@ -6,7 +6,11 @@
6
6
  *
7
7
  * Per site-architecture spec §10: $site.name — from project.json name $site.url — from project.json
8
8
  * url $site.state.* — site-wide reactive state $page.url — current page URL path $page.title — page
9
- * title $page.params — dynamic route parameters (if any)
9
+ * title $page.params — dynamic route parameters (if any) $page.locale/$page.dir — the route's
10
+ * language and writing direction (§13.4) $page.alternates — this page in every language it exists
11
+ * in (§13.5)
12
+ *
13
+ * @docs framework/site/i18n
10
14
  */
11
15
 
12
16
  import { dirname, relative, resolve } from "node:path";
@@ -16,6 +20,9 @@ import type {
16
20
  JxStateDefinition,
17
21
  ProjectConfig,
18
22
  } from "@jxsuite/schema/types";
23
+ import { localeDirection, localeLabel } from "@jxsuite/schema/locale";
24
+ import { localeOfRoute } from "./i18n.ts";
25
+ import type { ResolvedI18n, TranslationMember } from "./i18n.ts";
19
26
  import type { SiteRoute } from "../types.ts";
20
27
 
21
28
  /**
@@ -25,6 +32,9 @@ import type { SiteRoute } from "../types.ts";
25
32
  * @param {ProjectConfig} projectConfig - Loaded project configuration
26
33
  * @param {SiteRoute} route - The resolved route for this page
27
34
  * @param {string | null} [projectRoot] - Absolute path to the project root (for import rebasing)
35
+ * @param {ResolvedI18n | null} [i18n] - Validated locale config, when the project declares one
36
+ * @param {readonly TranslationMember[]} [translations] - This route's translation set, itself
37
+ * included
28
38
  * @returns {JxDocument} The mutated document
29
39
  */
30
40
  export function injectContext(
@@ -32,6 +42,8 @@ export function injectContext(
32
42
  projectConfig: ProjectConfig,
33
43
  route: SiteRoute,
34
44
  projectRoot: string | null = null,
45
+ i18n: ResolvedI18n | null = null,
46
+ translations: readonly TranslationMember[] = [],
35
47
  ) {
36
48
  if (!doc.state) {
37
49
  doc.state = {};
@@ -41,14 +53,45 @@ export function injectContext(
41
53
  doc.state.$site = {
42
54
  name: projectConfig.name ?? "Jx Site",
43
55
  url: projectConfig.url ?? "",
56
+ ...(i18n === null ? {} : { defaultLocale: i18n.defaultLocale, locales: i18n.locales }),
44
57
  ...projectConfig.state,
45
58
  };
46
59
 
47
- // $page context — read-only page-level data
60
+ /*
61
+ * $page context — read-only page-level data.
62
+ *
63
+ * The locale lives here rather than as a top-level `$locale` because that is what it is: a
64
+ * property of the route, not a third ambient namespace beside $site and $page. A document's own
65
+ * `$lang` still wins over the route's locale, so this is the resolved answer, not the prefix.
66
+ */
67
+ const pageLang =
68
+ (typeof doc.$lang === "string" ? doc.$lang : undefined) ??
69
+ localeOfRoute(route.urlPattern, i18n) ??
70
+ projectConfig.defaults?.lang;
71
+ /*
72
+ * The same translation set `<head>` advertises, in the shape a template can render: a language
73
+ * switcher is the one part of a multilingual site the framework cannot write for the author, and
74
+ * without this it could only be hand-maintained — a hardcoded list of URLs that goes stale the
75
+ * moment a page gains or loses a translation, silently, in the one place a reader would use it.
76
+ *
77
+ * Site-absolute URLs, not the absolute hrefs `<head>` needs: a switcher is an internal link, and
78
+ * it has to work before `url` is configured. `current` marks the member this route *is*, taken
79
+ * from the route rather than from `$page.locale`, because a document whose `$lang` disagrees with
80
+ * its directory is still served from that directory.
81
+ */
82
+ const alternates = translations.map((member) => ({
83
+ code: member.locale,
84
+ current: member.urlPattern === route.urlPattern,
85
+ dir: localeDirection(member.locale),
86
+ label: localeLabel(member.locale),
87
+ url: member.urlPattern,
88
+ }));
48
89
  doc.state.$page = {
49
90
  params: route._pathParams ?? {},
50
91
  title: doc.title ?? doc._pageTitle ?? projectConfig.name ?? "",
51
92
  url: route.urlPattern,
93
+ ...(alternates.length > 0 && { alternates }),
94
+ ...(pageLang === undefined ? {} : { dir: localeDirection(pageLang), locale: pageLang }),
52
95
  };
53
96
 
54
97
  // Merge project-level state into page state (page wins on conflicts)
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Content-Security-Policy, derived from the pages the build actually produced.
3
+ *
4
+ * A static site cannot use nonces — a nonce has to be fresh per response, and these responses are
5
+ * files — so hashes are the only route to a strict `script-src`. That works here because the inline
6
+ * scripts Jx emits are **constants**: the colour-scheme pre-paint script is one fixed IIFE, and the
7
+ * import map is the same two-entry object on every page of a build. A handful of hashes therefore
8
+ * covers a whole site, which matters because Cloudflare Pages caps a `_headers` file at roughly a
9
+ * hundred rules — a per-page policy would be unusable past a small site.
10
+ *
11
+ * Sources are collected by **scanning finished HTML** rather than by asking each emission site what
12
+ * it emitted. There are seven places that can put a `<script>` on a page and a hash that does not
13
+ * match the shipped bytes is worse than no policy at all, so the scan runs on the exact string
14
+ * about to be written to disk.
15
+ *
16
+ * @docs framework/site/deployment
17
+ */
18
+
19
+ import { createHash } from "node:crypto";
20
+ import type { CspConfig, HeadersConfig } from "@jxsuite/schema/types";
21
+
22
+ /** Everything a page contributed to the policy. Accumulated across the whole build. */
23
+ export interface CspSources {
24
+ /** `'sha256-…'` for each distinct inline script. */
25
+ scriptHashes: Set<string>;
26
+ scriptOrigins: Set<string>;
27
+ styleOrigins: Set<string>;
28
+ imgOrigins: Set<string>;
29
+ fontOrigins: Set<string>;
30
+ frameOrigins: Set<string>;
31
+ }
32
+
33
+ export function emptyCspSources(): CspSources {
34
+ return {
35
+ fontOrigins: new Set(),
36
+ frameOrigins: new Set(),
37
+ imgOrigins: new Set(),
38
+ scriptHashes: new Set(),
39
+ scriptOrigins: new Set(),
40
+ styleOrigins: new Set(),
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Script `type` values the browser executes, and therefore the ones `script-src` governs.
46
+ *
47
+ * An empty or absent type is classic JavaScript. Everything else — `application/ld+json` above all
48
+ * — is a _data block_: the browser never executes it, CSP never checks it, and hashing it would add
49
+ * a hash that authorizes nothing. Verified in Chrome against an enforced policy.
50
+ */
51
+ const EXECUTABLE_TYPES = new Set(["", "module", "importmap", "text/javascript", "module-shim"]);
52
+
53
+ const SCRIPT_TAG = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
54
+ const LINK_TAG = /<link\b([^>]*)>/gi;
55
+ const IMG_TAG = /<img\b([^>]*)>/gi;
56
+ const SOURCE_TAG = /<source\b([^>]*)>/gi;
57
+ const IFRAME_TAG = /<iframe\b([^>]*)>/gi;
58
+ const FONT_EXTENSION = /\.(woff2?|ttf|otf|eot)(\?|#|$)/i;
59
+
60
+ /** Read one double- or single-quoted attribute out of a raw tag attribute string. */
61
+ function attr(attrs: string, name: string): string | undefined {
62
+ return new RegExp(`\\b${name}\\s*=\\s*("([^"]*)"|'([^']*)')`, "i")
63
+ .exec(attrs)
64
+ ?.slice(2)
65
+ .find((v) => v !== undefined);
66
+ }
67
+
68
+ /** The origin of an absolute http(s) URL, or null for anything relative, `data:` or malformed. */
69
+ export function originOf(url: string | undefined): string | null {
70
+ if (url === undefined || !/^https?:\/\//i.test(url)) {
71
+ return null;
72
+ }
73
+ try {
74
+ return new URL(url).origin;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /** The CSP source expression for an inline script's exact bytes. */
81
+ export function hashOf(source: string): string {
82
+ return `'sha256-${createHash("sha256").update(source, "utf8").digest("base64")}'`;
83
+ }
84
+
85
+ /**
86
+ * Scan one finished page and add what it needs to the accumulated sources.
87
+ *
88
+ * @param {string} html - The exact bytes about to be written
89
+ * @param {CspSources} into
90
+ */
91
+ export function collectCspSources(html: string, into: CspSources): void {
92
+ for (const match of html.matchAll(SCRIPT_TAG)) {
93
+ const attrs = match[1] ?? "";
94
+ const src = attr(attrs, "src");
95
+ const type = (attr(attrs, "type") ?? "").trim().toLowerCase();
96
+ if (src !== undefined) {
97
+ const origin = originOf(src);
98
+ if (origin) {
99
+ into.scriptOrigins.add(origin);
100
+ }
101
+ continue;
102
+ }
103
+ if (EXECUTABLE_TYPES.has(type)) {
104
+ into.scriptHashes.add(hashOf(match[2] ?? ""));
105
+ }
106
+ }
107
+
108
+ for (const match of html.matchAll(LINK_TAG)) {
109
+ const attrs = match[1] ?? "";
110
+ const rel = (attr(attrs, "rel") ?? "").toLowerCase();
111
+ const href = attr(attrs, "href");
112
+ const origin = originOf(href);
113
+ if (!origin) {
114
+ continue;
115
+ }
116
+ if (rel.includes("stylesheet")) {
117
+ into.styleOrigins.add(origin);
118
+ }
119
+ if (FONT_EXTENSION.test(href ?? "") || (attr(attrs, "as") ?? "") === "font") {
120
+ into.fontOrigins.add(origin);
121
+ }
122
+ }
123
+
124
+ for (const [tag, group] of [
125
+ [IMG_TAG, into.imgOrigins],
126
+ [SOURCE_TAG, into.imgOrigins],
127
+ [IFRAME_TAG, into.frameOrigins],
128
+ ] as const) {
129
+ for (const match of html.matchAll(tag)) {
130
+ const attrs = match[1] ?? "";
131
+ for (const value of [attr(attrs, "src"), ...srcsetUrls(attr(attrs, "srcset"))]) {
132
+ const origin = originOf(value);
133
+ if (origin) {
134
+ group.add(origin);
135
+ }
136
+ }
137
+ }
138
+ }
139
+ }
140
+
141
+ /** The URLs in a `srcset`, without their descriptors. */
142
+ function srcsetUrls(srcset: string | undefined): string[] {
143
+ return srcset === undefined
144
+ ? []
145
+ : srcset
146
+ .split(",")
147
+ .map((candidate) => candidate.trim().split(/\s+/)[0] ?? "")
148
+ .filter(Boolean);
149
+ }
150
+
151
+ /** Normalize the config's three spellings into one shape, or null when CSP is off. */
152
+ export function normalizeCspConfig(security: HeadersConfig["security"]): CspConfig | null {
153
+ const csp = security?.csp;
154
+ if (csp === undefined || csp === false) {
155
+ return null;
156
+ }
157
+ if (csp === true) {
158
+ return {};
159
+ }
160
+ if (csp === "report-only") {
161
+ return { mode: "report-only" };
162
+ }
163
+ return csp;
164
+ }
165
+
166
+ /**
167
+ * Build the policy. Returns the headers to add, which is more than one when reporting is on:
168
+ * `report-to` names an endpoint group that `Reporting-Endpoints` has to define.
169
+ *
170
+ * `style-src` carries `'unsafe-inline'` and always will until the style pipeline changes. Every
171
+ * page has a generated `<style>` block whose content is per-page, so hashing them would put one
172
+ * hash per page into a site-wide header, and per-element `style=` attributes have no hash form at
173
+ * all. Both are recorded as a divergence rather than papered over — and note that a hash and
174
+ * `'unsafe-inline'` in the same directive cancel: the browser ignores the keyword, so a partial job
175
+ * here would be worse than an honest one.
176
+ *
177
+ * @param {CspSources} sources
178
+ * @param {CspConfig} config
179
+ * @returns {Record<string, string>}
180
+ */
181
+ export function buildCspHeaders(sources: CspSources, config: CspConfig): Record<string, string> {
182
+ const list = (fixed: string[], origins: Set<string>) => [...fixed, ...origins].join(" ");
183
+
184
+ const directives: Record<string, string | false> = {
185
+ "base-uri": "'self'",
186
+ "default-src": "'self'",
187
+ "font-src": list(["'self'"], sources.fontOrigins),
188
+ "form-action": "'self'",
189
+ // Matches the `X-Frame-Options: SAMEORIGIN` emitted beside it; two headers disagreeing about
190
+ // Framing is a worse outcome than either answer.
191
+ "frame-ancestors": "'self'",
192
+ "frame-src": list(["'self'"], sources.frameOrigins),
193
+ "img-src": list(["'self'", "data:"], sources.imgOrigins),
194
+ "object-src": "'none'",
195
+ "script-src": list(["'self'", ...[...sources.scriptHashes].toSorted()], sources.scriptOrigins),
196
+ "style-src": list(["'self'", "'unsafe-inline'"], sources.styleOrigins),
197
+ };
198
+
199
+ if (config.reportUri !== undefined && config.reportUri !== "") {
200
+ directives["report-to"] = "csp-endpoint";
201
+ // Deprecated in CSP3 and still the only one some browsers implement, so both are emitted.
202
+ directives["report-uri"] = config.reportUri;
203
+ }
204
+
205
+ for (const [name, value] of Object.entries<string | false>(config.directives ?? {})) {
206
+ directives[name] = value;
207
+ }
208
+
209
+ const policy = Object.entries(directives)
210
+ .filter(([, value]) => value !== false && value !== "")
211
+ .toSorted(([a], [b]) => (a < b ? -1 : 1))
212
+ .map(([name, value]) => `${name} ${String(value)}`)
213
+ .join("; ");
214
+
215
+ const headerName =
216
+ config.mode === "report-only"
217
+ ? "Content-Security-Policy-Report-Only"
218
+ : "Content-Security-Policy";
219
+ const headers: Record<string, string> = { [headerName]: policy };
220
+ if (config.reportUri !== undefined && config.reportUri !== "") {
221
+ headers["Reporting-Endpoints"] = `csp-endpoint="${config.reportUri}"`;
222
+ }
223
+ return headers;
224
+ }
@@ -59,10 +59,23 @@ export function mergeHead(
59
59
  // Add canonical URL if provided
60
60
  if (context.pageUrl && context.siteUrl) {
61
61
  const canonical = new URL(context.pageUrl, context.siteUrl).href;
62
- merged.set("link:canonical", {
63
- attributes: { href: canonical, rel: "canonical" },
64
- tagName: "link",
65
- });
62
+ /*
63
+ * An author-supplied canonical wins, like every other auto-injected entry (§8.4). This used a
64
+ * hand-written `link:canonical` key that `headEntryKey` never produces, so the author's entry
65
+ * and this one landed under different keys and the page got BOTH — which is the one thing a
66
+ * canonical link must not be.
67
+ */
68
+ const hasAuthored = [...merged.values()].some(
69
+ (e) =>
70
+ e && typeof e === "object" && e.tagName === "link" && e.attributes?.rel === "canonical",
71
+ );
72
+ if (!hasAuthored) {
73
+ const entry: JxHeadEntry = {
74
+ attributes: { href: canonical, rel: "canonical" },
75
+ tagName: "link",
76
+ };
77
+ merged.set(headEntryKey(entry), entry);
78
+ }
66
79
  // Auto OpenGraph identity (site-architecture §8.4) — author-supplied values win.
67
80
  if (!merged.has("meta:og:url")) {
68
81
  merged.set("meta:og:url", {
@@ -78,6 +91,26 @@ export function mergeHead(
78
91
  });
79
92
  }
80
93
 
94
+ /*
95
+ * Locale alternates (site-architecture.md §13.5). Keyed through `headEntryKey` like everything
96
+ * else, which is what keeps a set of them from collapsing: they share `rel="alternate"` and
97
+ * differ only in `hreflang`, and `x-default` conventionally shares its `href` with the default
98
+ * locale's entry. Before the key accounted for the qualifying attribute this whole feature was
99
+ * impossible — the set became one link.
100
+ *
101
+ * An author-supplied alternate for the same `hreflang` wins, like every other auto entry.
102
+ */
103
+ for (const alternate of context.alternates ?? []) {
104
+ const entry: JxHeadEntry = {
105
+ attributes: { href: alternate.href, hreflang: alternate.hreflang, rel: "alternate" },
106
+ tagName: "link",
107
+ };
108
+ const key = headEntryKey(entry);
109
+ if (!merged.has(key)) {
110
+ merged.set(key, entry);
111
+ }
112
+ }
113
+
81
114
  return [...merged.values()];
82
115
  }
83
116
 
@@ -116,9 +149,17 @@ function headEntryKey(entry: JxHeadEntry) {
116
149
  return `meta:${attrs.property}`;
117
150
  }
118
151
 
119
- // <link rel="..." href="..."> — keyed by rel+href
152
+ /*
153
+ * <link> — keyed by rel + href + the attribute that distinguishes two links sharing both.
154
+ *
155
+ * `rel` + `href` alone is not identity, and the cases where it fails are the ones a site actually
156
+ * needs: `hreflang="x-default"` conventionally points at the SAME href as the default locale's
157
+ * alternate, and an RSS and an Atom feed are both `rel="alternate"` differing only in `type`.
158
+ * `icon` differs by `sizes` and `stylesheet` by `media` for the same reason.
159
+ */
120
160
  if (tag === "link" && attrs.rel) {
121
- return `link:${attrs.rel}:${attrs.href ?? ""}`;
161
+ const qualifier = attrs.hreflang ?? attrs.type ?? attrs.media ?? attrs.sizes ?? "";
162
+ return `link:${attrs.rel}:${attrs.href ?? ""}:${String(qualifier)}`;
122
163
  }
123
164
 
124
165
  // <script src="..."> — keyed by src
@@ -128,9 +169,8 @@ function headEntryKey(entry: JxHeadEntry) {
128
169
 
129
170
  // <style> — unique per content hash
130
171
  if (tag === "style") {
131
- const content = Array.isArray(entry.children)
132
- ? entry.children.join("")
133
- : (entry.textContent ?? "");
172
+ const raw = Array.isArray(entry.children) ? entry.children.join("") : entry.textContent;
173
+ const content = typeof raw === "object" && raw !== null ? JSON.stringify(raw) : (raw ?? "");
134
174
  return `style:${simpleHash(content)}`;
135
175
  }
136
176
 
@@ -191,10 +231,14 @@ function renderHeadEntry(entry: JxHeadEntry) {
191
231
  return open;
192
232
  }
193
233
 
194
- // Elements with content
195
- const content = Array.isArray(entry.children)
196
- ? entry.children.join("")
197
- : (entry.textContent ?? "");
234
+ /*
235
+ * Elements with content. `textContent` may be an OBJECT: that is how a JSON-LD block is authored
236
+ * (§8.5), and serializing it here is what the spec has always promised. Left as-is it reached the
237
+ * page as the string "[object Object]".
238
+ */
239
+ const raw = Array.isArray(entry.children) ? entry.children.join("") : entry.textContent;
240
+ const content =
241
+ raw !== null && typeof raw === "object" ? JSON.stringify(raw, null, 2) : (raw ?? "");
198
242
  return `${open}${content}</${tag}>`;
199
243
  }
200
244
 
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Emits `dist/_headers` — the response headers the build knows and the host does not.
3
+ *
4
+ * Cacheability in particular is something only the build can decide: it is the thing that chose the
5
+ * filenames, so it is the only party that knows which of them embed a content hash and are
6
+ * therefore safe to cache forever. Left unsaid, every host applies its own default to output whose
7
+ * lifetime it cannot see (RFC 9111).
8
+ *
9
+ * Pure except for `writeHeaders`, which is the one function that touches disk.
10
+ *
11
+ * @docs framework/site/deployment
12
+ */
13
+
14
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { buildCspHeaders, normalizeCspConfig } from "./csp.ts";
17
+ import type { CspSources } from "./csp.ts";
18
+ import type { HeadersConfig, ProjectConfig } from "@jxsuite/schema/types";
19
+
20
+ /** One `_headers` stanza: a path pattern and the headers it sets. */
21
+ export interface HeaderRule {
22
+ pattern: string;
23
+ headers: Record<string, string>;
24
+ }
25
+
26
+ /**
27
+ * The only content-addressed output the build produces. `variantFilename()` embeds an 8-character
28
+ * digest of the source bytes, so a changed image is a changed URL and the old one can be cached
29
+ * forever (RFC 8246).
30
+ */
31
+ export const IMMUTABLE_PATTERN = "/images/_optimized/*";
32
+
33
+ /** A year, the conventional ceiling for `max-age`. */
34
+ const IMMUTABLE_MAX_AGE = 31_536_000;
35
+
36
+ /**
37
+ * Paths that must NEVER be marked immutable, asserted by a test.
38
+ *
39
+ * `/components/<tag>.js` and `/assets/<slug>.js` are named after the thing they contain, not after
40
+ * its content — editing a component reuses the URL. Marking either immutable is a year-long cache
41
+ * poisoning bug that only appears for visitors who came before the edit, which is the worst
42
+ * possible shape for one. Making them cacheable means content-hashing them first.
43
+ */
44
+ export const NEVER_IMMUTABLE = ["/components/*", "/assets/*"] as const;
45
+
46
+ const BANNER = "# Generated by @jxsuite/compiler — configure via build.headers in project.json.";
47
+ const AUTHOR_BANNER =
48
+ "# ─── public/_headers (hand-authored; these rules override the block above) ───";
49
+
50
+ /**
51
+ * The generated rules, general first. Order matters: on both Cloudflare and Netlify a later
52
+ * matching rule wins for a duplicate header name, so the specific `immutable` stanza must follow
53
+ * the catch-all that revalidates.
54
+ */
55
+ export function buildHeaderRules(
56
+ build: ProjectConfig["build"],
57
+ cspSources?: CspSources,
58
+ ): {
59
+ rules: HeaderRule[];
60
+ errors: string[];
61
+ } {
62
+ const cfg: HeadersConfig = build?.headers ?? {};
63
+ const errors: string[] = [];
64
+ if (cfg.enabled === false) {
65
+ return { errors, rules: [] };
66
+ }
67
+
68
+ const security = cfg.security ?? {};
69
+ const wide: Record<string, string> = {};
70
+
71
+ if (security.contentTypeOptions !== false) {
72
+ wide["X-Content-Type-Options"] = "nosniff";
73
+ }
74
+ if (security.referrerPolicy !== false) {
75
+ wide["Referrer-Policy"] = security.referrerPolicy ?? "strict-origin-when-cross-origin";
76
+ }
77
+ if (security.permissionsPolicy !== false) {
78
+ wide["Permissions-Policy"] =
79
+ security.permissionsPolicy ?? "camera=(), microphone=(), geolocation=()";
80
+ }
81
+ if (security.frameOptions !== false) {
82
+ wide["X-Frame-Options"] = security.frameOptions ?? "SAMEORIGIN";
83
+ }
84
+
85
+ const { hsts } = security;
86
+ if (hsts) {
87
+ const opts = hsts === true ? {} : hsts;
88
+ const maxAge = opts.maxAge ?? IMMUTABLE_MAX_AGE;
89
+ const includeSubDomains = opts.includeSubDomains ?? true;
90
+ const preload = opts.preload ?? false;
91
+ if (preload && !includeSubDomains) {
92
+ /*
93
+ * Submission to the preload list REQUIRES includeSubDomains. Emitting the header anyway
94
+ * produces something that looks submitted and is not, which is worse than not emitting it.
95
+ */
96
+ errors.push(
97
+ "build.headers.security.hsts: `preload` requires `includeSubDomains` — the preload list " +
98
+ "will not accept the header without it.",
99
+ );
100
+ } else {
101
+ wide["Strict-Transport-Security"] = [
102
+ `max-age=${maxAge}`,
103
+ includeSubDomains ? "includeSubDomains" : null,
104
+ preload ? "preload" : null,
105
+ ]
106
+ .filter(Boolean)
107
+ .join("; ");
108
+ }
109
+ }
110
+
111
+ /*
112
+ * One policy for the whole site, from the inline scripts and external origins the build actually
113
+ * emitted. It goes in the `/*` stanza rather than per page because the hashes are constants —
114
+ * and because a per-page policy would exhaust the host's rule budget on any real site.
115
+ */
116
+ const cspConfig = normalizeCspConfig(security);
117
+ if (cspConfig) {
118
+ if (cspSources === undefined) {
119
+ errors.push(
120
+ "build.headers.security.csp is set, but no page was scanned for inline scripts — the " +
121
+ "policy would block every one of them.",
122
+ );
123
+ } else {
124
+ Object.assign(wide, buildCspHeaders(cspSources, cspConfig));
125
+ }
126
+ }
127
+
128
+ const cache = cfg.cache ?? "auto";
129
+ if (cache === "auto") {
130
+ /*
131
+ * Everything else — HTML at `/about/`, component JS and CSS, the sitemap, the feed — revalidates
132
+ * every time. The ETag makes that cheap, and it is the only correct default for output whose URL
133
+ * does not change when its content does.
134
+ */
135
+ wide["Cache-Control"] = "public, max-age=0, must-revalidate";
136
+ }
137
+
138
+ const rules: HeaderRule[] = [];
139
+ if (Object.keys(wide).length > 0) {
140
+ rules.push({ headers: wide, pattern: "/*" });
141
+ }
142
+ if (cache === "auto") {
143
+ rules.push({
144
+ headers: { "Cache-Control": `public, max-age=${IMMUTABLE_MAX_AGE}, immutable` },
145
+ pattern: IMMUTABLE_PATTERN,
146
+ });
147
+ }
148
+ for (const [pattern, headers] of Object.entries(cfg.rules ?? {})) {
149
+ rules.push({ headers, pattern });
150
+ }
151
+ return { errors, rules };
152
+ }
153
+
154
+ /**
155
+ * Content-Type rules for output whose extension no host maps.
156
+ *
157
+ * `.xml` is served as `application/xml` and `.json` as `application/json` by default, which is not
158
+ * wrong but is not what a feed reader looks for. Only files that actually exist get a rule, so the
159
+ * emitted block never describes output this build did not produce.
160
+ */
161
+ export function contentTypeRules(outDir: string): HeaderRule[] {
162
+ const known: [string, string][] = [
163
+ ["feed.xml", "application/atom+xml; charset=utf-8"],
164
+ ["feed.json", "application/feed+json; charset=utf-8"],
165
+ ["manifest.webmanifest", "application/manifest+json; charset=utf-8"],
166
+ [".well-known/security.txt", "text/plain; charset=utf-8"],
167
+ ];
168
+ const rules: HeaderRule[] = [];
169
+ for (const [name, type] of known) {
170
+ if (existsSync(join(outDir, name))) {
171
+ rules.push({ headers: { "Content-Type": type }, pattern: `/${name}` });
172
+ }
173
+ }
174
+ // The RFC 5005 archives share the feed's type.
175
+ if (existsSync(join(outDir, "feed", "archive"))) {
176
+ rules.push({
177
+ headers: { "Content-Type": "application/atom+xml; charset=utf-8" },
178
+ pattern: "/feed/archive/*",
179
+ });
180
+ }
181
+ return rules;
182
+ }
183
+
184
+ /** Render rules in the Netlify / Cloudflare Pages `_headers` format. */
185
+ export function renderHeaders(rules: readonly HeaderRule[]): string {
186
+ const blocks = rules.map((rule) => {
187
+ const lines = Object.entries(rule.headers).map(([name, value]) => ` ${name}: ${value}`);
188
+ return [rule.pattern, ...lines].join("\n");
189
+ });
190
+ return `${[BANNER, "", ...blocks].join("\n")}\n`;
191
+ }
192
+
193
+ /**
194
+ * Write `dist/_headers`, preserving a hand-authored `public/_headers` **below** the generated
195
+ * block.
196
+ *
197
+ * `ensureRobotsSitemap` appends for the same reason this prepends: both run after the `public/`
198
+ * copy so they edit the deployed file, but a later `_headers` rule wins for a duplicate header
199
+ * name, so the author's block has to come last to override.
200
+ *
201
+ * The author's file is concatenated verbatim rather than merged. Both platforms have removal (`!
202
+ * Header-Name`) and conditional (`Language=`, `Country=`) extensions, and a structural merge would
203
+ * silently drop whatever it could not parse — deferring to each platform's own documented
204
+ * precedence is both simpler and more correct.
205
+ */
206
+ export function writeHeaders(outDir: string, rules: readonly HeaderRule[]): number {
207
+ if (rules.length === 0) {
208
+ return 0;
209
+ }
210
+ const path = join(outDir, "_headers");
211
+ const authored = existsSync(path) ? readFileSync(path, "utf8").trim() : "";
212
+ const generated = renderHeaders(rules);
213
+ const body = authored === "" ? generated : `${generated}\n${AUTHOR_BANNER}\n${authored}\n`;
214
+ writeFileSync(path, body, "utf8");
215
+ return 1;
216
+ }
217
+
218
+ /**
219
+ * Write `dist/.nojekyll`.
220
+ *
221
+ * GitHub Pages runs Jekyll, which excludes every `_`-prefixed path — which is `_headers`,
222
+ * `_redirects`, `_worker.js`, `_routes.json` and `_islands/`. One empty file closes the whole class
223
+ * of "works locally, half-broken on Pages", so it is unconditional rather than an adapter option.
224
+ */
225
+ export function writeNoJekyll(outDir: string): number {
226
+ const path = join(outDir, ".nojekyll");
227
+ if (existsSync(path)) {
228
+ return 0;
229
+ }
230
+ writeFileSync(path, "", "utf8");
231
+ return 1;
232
+ }