@uniflowed/i18n 0.0.0-alpha.18 → 0.0.0-alpha.41

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.
Files changed (3) hide show
  1. package/index.js +1 -1
  2. package/package.json +3 -1
  3. package/routing.js +99 -0
package/index.js CHANGED
@@ -202,7 +202,7 @@
202
202
  // definition-time contract checks, translations with partial coverage and an
203
203
  // `untranslated` list, lazily loaded locales with one load per locale, and
204
204
  // negotiation over `Accept-Language` including quality values and `*`.
205
- // `tests/library/i18n.test.js` covers each.
205
+ // `packages/i18n/i18n.test.js` covers each.
206
206
  //
207
207
  // **Not implemented, and a gap.** The catalogue is not extracted at build
208
208
  // time. `uf build` does not walk a project for `message(…)` calls, so there is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/i18n",
3
- "version": "0.0.0-alpha.18",
3
+ "version": "0.0.0-alpha.41",
4
4
  "description": "Type-safe internationalisation on MessageFormat 2: a message's arguments are checked at the call, part of the Unified Toolchain for Flow.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,6 +15,7 @@
15
15
  "./catalogue": "./catalogue.js",
16
16
  "./format": "./format.js",
17
17
  "./negotiate": "./negotiate.js",
18
+ "./routing": "./routing.js",
18
19
  "./syntax": "./syntax.js"
19
20
  },
20
21
  "files": [
@@ -22,6 +23,7 @@
22
23
  "format.js",
23
24
  "index.js",
24
25
  "negotiate.js",
26
+ "routing.js",
25
27
  "syntax.js",
26
28
  "!*.test.js"
27
29
  ]
package/routing.js ADDED
@@ -0,0 +1,99 @@
1
+ // @flow
2
+
3
+ import { negotiate, parseAcceptLanguage } from "./negotiate.js";
4
+
5
+ /** The same locale union is used by middleware, pages and generated parameters. */
6
+ export type LocaleRouting<L extends string> = {|
7
+ readonly locales: $ReadOnlyArray<L>,
8
+ readonly locale: (params: { readonly [string]: mixed }) => L,
9
+ readonly staticParams: () => $ReadOnlyArray<{| locale: L |}>,
10
+ readonly middleware: (request: Request) => Response | null,
11
+ readonly metadata: (path?: string) => {|
12
+ alternates: {| languages: { [string]: string } |},
13
+ |},
14
+ |};
15
+
16
+ /**
17
+ * A `[locale]` root segment. The router already knows how to enumerate dynamic
18
+ * routes and write their sitemap entries; this supplies the shared locale list.
19
+ * Negotiated redirects are private because a cookie can change their answer.
20
+ */
21
+ export function createLocaleRouting<L extends string>(options: {|
22
+ readonly locales: $ReadOnlyArray<L>,
23
+ readonly defaultLocale: L,
24
+ readonly cookie?: string,
25
+ |}): LocaleRouting<L> {
26
+ const locales = [...options.locales];
27
+ if (
28
+ locales.length === 0 ||
29
+ new Set(locales.map((locale) => locale.toLowerCase())).size !== locales.length ||
30
+ !locales.includes(options.defaultLocale) ||
31
+ locales.some((locale) => !/^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$/.test(locale))
32
+ ) {
33
+ throw new TypeError(
34
+ "locale routing needs distinct language tags and a defaultLocale in locales",
35
+ );
36
+ }
37
+ const cookie = options.cookie ?? "uf.locale";
38
+ if (!/^[A-Za-z0-9_.-]+$/.test(cookie)) {
39
+ throw new TypeError("locale routing cookie must be a cookie name");
40
+ }
41
+ return {
42
+ locales: Object.freeze(locales),
43
+ locale(params) {
44
+ const value = params.locale;
45
+ const found = locales.find((locale) => locale === value);
46
+ if (found == null) {
47
+ throw new RangeError(`unsupported route locale: ${String(value)}`);
48
+ }
49
+ return found;
50
+ },
51
+ staticParams() {
52
+ return locales.map((locale) => ({ locale }));
53
+ },
54
+ middleware(request) {
55
+ const url = new URL(request.url);
56
+ if (url.pathname !== "/" || !["GET", "HEAD"].includes(request.method)) return null;
57
+ let preferred = null;
58
+ for (const entry of (request.headers.get("cookie") ?? "").split(";")) {
59
+ const at = entry.indexOf("=");
60
+ if (at < 0 || entry.slice(0, at).trim() !== cookie) continue;
61
+ try {
62
+ preferred = decodeURIComponent(entry.slice(at + 1).trim());
63
+ } catch {
64
+ preferred = null;
65
+ }
66
+ break;
67
+ }
68
+ const accepted = locales.find((locale) => locale === preferred);
69
+ const selected =
70
+ accepted ??
71
+ negotiate(
72
+ parseAcceptLanguage(request.headers.get("accept-language") ?? ""),
73
+ locales,
74
+ options.defaultLocale,
75
+ );
76
+ // Only a configured language tag enters Location; never a cookie's bytes.
77
+ url.pathname = `/${selected}`;
78
+ return new Response(null, {
79
+ status: 307,
80
+ headers: {
81
+ location: url.href,
82
+ vary: "Accept-Language, Cookie",
83
+ "cache-control": "private, no-store",
84
+ },
85
+ });
86
+ },
87
+ metadata(path = "") {
88
+ if (path !== "" && (!path.startsWith("/") || path.startsWith("//") || /[?#\\]/.test(path))) {
89
+ throw new TypeError(
90
+ "locale metadata expects an application path without query or fragment",
91
+ );
92
+ }
93
+ const languages: { [string]: string } = {};
94
+ for (const locale of locales) languages[locale] = `/${locale}${path}`;
95
+ languages["x-default"] = `/${options.defaultLocale}${path}`;
96
+ return { alternates: { languages } };
97
+ },
98
+ };
99
+ }