@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.
@@ -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
+ }