@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.
@@ -0,0 +1,365 @@
1
+ /**
2
+ * Locale routing: which language a route is in, and what that means for the page it produces.
3
+ *
4
+ * The `i18n` key has been in the project schema since the beginning and **nothing read it**. A site
5
+ * with `pages/fr/about.json` got a French page only because `fr` happened to be an ordinary path
6
+ * segment; the config beside it was decoration. This module is the reader.
7
+ *
8
+ * Deliberately not a translation system. Jx has no message catalogue, no `t()` and no fallback
9
+ * chain — a locale here is a property of a _route_, and what the route serves is whatever the
10
+ * author put in that directory. That is the whole model, and everything below follows from it.
11
+ *
12
+ * @docs framework/site/i18n
13
+ */
14
+
15
+ import { canonicalizeLocale, localeDirection } from "@jxsuite/schema/locale";
16
+ import type { ResolvedI18n, TextDirection } from "@jxsuite/schema/locale";
17
+
18
+ /*
19
+ * `resolveI18n` and the two types beside it live in `@jxsuite/schema/locale` rather than here.
20
+ * Studio needs the same answer and cannot import this package — there is no `./site/i18n` export,
21
+ * the dependency graph carries `sharp` and `esbuild`, and Studio bundles for a browser. One
22
+ * resolution with two hosts is the only shape in which the tag Studio offers is certain to be the
23
+ * tag the build accepts.
24
+ *
25
+ * Everything below is route-shaped and has no Studio consumer, so it stays.
26
+ */
27
+ export { resolveI18n } from "@jxsuite/schema/locale";
28
+ export type { LocaleRouting, ResolvedI18n } from "@jxsuite/schema/locale";
29
+
30
+ /**
31
+ * The locale a URL belongs to.
32
+ *
33
+ * Matching is on the **first path segment**, compared canonically, so `/fr-CA/about/` resolves for
34
+ * a project that declared `fr-ca`. A path under no declared locale is the default locale — under
35
+ * `prefix-except-default` that is the point, and under `prefix-always` it is a page the author put
36
+ * outside the locale tree, which is theirs to place.
37
+ *
38
+ * @param {string} urlPattern - Site-absolute route pattern, e.g. `/fr/about/`
39
+ * @param {ResolvedI18n | null} i18n
40
+ * @returns {string | null} A canonical tag, or null when the project has no i18n
41
+ */
42
+ export function localeOfRoute(urlPattern: string, i18n: ResolvedI18n | null): string | null {
43
+ if (i18n === null) {
44
+ return null;
45
+ }
46
+ const first = urlPattern.split("/").find(Boolean);
47
+ if (first === undefined) {
48
+ return i18n.defaultLocale;
49
+ }
50
+ const canonical = canonicalizeLocale(first);
51
+ return canonical !== null && i18n.locales.includes(canonical) ? canonical : i18n.defaultLocale;
52
+ }
53
+
54
+ /**
55
+ * Routes sitting outside the locale tree under `prefix-always`.
56
+ *
57
+ * `prefix-always` is a promise that **every** URL names its language. A page at `/about/` under it
58
+ * breaks that promise silently: it builds, it serves, and `localeOfRoute` calls it the default
59
+ * locale — so a site that declared "no unprefixed URLs" ships them anyway, and the only reader who
60
+ * finds out is a visitor who lands on one and sees the wrong language with no way to switch.
61
+ *
62
+ * Reported rather than rejected. The author may genuinely mean it — a `/robots-info/` page, a
63
+ * landing page with no translations — and failing their build over a page that works would be the
64
+ * compiler overruling a decision it cannot see the reason for. Naming it is enough.
65
+ *
66
+ * `/` is excluded: under `prefix-always` the site root is the one URL that exists precisely to send
67
+ * a visitor somewhere else, and negotiation (`locale-negotiation.ts`) is what it is for.
68
+ *
69
+ * @param {readonly { urlPattern: string }[]} routes
70
+ * @param {ResolvedI18n | null} i18n
71
+ * @returns {string[]} Offending URL patterns, in route order
72
+ */
73
+ export function unprefixedRoutes(
74
+ routes: readonly { urlPattern: string }[],
75
+ i18n: ResolvedI18n | null,
76
+ ): string[] {
77
+ if (i18n === null || i18n.routing !== "prefix-always") {
78
+ return [];
79
+ }
80
+ const offenders: string[] = [];
81
+ for (const route of routes) {
82
+ if (route.urlPattern === "/") {
83
+ continue;
84
+ }
85
+ const first = route.urlPattern.split("/").find(Boolean);
86
+ const canonical = canonicalizeLocale(first);
87
+ if (canonical === null || !i18n.locales.includes(canonical)) {
88
+ offenders.push(route.urlPattern);
89
+ }
90
+ }
91
+ return offenders;
92
+ }
93
+
94
+ /**
95
+ * A route prefix that looks like one of the declared locales but is not one of them.
96
+ *
97
+ * The precise mistake this catches: `i18n.locales` says `fr-CA` and the pages live in `pages/fr/`.
98
+ * The segment is a well-formed tag, the directory is obviously meant to be French, and nothing
99
+ * matches — so every page under it silently becomes the default locale and claims the wrong
100
+ * language in `<html lang>`. Left alone that is invisible; there is no error and the page looks
101
+ * fine.
102
+ *
103
+ * Scoped to the **primary language** of a declared locale on purpose. Any 2-to-8-letter segment is
104
+ * a well-formed language tag — `/docs/`, `/api/`, `/it/` — so warning on "looks like a tag" would
105
+ * fire on ordinary paths. Warning on "looks like a locale you declared" fires only on the mistake.
106
+ *
107
+ * @param {string} urlPattern
108
+ * @param {ResolvedI18n | null} i18n
109
+ * @returns {{ segment: string; meant: string } | null}
110
+ */
111
+ export function undeclaredLocalePrefix(
112
+ urlPattern: string,
113
+ i18n: ResolvedI18n | null,
114
+ ): { segment: string; meant: string } | null {
115
+ if (i18n === null) {
116
+ return null;
117
+ }
118
+ const first = urlPattern.split("/").find(Boolean);
119
+ const canonical = canonicalizeLocale(first);
120
+ if (first === undefined || canonical === null || i18n.locales.includes(canonical)) {
121
+ return null;
122
+ }
123
+ const meant = i18n.locales.find(
124
+ (locale) => locale !== canonical && locale.split("-")[0] === canonical.split("-")[0],
125
+ );
126
+ return meant === undefined ? null : { meant, segment: first };
127
+ }
128
+
129
+ /**
130
+ * A route's identity across locales: its path with the locale prefix removed.
131
+ *
132
+ * `/fr-ca/about/` and `/about/` share the key `about/`, which is what makes them translations of
133
+ * one another. The directory layout is the default mapping, and it is enough whenever the paths are
134
+ * parallel.
135
+ *
136
+ * A **localized slug** is not parallel, and that is the case this cannot derive: `/fr-ca/a-propos/`
137
+ * shares nothing with `/about/`. A document says so itself with `$translationKey`, which overrides
138
+ * this exactly as `$lang` overrides the locale its route implies (§13.4) — one key, and the whole
139
+ * annotation follows.
140
+ *
141
+ * @param {string} urlPattern
142
+ * @param {ResolvedI18n | null} i18n
143
+ * @returns {string}
144
+ */
145
+ export function translationKey(urlPattern: string, i18n: ResolvedI18n | null): string {
146
+ const segments = urlPattern.split("/").filter(Boolean);
147
+ const canonical = canonicalizeLocale(segments[0]);
148
+ if (i18n !== null && canonical !== null && i18n.locales.includes(canonical)) {
149
+ return segments.slice(1).join("/");
150
+ }
151
+ return segments.join("/");
152
+ }
153
+
154
+ /** One `<link rel="alternate">` / sitemap `xhtml:link`. */
155
+ export interface LocaleAlternate {
156
+ hreflang: string;
157
+ href: string;
158
+ }
159
+
160
+ /** One route in a translation set: the locale it serves, and where it lives. */
161
+ export interface TranslationMember {
162
+ /** Canonical BCP 47 tag. */
163
+ locale: string;
164
+ /** Site-absolute route pattern, e.g. `/fr-ca/about/`. */
165
+ urlPattern: string;
166
+ }
167
+
168
+ /** A route as translation grouping sees it: its URL, and the key its document declared. */
169
+ export interface TranslationRoute {
170
+ /** From the document's `$translationKey`; absent when the key is derived from the path. */
171
+ translationKey?: string | undefined;
172
+ urlPattern: string;
173
+ }
174
+
175
+ /** Two routes claiming to be the same page in the same language. */
176
+ export interface TranslationConflict {
177
+ /** True when at least one of them said so with `$translationKey`. */
178
+ declared: boolean;
179
+ key: string;
180
+ locale: string;
181
+ /** In route order; the first is the one that keeps the set. */
182
+ urlPatterns: string[];
183
+ }
184
+
185
+ /** Everything a page needs to know about its own locale, gathered once per build. */
186
+ export interface PageLocaleContext {
187
+ /** Absolute alternates for `<head>` and the sitemap; empty without a site `url`. */
188
+ alternates: readonly LocaleAlternate[];
189
+ i18n: ResolvedI18n | null;
190
+ /** The page's translation set, site-absolute and including itself. */
191
+ translations: readonly TranslationMember[];
192
+ }
193
+
194
+ /**
195
+ * Group routes into translation sets: for each route, every route that is the same page in another
196
+ * language, itself included, ordered by tag.
197
+ *
198
+ * One derivation, two readers. {@link localeAlternates} turns a set into absolute `<link
199
+ * rel="alternate">` hrefs for crawlers; `injectContext` turns it into `$page.alternates` for a
200
+ * language switcher a reader can click. Deriving it twice is how the two would come to disagree
201
+ * about which pages are translations of one another — and the disagreement would be invisible,
202
+ * because one of them is only ever read by a machine.
203
+ *
204
+ * **A set of one is kept here and dropped there.** A lone `hreflang` pointing at itself is noise in
205
+ * `<head>` (§13.5), but a template asking "which languages is this page in" wants the honest
206
+ * answer, and dropping the page itself would leave a switcher unable to mark where the reader is.
207
+ *
208
+ * URLs stay **site-absolute** rather than becoming absolute hrefs, because a switcher is a link
209
+ * within the site and works before `url` is configured — which is every project during
210
+ * development.
211
+ *
212
+ * @param {readonly TranslationRoute[]} routes - Concrete routes only
213
+ * @param {ResolvedI18n | null} i18n
214
+ * @returns {{ conflicts: TranslationConflict[]; sets: Map<string, TranslationMember[]> }} Sets are
215
+ * keyed by `urlPattern`; members of one set share one array
216
+ */
217
+ export function translationSets(
218
+ routes: readonly TranslationRoute[],
219
+ i18n: ResolvedI18n | null,
220
+ ): { conflicts: TranslationConflict[]; sets: Map<string, TranslationMember[]> } {
221
+ const out = new Map<string, TranslationMember[]>();
222
+ const conflicts: TranslationConflict[] = [];
223
+ if (i18n === null) {
224
+ return { conflicts, sets: out };
225
+ }
226
+
227
+ const sets = new Map<string, TranslationMember[]>();
228
+ const declared = new Set<string>();
229
+ for (const route of routes) {
230
+ /*
231
+ * Slashes at the ends are trimmed so a declared key can be written the way the URL reads.
232
+ * The derived form has none — `/about/` reduces to `about` — and a `"/about"` that silently
233
+ * matched nothing would look exactly like a key that was ignored.
234
+ */
235
+ const key =
236
+ route.translationKey === undefined
237
+ ? translationKey(route.urlPattern, i18n)
238
+ : route.translationKey.replaceAll(/^\/+|\/+$/g, "");
239
+ if (route.translationKey !== undefined) {
240
+ declared.add(route.urlPattern);
241
+ }
242
+ const locale = localeOfRoute(route.urlPattern, i18n);
243
+ if (locale === null) {
244
+ continue;
245
+ }
246
+ const members = sets.get(key) ?? [];
247
+ /*
248
+ * A duplicate locale in one set means two routes claim to be the same page in one language.
249
+ * The first wins, which keeps the set single-valued rather than advertising a contradiction;
250
+ * the loser is keyed nowhere, so it carries no alternates and no switcher.
251
+ *
252
+ * How loudly to say so is the caller's decision, and it turns on which kind of collision it
253
+ * is: a declared one is a promise the author wrote down and broke, while a derived one may be
254
+ * a deliberate alias the compiler cannot see the reason for.
255
+ */
256
+ const taken = members.find((m) => m.locale === locale);
257
+ if (taken === undefined) {
258
+ members.push({ locale, urlPattern: route.urlPattern });
259
+ } else {
260
+ conflicts.push({
261
+ declared: declared.has(taken.urlPattern) || route.translationKey !== undefined,
262
+ key,
263
+ locale,
264
+ urlPatterns: [taken.urlPattern, route.urlPattern],
265
+ });
266
+ }
267
+ sets.set(key, members);
268
+ }
269
+
270
+ for (const members of sets.values()) {
271
+ const ordered = members.toSorted((a, b) =>
272
+ a.locale === b.locale ? 0 : a.locale < b.locale ? -1 : 1,
273
+ );
274
+ for (const member of members) {
275
+ out.set(member.urlPattern, ordered);
276
+ }
277
+ }
278
+ return { conflicts, sets: out };
279
+ }
280
+
281
+ /**
282
+ * Group routes into translation sets and give each one its alternates.
283
+ *
284
+ * A page with no translations gets **none** — a lone `hreflang` pointing at itself is noise, and
285
+ * the standards it would satisfy expect a set. `x-default` names the default locale's URL, which is
286
+ * the convention search engines act on for "the page to send an unmatched visitor to"; it is
287
+ * omitted when the set has no default-locale member, since inventing one would advertise a URL that
288
+ * does not exist.
289
+ *
290
+ * Reciprocity is automatic: every member of a set lists every member **including itself**, which is
291
+ * what the annotation is specified to do and what validators check for.
292
+ *
293
+ * @param {readonly TranslationRoute[]} routes - Concrete routes only
294
+ * @param {ResolvedI18n | null} i18n
295
+ * @param {string} siteUrl - Absolute site URL; alternates must be absolute
296
+ * @returns {Map<string, LocaleAlternate[]>} Keyed by `urlPattern`
297
+ */
298
+ export function localeAlternates(
299
+ routes: readonly TranslationRoute[],
300
+ i18n: ResolvedI18n | null,
301
+ siteUrl: string,
302
+ ): Map<string, LocaleAlternate[]> {
303
+ const out = new Map<string, LocaleAlternate[]>();
304
+ if (i18n === null || siteUrl === "") {
305
+ return out;
306
+ }
307
+
308
+ /* Built once per set rather than once per member: every member advertises the same list. */
309
+ const built = new Map<readonly TranslationMember[], LocaleAlternate[]>();
310
+ for (const [urlPattern, members] of translationSets(routes, i18n).sets) {
311
+ if (members.length < 2) {
312
+ continue;
313
+ }
314
+ let alternates = built.get(members);
315
+ if (alternates === undefined) {
316
+ alternates = members.map((m) => ({
317
+ href: new URL(m.urlPattern, siteUrl).href,
318
+ hreflang: m.locale,
319
+ }));
320
+ const fallback = members.find((m) => m.locale === i18n.defaultLocale);
321
+ if (fallback !== undefined) {
322
+ alternates.push({
323
+ href: new URL(fallback.urlPattern, siteUrl).href,
324
+ hreflang: "x-default",
325
+ });
326
+ }
327
+ built.set(members, alternates);
328
+ }
329
+ out.set(urlPattern, alternates);
330
+ }
331
+ return out;
332
+ }
333
+
334
+ /**
335
+ * The `lang` and `dir` a page ships with.
336
+ *
337
+ * An explicit `$lang` on the document wins over the locale its route implies: a page really can be
338
+ * a French translation living at `/en/a-propos/`, and the author who wrote that down means it.
339
+ *
340
+ * `dir` is emitted only when it is `rtl` or when the author asked for something. `ltr` is the
341
+ * default for every element in HTML, so writing it on every page of a left-to-right site is noise
342
+ * that says nothing.
343
+ *
344
+ * @param {object} args
345
+ * @returns {{ lang: string; dir?: TextDirection | string }}
346
+ */
347
+ export function pageLanguage({
348
+ pageLang,
349
+ pageDir,
350
+ routeLocale,
351
+ defaults,
352
+ }: {
353
+ pageLang?: string | undefined;
354
+ pageDir?: string | undefined;
355
+ routeLocale?: string | null;
356
+ defaults?: { lang?: string | undefined; dir?: string | undefined } | undefined;
357
+ }): { lang: string; dir?: string } {
358
+ const lang = pageLang ?? routeLocale ?? defaults?.lang ?? "en";
359
+ const explicit = pageDir ?? defaults?.dir;
360
+ if (explicit !== undefined) {
361
+ return { dir: explicit, lang };
362
+ }
363
+ const derived: TextDirection = localeDirection(lang);
364
+ return derived === "rtl" ? { dir: "rtl", lang } : { lang };
365
+ }
@@ -33,6 +33,13 @@ export interface ImageConfig {
33
33
  quality: { webp?: number; avif?: number; jpeg?: number; png?: number };
34
34
  sizes: string;
35
35
  lazyLoad: boolean;
36
+ /**
37
+ * Wrap a multi-format image in a `<picture>` with one `<source>` per format. Default true.
38
+ * Setting it false restores a bare `<img srcset>` in the preferred format, at the cost of
39
+ * browsers that cannot decode that format: `srcset` carries no type information, so they pick an
40
+ * undecodable candidate rather than falling back.
41
+ */
42
+ picture?: boolean;
36
43
  service?: "build" | "cloudflare";
37
44
  /**
38
45
  * Hostnames whose remote (https) images are routed through the optimization pipeline. Cloudflare