@menapo/vi18n 0.0.0-stage → 0.1.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/LICENSE +49 -0
- package/README.md +110 -2
- package/dist/apply-Bre-lY9J.js +228 -0
- package/dist/captured-Bj8bO6vd.js +1034 -0
- package/dist/check-D34P-S--.js +1 -0
- package/dist/check-DyrAbv73.js +201 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +41 -0
- package/dist/codec-DuvU2VQy.js +207 -0
- package/dist/collect-Cx4_SKOe.js +13 -0
- package/dist/collisions-CfpZU8e4.js +217 -0
- package/dist/compile-B5Mz6WJm.js +245 -0
- package/dist/compile-Cc97JaW2.js +2502 -0
- package/dist/concurrency-C9UUWuDC.js +96 -0
- package/dist/concurrency-DONL7t4V.js +1 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.js +39 -0
- package/dist/dir-zw6bGMKQ.js +290 -0
- package/dist/duplicates-yuRljQ6h.js +185 -0
- package/dist/engine-B7p40M__.js +531 -0
- package/dist/engine.d.ts +43 -0
- package/dist/engine.js +1241 -0
- package/dist/exclude-Du-Zd_qI.js +26 -0
- package/dist/export-DGsMbcTZ.js +103 -0
- package/dist/extract-Dujlsgnk.js +298 -0
- package/dist/formats-KPIg-08V.js +781 -0
- package/dist/i18next-CrUnKFfh.js +1596 -0
- package/dist/i18next-Rgo-v9l0.js +1 -0
- package/dist/import-Cwdhcywt.js +225 -0
- package/dist/index.d.ts +156 -0
- package/dist/index.js +239 -0
- package/dist/init-BecCTvKi.js +792 -0
- package/dist/library-D5CWvTQs.js +1882 -0
- package/dist/lingui--PcOOYry.js +833 -0
- package/dist/lint.d.ts +105 -0
- package/dist/lint.js +971 -0
- package/dist/load-CQRz4PpM.js +2950 -0
- package/dist/load-gW-oJt1Q.js +747 -0
- package/dist/locales-DX4MOBFE.js +827 -0
- package/dist/lsp-Cq1Nuu-Q.js +31 -0
- package/dist/match-BGr8EXB3.js +89 -0
- package/dist/mcp-Cf0T7qPW.js +28 -0
- package/dist/merge-driver-CpiiSVIc.js +422 -0
- package/dist/messages-Cxa-5tR0.js +166 -0
- package/dist/migrate-CntqvrW4.js +1 -0
- package/dist/migrate-dPgq9uzy.js +1484 -0
- package/dist/openai-CTaMDcl-.js +346 -0
- package/dist/output-DsaHKHEt.js +164 -0
- package/dist/parse.d.ts +62 -0
- package/dist/parse.js +156 -0
- package/dist/pipeline-C7g11THL.js +2031 -0
- package/dist/pipeline-wxS0SApG.js +1 -0
- package/dist/project-Dwx7iPm6.js +381 -0
- package/dist/project-pXipT8iG.js +1046 -0
- package/dist/pseudo-DRv30iN5.js +145 -0
- package/dist/raw-C4Y6vyGY.js +4708 -0
- package/dist/react-intl-CaBVwJ9u.js +519 -0
- package/dist/restore-Btq66Tid.js +175 -0
- package/dist/rewrite-CiTRAICl.js +803 -0
- package/dist/rich-DCUKl5HM.js +97 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/run-DYD_VXKy.js +694 -0
- package/dist/runtime-DTMmliT1.js +64 -0
- package/dist/scan-worker.d.ts +62 -0
- package/dist/scan-worker.js +1 -0
- package/dist/scoped-BMYpSthj.js +44 -0
- package/dist/server-BDkykMjb.js +11823 -0
- package/dist/server-qv3mz70d.js +10485 -0
- package/dist/server.d.ts +169 -0
- package/dist/server.js +1234 -0
- package/dist/sitemap-DV3o8QD7.js +159 -0
- package/dist/sources-D19czDSA.js +2242 -0
- package/dist/status-DZArW5aE.js +149 -0
- package/dist/stdio-CVaJfxA7.js +29 -0
- package/dist/stdio-FPOKQAgu.js +95 -0
- package/dist/sync-CcVzger_.js +658 -0
- package/dist/t-DC-c9zE5.js +454 -0
- package/dist/test.d.ts +116 -0
- package/dist/test.js +319 -0
- package/dist/translate-DZR3MkSA.js +299 -0
- package/dist/ts-BaU5yOf0.js +4863 -0
- package/dist/types-CgUCkFU1.d.ts +707 -0
- package/dist/types-D3QGM8XO.js +146 -0
- package/dist/types-DsdWBG0b.d.ts +558 -0
- package/dist/types-DtvQ7UdD.js +1948 -0
- package/dist/types-wSh9fCBY.js +11 -0
- package/dist/units-CQ5Q99RC.js +150 -0
- package/dist/url-CRfCOhUI.js +172 -0
- package/dist/url-CsBkRaJy.js +225 -0
- package/dist/validate-BxpupdAf.js +2615 -0
- package/dist/validate-D6DJepZv.js +365 -0
- package/dist/version-BlyLGlOa.js +28 -0
- package/dist/vite-scan-B_YW4X_a.js +242 -0
- package/dist/vite.d.ts +34 -0
- package/dist/vite.js +7558 -0
- package/dist/vue-i18n-CEMnpv7k.js +651 -0
- package/dist/which-C6PFI1Dm.js +116 -0
- package/dist/worker-84U6uYB3.js +1656 -0
- package/package.json +97 -3
- package/schemas/catalog.json +57 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,1234 @@
|
|
|
1
|
+
import { a as getState, h as createScope, t as textDirection } from "./dir-zw6bGMKQ.js";
|
|
2
|
+
import { a as hostLocale, c as localizedPathsFor, d as splitLocalePath, i as awayHref, l as matchRoute, n as atHome, o as localeOrigin, r as atHost, s as localizePath } from "./url-CsBkRaJy.js";
|
|
3
|
+
import { r as matchLocale } from "./match-BGr8EXB3.js";
|
|
4
|
+
import { t as createScopedApi } from "./scoped-BMYpSthj.js";
|
|
5
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
6
|
+
//#region src/server/catalogs.ts
|
|
7
|
+
/**
|
|
8
|
+
* Server registration and catalogs (INTERNALS §3.2 "Server registration").
|
|
9
|
+
* The boot module (or `registerCatalogs`) registers once; from then on this
|
|
10
|
+
* module owns what `/server` loads for it: the per-locale catalogs, the
|
|
11
|
+
* `sources` map and the client plan. The source catalog and `sources` start
|
|
12
|
+
* loading at registration, so `State.serverFallback` exists before the first
|
|
13
|
+
* request and untransformed code resolves source literals from the start.
|
|
14
|
+
*
|
|
15
|
+
* Module-level variables rather than one state object: the minifier renames
|
|
16
|
+
* them, which keeps `/server` inside its PLAN §6 budget.
|
|
17
|
+
*/
|
|
18
|
+
const S = getState();
|
|
19
|
+
/**
|
|
20
|
+
* Dev mode from the plugin's `define`, read the way the runtime reads it:
|
|
21
|
+
* code bundled without the plugin behaves as production.
|
|
22
|
+
*/
|
|
23
|
+
const DEV = typeof __VI18N_DEV__ !== "undefined" && !!__VI18N_DEV__;
|
|
24
|
+
/**
|
|
25
|
+
* `ROUTES` of the URL codec, read here again: the packed `/server` imports
|
|
26
|
+
* the runtime chunk, where a constant cannot fold this entry's branches out
|
|
27
|
+
* (§6 D134).
|
|
28
|
+
*/
|
|
29
|
+
const ROUTES = typeof __VI18N_ROUTES__ === "undefined" || !!__VI18N_ROUTES__;
|
|
30
|
+
const own = Object.hasOwn;
|
|
31
|
+
const noop = () => {};
|
|
32
|
+
const error = (text) => /* @__PURE__ */ new Error("vi18n: " + text);
|
|
33
|
+
const warned = /* @__PURE__ */ new Set();
|
|
34
|
+
function warnOnce(text) {
|
|
35
|
+
if (warned.has(text)) return;
|
|
36
|
+
if (warned.size > 499) warned.clear();
|
|
37
|
+
warned.add(text);
|
|
38
|
+
console.warn("vi18n: " + text);
|
|
39
|
+
}
|
|
40
|
+
const zones = /* @__PURE__ */ new Set();
|
|
41
|
+
/**
|
|
42
|
+
* True for an IANA zone name this engine's Intl accepts (INTERNALS §3.2).
|
|
43
|
+
* Names start with a letter. UTC offsets (`+01:00`, `-0530`, `+01`), which
|
|
44
|
+
* Intl accepts too, are thousands of spellings: a client cycling them
|
|
45
|
+
* through `vi18n_tz` would add a formatter per spelling to every cache
|
|
46
|
+
* keyed by zone.
|
|
47
|
+
*/
|
|
48
|
+
function isTimeZone(zone) {
|
|
49
|
+
if (typeof zone !== "string" || zone.length > 64 || !/^[a-z]/i.test(zone)) return false;
|
|
50
|
+
if (zones.has(zone)) return true;
|
|
51
|
+
try {
|
|
52
|
+
new Intl.DateTimeFormat("en", { timeZone: zone });
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
if (zones.size < 1024) zones.add(zone);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
/** The registration served; replaced when the state registers again. */
|
|
60
|
+
let reg = null;
|
|
61
|
+
let config;
|
|
62
|
+
let loaders;
|
|
63
|
+
/** Dev catalogs (`registration.catalogs`) are re-imported per scope, so HMR invalidation shows. */
|
|
64
|
+
let dev = false;
|
|
65
|
+
/**
|
|
66
|
+
* Build catalogs, settled or in flight (concurrent scopes share one load; a
|
|
67
|
+
* failed load is dropped so the next request retries), least recently used
|
|
68
|
+
* first.
|
|
69
|
+
*/
|
|
70
|
+
let loaded = /* @__PURE__ */ new Map();
|
|
71
|
+
/** `sources()` as a Map, once settled; every scope `/server` opens carries it. */
|
|
72
|
+
let sources;
|
|
73
|
+
/** Source catalog and `sources` both settled: scopes may open without waiting. */
|
|
74
|
+
let settled = false;
|
|
75
|
+
let readying;
|
|
76
|
+
/** The client plan: undefined until loaded, null without one (sentinel, dev, `registerCatalogs`). */
|
|
77
|
+
let plan;
|
|
78
|
+
let planning;
|
|
79
|
+
/**
|
|
80
|
+
* The config of the current registration, (re)starting this module when the
|
|
81
|
+
* state registered again. Throws before any registration: there is nothing
|
|
82
|
+
* to negotiate with, and a guess would render the wrong locale.
|
|
83
|
+
*/
|
|
84
|
+
function current() {
|
|
85
|
+
const r = S.registration;
|
|
86
|
+
if (!r) throw error(DEV ? "no registration yet: the Vite plugin's boot module registers on import, and servers built without it call registerCatalogs() first" : "no registration (registerCatalogs)");
|
|
87
|
+
if (r !== reg) {
|
|
88
|
+
reg = r;
|
|
89
|
+
config = r.config;
|
|
90
|
+
loaders = r.serverCatalogs ?? r.catalogs;
|
|
91
|
+
dev = DEV && !r.serverCatalogs && !!r.catalogs;
|
|
92
|
+
loaded = /* @__PURE__ */ new Map();
|
|
93
|
+
sources = void 0;
|
|
94
|
+
settled = false;
|
|
95
|
+
readying = void 0;
|
|
96
|
+
plan = r.clientPlan ? void 0 : null;
|
|
97
|
+
planning = void 0;
|
|
98
|
+
ready()?.catch(noop);
|
|
99
|
+
}
|
|
100
|
+
return config;
|
|
101
|
+
}
|
|
102
|
+
/** Resolves once the source catalog and `sources` are loaded; undefined when they already are. */
|
|
103
|
+
function ready() {
|
|
104
|
+
if (settled) return void 0;
|
|
105
|
+
const r = reg;
|
|
106
|
+
return readying ??= Promise.all([catalog(config.sourceLocale), loadSources(r)]).then(([c, map]) => {
|
|
107
|
+
if (r !== reg) return;
|
|
108
|
+
sources = map;
|
|
109
|
+
settled = true;
|
|
110
|
+
S.serverFallback = createScope("request", config.sourceLocale, c.messages, {
|
|
111
|
+
timeZone: config.ssr.timeZone,
|
|
112
|
+
timeZoneSource: "config",
|
|
113
|
+
sources: map
|
|
114
|
+
});
|
|
115
|
+
}, (e) => {
|
|
116
|
+
if (r === reg) readying = void 0;
|
|
117
|
+
throw e;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
121
|
+
/** A module namespace's default export, or the object itself (both are accepted everywhere). */
|
|
122
|
+
function unwrap(m) {
|
|
123
|
+
return isObject(m) && own(m, "default") && isObject(m.default) ? m.default : m;
|
|
124
|
+
}
|
|
125
|
+
async function loadSources(r) {
|
|
126
|
+
if (!r.sources) return void 0;
|
|
127
|
+
const data = unwrap(await r.sources());
|
|
128
|
+
if (!isObject(data)) throw error("sources() did not return an object");
|
|
129
|
+
const map = /* @__PURE__ */ new Map();
|
|
130
|
+
for (const k in data) if (own(data, k) && typeof data[k] === "string") map.set(k, data[k]);
|
|
131
|
+
return map;
|
|
132
|
+
}
|
|
133
|
+
const entries = (v) => new Map(Object.entries(isObject(v) ? v : {}));
|
|
134
|
+
function toCatalog(locale, m) {
|
|
135
|
+
const data = unwrap(m);
|
|
136
|
+
if (!isObject(data)) throw error("the " + locale + " catalog is not an object");
|
|
137
|
+
if (!(DEV && dev)) return { messages: entries(data) };
|
|
138
|
+
const d = data;
|
|
139
|
+
return {
|
|
140
|
+
messages: entries(d.messages),
|
|
141
|
+
labels: entries(d.labels)
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
const devMaps = /* @__PURE__ */ new WeakMap();
|
|
145
|
+
/** The catalog of a configured locale: a value when loaded (build), else a promise. */
|
|
146
|
+
function catalog(locale) {
|
|
147
|
+
const into = loaded;
|
|
148
|
+
let c = into.get(locale);
|
|
149
|
+
if (!c) {
|
|
150
|
+
if (!loaders || !own(loaders, locale)) return Promise.reject(error("no server catalog for locale " + JSON.stringify(locale)));
|
|
151
|
+
if (DEV && dev) return loaders[locale]().then((m) => {
|
|
152
|
+
if (!isObject(m)) return toCatalog(locale, m);
|
|
153
|
+
let d = devMaps.get(m);
|
|
154
|
+
if (!d) devMaps.set(m, d = toCatalog(locale, m));
|
|
155
|
+
return d;
|
|
156
|
+
});
|
|
157
|
+
c = loaders[locale]().then((m) => {
|
|
158
|
+
const k = toCatalog(locale, m);
|
|
159
|
+
into.set(locale, k);
|
|
160
|
+
return k;
|
|
161
|
+
}, (e) => {
|
|
162
|
+
into.delete(locale);
|
|
163
|
+
throw e;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
into.delete(locale);
|
|
167
|
+
into.set(locale, c);
|
|
168
|
+
for (const l of into.keys()) if (into.size > config.ssr.maxLoadedLocales && l !== config.sourceLocale) into.delete(l);
|
|
169
|
+
return c;
|
|
170
|
+
}
|
|
171
|
+
/** Loads the client plan once; the sentinel string (or anything malformed) means fallback mode. */
|
|
172
|
+
function loadPlan() {
|
|
173
|
+
if (plan !== void 0) return void 0;
|
|
174
|
+
const r = reg;
|
|
175
|
+
return planning ??= r.clientPlan().then((m) => {
|
|
176
|
+
if (r !== reg) return;
|
|
177
|
+
const p = m.default;
|
|
178
|
+
plan = typeof p === "object" && p ? p : null;
|
|
179
|
+
}, (e) => {
|
|
180
|
+
if (r === reg) planning = void 0;
|
|
181
|
+
throw e;
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const STRATEGIES = [
|
|
185
|
+
"url",
|
|
186
|
+
"cookie",
|
|
187
|
+
"header",
|
|
188
|
+
"localStorage",
|
|
189
|
+
"default"
|
|
190
|
+
];
|
|
191
|
+
const RULES = [
|
|
192
|
+
["locales", []],
|
|
193
|
+
["pseudoLocales", []],
|
|
194
|
+
["fallbacks", { values: [] }],
|
|
195
|
+
["routing", {}],
|
|
196
|
+
["routing.strategy", [STRATEGIES]],
|
|
197
|
+
["routing.routeStrategies", { values: [STRATEGIES] }],
|
|
198
|
+
["routing.url", [
|
|
199
|
+
"prefix_except_default",
|
|
200
|
+
"prefix",
|
|
201
|
+
"prefix_and_default",
|
|
202
|
+
"no_prefix"
|
|
203
|
+
]],
|
|
204
|
+
["routing.redirectRoot", "boolean"],
|
|
205
|
+
["routing.propagate", ["header", false]],
|
|
206
|
+
["routing.switch", ["in-place", "navigate"]],
|
|
207
|
+
["routing.cookie", {}],
|
|
208
|
+
["routing.cookie.name", "string"],
|
|
209
|
+
["routing.cookie.maxAge", "number"],
|
|
210
|
+
["routing.base", "string"],
|
|
211
|
+
["ssr", {}],
|
|
212
|
+
["ssr.timeZone", "string"],
|
|
213
|
+
["ssr.timeZoneFrom", { values: ["config", "request"] }],
|
|
214
|
+
["ssr.normalizeSpaces", "boolean"]
|
|
215
|
+
];
|
|
216
|
+
function matches(v, rule) {
|
|
217
|
+
if (typeof rule === "string") return typeof v === rule;
|
|
218
|
+
if (Array.isArray(rule)) {
|
|
219
|
+
if (!rule.length || Array.isArray(rule[0])) {
|
|
220
|
+
const list = rule[0];
|
|
221
|
+
return Array.isArray(v) && v.every((x) => list ? list.includes(x) : typeof x === "string");
|
|
222
|
+
}
|
|
223
|
+
return rule.includes(v);
|
|
224
|
+
}
|
|
225
|
+
return isObject(v) && (!rule.values || Object.values(v).every((x) => matches(x, rule.values)));
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* The last registration `registerCatalogs()` made: a server built without
|
|
229
|
+
* the Vite plugin, so no client plan exists and no client reads a payload.
|
|
230
|
+
*/
|
|
231
|
+
let bare;
|
|
232
|
+
/**
|
|
233
|
+
* `config.json` from `vi18n compile --out` arrives as untyped JSON: each
|
|
234
|
+
* field the server reads is checked, so a stale or hand-edited file fails at
|
|
235
|
+
* startup with the field's name instead of mid-request.
|
|
236
|
+
*/
|
|
237
|
+
function validateConfig(input) {
|
|
238
|
+
const c = unwrap(input);
|
|
239
|
+
const fail = (path) => {
|
|
240
|
+
throw new TypeError("vi18n: registerCatalogs: invalid config: " + path);
|
|
241
|
+
};
|
|
242
|
+
if (!isObject(c)) fail("not an object");
|
|
243
|
+
for (const [path, rule] of RULES) if (!matches(path.split(".").reduce((o, k) => o[k], c), rule)) fail(path);
|
|
244
|
+
if (!c.locales.length) fail("locales");
|
|
245
|
+
if (!c.locales.includes(c.sourceLocale)) fail("sourceLocale");
|
|
246
|
+
if (ROUTES) {
|
|
247
|
+
const { paths, domains } = c.routing;
|
|
248
|
+
if (paths !== void 0 && !matches(paths, { values: { values: "string" } })) fail("routing.paths");
|
|
249
|
+
if (domains !== void 0 && !matches(domains, { values: "string" })) fail("routing.domains");
|
|
250
|
+
}
|
|
251
|
+
if (!isTimeZone(c.ssr.timeZone)) fail("ssr.timeZone");
|
|
252
|
+
return c;
|
|
253
|
+
}
|
|
254
|
+
function registerCatalogs(loaders, options) {
|
|
255
|
+
const config = validateConfig(options.config);
|
|
256
|
+
if (!isObject(loaders)) throw new TypeError("vi18n: registerCatalogs: loaders must be an object");
|
|
257
|
+
S.register(bare = {
|
|
258
|
+
mode: "build",
|
|
259
|
+
consumer: "server",
|
|
260
|
+
config,
|
|
261
|
+
serverCatalogs: loaders,
|
|
262
|
+
sources: options.sources
|
|
263
|
+
});
|
|
264
|
+
current();
|
|
265
|
+
}
|
|
266
|
+
const registerBefore = S.register;
|
|
267
|
+
S.register = (r) => {
|
|
268
|
+
const done = registerBefore(r);
|
|
269
|
+
if (r.consumer === "server" && S.registration === r) current();
|
|
270
|
+
return done;
|
|
271
|
+
};
|
|
272
|
+
//#endregion
|
|
273
|
+
//#region src/server/als.ts
|
|
274
|
+
/**
|
|
275
|
+
* Request isolation (PLAN §5.13 "Isolation"). Scopes travel in an
|
|
276
|
+
* AsyncLocalStorage; there is deliberately no synchronous-slot fallback,
|
|
277
|
+
* because a slot still readable after an `await` would serve one request's
|
|
278
|
+
* locale to another.
|
|
279
|
+
*/
|
|
280
|
+
const als = S.serverScopes ??= new AsyncLocalStorage();
|
|
281
|
+
/** Framework request stores (SvelteKit `getRequestEvent()`) supply the scope; null removes it. */
|
|
282
|
+
function setScopeResolver(resolve) {
|
|
283
|
+
als.resolver = resolve;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* The current scope. A `withLocale`/`createTranslator` scope entered inside
|
|
287
|
+
* a request outranks the framework's request store: the store would still
|
|
288
|
+
* name the request, and an e-mail rendered for another user inside it must
|
|
289
|
+
* use that user's locale. Otherwise the resolver wins over the ALS, as
|
|
290
|
+
* frameworks may run render code outside the async context `handle` opened.
|
|
291
|
+
*/
|
|
292
|
+
function getScope() {
|
|
293
|
+
const s = als.getStore();
|
|
294
|
+
const user = als.resolver;
|
|
295
|
+
if (s?.kind === "translator" || !user) return s;
|
|
296
|
+
let u;
|
|
297
|
+
try {
|
|
298
|
+
u = user();
|
|
299
|
+
} catch {}
|
|
300
|
+
return u ?? s;
|
|
301
|
+
}
|
|
302
|
+
S.serverResolver = getScope;
|
|
303
|
+
/**
|
|
304
|
+
* Some ALS implementations resolve but do not propagate (polyfills that hold
|
|
305
|
+
* the store only synchronously): reading another request's locale later
|
|
306
|
+
* would be silent, so the first scope opener checks that a store survives
|
|
307
|
+
* an `await`, and fails loudly otherwise.
|
|
308
|
+
*/
|
|
309
|
+
async function assertPropagation(store) {
|
|
310
|
+
const k = {};
|
|
311
|
+
if (!await store.run(k, async () => {
|
|
312
|
+
await Promise.resolve();
|
|
313
|
+
return store.getStore() === k;
|
|
314
|
+
})) throw error("AsyncLocalStorage loses its store across await; enable nodejs_als or nodejs_compat (compatibility date 2026-08-04+)");
|
|
315
|
+
}
|
|
316
|
+
let probed;
|
|
317
|
+
let ok = false;
|
|
318
|
+
/** Runs the probe once per process; a failure is remembered, so every request reports it. */
|
|
319
|
+
function propagation() {
|
|
320
|
+
if (ok) return void 0;
|
|
321
|
+
return probed ??= assertPropagation(als).then(() => {
|
|
322
|
+
ok = true;
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
//#endregion
|
|
326
|
+
//#region src/server/inject.ts
|
|
327
|
+
/**
|
|
328
|
+
* The streaming HTML transform of `handle()` (PLAN §5.13 step 5): `lang` and
|
|
329
|
+
* `dir` on `<html>` unless the app set them, the payload before `</head>`,
|
|
330
|
+
* a late payload before `</body>` for ids recorded after the head was
|
|
331
|
+
* flushed. It works on bytes, not text: the needles are ASCII, so chunks
|
|
332
|
+
* without a match pass through as the same buffers, with no decode/encode
|
|
333
|
+
* round trip. The inserted markup is UTF-8 (payload text included), so pages
|
|
334
|
+
* must be UTF-8 too; `handle()` warns in dev about any other declared charset.
|
|
335
|
+
*/
|
|
336
|
+
const enc = new TextEncoder();
|
|
337
|
+
/**
|
|
338
|
+
* Tag names; `tagAt` returns their index (literals below, which the minifier
|
|
339
|
+
* keeps short). 4–5 start raw text and 6 a comment, skipped whole: a
|
|
340
|
+
* `</head>` inside an inline script's string literal does not end the head.
|
|
341
|
+
* `<title>` and `<textarea>` are left out: their text is escaped (`<`)
|
|
342
|
+
* by every renderer, unlike script and style source.
|
|
343
|
+
*/
|
|
344
|
+
const TAGS = [
|
|
345
|
+
"<html",
|
|
346
|
+
"</head",
|
|
347
|
+
"<body",
|
|
348
|
+
"</body",
|
|
349
|
+
"<script",
|
|
350
|
+
"<style",
|
|
351
|
+
"<!--"
|
|
352
|
+
];
|
|
353
|
+
/** A `<html` start tag longer than this is not one we can edit; it passes through. */
|
|
354
|
+
const MAX_TAG = 8192;
|
|
355
|
+
/**
|
|
356
|
+
* Which of `names` starts at `at`, -1 for none, -2 when `buf` ends first. A
|
|
357
|
+
* tag name must be followed by a delimiter; `<!--` and `-->` need none.
|
|
358
|
+
*/
|
|
359
|
+
function tagAt(buf, at, names) {
|
|
360
|
+
let undecided = false;
|
|
361
|
+
for (let t = 0; t < names.length; t++) {
|
|
362
|
+
const name = names[t];
|
|
363
|
+
let k = 0;
|
|
364
|
+
for (; k < name.length && at + k < buf.length; k++) {
|
|
365
|
+
const b = buf[at + k];
|
|
366
|
+
if ((b > 64 && b < 91 ? b | 32 : b) !== name.charCodeAt(k)) break;
|
|
367
|
+
}
|
|
368
|
+
const d = buf[at + k];
|
|
369
|
+
if (k === name.length && (name.length < 5 || d <= 32 || d === 62 || d === 47)) return t;
|
|
370
|
+
if (d === void 0) undecided = true;
|
|
371
|
+
}
|
|
372
|
+
return undecided ? -2 : -1;
|
|
373
|
+
}
|
|
374
|
+
/** Index of the `>` closing the tag that starts at `from`, honouring quoted values; -1 if not in `buf`. */
|
|
375
|
+
function tagEnd(buf, from) {
|
|
376
|
+
let quote = 0;
|
|
377
|
+
for (let i = from; i < buf.length; i++) {
|
|
378
|
+
const b = buf[i];
|
|
379
|
+
if (quote) {
|
|
380
|
+
if (b === quote) quote = 0;
|
|
381
|
+
} else if (b === 34 || b === 39) quote = b;
|
|
382
|
+
else if (b === 62) return i;
|
|
383
|
+
}
|
|
384
|
+
return -1;
|
|
385
|
+
}
|
|
386
|
+
/** The attributes to add to an `<html …>` tag (its text without `<html` and `>`). */
|
|
387
|
+
function missingAttributes(tag, lang, dir) {
|
|
388
|
+
const names = (" " + tag.replace(/"[^"]*"|'[^']*'/g, "")).toLowerCase();
|
|
389
|
+
return (/[\s/]lang(?![^\s=/>])/.test(names) ? "" : ` lang="${lang}"`) + (/[\s/]dir(?![^\s=/>])/.test(names) ? "" : ` dir="${dir}"`);
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* The transform. Phases: 0 looks for `<html`, 1 for `</head` (`<body` ends
|
|
393
|
+
* both), 2 for `</body`, 3 passes everything through. Before phase 3,
|
|
394
|
+
* comments and raw-text elements are skipped whole, as a parser would. A
|
|
395
|
+
* chunk that ends inside a candidate tag keeps that tail back (at most
|
|
396
|
+
* `MAX_TAG` bytes) and joins it to the next chunk.
|
|
397
|
+
*/
|
|
398
|
+
function injector(hooks) {
|
|
399
|
+
let phase = 0;
|
|
400
|
+
let carry = null;
|
|
401
|
+
/** The end tag (`<\/script`…) or `-->` ending the raw text being skipped; "" outside it. */
|
|
402
|
+
let raw = "";
|
|
403
|
+
return {
|
|
404
|
+
transform(chunk, controller) {
|
|
405
|
+
let buf = chunk;
|
|
406
|
+
if (carry) {
|
|
407
|
+
buf = new Uint8Array(carry.length + chunk.length);
|
|
408
|
+
buf.set(carry);
|
|
409
|
+
buf.set(chunk, carry.length);
|
|
410
|
+
carry = null;
|
|
411
|
+
}
|
|
412
|
+
let pos = 0;
|
|
413
|
+
const emit = (end) => {
|
|
414
|
+
if (end > pos) controller.enqueue(buf.subarray(pos, end));
|
|
415
|
+
pos = end;
|
|
416
|
+
};
|
|
417
|
+
const hold = (at) => {
|
|
418
|
+
emit(at);
|
|
419
|
+
carry = buf.slice(at);
|
|
420
|
+
};
|
|
421
|
+
const insert = (text) => {
|
|
422
|
+
if (text) controller.enqueue(enc.encode(text));
|
|
423
|
+
};
|
|
424
|
+
let i = 0;
|
|
425
|
+
while (phase < 3) {
|
|
426
|
+
const lt = buf.indexOf(raw ? raw.charCodeAt(0) : 60, i);
|
|
427
|
+
if (lt < 0) break;
|
|
428
|
+
i = lt + 1;
|
|
429
|
+
const t = tagAt(buf, lt, raw ? [raw] : TAGS);
|
|
430
|
+
if (t === -2) return hold(lt);
|
|
431
|
+
if (raw) {
|
|
432
|
+
if (!t) raw = "";
|
|
433
|
+
} else if (t > 3) raw = t > 5 ? "-->" : "</" + TAGS[t].slice(1);
|
|
434
|
+
else if (t === 0 && !phase) {
|
|
435
|
+
const end = tagEnd(buf, lt + 5);
|
|
436
|
+
if (end >= 0) {
|
|
437
|
+
const { lang, dir } = hooks.attributes();
|
|
438
|
+
emit(buf[end - 1] === 47 ? end - 1 : end);
|
|
439
|
+
insert(missingAttributes(new TextDecoder().decode(buf.subarray(lt + 5, end)), lang, dir));
|
|
440
|
+
i = end + 1;
|
|
441
|
+
} else if (buf.length - lt <= MAX_TAG) return hold(lt);
|
|
442
|
+
phase = 1;
|
|
443
|
+
} else if (t === 1 && phase < 2) {
|
|
444
|
+
emit(lt);
|
|
445
|
+
insert(hooks.head());
|
|
446
|
+
phase = 2;
|
|
447
|
+
} else if (t === 2) {
|
|
448
|
+
if (phase < 2) phase = 2;
|
|
449
|
+
} else if (t === 3) {
|
|
450
|
+
emit(lt);
|
|
451
|
+
insert(hooks.tail());
|
|
452
|
+
phase = 3;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
emit(buf.length);
|
|
456
|
+
},
|
|
457
|
+
flush(controller) {
|
|
458
|
+
if (carry) controller.enqueue(carry);
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
//#endregion
|
|
463
|
+
//#region src/server/types.ts
|
|
464
|
+
/** Header the client runtime adds to same-origin `fetch` calls (`routing.propagate`). */
|
|
465
|
+
const LOCALE_HEADER = "x-vi18n-locale";
|
|
466
|
+
/** Cookie the client runtime writes with the browser's IANA zone. */
|
|
467
|
+
const TIME_ZONE_COOKIE = "vi18n_tz";
|
|
468
|
+
//#endregion
|
|
469
|
+
//#region src/server/negotiate.ts
|
|
470
|
+
/**
|
|
471
|
+
* Server locale resolution (INTERNALS §2.11.9, PLAN §5.14). The SPA boot
|
|
472
|
+
* script and the client runtime implement the same algorithm, and
|
|
473
|
+
* `tests/fixtures/resolution/cases.json` keeps the three equal. Every input
|
|
474
|
+
* (URL segment, header, cookie, Referer) is matched against `locales` and
|
|
475
|
+
* nothing else, so a request can only ever select a configured locale.
|
|
476
|
+
*/
|
|
477
|
+
/** The base as a path starting and ending with `/`, as the URL codec reads it. */
|
|
478
|
+
function baseOf(config) {
|
|
479
|
+
const b = config.routing.base;
|
|
480
|
+
return b && b[0] === "/" ? b.endsWith("/") ? b : b + "/" : "/";
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* The pathname as route globs see it (INTERNALS §2.7.3): delocalized,
|
|
484
|
+
* without the base and without a trailing slash; the root is `/`.
|
|
485
|
+
*/
|
|
486
|
+
function routeOf(pathname, config) {
|
|
487
|
+
const p = splitLocalePath(pathname, config).path;
|
|
488
|
+
const base = baseOf(config);
|
|
489
|
+
const s = p.startsWith(base) ? "/" + p.slice(base.length) : p + "/" === base ? "/" : p;
|
|
490
|
+
return s.length > 1 && s.endsWith("/") ? s.slice(0, -1) : s;
|
|
491
|
+
}
|
|
492
|
+
const keyCache = /* @__PURE__ */ new WeakMap();
|
|
493
|
+
function keysOf(o) {
|
|
494
|
+
let k = keyCache.get(o);
|
|
495
|
+
if (!k) keyCache.set(o, k = Object.keys(o));
|
|
496
|
+
return k;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* The `url` step: an unprefixed path is the source locale's canonical page
|
|
500
|
+
* (on a listed host, the host's locale's) unless every locale is prefixed.
|
|
501
|
+
*/
|
|
502
|
+
function urlStep(config, pathname) {
|
|
503
|
+
const url = config.routing.url;
|
|
504
|
+
if (url === "no_prefix") return null;
|
|
505
|
+
return splitLocalePath(pathname, config).locale ?? (url === "prefix" ? null : ROUTES && config.routing.home || config.sourceLocale);
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* The canonical URL of a page the URL names, where localized paths or
|
|
509
|
+
* domains are configured (§6 D133), or null when `pathname` is it: the
|
|
510
|
+
* locale's own path for the route (`/de/about` → `/de/ueber-uns`), without
|
|
511
|
+
* the prefix of the unprefixed locale, on the host serving the locale (an
|
|
512
|
+
* absolute URL, from the config only). Under `prefix_and_default` the
|
|
513
|
+
* requested prefix form stays. A target that would not read back as the
|
|
514
|
+
* same route is never answered, so no config can loop.
|
|
515
|
+
*/
|
|
516
|
+
function canonical(pathname, config) {
|
|
517
|
+
const url = config.routing.url;
|
|
518
|
+
if (url === "no_prefix") return null;
|
|
519
|
+
const split = splitLocalePath(pathname, config);
|
|
520
|
+
const l = split.locale ?? (url === "prefix" ? null : config.routing.home ?? config.sourceLocale);
|
|
521
|
+
if (l === null) return null;
|
|
522
|
+
const away = awayHref(split.path, l, config);
|
|
523
|
+
if (away !== null) return away;
|
|
524
|
+
const c = url === "prefix_and_default" && split.locale !== null ? {
|
|
525
|
+
...config,
|
|
526
|
+
routing: {
|
|
527
|
+
...config.routing,
|
|
528
|
+
url: "prefix"
|
|
529
|
+
}
|
|
530
|
+
} : config;
|
|
531
|
+
const target = localizePath(split.path, l, c);
|
|
532
|
+
return target !== pathname && splitLocalePath(target, c).path === split.path ? target : null;
|
|
533
|
+
}
|
|
534
|
+
/** One cookie's decoded value from a `Cookie` header, or null. */
|
|
535
|
+
function readCookie(header, name) {
|
|
536
|
+
for (const part of header ? header.split(";") : []) {
|
|
537
|
+
const eq = part.indexOf("=");
|
|
538
|
+
if (eq > 0 && part.slice(0, eq).trim() === name) try {
|
|
539
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
540
|
+
} catch {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
const MAX_HEADER = 1024;
|
|
547
|
+
const MAX_ENTRIES = 20;
|
|
548
|
+
const QVALUE = /^\s*q\s*=\s*(0(?:\.\d{0,3})?|1(?:\.0{0,3})?)\s*$/i;
|
|
549
|
+
/**
|
|
550
|
+
* Accept-Language as language tags by preference. Hardened for hostile
|
|
551
|
+
* input: at most 1024 characters and 20 entries are read (an entry cut by
|
|
552
|
+
* the length cap is dropped), `q=0` means refused, a missing q is 1, a
|
|
553
|
+
* malformed q drops its entry, and equal q keeps header order (stable sort).
|
|
554
|
+
* Tags are not validated here; `matchLocale` canonicalises them in try/catch.
|
|
555
|
+
*/
|
|
556
|
+
function parseAcceptLanguage(header) {
|
|
557
|
+
if (!header) return [];
|
|
558
|
+
const cut = header.length > MAX_HEADER;
|
|
559
|
+
const parts = (cut ? header.slice(0, MAX_HEADER) : header).split(",", 21);
|
|
560
|
+
if (cut && parts.length <= MAX_ENTRIES) parts.pop();
|
|
561
|
+
const entries = [];
|
|
562
|
+
for (let i = 0; i < parts.length && i < MAX_ENTRIES; i++) {
|
|
563
|
+
const part = parts[i];
|
|
564
|
+
const semi = part.indexOf(";");
|
|
565
|
+
const tag = (semi < 0 ? part : part.slice(0, semi)).trim();
|
|
566
|
+
if (!tag || tag === "*") continue;
|
|
567
|
+
let q = 1;
|
|
568
|
+
if (semi >= 0) {
|
|
569
|
+
const m = QVALUE.exec(part.slice(semi + 1));
|
|
570
|
+
if (!m) continue;
|
|
571
|
+
q = Number(m[1]);
|
|
572
|
+
}
|
|
573
|
+
if (q > 0) entries.push({
|
|
574
|
+
tag,
|
|
575
|
+
q
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
entries.sort((a, b) => b.q - a.q);
|
|
579
|
+
return entries.map((e) => e.tag);
|
|
580
|
+
}
|
|
581
|
+
const ISLANDS = "/_server-islands/";
|
|
582
|
+
/** §2.11.9 for one request, with the canonical and root redirects. */
|
|
583
|
+
function negotiateFor(request, url, config) {
|
|
584
|
+
if (ROUTES) config = atHost(config, url.host);
|
|
585
|
+
const { locales, sourceLocale, routing } = config;
|
|
586
|
+
const pathname = url.pathname;
|
|
587
|
+
const { routeStrategies, strategy } = routing;
|
|
588
|
+
const keys = keysOf(routeStrategies);
|
|
589
|
+
const route = keys.length ? matchRoute(keys, routeOf(pathname, config)) : null;
|
|
590
|
+
const chain = route === null ? strategy : routeStrategies[route];
|
|
591
|
+
let locale = null;
|
|
592
|
+
let source = "default";
|
|
593
|
+
let vary;
|
|
594
|
+
let redirect;
|
|
595
|
+
let status;
|
|
596
|
+
const headers = request.headers;
|
|
597
|
+
if (headers.get("sec-fetch-mode") !== "navigate" && !(urlStep(config, pathname) && chain.includes("url"))) {
|
|
598
|
+
const x = headers.get(LOCALE_HEADER);
|
|
599
|
+
if (x !== null && locales.includes(x)) {
|
|
600
|
+
locale = x;
|
|
601
|
+
source = "header-x";
|
|
602
|
+
vary = "x-vi18n-locale";
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (locale === null && routeOf(pathname, config).startsWith(ISLANDS)) {
|
|
606
|
+
let ref = null;
|
|
607
|
+
try {
|
|
608
|
+
ref = new URL(headers.get("referer")).pathname;
|
|
609
|
+
} catch {}
|
|
610
|
+
const l = ref === null || ref === "/" ? null : urlStep(config, ref);
|
|
611
|
+
if (l !== null) {
|
|
612
|
+
locale = l;
|
|
613
|
+
source = "referer";
|
|
614
|
+
vary = "Referer";
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (locale === null) {
|
|
618
|
+
const base = baseOf(config);
|
|
619
|
+
const atRoot = pathname === base || pathname + "/" === base;
|
|
620
|
+
const redirecting = atRoot && routing.redirectRoot;
|
|
621
|
+
let asked = false;
|
|
622
|
+
for (const step of chain) {
|
|
623
|
+
let l = null;
|
|
624
|
+
if (step === "url") {
|
|
625
|
+
if (!redirecting) l = urlStep(config, pathname);
|
|
626
|
+
} else if (step === "cookie") {
|
|
627
|
+
asked = true;
|
|
628
|
+
l = readCookie(headers.get("cookie"), routing.cookie.name);
|
|
629
|
+
} else if (step === "header") {
|
|
630
|
+
asked = true;
|
|
631
|
+
l = matchLocale(parseAcceptLanguage(headers.get("accept-language")), config);
|
|
632
|
+
} else if (step === "default") l = sourceLocale;
|
|
633
|
+
if (l !== null && locales.includes(l)) {
|
|
634
|
+
locale = l;
|
|
635
|
+
source = step;
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
locale ??= sourceLocale;
|
|
640
|
+
if (asked || atRoot && source !== "url") vary = "Accept-Language";
|
|
641
|
+
const target = ROUTES && awayHref(base, locale, config) || localizePath(base, locale, config);
|
|
642
|
+
if (redirecting && locale !== urlStep(config, pathname) && target !== pathname) {
|
|
643
|
+
redirect = new URL(target + url.search, url);
|
|
644
|
+
status = 307;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
if (ROUTES && (routing.paths || routing.domains)) {
|
|
648
|
+
const target = !redirect && (request.method === "GET" || request.method === "HEAD") ? canonical(pathname, config) : null;
|
|
649
|
+
if (target !== null) {
|
|
650
|
+
redirect = new URL(target + url.search, url);
|
|
651
|
+
status = 301;
|
|
652
|
+
}
|
|
653
|
+
} else if (!redirect && routing.url === "prefix_except_default" && (request.method === "GET" || request.method === "HEAD")) {
|
|
654
|
+
const split = splitLocalePath(pathname, config);
|
|
655
|
+
if (split.locale === sourceLocale) {
|
|
656
|
+
redirect = new URL(split.path + url.search, url);
|
|
657
|
+
status = 301;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const n = {
|
|
661
|
+
locale,
|
|
662
|
+
source
|
|
663
|
+
};
|
|
664
|
+
if (vary) n.vary = vary;
|
|
665
|
+
if (redirect) {
|
|
666
|
+
n.redirect = redirect;
|
|
667
|
+
n.status = status;
|
|
668
|
+
}
|
|
669
|
+
return n;
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* The locale `request` would render in, the redirect `handle()` would
|
|
673
|
+
* answer, and the header its response varies on.
|
|
674
|
+
*/
|
|
675
|
+
function negotiate(request) {
|
|
676
|
+
return negotiateFor(request, new URL(request.url), current());
|
|
677
|
+
}
|
|
678
|
+
//#endregion
|
|
679
|
+
//#region src/server/payload.ts
|
|
680
|
+
/**
|
|
681
|
+
* The SSR payload (INTERNALS §2.10, PLAN §5.13 "Payload contents"): what a
|
|
682
|
+
* page inlines so hydration renders with zero catalog requests. Ids alone
|
|
683
|
+
* select the client bundles: an id used by exactly one root (`pins`) brings
|
|
684
|
+
* that root's whole key set, other ids bring only their non-`r.*` bundles,
|
|
685
|
+
* and server-only modules record nothing, so island-free pages get nothing.
|
|
686
|
+
*
|
|
687
|
+
* Bundle JSON is serialised (and escaped) once per catalog and key, and
|
|
688
|
+
* spliced into each request's payload: a page's payload costs string
|
|
689
|
+
* concatenation, not a `JSON.stringify` of every message it carries.
|
|
690
|
+
*
|
|
691
|
+
* With `ssr.payload: "preload"` the same selection is preloaded instead:
|
|
692
|
+
* one `<link rel="preload" as="fetch" crossorigin>` per bundle ahead of the
|
|
693
|
+
* payload, which names the keys for the client to load at once.
|
|
694
|
+
*/
|
|
695
|
+
const sent = /* @__PURE__ */ new WeakMap();
|
|
696
|
+
/**
|
|
697
|
+
* `ssr.payload: "preload"` compiled in: the plugin defines it false for the
|
|
698
|
+
* default config, folding the branch out (PLAN §6); copies built without the
|
|
699
|
+
* plugin (Nitro's) keep it and follow the registration's config.
|
|
700
|
+
*/
|
|
701
|
+
const PRELOAD = typeof __VI18N_PRELOAD__ === "undefined" || !!__VI18N_PRELOAD__;
|
|
702
|
+
const escapeJson = (json) => json.replace(/[<\u2028\u2029]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
|
|
703
|
+
const attr = (v) => v.replace(/&/g, "&").replace(/"/g, """);
|
|
704
|
+
/** Id → keys of the bundles holding it, per plan. */
|
|
705
|
+
const indexes = /* @__PURE__ */ new WeakMap();
|
|
706
|
+
function indexOf(plan) {
|
|
707
|
+
let idx = indexes.get(plan);
|
|
708
|
+
if (!idx) {
|
|
709
|
+
idx = /* @__PURE__ */ new Map();
|
|
710
|
+
for (const key in plan.bundles) {
|
|
711
|
+
if (!own(plan.bundles, key)) continue;
|
|
712
|
+
for (const id of plan.bundles[key]) {
|
|
713
|
+
const ks = idx.get(id);
|
|
714
|
+
if (ks) ks.push(key);
|
|
715
|
+
else idx.set(id, [key]);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
indexes.set(plan, idx);
|
|
719
|
+
}
|
|
720
|
+
return idx;
|
|
721
|
+
}
|
|
722
|
+
/** Escaped bundle JSON per catalog and key (for one plan); null when the catalog lacks one of its ids. */
|
|
723
|
+
const bundleCache = /* @__PURE__ */ new WeakMap();
|
|
724
|
+
function bundleJson(plan, messages, key) {
|
|
725
|
+
let c = bundleCache.get(messages);
|
|
726
|
+
if (c?.plan !== plan) bundleCache.set(messages, c = {
|
|
727
|
+
plan,
|
|
728
|
+
json: /* @__PURE__ */ new Map()
|
|
729
|
+
});
|
|
730
|
+
let json = c.json.get(key);
|
|
731
|
+
if (json === void 0) {
|
|
732
|
+
json = "{";
|
|
733
|
+
for (const id of own(plan.bundles, key) && plan.bundles[key] || []) {
|
|
734
|
+
const m = messages.get(id);
|
|
735
|
+
if (m === void 0) {
|
|
736
|
+
json = null;
|
|
737
|
+
break;
|
|
738
|
+
}
|
|
739
|
+
json += (json.length > 1 ? "," : "") + JSON.stringify(id) + ":" + JSON.stringify(m);
|
|
740
|
+
}
|
|
741
|
+
if (json !== null) json = escapeJson(json + "}");
|
|
742
|
+
c.json.set(key, json);
|
|
743
|
+
}
|
|
744
|
+
return json;
|
|
745
|
+
}
|
|
746
|
+
/**
|
|
747
|
+
* Picks the bundles for `fresh` ids (§2.10 selection), recording them in
|
|
748
|
+
* `st.keys`. Returns null when none of the ids is a client id. Outside
|
|
749
|
+
* `dupBelow` duplicates an id lives in exactly one bundle, so "bundles not
|
|
750
|
+
* yet selected" needs no check of its own.
|
|
751
|
+
*/
|
|
752
|
+
function select(plan, fresh, st) {
|
|
753
|
+
const idx = indexOf(plan);
|
|
754
|
+
const out = [];
|
|
755
|
+
const add = (k) => {
|
|
756
|
+
if (!st.keys.has(k)) {
|
|
757
|
+
st.keys.add(k);
|
|
758
|
+
out.push(k);
|
|
759
|
+
}
|
|
760
|
+
};
|
|
761
|
+
let client = false;
|
|
762
|
+
for (const id of fresh) {
|
|
763
|
+
const keys = idx.get(id);
|
|
764
|
+
if (!keys) continue;
|
|
765
|
+
client = true;
|
|
766
|
+
const root = own(plan.pins, id) ? plan.pins[id] : null;
|
|
767
|
+
if (root !== null) {
|
|
768
|
+
if (own(plan.roots, root)) for (const k of plan.roots[root]) add(k);
|
|
769
|
+
} else for (const k of keys) if (!k.startsWith("r.")) add(k);
|
|
770
|
+
}
|
|
771
|
+
if (!client) return null;
|
|
772
|
+
if (plan.base !== null) add(plan.base);
|
|
773
|
+
return out.sort();
|
|
774
|
+
}
|
|
775
|
+
/**
|
|
776
|
+
* The `<script>` to insert for `scope` now: the payload when none was
|
|
777
|
+
* emitted yet, otherwise a late payload of what is new, after the preload
|
|
778
|
+
* links of its bundles under `ssr.payload: "preload"`. "" when there is
|
|
779
|
+
* nothing to send (no client ids, nothing new).
|
|
780
|
+
*/
|
|
781
|
+
function scriptFor(scope, nonce) {
|
|
782
|
+
const used = scope.used;
|
|
783
|
+
if (!used || !used.size) return "";
|
|
784
|
+
let st = sent.get(scope);
|
|
785
|
+
if (!st) sent.set(scope, st = {
|
|
786
|
+
head: false,
|
|
787
|
+
ids: /* @__PURE__ */ new Set(),
|
|
788
|
+
keys: /* @__PURE__ */ new Set()
|
|
789
|
+
});
|
|
790
|
+
const fresh = [];
|
|
791
|
+
for (const id of used) if (!st.ids.has(id)) {
|
|
792
|
+
st.ids.add(id);
|
|
793
|
+
fresh.push(id);
|
|
794
|
+
}
|
|
795
|
+
if (!fresh.length) return "";
|
|
796
|
+
const late = st.head;
|
|
797
|
+
const config = current();
|
|
798
|
+
const plan$1 = plan ?? null;
|
|
799
|
+
const messages = scope.messages;
|
|
800
|
+
const locale = scope.locale;
|
|
801
|
+
let bundles = "";
|
|
802
|
+
let extra = "";
|
|
803
|
+
let links = "";
|
|
804
|
+
if (plan$1) {
|
|
805
|
+
const keys = select(plan$1, fresh, st);
|
|
806
|
+
if (!keys) return "";
|
|
807
|
+
if (PRELOAD && config.ssr.payload === "preload") {
|
|
808
|
+
const hashes = own(plan$1.hashes, locale) && plan$1.hashes[locale] || {};
|
|
809
|
+
const named = [];
|
|
810
|
+
for (const k of keys) {
|
|
811
|
+
if (!own(hashes, k)) continue;
|
|
812
|
+
named.push(k);
|
|
813
|
+
links += "<link rel=\"preload\" as=\"fetch\" crossorigin href=\"" + attr(plan$1.assetsUrl + locale + "/" + k + "." + hashes[k] + ".json") + "\">";
|
|
814
|
+
}
|
|
815
|
+
if (late && !named.length) return "";
|
|
816
|
+
extra = ",\"preload\":" + escapeJson(JSON.stringify(named));
|
|
817
|
+
} else {
|
|
818
|
+
for (const k of keys) {
|
|
819
|
+
const json = bundleJson(plan$1, messages, k);
|
|
820
|
+
if (json !== null) bundles += (bundles ? "," : "") + JSON.stringify(k) + ":" + json;
|
|
821
|
+
}
|
|
822
|
+
if (late && !bundles) return "";
|
|
823
|
+
}
|
|
824
|
+
} else {
|
|
825
|
+
fresh.sort();
|
|
826
|
+
let m = "";
|
|
827
|
+
for (const id of fresh) {
|
|
828
|
+
const msg = messages.get(id);
|
|
829
|
+
if (msg !== void 0) m += (m ? "," : "") + JSON.stringify(id) + ":" + JSON.stringify(msg);
|
|
830
|
+
}
|
|
831
|
+
if (late && !m) return "";
|
|
832
|
+
extra = ",\"messages\":" + escapeJson("{" + m + "}");
|
|
833
|
+
}
|
|
834
|
+
const v = plan$1 ? plan$1.v : reg.mode === "dev" ? "dev" : null;
|
|
835
|
+
if (!late) st.head = true;
|
|
836
|
+
const json = escapeJson(JSON.stringify(late ? {
|
|
837
|
+
v,
|
|
838
|
+
locale
|
|
839
|
+
} : {
|
|
840
|
+
v,
|
|
841
|
+
locale,
|
|
842
|
+
timeZone: scope.timeZone ?? config.ssr.timeZone,
|
|
843
|
+
now: scope.now ?? Date.now(),
|
|
844
|
+
manifest: plan$1 && own(plan$1.hashes, locale) && plan$1.hashes[locale] || null
|
|
845
|
+
}).slice(0, -1)) + ",\"bundles\":{" + bundles + "}" + extra + "}";
|
|
846
|
+
return links + "<script type=\"application/json\" " + (late ? "data-vi18n-late" : "id=\"__vi18n\"") + (nonce ? " nonce=\"" + attr(nonce) + "\"" : "") + ">" + json + "<\/script>";
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* The payload element for HTML-string hooks (Nuxt `render:html`), with its
|
|
850
|
+
* preload links under `ssr.payload: "preload"`: the ids rendered so far in
|
|
851
|
+
* the current request scope, or "" when there are none.
|
|
852
|
+
* After it, `handle()` only adds late payloads for ids recorded later.
|
|
853
|
+
*/
|
|
854
|
+
function payload(options) {
|
|
855
|
+
const scope = getScope();
|
|
856
|
+
return scope ? scriptFor(scope, options?.nonce) : "";
|
|
857
|
+
}
|
|
858
|
+
/** `<html lang dir>` for the current scope. */
|
|
859
|
+
function htmlAttributes() {
|
|
860
|
+
const locale = S.resolve().locale;
|
|
861
|
+
return {
|
|
862
|
+
lang: locale,
|
|
863
|
+
dir: textDirection(locale)
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
//#endregion
|
|
867
|
+
//#region src/server/translator.ts
|
|
868
|
+
/**
|
|
869
|
+
* Scope opening shared by every `/server` entry point, plus `withLocale` and
|
|
870
|
+
* `createTranslator` (PLAN §5.13 "E-mail, jobs and webhooks"). Every scope
|
|
871
|
+
* waits for the source catalog and `sources` (so untransformed code resolves
|
|
872
|
+
* source literals from the first request), its locale's catalog and, when the
|
|
873
|
+
* build has one, the client plan.
|
|
874
|
+
*/
|
|
875
|
+
/**
|
|
876
|
+
* The configured locale `input` reaches: itself, else the closest match
|
|
877
|
+
* (`de-AT` reaches `de`), else null.
|
|
878
|
+
*/
|
|
879
|
+
function matched(config, input) {
|
|
880
|
+
if (input === void 0) return null;
|
|
881
|
+
return config.locales.includes(input) ? input : matchLocale([input], config);
|
|
882
|
+
}
|
|
883
|
+
/**
|
|
884
|
+
* A configured locale for `input`, the source locale when nothing matches.
|
|
885
|
+
* Inputs here are user data (a stored profile locale), so a miss renders the
|
|
886
|
+
* source locale rather than throwing.
|
|
887
|
+
*/
|
|
888
|
+
function pickLocale(config, input) {
|
|
889
|
+
const m = matched(config, input);
|
|
890
|
+
if (m === null && DEV) warnOnce(`${JSON.stringify(input)} is not a configured locale; using ${config.sourceLocale}`);
|
|
891
|
+
return m ?? config.sourceLocale;
|
|
892
|
+
}
|
|
893
|
+
/** An app-given zone: wrong zones are programming errors, unlike a visitor's cookie. */
|
|
894
|
+
function explicitZone(zone) {
|
|
895
|
+
if (!isTimeZone(zone)) throw new RangeError("vi18n: invalid time zone " + JSON.stringify(zone));
|
|
896
|
+
return zone;
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Waits for whatever this scope needs that is not loaded or checked yet
|
|
900
|
+
* (the ALS probe runs before the first scope opens); resolves to the
|
|
901
|
+
* locale's catalog.
|
|
902
|
+
*/
|
|
903
|
+
async function prepare(locale) {
|
|
904
|
+
const p = propagation();
|
|
905
|
+
const r = ready();
|
|
906
|
+
const plan = loadPlan();
|
|
907
|
+
const c = catalog(locale);
|
|
908
|
+
if (!p && !r && !plan && !(c instanceof Promise)) return c;
|
|
909
|
+
return (await Promise.all([
|
|
910
|
+
p,
|
|
911
|
+
r,
|
|
912
|
+
plan,
|
|
913
|
+
c
|
|
914
|
+
]))[3];
|
|
915
|
+
}
|
|
916
|
+
/** A scope once its catalogs are loaded, carrying the registration's `sources` and dev labels. */
|
|
917
|
+
async function openScope(kind, locale, timeZone, timeZoneSource, now, trackUsed) {
|
|
918
|
+
const cat = await prepare(locale);
|
|
919
|
+
const scope = createScope(kind, locale, cat.messages, {
|
|
920
|
+
timeZone,
|
|
921
|
+
now,
|
|
922
|
+
timeZoneSource,
|
|
923
|
+
trackUsed,
|
|
924
|
+
sources
|
|
925
|
+
});
|
|
926
|
+
if (DEV && cat.labels) scope.labels = cat.labels;
|
|
927
|
+
return scope;
|
|
928
|
+
}
|
|
929
|
+
function translatorScope(locale, options) {
|
|
930
|
+
const config = current();
|
|
931
|
+
const zone = options?.timeZone;
|
|
932
|
+
return openScope("translator", pickLocale(config, locale), zone === void 0 ? config.ssr.timeZone : explicitZone(zone), zone === void 0 ? "config" : "explicit", options?.now, false);
|
|
933
|
+
}
|
|
934
|
+
/**
|
|
935
|
+
* Runs `fn` with `t` and `fmt` bound to `locale` (and the zone in `options`),
|
|
936
|
+
* also inside a request: an e-mail for another user renders in theirs.
|
|
937
|
+
*/
|
|
938
|
+
async function withLocale(locale, fn, options) {
|
|
939
|
+
const scope = await translatorScope(locale, options);
|
|
940
|
+
return await als.run(scope, fn);
|
|
941
|
+
}
|
|
942
|
+
/** `t`, `rich*` and `fmt` bound to one locale and zone, for code that cannot run inside a scope. */
|
|
943
|
+
async function createTranslator(locale, options) {
|
|
944
|
+
return createScopedApi(await translatorScope(locale, options));
|
|
945
|
+
}
|
|
946
|
+
//#endregion
|
|
947
|
+
//#region src/server/handle.ts
|
|
948
|
+
/**
|
|
949
|
+
* Request scopes (PLAN §5.13 `handle(request, next)` steps 1–5, INTERNALS
|
|
950
|
+
* §3.2): negotiate, maybe redirect, open the scope with its zone and `now`,
|
|
951
|
+
* render inside it, then fix up the response.
|
|
952
|
+
*/
|
|
953
|
+
/**
|
|
954
|
+
* The scope's zone, chosen before rendering (INTERNALS §6 D77): explicit →
|
|
955
|
+
* the route's `timeZoneFrom: config` → the `vi18n_tz` cookie → `cf.timezone`
|
|
956
|
+
* → `ssr.timeZone`. Invalid zones from the client are ignored.
|
|
957
|
+
*/
|
|
958
|
+
function zoneFor(request, url, config, explicit) {
|
|
959
|
+
const ssr = config.ssr;
|
|
960
|
+
if (explicit !== void 0) return [explicitZone(explicit), "explicit"];
|
|
961
|
+
const from = ssr.timeZoneFrom;
|
|
962
|
+
const keys = keysOf(from);
|
|
963
|
+
const p = keys.length ? matchRoute(keys, routeOf(url.pathname, config)) : null;
|
|
964
|
+
if (p === null || from[p] !== "config") {
|
|
965
|
+
const c = readCookie(request.headers.get("cookie"), TIME_ZONE_COOKIE);
|
|
966
|
+
if (c !== null && isTimeZone(c)) return [c, "cookie"];
|
|
967
|
+
const cf = request.cf?.timezone;
|
|
968
|
+
if (isTimeZone(cf)) return [cf, "cf"];
|
|
969
|
+
}
|
|
970
|
+
return [ssr.timeZone, "config"];
|
|
971
|
+
}
|
|
972
|
+
/** Opens the request scope once its catalogs are loaded. */
|
|
973
|
+
function open(config, request, url, locale, options) {
|
|
974
|
+
const [zone, source] = zoneFor(request, url, config, options?.timeZone);
|
|
975
|
+
const now = options?.now;
|
|
976
|
+
return openScope("request", locale, zone, source, Number.isFinite(now) ? now : Date.now(), true);
|
|
977
|
+
}
|
|
978
|
+
const PUBLIC = /(?:^|[\s,])(?:public|s-maxage\s*=)/i;
|
|
979
|
+
const HTML = /^\s*text\/html\s*(?:;|$)/i;
|
|
980
|
+
/**
|
|
981
|
+
* `body`, read inside `scope` whoever reads it, through `inject` when
|
|
982
|
+
* given. Servers read a response after handle() returned, and a lazy body
|
|
983
|
+
* (`ReadableStream.from` over a generator, a pull source) renders as it is
|
|
984
|
+
* pulled. A pipe set up inside the scope is not enough: which context a
|
|
985
|
+
* pipe pulls in is up to the platform's stream scheduling (Node 24 resumes
|
|
986
|
+
* a parked pipe from the call that unblocked it), so every read and the
|
|
987
|
+
* cancel enter the scope themselves.
|
|
988
|
+
*/
|
|
989
|
+
function inScope(body, scope, inject) {
|
|
990
|
+
const reader = body.getReader();
|
|
991
|
+
return new ReadableStream({
|
|
992
|
+
pull: (c) => als.run(scope, () => reader.read()).then((r) => r.done ? (inject?.flush(c), c.close()) : inject ? inject.transform(r.value, c) : c.enqueue(r.value)),
|
|
993
|
+
cancel: (reason) => als.run(scope, () => reader.cancel(reason))
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* A copy of `response` with the scope's headers and its body read inside
|
|
998
|
+
* the scope: injected for HTML, unchanged otherwise.
|
|
999
|
+
*/
|
|
1000
|
+
function finish(request, response, scope, n, url, config, options) {
|
|
1001
|
+
if (response.status < 200) return response;
|
|
1002
|
+
const type = response.headers.get("content-type");
|
|
1003
|
+
const html = HTML.test(type);
|
|
1004
|
+
const zoned = scope.timeZoneSource === "cookie" || scope.timeZoneSource === "cf";
|
|
1005
|
+
const perUser = zoned && (scope.formattedTime === true || html);
|
|
1006
|
+
const vary = n?.vary;
|
|
1007
|
+
const body = response.body;
|
|
1008
|
+
if (!html && !vary && !perUser && !body) return response;
|
|
1009
|
+
const encoding = response.headers.get("content-encoding");
|
|
1010
|
+
const plain = html && !(encoding && encoding.toLowerCase() !== "identity");
|
|
1011
|
+
const publicly = () => {
|
|
1012
|
+
const cc = headers.get("cache-control");
|
|
1013
|
+
if (DEV && scope.formattedTime && zoned && cc && PUBLIC.test(cc)) warnOnce(`${routeOf(url.pathname, config)} formats times in a per-user zone but is publicly cacheable; set ssr.timeZoneFrom: config`);
|
|
1014
|
+
};
|
|
1015
|
+
const script = () => options?.payload ?? reg !== bare ? scriptFor(scope, options?.nonce) : "";
|
|
1016
|
+
const res = new Response(body && inScope(body, scope, plain && request.method !== "HEAD" ? injector({
|
|
1017
|
+
attributes: () => ({
|
|
1018
|
+
lang: scope.locale,
|
|
1019
|
+
dir: textDirection(scope.locale)
|
|
1020
|
+
}),
|
|
1021
|
+
head: script,
|
|
1022
|
+
tail: DEV ? () => (publicly(), script()) : script
|
|
1023
|
+
}) : void 0), response);
|
|
1024
|
+
const headers = res.headers;
|
|
1025
|
+
const lower = vary?.toLowerCase();
|
|
1026
|
+
if (vary && !(headers.get("vary") ?? "").split(",").some((t) => (t = t.trim().toLowerCase()) === "*" || t === lower)) headers.append("vary", vary);
|
|
1027
|
+
const cc = headers.get("cache-control");
|
|
1028
|
+
if (vary === "Accept-Language" ? !/private|no-store/i.test(cc) : perUser && !cc) headers.set("cache-control", cc ? "private, " + cc : "private");
|
|
1029
|
+
if (DEV) publicly();
|
|
1030
|
+
if (html) {
|
|
1031
|
+
if (!headers.has("content-language")) headers.set("content-language", scope.locale);
|
|
1032
|
+
if (!plain) {
|
|
1033
|
+
if (DEV) warnOnce(`${url.pathname}: ${encoding}-encoded HTML is not injected; let the server compress after handle()`);
|
|
1034
|
+
} else {
|
|
1035
|
+
headers.delete("content-length");
|
|
1036
|
+
const etag = headers.get("etag");
|
|
1037
|
+
if (etag && !etag.startsWith("W/")) headers.set("etag", "W/" + etag);
|
|
1038
|
+
if (DEV && /charset=(?!"?utf-?8)/i.test(type)) warnOnce(`${url.pathname}: the payload is UTF-8; serve HTML as UTF-8 (${type})`);
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
return res;
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Negotiates the locale, answers the canonical or root redirect, renders
|
|
1045
|
+
* `next(request)` (the original request: rewriting stays with the router)
|
|
1046
|
+
* inside the request scope, and streams HTML through the injection.
|
|
1047
|
+
*/
|
|
1048
|
+
async function handle(request, next, options) {
|
|
1049
|
+
const config = current();
|
|
1050
|
+
const url = new URL(request.url);
|
|
1051
|
+
const f = matched(config, options?.locale);
|
|
1052
|
+
const n = f === null ? negotiateFor(request, url, config) : null;
|
|
1053
|
+
if (n?.redirect) {
|
|
1054
|
+
const headers = new Headers({ location: ROUTES && n.redirect.host !== url.host ? n.redirect.href : n.redirect.pathname + n.redirect.search });
|
|
1055
|
+
if (n.status === 307) {
|
|
1056
|
+
headers.set("cache-control", "private");
|
|
1057
|
+
if (n.vary) headers.set("vary", n.vary);
|
|
1058
|
+
}
|
|
1059
|
+
return new Response(null, {
|
|
1060
|
+
status: n.status,
|
|
1061
|
+
headers
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
const scope = await open(config, request, url, f ?? n.locale, options);
|
|
1065
|
+
if (ROUTES) scope.home = hostLocale(url.host, config);
|
|
1066
|
+
const response = await als.run(scope, () => next(request));
|
|
1067
|
+
if (DEV && !(response instanceof Response)) throw error("handle(): next() must return a Response");
|
|
1068
|
+
return finish(request, response, scope, n, url, config, options);
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* Runs `fn` in the request's scope, for servers that build the response
|
|
1072
|
+
* themselves (Nuxt), and returns its value; a `Response` with a body comes
|
|
1073
|
+
* back with the same status, headers and bytes, its body read inside the
|
|
1074
|
+
* scope.
|
|
1075
|
+
*/
|
|
1076
|
+
async function withRequest(request, fn, options) {
|
|
1077
|
+
const config = current();
|
|
1078
|
+
const url = new URL(request.url);
|
|
1079
|
+
const scope = await open(config, request, url, matched(config, options?.locale) ?? negotiateFor(request, url, config).locale, options);
|
|
1080
|
+
if (ROUTES) scope.home = hostLocale(url.host, config);
|
|
1081
|
+
const value = await als.run(scope, fn);
|
|
1082
|
+
return value instanceof Response && value.body ? new Response(inScope(value.body, scope), value) : value;
|
|
1083
|
+
}
|
|
1084
|
+
/**
|
|
1085
|
+
* Re-scopes the current request to `locale` (Astro server islands whose
|
|
1086
|
+
* props carry it). The scope object is updated in place, so the request's
|
|
1087
|
+
* resolver, its payload and its `<html>` attributes all follow.
|
|
1088
|
+
*/
|
|
1089
|
+
async function scopeLocale(locale) {
|
|
1090
|
+
const scope = getScope();
|
|
1091
|
+
if (!scope) throw error("scopeLocale() called outside a request scope");
|
|
1092
|
+
const l = pickLocale(current(), locale);
|
|
1093
|
+
if (l === scope.locale) return;
|
|
1094
|
+
const cat = await prepare(l);
|
|
1095
|
+
scope.locale = l;
|
|
1096
|
+
scope.messages = cat.messages;
|
|
1097
|
+
scope.labels = cat.labels;
|
|
1098
|
+
}
|
|
1099
|
+
//#endregion
|
|
1100
|
+
//#region src/server/seo.ts
|
|
1101
|
+
/**
|
|
1102
|
+
* Prerender and SEO helpers (PLAN §5.13, §5.14): every locale's URL of a page
|
|
1103
|
+
* for crawlers (`alternates`), and the paths or params a prerenderer walks
|
|
1104
|
+
* (`localizedPaths`, `localizedParams`). Pseudo-locales are never advertised.
|
|
1105
|
+
*/
|
|
1106
|
+
const advertised = (config) => config.locales.filter((l) => !config.pseudoLocales.includes(l));
|
|
1107
|
+
/** `pt-BR` → `pt_BR`; a missing region comes from the likely subtags (`de` → `de_DE`). */
|
|
1108
|
+
function ogLocale(locale) {
|
|
1109
|
+
try {
|
|
1110
|
+
const l = new Intl.Locale(locale);
|
|
1111
|
+
const region = l.region ?? l.maximize().region;
|
|
1112
|
+
return region ? l.language + "_" + region : l.language;
|
|
1113
|
+
} catch {
|
|
1114
|
+
return locale;
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* hreflang links for every locale plus `x-default` (the source locale's
|
|
1119
|
+
* URL), the self-canonical (`origin + pathname`: queries and fragments are
|
|
1120
|
+
* not separate pages), and `og:locale` metadata for the current locale.
|
|
1121
|
+
*/
|
|
1122
|
+
function alternates(url) {
|
|
1123
|
+
const config = current();
|
|
1124
|
+
const locale = getScope()?.locale ?? config.sourceLocale;
|
|
1125
|
+
if (ROUTES && config.routing.domains) return onDomains(url, config, locale);
|
|
1126
|
+
const path = splitLocalePath(url.pathname, config).path;
|
|
1127
|
+
const origin = url.origin;
|
|
1128
|
+
const links = [];
|
|
1129
|
+
const locales = advertised(config);
|
|
1130
|
+
const alternate = (hreflang, l) => void links.push({
|
|
1131
|
+
rel: "alternate",
|
|
1132
|
+
hreflang,
|
|
1133
|
+
href: origin + localizePath(path, l, config)
|
|
1134
|
+
});
|
|
1135
|
+
if (config.routing.url !== "no_prefix") {
|
|
1136
|
+
for (const l of locales) alternate(l, l);
|
|
1137
|
+
alternate("x-default", config.sourceLocale);
|
|
1138
|
+
}
|
|
1139
|
+
links.push({
|
|
1140
|
+
rel: "canonical",
|
|
1141
|
+
href: origin + url.pathname
|
|
1142
|
+
});
|
|
1143
|
+
const meta = [{
|
|
1144
|
+
property: "og:locale",
|
|
1145
|
+
content: ogLocale(locale)
|
|
1146
|
+
}];
|
|
1147
|
+
for (const l of locales) if (l !== locale) meta.push({
|
|
1148
|
+
property: "og:locale:alternate",
|
|
1149
|
+
content: ogLocale(l)
|
|
1150
|
+
});
|
|
1151
|
+
return {
|
|
1152
|
+
links,
|
|
1153
|
+
meta
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
/** `og:locale` for `locale`, then `og:locale:alternate` for every other advertised locale. */
|
|
1157
|
+
function ogMeta(config, locale) {
|
|
1158
|
+
const meta = [{
|
|
1159
|
+
property: "og:locale",
|
|
1160
|
+
content: ogLocale(locale)
|
|
1161
|
+
}];
|
|
1162
|
+
for (const l of advertised(config)) if (l !== locale) meta.push({
|
|
1163
|
+
property: "og:locale:alternate",
|
|
1164
|
+
content: ogLocale(l)
|
|
1165
|
+
});
|
|
1166
|
+
return meta;
|
|
1167
|
+
}
|
|
1168
|
+
/**
|
|
1169
|
+
* `alternates()` with domains (§6 D135): every URL comes from the config,
|
|
1170
|
+
* none from the request's `Host`. Each locale's alternate, `x-default` and
|
|
1171
|
+
* the canonical are on the locale's first listed origin, unprefixed, so a
|
|
1172
|
+
* page served on an alias, a preview host or with a forged `Host` still
|
|
1173
|
+
* names the configured site.
|
|
1174
|
+
*/
|
|
1175
|
+
function onDomains(url, config, locale) {
|
|
1176
|
+
const path = splitLocalePath(url.pathname, atHost(config, url.host)).path;
|
|
1177
|
+
const at = (l) => (localeOrigin(l, config) ?? url.origin) + localizePath(path, l, atHome(config, l));
|
|
1178
|
+
const links = advertised(config).map((l) => ({
|
|
1179
|
+
rel: "alternate",
|
|
1180
|
+
hreflang: l,
|
|
1181
|
+
href: at(l)
|
|
1182
|
+
}));
|
|
1183
|
+
links.push({
|
|
1184
|
+
rel: "alternate",
|
|
1185
|
+
hreflang: "x-default",
|
|
1186
|
+
href: at(config.sourceLocale)
|
|
1187
|
+
});
|
|
1188
|
+
links.push({
|
|
1189
|
+
rel: "canonical",
|
|
1190
|
+
href: at(locale)
|
|
1191
|
+
});
|
|
1192
|
+
return {
|
|
1193
|
+
links,
|
|
1194
|
+
meta: ogMeta(config, locale)
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
/** Rewritten for the codec with the app's base left out. */
|
|
1198
|
+
const atRoot = (config) => ({
|
|
1199
|
+
...config,
|
|
1200
|
+
routing: {
|
|
1201
|
+
...config.routing,
|
|
1202
|
+
base: "/"
|
|
1203
|
+
}
|
|
1204
|
+
});
|
|
1205
|
+
/**
|
|
1206
|
+
* Every path in every advertised locale, for prerender entry lists. Paths are
|
|
1207
|
+
* base-free, as for `/config`'s `localizedPaths` and `localizedParams`:
|
|
1208
|
+
* prerenderers list routes, and one helper must not need different input
|
|
1209
|
+
* at build time and at request time (PLAN §4).
|
|
1210
|
+
*/
|
|
1211
|
+
function localizedPaths(paths) {
|
|
1212
|
+
return localizedPathsFor(paths, atRoot(current()));
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* Astro `getStaticPaths()` params for a `[...lang]` route: the locale segment
|
|
1216
|
+
* of each advertised locale, `undefined` where the locale's URL carries none
|
|
1217
|
+
* (the source locale under `prefix_except_default`).
|
|
1218
|
+
*/
|
|
1219
|
+
function localizedParams(key = "lang") {
|
|
1220
|
+
const config = current();
|
|
1221
|
+
const root = atRoot(config);
|
|
1222
|
+
const out = [];
|
|
1223
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1224
|
+
for (const l of advertised(config)) {
|
|
1225
|
+
const localized = localizePath("/", l, root);
|
|
1226
|
+
const segment = localized === "/" ? void 0 : localized.slice(1);
|
|
1227
|
+
if (seen.has(segment)) continue;
|
|
1228
|
+
seen.add(segment);
|
|
1229
|
+
out.push({ params: { [key]: segment } });
|
|
1230
|
+
}
|
|
1231
|
+
return out;
|
|
1232
|
+
}
|
|
1233
|
+
//#endregion
|
|
1234
|
+
export { alternates, createTranslator, getScope, handle, htmlAttributes, localizedParams, localizedPaths, negotiate, payload, registerCatalogs, scopeLocale, setScopeResolver, withLocale, withRequest };
|