@jxsuite/compiler 1.5.0 → 2.0.0
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/dist/cli.js +563 -228
- package/dist/compiler.js +335 -132
- package/package.json +11 -9
- package/src/compiler.ts +1 -1
- package/src/shadow.ts +65 -0
- package/src/shared.ts +234 -52
- package/src/site/bundler.ts +149 -18
- package/src/site/client-runtime.ts +374 -0
- package/src/site/context-injection.ts +45 -2
- package/src/site/csp.ts +224 -0
- package/src/site/head-merger.ts +57 -13
- package/src/site/headers-emitter.ts +232 -0
- package/src/site/i18n.ts +365 -0
- package/src/site/image-optimizer.ts +7 -0
- package/src/site/image-transform.ts +269 -118
- package/src/site/img-loading.ts +55 -0
- package/src/site/link-relations.ts +251 -0
- package/src/site/locale-negotiation.ts +258 -0
- package/src/site/pages-discovery.ts +149 -0
- package/src/site/preview-server.ts +9 -1
- package/src/site/schema-command.ts +109 -18
- package/src/site/service-worker.ts +294 -0
- package/src/site/site-build.ts +903 -95
- package/src/site/site-loader.ts +1 -0
- package/src/site/validate-command.ts +1 -1
- package/src/site/well-known.ts +221 -0
- package/src/targets/compile-client.ts +85 -18
- package/src/targets/compile-element.ts +393 -46
- package/src/targets/compile-server.ts +19 -2
- package/src/targets/compile-static.ts +4 -1
- package/src/types.ts +2 -0
|
@@ -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
|
-
|
|
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)
|
package/src/site/csp.ts
ADDED
|
@@ -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
|
+
}
|
package/src/site/head-merger.ts
CHANGED
|
@@ -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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
132
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
+
}
|