@jxsuite/compiler 1.4.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 +564 -230
- package/dist/compiler.js +334 -133
- package/package.json +11 -9
- package/src/compiler.ts +1 -1
- package/src/shadow.ts +65 -0
- package/src/shared.ts +461 -51
- 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 +138 -87
- package/src/site/service-worker.ts +294 -0
- package/src/site/site-build.ts +956 -109
- package/src/site/site-loader.ts +1 -0
- package/src/site/validate-command.ts +134 -38
- package/src/site/well-known.ts +221 -0
- package/src/targets/compile-client.ts +144 -81
- package/src/targets/compile-element.ts +620 -58
- package/src/targets/compile-server.ts +19 -2
- package/src/targets/compile-static.ts +12 -3
- package/src/types.ts +2 -0
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link relation types (RFC 8288 §2.1), checked rather than trusted.
|
|
3
|
+
*
|
|
4
|
+
* A `rel` is the only thing that says what a `<link>` _means_. Get the value wrong — `stylshet`,
|
|
5
|
+
* `canonicial`, `alternative` — and the tag is still well-formed HTML, still renders, still passes
|
|
6
|
+
* every other check in this build, and simply does nothing: the stylesheet never loads, the
|
|
7
|
+
* canonical never consolidates, the alternate is never followed. There is no runtime symptom to
|
|
8
|
+
* notice, which is why this is a build-time check and not something an author eventually spots.
|
|
9
|
+
*
|
|
10
|
+
* **A warning, never an error.** RFC 8288 §2.1.2 admits extension relation types, the registry
|
|
11
|
+
* gains entries between releases, and a `rel` nobody here recognizes is a page that works. Failing
|
|
12
|
+
* a build over one would make Jx wrong about a site that is right.
|
|
13
|
+
*
|
|
14
|
+
* @docs framework/site/seo
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The IANA Link Relation Types registry, snapshotted from
|
|
19
|
+
* `https://www.iana.org/assignments/link-relations/link-relations-1.csv` on 2026-08-16.
|
|
20
|
+
*
|
|
21
|
+
* A snapshot, and named as one: the registry is the authority and this is a copy that will go stale
|
|
22
|
+
* between releases. That is survivable precisely because an unrecognized value is a warning — a
|
|
23
|
+
* relation registered after this list was taken produces one line of noise, not a broken build.
|
|
24
|
+
* Regenerate from the CSV rather than hand-editing; the ordering is the CSV's, sorted.
|
|
25
|
+
*/
|
|
26
|
+
export const IANA_LINK_RELATIONS: ReadonlySet<string> = new Set([
|
|
27
|
+
"about",
|
|
28
|
+
"acl",
|
|
29
|
+
"alternate",
|
|
30
|
+
"amphtml",
|
|
31
|
+
"api-catalog",
|
|
32
|
+
"appendix",
|
|
33
|
+
"apple-touch-icon",
|
|
34
|
+
"apple-touch-startup-image",
|
|
35
|
+
"archives",
|
|
36
|
+
"author",
|
|
37
|
+
"blocked-by",
|
|
38
|
+
"bookmark",
|
|
39
|
+
"c2pa-manifest",
|
|
40
|
+
"canonical",
|
|
41
|
+
"chapter",
|
|
42
|
+
"cite-as",
|
|
43
|
+
"collection",
|
|
44
|
+
"compression-dictionary",
|
|
45
|
+
"contents",
|
|
46
|
+
"convertedfrom",
|
|
47
|
+
"copyright",
|
|
48
|
+
"create-form",
|
|
49
|
+
"current",
|
|
50
|
+
"deprecation",
|
|
51
|
+
"describedby",
|
|
52
|
+
"describes",
|
|
53
|
+
"disclosure",
|
|
54
|
+
"dns-prefetch",
|
|
55
|
+
"dpp",
|
|
56
|
+
"duplicate",
|
|
57
|
+
"edit",
|
|
58
|
+
"edit-form",
|
|
59
|
+
"edit-media",
|
|
60
|
+
"enclosure",
|
|
61
|
+
"external",
|
|
62
|
+
"first",
|
|
63
|
+
"geofeed",
|
|
64
|
+
"glossary",
|
|
65
|
+
"help",
|
|
66
|
+
"hosts",
|
|
67
|
+
"hub",
|
|
68
|
+
"ice-server",
|
|
69
|
+
"icon",
|
|
70
|
+
"index",
|
|
71
|
+
"intervalafter",
|
|
72
|
+
"intervalbefore",
|
|
73
|
+
"intervalcontains",
|
|
74
|
+
"intervaldisjoint",
|
|
75
|
+
"intervalduring",
|
|
76
|
+
"intervalequals",
|
|
77
|
+
"intervalfinishedby",
|
|
78
|
+
"intervalfinishes",
|
|
79
|
+
"intervalin",
|
|
80
|
+
"intervalmeets",
|
|
81
|
+
"intervalmetby",
|
|
82
|
+
"intervaloverlappedby",
|
|
83
|
+
"intervaloverlaps",
|
|
84
|
+
"intervalstartedby",
|
|
85
|
+
"intervalstarts",
|
|
86
|
+
"item",
|
|
87
|
+
"last",
|
|
88
|
+
"latest-version",
|
|
89
|
+
"license",
|
|
90
|
+
"linkset",
|
|
91
|
+
"lrdd",
|
|
92
|
+
"manifest",
|
|
93
|
+
"mask-icon",
|
|
94
|
+
"me",
|
|
95
|
+
"media-feed",
|
|
96
|
+
"memento",
|
|
97
|
+
"micropub",
|
|
98
|
+
"modulepreload",
|
|
99
|
+
"monitor",
|
|
100
|
+
"monitor-group",
|
|
101
|
+
"next",
|
|
102
|
+
"next-archive",
|
|
103
|
+
"nofollow",
|
|
104
|
+
"noopener",
|
|
105
|
+
"noreferrer",
|
|
106
|
+
"opener",
|
|
107
|
+
"openid2.local_id",
|
|
108
|
+
"openid2.provider",
|
|
109
|
+
"original",
|
|
110
|
+
"p3pv1",
|
|
111
|
+
"payment",
|
|
112
|
+
"pingback",
|
|
113
|
+
"preconnect",
|
|
114
|
+
"predecessor-version",
|
|
115
|
+
"prefetch",
|
|
116
|
+
"preload",
|
|
117
|
+
"prerender",
|
|
118
|
+
"prev",
|
|
119
|
+
"prev-archive",
|
|
120
|
+
"preview",
|
|
121
|
+
"previous",
|
|
122
|
+
"privacy-policy",
|
|
123
|
+
"profile",
|
|
124
|
+
"publication",
|
|
125
|
+
"rdap-active",
|
|
126
|
+
"rdap-bottom",
|
|
127
|
+
"rdap-down",
|
|
128
|
+
"rdap-top",
|
|
129
|
+
"rdap-up",
|
|
130
|
+
"related",
|
|
131
|
+
"replies",
|
|
132
|
+
"restconf",
|
|
133
|
+
"ruleinput",
|
|
134
|
+
"search",
|
|
135
|
+
"section",
|
|
136
|
+
"self",
|
|
137
|
+
"service",
|
|
138
|
+
"service-desc",
|
|
139
|
+
"service-doc",
|
|
140
|
+
"service-meta",
|
|
141
|
+
"sip-trunking-capability",
|
|
142
|
+
"sponsored",
|
|
143
|
+
"start",
|
|
144
|
+
"status",
|
|
145
|
+
"stylesheet",
|
|
146
|
+
"subsection",
|
|
147
|
+
"successor-version",
|
|
148
|
+
"sunset",
|
|
149
|
+
"tag",
|
|
150
|
+
"terms-of-service",
|
|
151
|
+
"timegate",
|
|
152
|
+
"timemap",
|
|
153
|
+
"type",
|
|
154
|
+
"ugc",
|
|
155
|
+
"up",
|
|
156
|
+
"version-history",
|
|
157
|
+
"via",
|
|
158
|
+
"webmention",
|
|
159
|
+
"working-copy",
|
|
160
|
+
"working-copy-of",
|
|
161
|
+
]);
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Values HTML accepts that the registry does not list.
|
|
165
|
+
*
|
|
166
|
+
* `shortcut` is the whole set, and it is here because `rel="shortcut icon"` predates the registry
|
|
167
|
+
* and still appears in every favicon snippet on the web. The HTML Standard handles it explicitly as
|
|
168
|
+
* a legacy spelling of `icon`; warning about it would be crying wolf on correct, working markup.
|
|
169
|
+
*/
|
|
170
|
+
const HTML_LEGACY_RELATIONS: ReadonlySet<string> = new Set(["shortcut"]);
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* True when the token is an extension relation type — RFC 8288 §2.1.2's escape hatch, which is an
|
|
174
|
+
* absolute URI and is how anyone declares a relation the registry does not carry.
|
|
175
|
+
*
|
|
176
|
+
* @param {string} token
|
|
177
|
+
* @returns {boolean}
|
|
178
|
+
*/
|
|
179
|
+
function isExtensionRelation(token: string): boolean {
|
|
180
|
+
// A registered name can never contain a colon, so this test cannot swallow a typo'd one.
|
|
181
|
+
const colon = token.indexOf(":");
|
|
182
|
+
if (colon === -1) {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
/*
|
|
186
|
+
* Something has to follow the scheme. `new URL("canonical:")` parses — a scheme with an empty
|
|
187
|
+
* opaque path is a URI — so without this, one stray keystroke at the end of a registered name
|
|
188
|
+
* would be silently reclassified as a deliberate extension relation, which is the precise
|
|
189
|
+
* mistake this whole module exists to catch.
|
|
190
|
+
*/
|
|
191
|
+
if (colon === token.length - 1) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
return new URL(token).protocol !== "";
|
|
196
|
+
} catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The relation tokens in a `rel` attribute that are neither registered, nor legacy HTML, nor an
|
|
203
|
+
* extension URI.
|
|
204
|
+
*
|
|
205
|
+
* `rel` is a space-separated set (`rel="shortcut icon"`, `rel="noopener noreferrer"`), so each
|
|
206
|
+
* token is judged on its own; comparison is case-insensitive, which is what the HTML Standard
|
|
207
|
+
* specifies for link types.
|
|
208
|
+
*
|
|
209
|
+
* @param {unknown} rel - The raw attribute value
|
|
210
|
+
* @returns {string[]} Unrecognized tokens, as written
|
|
211
|
+
*/
|
|
212
|
+
export function unregisteredRelations(rel: unknown): string[] {
|
|
213
|
+
if (typeof rel !== "string") {
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
return rel.split(/\s+/).filter((token) => {
|
|
217
|
+
if (token === "") {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
const lower = token.toLowerCase();
|
|
221
|
+
return (
|
|
222
|
+
!IANA_LINK_RELATIONS.has(lower) &&
|
|
223
|
+
!HTML_LEGACY_RELATIONS.has(lower) &&
|
|
224
|
+
!isExtensionRelation(token)
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Every unrecognized relation token across a set of `<head>` entries, deduplicated.
|
|
231
|
+
*
|
|
232
|
+
* Deduplicated because one mistyped `rel` in a layout is on every page of the site, and a warning
|
|
233
|
+
* repeated four hundred times is a warning nobody reads.
|
|
234
|
+
*
|
|
235
|
+
* @param {readonly { tagName?: string; attributes?: Record<string, unknown> }[]} entries
|
|
236
|
+
* @returns {string[]}
|
|
237
|
+
*/
|
|
238
|
+
export function unregisteredHeadRelations(
|
|
239
|
+
entries: readonly { tagName?: string; attributes?: Record<string, unknown> }[],
|
|
240
|
+
): string[] {
|
|
241
|
+
const found = new Set<string>();
|
|
242
|
+
for (const entry of entries) {
|
|
243
|
+
if (entry?.tagName !== "link") {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
for (const token of unregisteredRelations(entry.attributes?.rel)) {
|
|
247
|
+
found.add(token);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return [...found];
|
|
251
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sending a visitor to their own language: RFC 4647 Lookup over `Accept-Language`.
|
|
3
|
+
*
|
|
4
|
+
* Discovery already ships — a translated page advertises its siblings with `hreflang` alternates
|
|
5
|
+
* and `xhtml:link` entries, so a crawler can find every translation. What that does **not** do is
|
|
6
|
+
* route a person. A visitor arriving at the bare `/` gets whatever `/` happens to hold, and their
|
|
7
|
+
* `Accept-Language` header — the one thing that says which language they read — is never
|
|
8
|
+
* consulted.
|
|
9
|
+
*
|
|
10
|
+
* **Not every deployment can fix that, and the honest thing is to say which.** Negotiation needs a
|
|
11
|
+
* request, and adapter-less static output has no runtime that sees one: `dist/` is files, and the
|
|
12
|
+
* preview server is a pure file mapper. That is a permanent property of the output shape, not
|
|
13
|
+
* missing work. A site with `build.adapter` set gets a generated worker, the worker sees the
|
|
14
|
+
* request, and this is where it decides.
|
|
15
|
+
*
|
|
16
|
+
* Two things this deliberately does NOT do:
|
|
17
|
+
*
|
|
18
|
+
* - **It negotiates the bare `/` only.** A visitor who asked for `/fr/about/` has expressed a
|
|
19
|
+
* preference far stronger than a header, and overriding it would make a shared link mean
|
|
20
|
+
* different things to different people.
|
|
21
|
+
* - **It never guesses from an IP address.** Where someone is has never been what they read.
|
|
22
|
+
*
|
|
23
|
+
* @docs framework/site/i18n
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { ResolvedI18n } from "./i18n.ts";
|
|
27
|
+
|
|
28
|
+
/** One `Accept-Language` entry: a language range and the quality it was offered at. */
|
|
29
|
+
interface WeightedRange {
|
|
30
|
+
range: string;
|
|
31
|
+
quality: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse `Accept-Language` into ranges, best first (RFC 9110 §12.5.4).
|
|
36
|
+
*
|
|
37
|
+
* `q=0` means **not acceptable**, so those ranges are dropped rather than ranked last — a visitor
|
|
38
|
+
* who wrote `de;q=0` is saying "not German", and treating that as a weak preference for German
|
|
39
|
+
* would be exactly backwards. Ties keep their header order, which is the conventional reading of an
|
|
40
|
+
* unweighted list.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} header
|
|
43
|
+
* @returns {WeightedRange[]}
|
|
44
|
+
*/
|
|
45
|
+
export function parseAcceptLanguage(header: string): WeightedRange[] {
|
|
46
|
+
const parsed: { entry: WeightedRange; at: number }[] = [];
|
|
47
|
+
for (const [index, part] of header.split(",").entries()) {
|
|
48
|
+
const [rawRange, ...params] = part.split(";");
|
|
49
|
+
const range = (rawRange ?? "").trim().toLowerCase();
|
|
50
|
+
if (range === "") {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
let quality = 1;
|
|
54
|
+
for (const param of params) {
|
|
55
|
+
const [name, value] = param.split("=");
|
|
56
|
+
if ((name ?? "").trim().toLowerCase() === "q") {
|
|
57
|
+
// A malformed qvalue is a malformed header field; NaN falls through to the drop below.
|
|
58
|
+
const parsedQuality = Number((value ?? "").trim());
|
|
59
|
+
quality = Number.isNaN(parsedQuality) ? 0 : parsedQuality;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (quality <= 0) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
parsed.push({ at: index, entry: { quality, range } });
|
|
66
|
+
}
|
|
67
|
+
return parsed
|
|
68
|
+
.toSorted((a, b) => b.entry.quality - a.entry.quality || a.at - b.at)
|
|
69
|
+
.map((p) => p.entry);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The available tag a single language range selects, or null (RFC 4647 §3.4, Lookup).
|
|
74
|
+
*
|
|
75
|
+
* Lookup, not Filtering, and the difference is the whole point: Filtering returns every tag that
|
|
76
|
+
* matches, which is the right answer for a content-negotiation menu and the wrong one for "which
|
|
77
|
+
* page do I send this person to". A visitor gets one page.
|
|
78
|
+
*
|
|
79
|
+
* The algorithm is progressive truncation — `de-CH-1901` tries `de-CH-1901`, then `de-CH`, then
|
|
80
|
+
* `de`. A single-character subtag is removed together with the one before it, because a lone `x` or
|
|
81
|
+
* `u` is an extension singleton and never a tag on its own.
|
|
82
|
+
*
|
|
83
|
+
* @param {string} range - A lower-cased language range
|
|
84
|
+
* @param {readonly string[]} available - Canonical tags, in preference order
|
|
85
|
+
* @returns {string | null}
|
|
86
|
+
*/
|
|
87
|
+
export function lookupRange(range: string, available: readonly string[]): string | null {
|
|
88
|
+
let candidate = range;
|
|
89
|
+
while (candidate !== "") {
|
|
90
|
+
const wanted = candidate;
|
|
91
|
+
const match = available.find((tag) => tag.toLowerCase() === wanted);
|
|
92
|
+
if (match !== undefined) {
|
|
93
|
+
return match;
|
|
94
|
+
}
|
|
95
|
+
const cut = candidate.lastIndexOf("-");
|
|
96
|
+
if (cut === -1) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
candidate = candidate.slice(0, cut);
|
|
100
|
+
// A trailing single-character subtag is a singleton, never a tag — drop it with its parent.
|
|
101
|
+
const previous = candidate.lastIndexOf("-");
|
|
102
|
+
if (candidate.length - previous === 2) {
|
|
103
|
+
candidate = candidate.slice(0, previous);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* The locale to serve a request carrying this `Accept-Language`, always one of `available`.
|
|
111
|
+
*
|
|
112
|
+
* `*` selects the first available locale, which is the declared default: the range means "anything
|
|
113
|
+
* is acceptable", and the site's own preference is the only tie-break there is.
|
|
114
|
+
*
|
|
115
|
+
* @param {string | null | undefined} header - The raw `Accept-Language`, if any
|
|
116
|
+
* @param {readonly string[]} available - Canonical tags, default first
|
|
117
|
+
* @param {string} fallback - The locale to serve when nothing matches
|
|
118
|
+
* @returns {string}
|
|
119
|
+
*/
|
|
120
|
+
export function negotiateLocale(
|
|
121
|
+
header: string | null | undefined,
|
|
122
|
+
available: readonly string[],
|
|
123
|
+
fallback: string,
|
|
124
|
+
): string {
|
|
125
|
+
if (typeof header !== "string" || header.trim() === "") {
|
|
126
|
+
return fallback;
|
|
127
|
+
}
|
|
128
|
+
for (const { range } of parseAcceptLanguage(header)) {
|
|
129
|
+
if (range === "*") {
|
|
130
|
+
return available[0] ?? fallback;
|
|
131
|
+
}
|
|
132
|
+
const match = lookupRange(range, available);
|
|
133
|
+
if (match !== null) {
|
|
134
|
+
return match;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return fallback;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The site-absolute path a locale's home page lives at.
|
|
142
|
+
*
|
|
143
|
+
* Under `prefix-except-default` the default locale has no prefix, which is what makes `/` a real
|
|
144
|
+
* page there and a redirect target nowhere else.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} locale - A canonical tag
|
|
147
|
+
* @param {ResolvedI18n} i18n
|
|
148
|
+
* @returns {string}
|
|
149
|
+
*/
|
|
150
|
+
export function localeHome(locale: string, i18n: ResolvedI18n): string {
|
|
151
|
+
if (i18n.routing === "prefix-except-default" && locale === i18n.defaultLocale) {
|
|
152
|
+
return "/";
|
|
153
|
+
}
|
|
154
|
+
return `/${locale.toLowerCase()}/`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The negotiation middleware for the generated worker, or `""` when there is nothing to negotiate.
|
|
159
|
+
*
|
|
160
|
+
* Emitted as source text rather than imported, matching every other generated artefact here — the
|
|
161
|
+
* worker is bundled from the _project_ root, so a compiler-internal import would not resolve. The
|
|
162
|
+
* copy cannot silently drift: a test evaluates this source and runs the same corpus through it as
|
|
163
|
+
* through {@link negotiateLocale}, failing on the first disagreement.
|
|
164
|
+
*
|
|
165
|
+
* **Middleware, not a route.** When negotiation lands on the locale `/` already serves, the request
|
|
166
|
+
* must continue down the ordinary chain — the ASSETS binding on Cloudflare, static serving
|
|
167
|
+
* elsewhere. A route would have to reproduce whichever of those the adapter uses; `await next()`
|
|
168
|
+
* does not have to know.
|
|
169
|
+
*
|
|
170
|
+
* **`Vary: Accept-Language` is not optional.** Without it any cache in front of the site stores the
|
|
171
|
+
* first visitor's answer and serves it to everyone, which turns a helpful default into a site stuck
|
|
172
|
+
* in one language for every later reader. That is worse than not negotiating at all, and it is
|
|
173
|
+
* invisible to the author — whose own browser was the first visitor.
|
|
174
|
+
*
|
|
175
|
+
* @param {ResolvedI18n | null} i18n
|
|
176
|
+
* @returns {string} JS source, or "" when the site declares fewer than two locales
|
|
177
|
+
*/
|
|
178
|
+
export function localeNegotiationMiddleware(i18n: ResolvedI18n | null): string {
|
|
179
|
+
if (i18n === null || i18n.locales.length < 2) {
|
|
180
|
+
return "";
|
|
181
|
+
}
|
|
182
|
+
const locales = JSON.stringify(i18n.locales);
|
|
183
|
+
const fallback = JSON.stringify(i18n.defaultLocale);
|
|
184
|
+
const homes = JSON.stringify(
|
|
185
|
+
Object.fromEntries(i18n.locales.map((locale) => [locale, localeHome(locale, i18n)])),
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
return `
|
|
189
|
+
// ─── Locale negotiation (RFC 4647 Lookup over Accept-Language) ───────────────
|
|
190
|
+
const JX_LOCALES = ${locales}
|
|
191
|
+
const JX_DEFAULT_LOCALE = ${fallback}
|
|
192
|
+
const JX_LOCALE_HOMES = ${homes}
|
|
193
|
+
|
|
194
|
+
function jxParseAcceptLanguage(header) {
|
|
195
|
+
const parsed = []
|
|
196
|
+
header.split(',').forEach((part, index) => {
|
|
197
|
+
const [rawRange, ...params] = part.split(';')
|
|
198
|
+
const range = (rawRange || '').trim().toLowerCase()
|
|
199
|
+
if (range === '') return
|
|
200
|
+
let quality = 1
|
|
201
|
+
for (const param of params) {
|
|
202
|
+
const [name, value] = param.split('=')
|
|
203
|
+
if ((name || '').trim().toLowerCase() === 'q') {
|
|
204
|
+
const q = Number((value || '').trim())
|
|
205
|
+
quality = Number.isNaN(q) ? 0 : q
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (quality <= 0) return
|
|
209
|
+
parsed.push({ at: index, quality, range })
|
|
210
|
+
})
|
|
211
|
+
return parsed.sort((a, b) => b.quality - a.quality || a.at - b.at).map((p) => p.range)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function jxLookupRange(range, available) {
|
|
215
|
+
let candidate = range
|
|
216
|
+
while (candidate !== '') {
|
|
217
|
+
const wanted = candidate
|
|
218
|
+
const match = available.find((tag) => tag.toLowerCase() === wanted)
|
|
219
|
+
if (match !== undefined) return match
|
|
220
|
+
const cut = candidate.lastIndexOf('-')
|
|
221
|
+
if (cut === -1) return null
|
|
222
|
+
candidate = candidate.slice(0, cut)
|
|
223
|
+
const previous = candidate.lastIndexOf('-')
|
|
224
|
+
if (candidate.length - previous === 2) candidate = candidate.slice(0, previous)
|
|
225
|
+
}
|
|
226
|
+
return null
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function jxNegotiateLocale(header, available, fallback) {
|
|
230
|
+
if (typeof header !== 'string' || header.trim() === '') return fallback
|
|
231
|
+
for (const range of jxParseAcceptLanguage(header)) {
|
|
232
|
+
if (range === '*') return available[0] || fallback
|
|
233
|
+
const match = jxLookupRange(range, available)
|
|
234
|
+
if (match !== null) return match
|
|
235
|
+
}
|
|
236
|
+
return fallback
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
app.use('/', async (c, next) => {
|
|
240
|
+
const locale = jxNegotiateLocale(c.req.header('accept-language'), JX_LOCALES, JX_DEFAULT_LOCALE)
|
|
241
|
+
const home = JX_LOCALE_HOMES[locale] || '/'
|
|
242
|
+
if (home !== '/') {
|
|
243
|
+
// 302, never 301: the target depends on the request, so it must never be cached as permanent.
|
|
244
|
+
return new Response(null, {
|
|
245
|
+
headers: { 'Content-Language': locale, Location: home, Vary: 'Accept-Language' },
|
|
246
|
+
status: 302,
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
// '/' already holds this locale (prefix-except-default). Let the chain serve it, then say the
|
|
250
|
+
// Response depended on the header — a cache that missed this would pin every visitor to it.
|
|
251
|
+
await next()
|
|
252
|
+
const res = new Response(c.res.body, c.res)
|
|
253
|
+
res.headers.set('Vary', 'Accept-Language')
|
|
254
|
+
res.headers.set('Content-Language', locale)
|
|
255
|
+
c.res = res
|
|
256
|
+
})
|
|
257
|
+
`;
|
|
258
|
+
}
|