@facilis.dev/i18n-db 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 +21 -0
- package/README.md +18 -2
- package/dist/catalog.d.ts +31 -0
- package/dist/catalog.js +73 -0
- package/dist/client.d.ts +17 -0
- package/dist/client.js +61 -0
- package/dist/columns.d.ts +6 -0
- package/dist/columns.js +9 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +11 -0
- package/dist/messages.d.ts +100 -0
- package/dist/messages.js +30 -0
- package/dist/test-support.d.ts +8 -0
- package/dist/test-support.js +28 -0
- package/dist/translations.d.ts +45 -0
- package/dist/translations.js +146 -0
- package/package.json +46 -4
- package/src/catalog.ts +93 -0
- package/src/client.ts +91 -0
- package/src/columns.ts +12 -0
- package/src/index.ts +29 -0
- package/src/messages.ts +38 -0
- package/src/test-support.ts +41 -0
- package/src/translations.ts +178 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 facilis-dev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @facilis.dev/i18n-db
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
database i18n: translated content fields plus an optional catalogue overlay
|
|
4
|
+
|
|
5
|
+
Part of [Facilis](https://github.com/facilis-dev/facilis), a browser-first low-code framework: the Studio
|
|
6
|
+
runs a generated project in a WebContainer where vite dev is the only dev server
|
|
7
|
+
and Nitro v3 runs as a Vite plugin. Framework contracts live in
|
|
8
|
+
[docs/SPEC.md](https://github.com/facilis-dev/facilis/blob/main/docs/SPEC.md) and architectural decisions in
|
|
9
|
+
[docs/DECISIONS.md](https://github.com/facilis-dev/facilis/blob/main/docs/DECISIONS.md).
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
npm install @facilis.dev/i18n-db
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The package publishes its TypeScript sources and its compiled dist/; the exports map
|
|
16
|
+
of the published manifest points at dist/, so Node and bundlers load JavaScript and
|
|
17
|
+
the bundled .d.ts files.
|
|
18
|
+
|
|
19
|
+
Licensed under the MIT License.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { PolicyScopedDatabase } from "@facilis.dev/db";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
/** A flat key/value catalogue for one locale (O-001: flat keys). */
|
|
4
|
+
export type I18nCatalog = Record<string, string>;
|
|
5
|
+
/** Every catalogue for the requested locales, each keyed by message key. */
|
|
6
|
+
export type I18nCatalogs = Record<string, I18nCatalog>;
|
|
7
|
+
/**
|
|
8
|
+
* Query schema the project route passes to `defineAuthorizedHandler`: `?locales=en,it`,
|
|
9
|
+
* default "en". It is exported (not wrapped in a handler) so the route's config literal is
|
|
10
|
+
* visible to the Nitro AST metadata pass (security invariant 2).
|
|
11
|
+
*/
|
|
12
|
+
export declare const catalogQuery: z.ZodObject<{
|
|
13
|
+
locales: z.ZodDefault<z.ZodString>;
|
|
14
|
+
}, z.core.$strip>;
|
|
15
|
+
/** `"en, it ,en"` -> `["en","it"]`; a blank list falls back to the contract default. */
|
|
16
|
+
export declare function parseCatalogLocales(raw: string): string[];
|
|
17
|
+
/**
|
|
18
|
+
* Reads one locale through the caller's policy-scoped database. RLS restricts the rows to
|
|
19
|
+
* `app.tenant_id`, so no tenant id is ever part of the query.
|
|
20
|
+
*/
|
|
21
|
+
export declare function loadCatalog(db: PolicyScopedDatabase, locale: string): Promise<I18nCatalog>;
|
|
22
|
+
/**
|
|
23
|
+
* Reads several locales in one round trip. Requested locales with no rows still appear as
|
|
24
|
+
* empty catalogues so the route response has a stable shape.
|
|
25
|
+
*/
|
|
26
|
+
export declare function loadCatalogs(db: PolicyScopedDatabase, locales: readonly string[]): Promise<I18nCatalogs>;
|
|
27
|
+
/**
|
|
28
|
+
* Upserts every key of one locale (seeding/admin). The tenant comes from the insert default,
|
|
29
|
+
* i.e. the policy-scope GUC, and `excluded.value` makes a re-save overwrite the old text.
|
|
30
|
+
*/
|
|
31
|
+
export declare function saveCatalog(db: PolicyScopedDatabase, locale: string, messages: I18nCatalog): Promise<void>;
|
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { inArray, sql } from "drizzle-orm";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { i18nMessages } from "./messages.js";
|
|
4
|
+
/**
|
|
5
|
+
* Query schema the project route passes to `defineAuthorizedHandler`: `?locales=en,it`,
|
|
6
|
+
* default "en". It is exported (not wrapped in a handler) so the route's config literal is
|
|
7
|
+
* visible to the Nitro AST metadata pass (security invariant 2).
|
|
8
|
+
*/
|
|
9
|
+
export const catalogQuery = z.object({
|
|
10
|
+
locales: z.string().default("en"),
|
|
11
|
+
});
|
|
12
|
+
/** `"en, it ,en"` -> `["en","it"]`; a blank list falls back to the contract default. */
|
|
13
|
+
export function parseCatalogLocales(raw) {
|
|
14
|
+
const locales = raw
|
|
15
|
+
.split(",")
|
|
16
|
+
.map((locale) => locale.trim())
|
|
17
|
+
.filter((locale) => locale.length > 0);
|
|
18
|
+
return locales.length === 0 ? ["en"] : [...new Set(locales)];
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Reads one locale through the caller's policy-scoped database. RLS restricts the rows to
|
|
22
|
+
* `app.tenant_id`, so no tenant id is ever part of the query.
|
|
23
|
+
*/
|
|
24
|
+
export async function loadCatalog(db, locale) {
|
|
25
|
+
const rows = await db
|
|
26
|
+
.select({ key: i18nMessages.key, value: i18nMessages.value })
|
|
27
|
+
.from(i18nMessages)
|
|
28
|
+
.where(sql `${i18nMessages.locale} = ${locale}`)
|
|
29
|
+
.orderBy(i18nMessages.key);
|
|
30
|
+
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Reads several locales in one round trip. Requested locales with no rows still appear as
|
|
34
|
+
* empty catalogues so the route response has a stable shape.
|
|
35
|
+
*/
|
|
36
|
+
export async function loadCatalogs(db, locales) {
|
|
37
|
+
const catalogs = Object.fromEntries(locales.map((locale) => [locale, {}]));
|
|
38
|
+
if (locales.length === 0)
|
|
39
|
+
return catalogs;
|
|
40
|
+
const rows = await db
|
|
41
|
+
.select({
|
|
42
|
+
locale: i18nMessages.locale,
|
|
43
|
+
key: i18nMessages.key,
|
|
44
|
+
value: i18nMessages.value,
|
|
45
|
+
})
|
|
46
|
+
.from(i18nMessages)
|
|
47
|
+
.where(inArray(i18nMessages.locale, [...locales]))
|
|
48
|
+
.orderBy(i18nMessages.locale, i18nMessages.key);
|
|
49
|
+
for (const row of rows) {
|
|
50
|
+
const catalog = catalogs[row.locale];
|
|
51
|
+
// inArray() guarantees this, but the record is indexed so keep the narrowing explicit.
|
|
52
|
+
if (catalog === undefined)
|
|
53
|
+
continue;
|
|
54
|
+
catalog[row.key] = row.value;
|
|
55
|
+
}
|
|
56
|
+
return catalogs;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Upserts every key of one locale (seeding/admin). The tenant comes from the insert default,
|
|
60
|
+
* i.e. the policy-scope GUC, and `excluded.value` makes a re-save overwrite the old text.
|
|
61
|
+
*/
|
|
62
|
+
export async function saveCatalog(db, locale, messages) {
|
|
63
|
+
const rows = Object.entries(messages).map(([key, value]) => ({ locale, key, value }));
|
|
64
|
+
if (rows.length === 0)
|
|
65
|
+
return;
|
|
66
|
+
await db
|
|
67
|
+
.insert(i18nMessages)
|
|
68
|
+
.values(rows)
|
|
69
|
+
.onConflictDoUpdate({
|
|
70
|
+
target: [i18nMessages.tenantId, i18nMessages.locale, i18nMessages.key],
|
|
71
|
+
set: { value: sql `excluded.value`, updatedAt: sql `now()` },
|
|
72
|
+
});
|
|
73
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { matchLocale, normalizeLocale, parseAcceptLanguage, resolveTranslation, resolveUserLocale, translationsSchema, type ResolveUserLocaleOptions, type Translations, } from "./translations.js";
|
|
2
|
+
export interface HydrateCatalogsOptions {
|
|
3
|
+
/** Locales to load; every one is registered, even when the database has no rows for it. */
|
|
4
|
+
readonly locales: readonly string[];
|
|
5
|
+
/** Bundled JSON catalogues; DB rows win per key and missing rows fall back here. */
|
|
6
|
+
readonly base?: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
|
7
|
+
/** Applied with `setDefaultLocale` after registration when given. */
|
|
8
|
+
readonly defaultLocale?: string;
|
|
9
|
+
/** Catalogue endpoint; defaults to "/api/i18n". */
|
|
10
|
+
readonly endpoint?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Fetches the DB catalogue overlay, merges it over the bundled JSON per key and registers
|
|
14
|
+
* every locale (D-057). Throws on a failed request, a network error or a malformed payload
|
|
15
|
+
* so the caller can decide (the docs show the try/catch); it never logs.
|
|
16
|
+
*/
|
|
17
|
+
export declare function hydrateCatalogs(options: HydrateCatalogsOptions): Promise<void>;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { registerCatalog, setDefaultLocale } from "@facilis.dev/core/i18n";
|
|
2
|
+
// The browser half also exposes the pure locale/translation helpers so a generated app can
|
|
3
|
+
// resolve content without a second import; the column helper and Drizzle stay server-only.
|
|
4
|
+
export { matchLocale, normalizeLocale, parseAcceptLanguage, resolveTranslation, resolveUserLocale, translationsSchema, } from "./translations.js";
|
|
5
|
+
const DEFAULT_ENDPOINT = "/api/i18n";
|
|
6
|
+
/**
|
|
7
|
+
* Fetches the DB catalogue overlay, merges it over the bundled JSON per key and registers
|
|
8
|
+
* every locale (D-057). Throws on a failed request, a network error or a malformed payload
|
|
9
|
+
* so the caller can decide (the docs show the try/catch); it never logs.
|
|
10
|
+
*/
|
|
11
|
+
export async function hydrateCatalogs(options) {
|
|
12
|
+
const { locales, base, defaultLocale, endpoint = DEFAULT_ENDPOINT } = options;
|
|
13
|
+
const remote = await fetchCatalogs(endpoint, locales);
|
|
14
|
+
for (const locale of locales) {
|
|
15
|
+
// Spreading a missing catalogue is a no-op, which is exactly the per-key fallback.
|
|
16
|
+
registerCatalog(locale, { ...base?.[locale], ...remote[locale] });
|
|
17
|
+
}
|
|
18
|
+
if (defaultLocale !== undefined)
|
|
19
|
+
setDefaultLocale(defaultLocale);
|
|
20
|
+
}
|
|
21
|
+
async function fetchCatalogs(endpoint, locales) {
|
|
22
|
+
// The endpoint may already carry a query string; append rather than replace it.
|
|
23
|
+
const separator = endpoint.includes("?") ? "&" : "?";
|
|
24
|
+
const url = `${endpoint}${separator}locales=${encodeURIComponent(locales.join(","))}`;
|
|
25
|
+
const response = await fetch(url);
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
throw new Error(`i18n: catalogue request failed with HTTP ${response.status}`);
|
|
28
|
+
}
|
|
29
|
+
let payload;
|
|
30
|
+
try {
|
|
31
|
+
payload = await response.json();
|
|
32
|
+
}
|
|
33
|
+
catch (cause) {
|
|
34
|
+
throw new Error("i18n: catalogue response is not valid JSON", { cause });
|
|
35
|
+
}
|
|
36
|
+
return validateCatalogs(payload);
|
|
37
|
+
}
|
|
38
|
+
/** Validates `{ "<locale>": { "<key>": "<value>" } }` by hand: the client has no zod. */
|
|
39
|
+
function validateCatalogs(payload) {
|
|
40
|
+
if (!isRecord(payload)) {
|
|
41
|
+
throw new Error("i18n: catalogue response must be a JSON object");
|
|
42
|
+
}
|
|
43
|
+
const catalogs = {};
|
|
44
|
+
for (const [locale, rawCatalog] of Object.entries(payload)) {
|
|
45
|
+
if (!isRecord(rawCatalog)) {
|
|
46
|
+
throw new Error(`i18n: catalogue for "${locale}" must be a JSON object`);
|
|
47
|
+
}
|
|
48
|
+
const catalog = {};
|
|
49
|
+
for (const [key, value] of Object.entries(rawCatalog)) {
|
|
50
|
+
if (typeof value !== "string") {
|
|
51
|
+
throw new Error(`i18n: message "${key}" for "${locale}" must be a string`);
|
|
52
|
+
}
|
|
53
|
+
catalog[key] = value;
|
|
54
|
+
}
|
|
55
|
+
catalogs[locale] = catalog;
|
|
56
|
+
}
|
|
57
|
+
return catalogs;
|
|
58
|
+
}
|
|
59
|
+
function isRecord(value) {
|
|
60
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
61
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A translatable content field: one NOT NULL `jsonb` column holding `{ locale: text }`.
|
|
3
|
+
* A project schema re-exports this helper (`title: translatedText("title")`) and lets
|
|
4
|
+
* drizzle-kit generate the migration; the package ships none.
|
|
5
|
+
*/
|
|
6
|
+
export declare function translatedText(name: string): import("drizzle-orm").NotNull<import("drizzle-orm").$Type<import("drizzle-orm/pg-core").PgJsonbBuilderInitial<string>, Readonly<Record<string, string>>>>;
|
package/dist/columns.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsonb } from "drizzle-orm/pg-core";
|
|
2
|
+
/**
|
|
3
|
+
* A translatable content field: one NOT NULL `jsonb` column holding `{ locale: text }`.
|
|
4
|
+
* A project schema re-exports this helper (`title: translatedText("title")`) and lets
|
|
5
|
+
* drizzle-kit generate the migration; the package ships none.
|
|
6
|
+
*/
|
|
7
|
+
export function translatedText(name) {
|
|
8
|
+
return jsonb(name).$type().notNull();
|
|
9
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@facilis.dev/i18n-db` — database i18n for generated projects: translated content fields
|
|
3
|
+
* (a typed `jsonb` column + locale resolution) and the optional database catalogue overlay.
|
|
4
|
+
*
|
|
5
|
+
* The root entry point is server-only (Drizzle + policy-scoped DB). The browser half lives
|
|
6
|
+
* behind the `@facilis.dev/i18n-db/client` subpath so a client bundle never pulls drizzle.
|
|
7
|
+
*/
|
|
8
|
+
export { translatedText } from "./columns.js";
|
|
9
|
+
export { matchLocale, normalizeLocale, parseAcceptLanguage, resolveTranslation, resolveUserLocale, translationsSchema, type ResolveUserLocaleOptions, type Translations, } from "./translations.js";
|
|
10
|
+
export { i18nMessages, type I18nMessageRow } from "./messages.js";
|
|
11
|
+
export { catalogQuery, loadCatalog, loadCatalogs, parseCatalogLocales, saveCatalog, type I18nCatalog, type I18nCatalogs, } from "./catalog.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@facilis.dev/i18n-db` — database i18n for generated projects: translated content fields
|
|
3
|
+
* (a typed `jsonb` column + locale resolution) and the optional database catalogue overlay.
|
|
4
|
+
*
|
|
5
|
+
* The root entry point is server-only (Drizzle + policy-scoped DB). The browser half lives
|
|
6
|
+
* behind the `@facilis.dev/i18n-db/client` subpath so a client bundle never pulls drizzle.
|
|
7
|
+
*/
|
|
8
|
+
export { translatedText } from "./columns.js";
|
|
9
|
+
export { matchLocale, normalizeLocale, parseAcceptLanguage, resolveTranslation, resolveUserLocale, translationsSchema, } from "./translations.js";
|
|
10
|
+
export { i18nMessages } from "./messages.js";
|
|
11
|
+
export { catalogQuery, loadCatalog, loadCatalogs, parseCatalogLocales, saveCatalog, } from "./catalog.js";
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant-scoped catalogue overlay (D-057): one row per locale/key, unique per tenant.
|
|
3
|
+
*
|
|
4
|
+
* The JSON catalogues of `@facilis.dev/core/i18n` stay the base; these rows win key by key.
|
|
5
|
+
* A project re-exports this table from its `db/schema.ts` and lets drizzle-kit generate
|
|
6
|
+
* the migration — the package intentionally ships no migration.
|
|
7
|
+
*/
|
|
8
|
+
export declare const i18nMessages: Omit<import("drizzle-orm/pg-core").PgTableWithColumns<{
|
|
9
|
+
name: "i18n_messages";
|
|
10
|
+
schema: undefined;
|
|
11
|
+
columns: {
|
|
12
|
+
tenantId: import("drizzle-orm/pg-core").PgColumn<{
|
|
13
|
+
name: string;
|
|
14
|
+
tableName: "i18n_messages";
|
|
15
|
+
dataType: "string";
|
|
16
|
+
columnType: "PgUUID";
|
|
17
|
+
data: string;
|
|
18
|
+
driverParam: string;
|
|
19
|
+
notNull: true;
|
|
20
|
+
hasDefault: true;
|
|
21
|
+
isPrimaryKey: false;
|
|
22
|
+
isAutoincrement: false;
|
|
23
|
+
hasRuntimeDefault: false;
|
|
24
|
+
enumValues: undefined;
|
|
25
|
+
baseColumn: never;
|
|
26
|
+
identity: undefined;
|
|
27
|
+
generated: undefined;
|
|
28
|
+
}, {}, {}>;
|
|
29
|
+
locale: import("drizzle-orm/pg-core").PgColumn<{
|
|
30
|
+
name: "locale";
|
|
31
|
+
tableName: "i18n_messages";
|
|
32
|
+
dataType: "string";
|
|
33
|
+
columnType: "PgText";
|
|
34
|
+
data: string;
|
|
35
|
+
driverParam: string;
|
|
36
|
+
notNull: true;
|
|
37
|
+
hasDefault: false;
|
|
38
|
+
isPrimaryKey: false;
|
|
39
|
+
isAutoincrement: false;
|
|
40
|
+
hasRuntimeDefault: false;
|
|
41
|
+
enumValues: [string, ...string[]];
|
|
42
|
+
baseColumn: never;
|
|
43
|
+
identity: undefined;
|
|
44
|
+
generated: undefined;
|
|
45
|
+
}, {}, {}>;
|
|
46
|
+
key: import("drizzle-orm/pg-core").PgColumn<{
|
|
47
|
+
name: "key";
|
|
48
|
+
tableName: "i18n_messages";
|
|
49
|
+
dataType: "string";
|
|
50
|
+
columnType: "PgText";
|
|
51
|
+
data: string;
|
|
52
|
+
driverParam: string;
|
|
53
|
+
notNull: true;
|
|
54
|
+
hasDefault: false;
|
|
55
|
+
isPrimaryKey: false;
|
|
56
|
+
isAutoincrement: false;
|
|
57
|
+
hasRuntimeDefault: false;
|
|
58
|
+
enumValues: [string, ...string[]];
|
|
59
|
+
baseColumn: never;
|
|
60
|
+
identity: undefined;
|
|
61
|
+
generated: undefined;
|
|
62
|
+
}, {}, {}>;
|
|
63
|
+
value: import("drizzle-orm/pg-core").PgColumn<{
|
|
64
|
+
name: "value";
|
|
65
|
+
tableName: "i18n_messages";
|
|
66
|
+
dataType: "string";
|
|
67
|
+
columnType: "PgText";
|
|
68
|
+
data: string;
|
|
69
|
+
driverParam: string;
|
|
70
|
+
notNull: true;
|
|
71
|
+
hasDefault: false;
|
|
72
|
+
isPrimaryKey: false;
|
|
73
|
+
isAutoincrement: false;
|
|
74
|
+
hasRuntimeDefault: false;
|
|
75
|
+
enumValues: [string, ...string[]];
|
|
76
|
+
baseColumn: never;
|
|
77
|
+
identity: undefined;
|
|
78
|
+
generated: undefined;
|
|
79
|
+
}, {}, {}>;
|
|
80
|
+
updatedAt: import("drizzle-orm/pg-core").PgColumn<{
|
|
81
|
+
name: "updated_at";
|
|
82
|
+
tableName: "i18n_messages";
|
|
83
|
+
dataType: "date";
|
|
84
|
+
columnType: "PgTimestamp";
|
|
85
|
+
data: Date;
|
|
86
|
+
driverParam: string;
|
|
87
|
+
notNull: false;
|
|
88
|
+
hasDefault: true;
|
|
89
|
+
isPrimaryKey: false;
|
|
90
|
+
isAutoincrement: false;
|
|
91
|
+
hasRuntimeDefault: false;
|
|
92
|
+
enumValues: undefined;
|
|
93
|
+
baseColumn: never;
|
|
94
|
+
identity: undefined;
|
|
95
|
+
generated: undefined;
|
|
96
|
+
}, {}, {}>;
|
|
97
|
+
};
|
|
98
|
+
dialect: 'pg';
|
|
99
|
+
}>, "enableRLS">;
|
|
100
|
+
export type I18nMessageRow = typeof i18nMessages.$inferSelect;
|
package/dist/messages.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { pgPolicy, pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core";
|
|
3
|
+
import { tenantColumn, tenantIsolationPolicy } from "@facilis.dev/db";
|
|
4
|
+
/**
|
|
5
|
+
* Tenant-scoped catalogue overlay (D-057): one row per locale/key, unique per tenant.
|
|
6
|
+
*
|
|
7
|
+
* The JSON catalogues of `@facilis.dev/core/i18n` stay the base; these rows win key by key.
|
|
8
|
+
* A project re-exports this table from its `db/schema.ts` and lets drizzle-kit generate
|
|
9
|
+
* the migration — the package intentionally ships no migration.
|
|
10
|
+
*/
|
|
11
|
+
export const i18nMessages = pgTable("i18n_messages", {
|
|
12
|
+
// The insert never names the tenant; the column takes it from the policy-scope GUC,
|
|
13
|
+
// so raw SQL and app code are both held to the deployment tenant (D-040).
|
|
14
|
+
tenantId: tenantColumn(),
|
|
15
|
+
locale: text("locale").notNull(),
|
|
16
|
+
key: text("key").notNull(),
|
|
17
|
+
value: text("value").notNull(),
|
|
18
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
|
|
19
|
+
}, (table) => [
|
|
20
|
+
primaryKey({ columns: [table.tenantId, table.locale, table.key] }),
|
|
21
|
+
tenantIsolationPolicy(),
|
|
22
|
+
// A RESTRICTIVE policy only narrows the rows a permissive policy already allows; with
|
|
23
|
+
// no permissive policy Postgres denies every row (D-008). This clause admits the scoped
|
|
24
|
+
// tenant and the restrictive tenant_isolation AND's it down to that tenant's rows.
|
|
25
|
+
pgPolicy("i18n_messages_tenant_access", {
|
|
26
|
+
for: "all",
|
|
27
|
+
using: sql `nullif(current_setting('app.tenant_id', true), '') IS NOT NULL`,
|
|
28
|
+
withCheck: sql `nullif(current_setting('app.tenant_id', true), '') IS NOT NULL`,
|
|
29
|
+
}),
|
|
30
|
+
]).enableRLS();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type FacilisDb } from "@facilis.dev/db";
|
|
2
|
+
/**
|
|
3
|
+
* Hand-written DDL matching ./messages.ts exactly (no drizzle-kit in tests): column names,
|
|
4
|
+
* types, nullability, the GUC tenant default and the composite primary key.
|
|
5
|
+
*/
|
|
6
|
+
export declare const I18N_MESSAGES_TABLE_STATEMENTS: readonly string[];
|
|
7
|
+
/** Boots an in-memory PGlite database with the catalogue table, its RLS policies and the app role. */
|
|
8
|
+
export declare function createI18nTestDb(): Promise<FacilisDb>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { getTableConfig } from "drizzle-orm/pg-core";
|
|
2
|
+
import { applyStatements, createDb, ensureAppRole, policyCreateSql, } from "@facilis.dev/db";
|
|
3
|
+
import { i18nMessages } from "./messages.js";
|
|
4
|
+
/**
|
|
5
|
+
* Hand-written DDL matching ./messages.ts exactly (no drizzle-kit in tests): column names,
|
|
6
|
+
* types, nullability, the GUC tenant default and the composite primary key.
|
|
7
|
+
*/
|
|
8
|
+
export const I18N_MESSAGES_TABLE_STATEMENTS = [
|
|
9
|
+
`CREATE TABLE IF NOT EXISTS i18n_messages (
|
|
10
|
+
tenant_id uuid NOT NULL DEFAULT nullif(current_setting('app.tenant_id', true), '')::uuid,
|
|
11
|
+
locale text NOT NULL,
|
|
12
|
+
key text NOT NULL,
|
|
13
|
+
value text NOT NULL,
|
|
14
|
+
updated_at timestamp with time zone DEFAULT now(),
|
|
15
|
+
PRIMARY KEY (tenant_id, locale, key)
|
|
16
|
+
)`,
|
|
17
|
+
];
|
|
18
|
+
function policyStatements() {
|
|
19
|
+
const policies = getTableConfig(i18nMessages).policies.map((policy) => policyCreateSql("i18n_messages", policy));
|
|
20
|
+
return ["ALTER TABLE i18n_messages ENABLE ROW LEVEL SECURITY", ...policies];
|
|
21
|
+
}
|
|
22
|
+
/** Boots an in-memory PGlite database with the catalogue table, its RLS policies and the app role. */
|
|
23
|
+
export async function createI18nTestDb() {
|
|
24
|
+
const db = await createDb();
|
|
25
|
+
await applyStatements(db, [...I18N_MESSAGES_TABLE_STATEMENTS, ...policyStatements()]);
|
|
26
|
+
await ensureAppRole(db);
|
|
27
|
+
return db;
|
|
28
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* A text stored as a map of locale tag -> translated string, e.g.
|
|
4
|
+
* `{ en: "Todo", it: "Da fare" }`. The raw value is what the database stores and what
|
|
5
|
+
* admin/edit APIs accept; serving maps a row through `resolveTranslation`.
|
|
6
|
+
*/
|
|
7
|
+
export type Translations = Readonly<Record<string, string>>;
|
|
8
|
+
/**
|
|
9
|
+
* Validates admin/edit payloads: every key must be a structurally valid locale tag and
|
|
10
|
+
* every value a non-empty string. Keys stay verbatim; matching normalizes them later, so
|
|
11
|
+
* `{ "IT": "Ciao" }` is stored as written but still served for `it`.
|
|
12
|
+
*/
|
|
13
|
+
export declare const translationsSchema: z.ZodType<Translations>;
|
|
14
|
+
/** Intl-canonical form of a locale tag (`it-ch` -> `it-CH`), or null when it is invalid. */
|
|
15
|
+
export declare function normalizeLocale(tag: string): string | null;
|
|
16
|
+
/**
|
|
17
|
+
* Picks the supported tag matching `locale`: exact canonical match, then its base language
|
|
18
|
+
* (`it-CH` -> `it`), then any supported variant of that language. `fallback` is returned
|
|
19
|
+
* when the tag is invalid or unsupported, so callers always get a usable locale.
|
|
20
|
+
*/
|
|
21
|
+
export declare function matchLocale(locale: string | undefined, supported: readonly string[], fallback: string): string;
|
|
22
|
+
/** Parses Accept-Language: q-ordered (stable ties), canonicalized, no wildcard, no q=0. */
|
|
23
|
+
export declare function parseAcceptLanguage(header: string | null | undefined): string[];
|
|
24
|
+
export interface ResolveUserLocaleOptions {
|
|
25
|
+
/** Saved preference (e.g. a profile column); wins over the request header. */
|
|
26
|
+
readonly userLocale?: string | null;
|
|
27
|
+
/** Raw Accept-Language header; second in precedence. */
|
|
28
|
+
readonly acceptLanguage?: string | null;
|
|
29
|
+
/** Application locales to filter candidates through; omitted/empty means no filter. */
|
|
30
|
+
readonly supported?: readonly string[];
|
|
31
|
+
/** Last resort; defaults to "en". */
|
|
32
|
+
readonly defaultLocale?: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolves the language a request should be served in: saved preference, then
|
|
36
|
+
* Accept-Language tags in q-order, then the default — each filtered by `supported`.
|
|
37
|
+
* Storing the preference is the app's job; this only resolves it.
|
|
38
|
+
*/
|
|
39
|
+
export declare function resolveUserLocale(options: ResolveUserLocaleOptions): string;
|
|
40
|
+
/**
|
|
41
|
+
* Serves only one language: exact locale, then its base language (`it-CH` -> `it`), then
|
|
42
|
+
* `fallbackLocale`, then the first non-empty value, else "". Tolerates null/malformed row
|
|
43
|
+
* data (it can predate the schema) and never throws.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveTranslation(value: Translations | null | undefined, locale: string, fallbackLocale?: string): string;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Validates admin/edit payloads: every key must be a structurally valid locale tag and
|
|
4
|
+
* every value a non-empty string. Keys stay verbatim; matching normalizes them later, so
|
|
5
|
+
* `{ "IT": "Ciao" }` is stored as written but still served for `it`.
|
|
6
|
+
*/
|
|
7
|
+
export const translationsSchema = z
|
|
8
|
+
.record(z.string(), z.string().min(1))
|
|
9
|
+
.superRefine((value, ctx) => {
|
|
10
|
+
for (const tag of Object.keys(value)) {
|
|
11
|
+
if (normalizeLocale(tag) === null) {
|
|
12
|
+
ctx.addIssue({
|
|
13
|
+
code: "custom",
|
|
14
|
+
message: `"${tag}" is not a valid locale tag`,
|
|
15
|
+
path: [tag],
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
/** Intl-canonical form of a locale tag (`it-ch` -> `it-CH`), or null when it is invalid. */
|
|
21
|
+
export function normalizeLocale(tag) {
|
|
22
|
+
try {
|
|
23
|
+
const [canonical] = Intl.getCanonicalLocales(tag.trim());
|
|
24
|
+
return canonical ?? null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Intl throws RangeError for malformed tags; invalid input is a value, not a crash.
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Picks the supported tag matching `locale`: exact canonical match, then its base language
|
|
33
|
+
* (`it-CH` -> `it`), then any supported variant of that language. `fallback` is returned
|
|
34
|
+
* when the tag is invalid or unsupported, so callers always get a usable locale.
|
|
35
|
+
*/
|
|
36
|
+
export function matchLocale(locale, supported, fallback) {
|
|
37
|
+
return findSupportedLocale(locale, supported) ?? fallback;
|
|
38
|
+
}
|
|
39
|
+
/** Parses Accept-Language: q-ordered (stable ties), canonicalized, no wildcard, no q=0. */
|
|
40
|
+
export function parseAcceptLanguage(header) {
|
|
41
|
+
if (header === null || header === undefined || header.trim() === "")
|
|
42
|
+
return [];
|
|
43
|
+
const ranked = [];
|
|
44
|
+
header.split(",").forEach((part, index) => {
|
|
45
|
+
const [rawTag, ...parameters] = part.split(";");
|
|
46
|
+
const tag = rawTag?.trim() ?? "";
|
|
47
|
+
// "*" means "anything else": it cannot resolve to a concrete language.
|
|
48
|
+
if (tag === "" || tag === "*")
|
|
49
|
+
return;
|
|
50
|
+
let q = 1;
|
|
51
|
+
for (const parameter of parameters) {
|
|
52
|
+
const [name, rawValue] = parameter.split("=");
|
|
53
|
+
if (name?.trim().toLowerCase() !== "q")
|
|
54
|
+
continue;
|
|
55
|
+
const parsed = Number.parseFloat(rawValue ?? "");
|
|
56
|
+
q = Number.isNaN(parsed) ? 1 : Math.min(Math.max(parsed, 0), 1);
|
|
57
|
+
}
|
|
58
|
+
if (q <= 0)
|
|
59
|
+
return;
|
|
60
|
+
const normalized = normalizeLocale(tag);
|
|
61
|
+
if (normalized === null)
|
|
62
|
+
return;
|
|
63
|
+
ranked.push({ tag: normalized, q, index });
|
|
64
|
+
});
|
|
65
|
+
const ordered = ranked
|
|
66
|
+
.toSorted((left, right) => right.q - left.q || left.index - right.index)
|
|
67
|
+
.map((entry) => entry.tag);
|
|
68
|
+
return ordered.filter((tag, index) => ordered.indexOf(tag) === index);
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Resolves the language a request should be served in: saved preference, then
|
|
72
|
+
* Accept-Language tags in q-order, then the default — each filtered by `supported`.
|
|
73
|
+
* Storing the preference is the app's job; this only resolves it.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveUserLocale(options) {
|
|
76
|
+
const { userLocale, acceptLanguage, supported, defaultLocale = "en" } = options;
|
|
77
|
+
const candidates = supported !== undefined && supported.length > 0 ? supported : null;
|
|
78
|
+
for (const tag of [userLocale ?? undefined, ...parseAcceptLanguage(acceptLanguage)]) {
|
|
79
|
+
if (tag === undefined)
|
|
80
|
+
continue;
|
|
81
|
+
const resolved = candidates === null
|
|
82
|
+
? (normalizeLocale(tag) ?? undefined)
|
|
83
|
+
: findSupportedLocale(tag, candidates);
|
|
84
|
+
if (resolved !== undefined)
|
|
85
|
+
return resolved;
|
|
86
|
+
}
|
|
87
|
+
return candidates === null
|
|
88
|
+
? (normalizeLocale(defaultLocale) ?? defaultLocale)
|
|
89
|
+
: (findSupportedLocale(defaultLocale, candidates) ?? defaultLocale);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Serves only one language: exact locale, then its base language (`it-CH` -> `it`), then
|
|
93
|
+
* `fallbackLocale`, then the first non-empty value, else "". Tolerates null/malformed row
|
|
94
|
+
* data (it can predate the schema) and never throws.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveTranslation(value, locale, fallbackLocale = "en") {
|
|
97
|
+
const source = value;
|
|
98
|
+
if (typeof source !== "object" || source === null || Array.isArray(source))
|
|
99
|
+
return "";
|
|
100
|
+
const entries = [];
|
|
101
|
+
for (const [key, rawValue] of Object.entries(source)) {
|
|
102
|
+
if (typeof rawValue === "string")
|
|
103
|
+
entries.push([key, rawValue]);
|
|
104
|
+
}
|
|
105
|
+
const exact = lookupLocale(entries, locale);
|
|
106
|
+
if (exact !== undefined)
|
|
107
|
+
return exact;
|
|
108
|
+
const normalized = normalizeLocale(locale);
|
|
109
|
+
const base = normalized === null ? undefined : lookupLocale(entries, baseLanguage(normalized));
|
|
110
|
+
if (base !== undefined)
|
|
111
|
+
return base;
|
|
112
|
+
const fallback = lookupLocale(entries, fallbackLocale);
|
|
113
|
+
if (fallback !== undefined)
|
|
114
|
+
return fallback;
|
|
115
|
+
return entries.find(([, text]) => text.length > 0)?.[1] ?? "";
|
|
116
|
+
}
|
|
117
|
+
function findSupportedLocale(locale, supported) {
|
|
118
|
+
if (locale === null || locale === undefined)
|
|
119
|
+
return undefined;
|
|
120
|
+
const normalized = normalizeLocale(locale);
|
|
121
|
+
if (normalized === null)
|
|
122
|
+
return undefined;
|
|
123
|
+
const base = baseLanguage(normalized);
|
|
124
|
+
const variants = supported.map((tag) => ({ tag, normalized: normalizeLocale(tag) }));
|
|
125
|
+
const exact = variants.find((entry) => entry.normalized === normalized);
|
|
126
|
+
if (exact !== undefined)
|
|
127
|
+
return exact.tag;
|
|
128
|
+
const baseExact = variants.find((entry) => entry.normalized === base);
|
|
129
|
+
if (baseExact !== undefined)
|
|
130
|
+
return baseExact.tag;
|
|
131
|
+
return variants.find((entry) => entry.normalized?.startsWith(`${base}-`))?.tag;
|
|
132
|
+
}
|
|
133
|
+
function lookupLocale(entries, tag) {
|
|
134
|
+
const normalized = normalizeLocale(tag);
|
|
135
|
+
if (normalized === null)
|
|
136
|
+
return undefined;
|
|
137
|
+
for (const [key, text] of entries) {
|
|
138
|
+
// Empty values are treated as absent so the fallback chain can still serve something.
|
|
139
|
+
if (text.length > 0 && normalizeLocale(key) === normalized)
|
|
140
|
+
return text;
|
|
141
|
+
}
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
function baseLanguage(normalized) {
|
|
145
|
+
return normalized.split("-")[0] ?? normalized;
|
|
146
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@facilis.dev/i18n-db",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "database i18n: translated content fields plus an optional catalogue overlay",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./dist/index.js",
|
|
9
|
+
"./client": "./dist/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"src",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=22.15.0"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@facilis.dev/core": "0.1.0",
|
|
23
|
+
"@facilis.dev/db": "0.1.0",
|
|
24
|
+
"drizzle-orm": "0.45.4",
|
|
25
|
+
"zod": "4.6.7"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/facilis-dev/facilis.git",
|
|
33
|
+
"directory": "packages/i18n-db"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/facilis-dev/facilis/tree/main/packages/i18n-db#readme",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/facilis-dev/facilis/issues"
|
|
38
|
+
},
|
|
39
|
+
"keywords": [
|
|
40
|
+
"facilis",
|
|
41
|
+
"framework",
|
|
42
|
+
"vite",
|
|
43
|
+
"nitro",
|
|
44
|
+
"low-code"
|
|
45
|
+
],
|
|
46
|
+
"main": "./dist/index.js",
|
|
47
|
+
"types": "./dist/index.d.ts"
|
|
48
|
+
}
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { inArray, sql } from "drizzle-orm";
|
|
2
|
+
import type { PolicyScopedDatabase } from "@facilis.dev/db";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
import { i18nMessages } from "./messages.ts";
|
|
6
|
+
|
|
7
|
+
/** A flat key/value catalogue for one locale (O-001: flat keys). */
|
|
8
|
+
export type I18nCatalog = Record<string, string>;
|
|
9
|
+
|
|
10
|
+
/** Every catalogue for the requested locales, each keyed by message key. */
|
|
11
|
+
export type I18nCatalogs = Record<string, I18nCatalog>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Query schema the project route passes to `defineAuthorizedHandler`: `?locales=en,it`,
|
|
15
|
+
* default "en". It is exported (not wrapped in a handler) so the route's config literal is
|
|
16
|
+
* visible to the Nitro AST metadata pass (security invariant 2).
|
|
17
|
+
*/
|
|
18
|
+
export const catalogQuery = z.object({
|
|
19
|
+
locales: z.string().default("en"),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** `"en, it ,en"` -> `["en","it"]`; a blank list falls back to the contract default. */
|
|
23
|
+
export function parseCatalogLocales(raw: string): string[] {
|
|
24
|
+
const locales = raw
|
|
25
|
+
.split(",")
|
|
26
|
+
.map((locale) => locale.trim())
|
|
27
|
+
.filter((locale) => locale.length > 0);
|
|
28
|
+
return locales.length === 0 ? ["en"] : [...new Set(locales)];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reads one locale through the caller's policy-scoped database. RLS restricts the rows to
|
|
33
|
+
* `app.tenant_id`, so no tenant id is ever part of the query.
|
|
34
|
+
*/
|
|
35
|
+
export async function loadCatalog(db: PolicyScopedDatabase, locale: string): Promise<I18nCatalog> {
|
|
36
|
+
const rows = await db
|
|
37
|
+
.select({ key: i18nMessages.key, value: i18nMessages.value })
|
|
38
|
+
.from(i18nMessages)
|
|
39
|
+
.where(sql`${i18nMessages.locale} = ${locale}`)
|
|
40
|
+
.orderBy(i18nMessages.key);
|
|
41
|
+
return Object.fromEntries(rows.map((row) => [row.key, row.value]));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reads several locales in one round trip. Requested locales with no rows still appear as
|
|
46
|
+
* empty catalogues so the route response has a stable shape.
|
|
47
|
+
*/
|
|
48
|
+
export async function loadCatalogs(
|
|
49
|
+
db: PolicyScopedDatabase,
|
|
50
|
+
locales: readonly string[],
|
|
51
|
+
): Promise<I18nCatalogs> {
|
|
52
|
+
const catalogs: I18nCatalogs = Object.fromEntries(locales.map((locale) => [locale, {}]));
|
|
53
|
+
if (locales.length === 0) return catalogs;
|
|
54
|
+
|
|
55
|
+
const rows = await db
|
|
56
|
+
.select({
|
|
57
|
+
locale: i18nMessages.locale,
|
|
58
|
+
key: i18nMessages.key,
|
|
59
|
+
value: i18nMessages.value,
|
|
60
|
+
})
|
|
61
|
+
.from(i18nMessages)
|
|
62
|
+
.where(inArray(i18nMessages.locale, [...locales]))
|
|
63
|
+
.orderBy(i18nMessages.locale, i18nMessages.key);
|
|
64
|
+
|
|
65
|
+
for (const row of rows) {
|
|
66
|
+
const catalog = catalogs[row.locale];
|
|
67
|
+
// inArray() guarantees this, but the record is indexed so keep the narrowing explicit.
|
|
68
|
+
if (catalog === undefined) continue;
|
|
69
|
+
catalog[row.key] = row.value;
|
|
70
|
+
}
|
|
71
|
+
return catalogs;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Upserts every key of one locale (seeding/admin). The tenant comes from the insert default,
|
|
76
|
+
* i.e. the policy-scope GUC, and `excluded.value` makes a re-save overwrite the old text.
|
|
77
|
+
*/
|
|
78
|
+
export async function saveCatalog(
|
|
79
|
+
db: PolicyScopedDatabase,
|
|
80
|
+
locale: string,
|
|
81
|
+
messages: I18nCatalog,
|
|
82
|
+
): Promise<void> {
|
|
83
|
+
const rows = Object.entries(messages).map(([key, value]) => ({ locale, key, value }));
|
|
84
|
+
if (rows.length === 0) return;
|
|
85
|
+
|
|
86
|
+
await db
|
|
87
|
+
.insert(i18nMessages)
|
|
88
|
+
.values(rows)
|
|
89
|
+
.onConflictDoUpdate({
|
|
90
|
+
target: [i18nMessages.tenantId, i18nMessages.locale, i18nMessages.key],
|
|
91
|
+
set: { value: sql`excluded.value`, updatedAt: sql`now()` },
|
|
92
|
+
});
|
|
93
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { registerCatalog, setDefaultLocale } from "@facilis.dev/core/i18n";
|
|
2
|
+
|
|
3
|
+
// The browser half also exposes the pure locale/translation helpers so a generated app can
|
|
4
|
+
// resolve content without a second import; the column helper and Drizzle stay server-only.
|
|
5
|
+
export {
|
|
6
|
+
matchLocale,
|
|
7
|
+
normalizeLocale,
|
|
8
|
+
parseAcceptLanguage,
|
|
9
|
+
resolveTranslation,
|
|
10
|
+
resolveUserLocale,
|
|
11
|
+
translationsSchema,
|
|
12
|
+
type ResolveUserLocaleOptions,
|
|
13
|
+
type Translations,
|
|
14
|
+
} from "./translations.ts";
|
|
15
|
+
|
|
16
|
+
export interface HydrateCatalogsOptions {
|
|
17
|
+
/** Locales to load; every one is registered, even when the database has no rows for it. */
|
|
18
|
+
readonly locales: readonly string[];
|
|
19
|
+
/** Bundled JSON catalogues; DB rows win per key and missing rows fall back here. */
|
|
20
|
+
readonly base?: Readonly<Record<string, Readonly<Record<string, string>>>>;
|
|
21
|
+
/** Applied with `setDefaultLocale` after registration when given. */
|
|
22
|
+
readonly defaultLocale?: string;
|
|
23
|
+
/** Catalogue endpoint; defaults to "/api/i18n". */
|
|
24
|
+
readonly endpoint?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULT_ENDPOINT = "/api/i18n";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Fetches the DB catalogue overlay, merges it over the bundled JSON per key and registers
|
|
31
|
+
* every locale (D-057). Throws on a failed request, a network error or a malformed payload
|
|
32
|
+
* so the caller can decide (the docs show the try/catch); it never logs.
|
|
33
|
+
*/
|
|
34
|
+
export async function hydrateCatalogs(options: HydrateCatalogsOptions): Promise<void> {
|
|
35
|
+
const { locales, base, defaultLocale, endpoint = DEFAULT_ENDPOINT } = options;
|
|
36
|
+
const remote = await fetchCatalogs(endpoint, locales);
|
|
37
|
+
|
|
38
|
+
for (const locale of locales) {
|
|
39
|
+
// Spreading a missing catalogue is a no-op, which is exactly the per-key fallback.
|
|
40
|
+
registerCatalog(locale, { ...base?.[locale], ...remote[locale] });
|
|
41
|
+
}
|
|
42
|
+
if (defaultLocale !== undefined) setDefaultLocale(defaultLocale);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function fetchCatalogs(
|
|
46
|
+
endpoint: string,
|
|
47
|
+
locales: readonly string[],
|
|
48
|
+
): Promise<Record<string, Record<string, string>>> {
|
|
49
|
+
// The endpoint may already carry a query string; append rather than replace it.
|
|
50
|
+
const separator = endpoint.includes("?") ? "&" : "?";
|
|
51
|
+
const url = `${endpoint}${separator}locales=${encodeURIComponent(locales.join(","))}`;
|
|
52
|
+
const response = await fetch(url);
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
throw new Error(`i18n: catalogue request failed with HTTP ${response.status}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let payload: unknown;
|
|
58
|
+
try {
|
|
59
|
+
payload = await response.json();
|
|
60
|
+
} catch (cause) {
|
|
61
|
+
throw new Error("i18n: catalogue response is not valid JSON", { cause });
|
|
62
|
+
}
|
|
63
|
+
return validateCatalogs(payload);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Validates `{ "<locale>": { "<key>": "<value>" } }` by hand: the client has no zod. */
|
|
67
|
+
function validateCatalogs(payload: unknown): Record<string, Record<string, string>> {
|
|
68
|
+
if (!isRecord(payload)) {
|
|
69
|
+
throw new Error("i18n: catalogue response must be a JSON object");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const catalogs: Record<string, Record<string, string>> = {};
|
|
73
|
+
for (const [locale, rawCatalog] of Object.entries(payload)) {
|
|
74
|
+
if (!isRecord(rawCatalog)) {
|
|
75
|
+
throw new Error(`i18n: catalogue for "${locale}" must be a JSON object`);
|
|
76
|
+
}
|
|
77
|
+
const catalog: Record<string, string> = {};
|
|
78
|
+
for (const [key, value] of Object.entries(rawCatalog)) {
|
|
79
|
+
if (typeof value !== "string") {
|
|
80
|
+
throw new Error(`i18n: message "${key}" for "${locale}" must be a string`);
|
|
81
|
+
}
|
|
82
|
+
catalog[key] = value;
|
|
83
|
+
}
|
|
84
|
+
catalogs[locale] = catalog;
|
|
85
|
+
}
|
|
86
|
+
return catalogs;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
90
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
91
|
+
}
|
package/src/columns.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsonb } from "drizzle-orm/pg-core";
|
|
2
|
+
|
|
3
|
+
import type { Translations } from "./translations.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A translatable content field: one NOT NULL `jsonb` column holding `{ locale: text }`.
|
|
7
|
+
* A project schema re-exports this helper (`title: translatedText("title")`) and lets
|
|
8
|
+
* drizzle-kit generate the migration; the package ships none.
|
|
9
|
+
*/
|
|
10
|
+
export function translatedText(name: string) {
|
|
11
|
+
return jsonb(name).$type<Translations>().notNull();
|
|
12
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@facilis.dev/i18n-db` — database i18n for generated projects: translated content fields
|
|
3
|
+
* (a typed `jsonb` column + locale resolution) and the optional database catalogue overlay.
|
|
4
|
+
*
|
|
5
|
+
* The root entry point is server-only (Drizzle + policy-scoped DB). The browser half lives
|
|
6
|
+
* behind the `@facilis.dev/i18n-db/client` subpath so a client bundle never pulls drizzle.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export { translatedText } from "./columns.ts";
|
|
10
|
+
export {
|
|
11
|
+
matchLocale,
|
|
12
|
+
normalizeLocale,
|
|
13
|
+
parseAcceptLanguage,
|
|
14
|
+
resolveTranslation,
|
|
15
|
+
resolveUserLocale,
|
|
16
|
+
translationsSchema,
|
|
17
|
+
type ResolveUserLocaleOptions,
|
|
18
|
+
type Translations,
|
|
19
|
+
} from "./translations.ts";
|
|
20
|
+
export { i18nMessages, type I18nMessageRow } from "./messages.ts";
|
|
21
|
+
export {
|
|
22
|
+
catalogQuery,
|
|
23
|
+
loadCatalog,
|
|
24
|
+
loadCatalogs,
|
|
25
|
+
parseCatalogLocales,
|
|
26
|
+
saveCatalog,
|
|
27
|
+
type I18nCatalog,
|
|
28
|
+
type I18nCatalogs,
|
|
29
|
+
} from "./catalog.ts";
|
package/src/messages.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { sql } from "drizzle-orm";
|
|
2
|
+
import { pgPolicy, pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core";
|
|
3
|
+
|
|
4
|
+
import { tenantColumn, tenantIsolationPolicy } from "@facilis.dev/db";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tenant-scoped catalogue overlay (D-057): one row per locale/key, unique per tenant.
|
|
8
|
+
*
|
|
9
|
+
* The JSON catalogues of `@facilis.dev/core/i18n` stay the base; these rows win key by key.
|
|
10
|
+
* A project re-exports this table from its `db/schema.ts` and lets drizzle-kit generate
|
|
11
|
+
* the migration — the package intentionally ships no migration.
|
|
12
|
+
*/
|
|
13
|
+
export const i18nMessages = pgTable(
|
|
14
|
+
"i18n_messages",
|
|
15
|
+
{
|
|
16
|
+
// The insert never names the tenant; the column takes it from the policy-scope GUC,
|
|
17
|
+
// so raw SQL and app code are both held to the deployment tenant (D-040).
|
|
18
|
+
tenantId: tenantColumn(),
|
|
19
|
+
locale: text("locale").notNull(),
|
|
20
|
+
key: text("key").notNull(),
|
|
21
|
+
value: text("value").notNull(),
|
|
22
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
|
|
23
|
+
},
|
|
24
|
+
(table) => [
|
|
25
|
+
primaryKey({ columns: [table.tenantId, table.locale, table.key] }),
|
|
26
|
+
tenantIsolationPolicy(),
|
|
27
|
+
// A RESTRICTIVE policy only narrows the rows a permissive policy already allows; with
|
|
28
|
+
// no permissive policy Postgres denies every row (D-008). This clause admits the scoped
|
|
29
|
+
// tenant and the restrictive tenant_isolation AND's it down to that tenant's rows.
|
|
30
|
+
pgPolicy("i18n_messages_tenant_access", {
|
|
31
|
+
for: "all",
|
|
32
|
+
using: sql`nullif(current_setting('app.tenant_id', true), '') IS NOT NULL`,
|
|
33
|
+
withCheck: sql`nullif(current_setting('app.tenant_id', true), '') IS NOT NULL`,
|
|
34
|
+
}),
|
|
35
|
+
],
|
|
36
|
+
).enableRLS();
|
|
37
|
+
|
|
38
|
+
export type I18nMessageRow = typeof i18nMessages.$inferSelect;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { getTableConfig } from "drizzle-orm/pg-core";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
applyStatements,
|
|
5
|
+
createDb,
|
|
6
|
+
ensureAppRole,
|
|
7
|
+
policyCreateSql,
|
|
8
|
+
type FacilisDb,
|
|
9
|
+
} from "@facilis.dev/db";
|
|
10
|
+
|
|
11
|
+
import { i18nMessages } from "./messages.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Hand-written DDL matching ./messages.ts exactly (no drizzle-kit in tests): column names,
|
|
15
|
+
* types, nullability, the GUC tenant default and the composite primary key.
|
|
16
|
+
*/
|
|
17
|
+
export const I18N_MESSAGES_TABLE_STATEMENTS: readonly string[] = [
|
|
18
|
+
`CREATE TABLE IF NOT EXISTS i18n_messages (
|
|
19
|
+
tenant_id uuid NOT NULL DEFAULT nullif(current_setting('app.tenant_id', true), '')::uuid,
|
|
20
|
+
locale text NOT NULL,
|
|
21
|
+
key text NOT NULL,
|
|
22
|
+
value text NOT NULL,
|
|
23
|
+
updated_at timestamp with time zone DEFAULT now(),
|
|
24
|
+
PRIMARY KEY (tenant_id, locale, key)
|
|
25
|
+
)`,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
function policyStatements(): readonly string[] {
|
|
29
|
+
const policies = getTableConfig(i18nMessages).policies.map((policy) =>
|
|
30
|
+
policyCreateSql("i18n_messages", policy),
|
|
31
|
+
);
|
|
32
|
+
return ["ALTER TABLE i18n_messages ENABLE ROW LEVEL SECURITY", ...policies];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Boots an in-memory PGlite database with the catalogue table, its RLS policies and the app role. */
|
|
36
|
+
export async function createI18nTestDb(): Promise<FacilisDb> {
|
|
37
|
+
const db = await createDb();
|
|
38
|
+
await applyStatements(db, [...I18N_MESSAGES_TABLE_STATEMENTS, ...policyStatements()]);
|
|
39
|
+
await ensureAppRole(db);
|
|
40
|
+
return db;
|
|
41
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A text stored as a map of locale tag -> translated string, e.g.
|
|
5
|
+
* `{ en: "Todo", it: "Da fare" }`. The raw value is what the database stores and what
|
|
6
|
+
* admin/edit APIs accept; serving maps a row through `resolveTranslation`.
|
|
7
|
+
*/
|
|
8
|
+
export type Translations = Readonly<Record<string, string>>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Validates admin/edit payloads: every key must be a structurally valid locale tag and
|
|
12
|
+
* every value a non-empty string. Keys stay verbatim; matching normalizes them later, so
|
|
13
|
+
* `{ "IT": "Ciao" }` is stored as written but still served for `it`.
|
|
14
|
+
*/
|
|
15
|
+
export const translationsSchema: z.ZodType<Translations> = z
|
|
16
|
+
.record(z.string(), z.string().min(1))
|
|
17
|
+
.superRefine((value, ctx) => {
|
|
18
|
+
for (const tag of Object.keys(value)) {
|
|
19
|
+
if (normalizeLocale(tag) === null) {
|
|
20
|
+
ctx.addIssue({
|
|
21
|
+
code: "custom",
|
|
22
|
+
message: `"${tag}" is not a valid locale tag`,
|
|
23
|
+
path: [tag],
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/** Intl-canonical form of a locale tag (`it-ch` -> `it-CH`), or null when it is invalid. */
|
|
30
|
+
export function normalizeLocale(tag: string): string | null {
|
|
31
|
+
try {
|
|
32
|
+
const [canonical] = Intl.getCanonicalLocales(tag.trim());
|
|
33
|
+
return canonical ?? null;
|
|
34
|
+
} catch {
|
|
35
|
+
// Intl throws RangeError for malformed tags; invalid input is a value, not a crash.
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Picks the supported tag matching `locale`: exact canonical match, then its base language
|
|
42
|
+
* (`it-CH` -> `it`), then any supported variant of that language. `fallback` is returned
|
|
43
|
+
* when the tag is invalid or unsupported, so callers always get a usable locale.
|
|
44
|
+
*/
|
|
45
|
+
export function matchLocale(
|
|
46
|
+
locale: string | undefined,
|
|
47
|
+
supported: readonly string[],
|
|
48
|
+
fallback: string,
|
|
49
|
+
): string {
|
|
50
|
+
return findSupportedLocale(locale, supported) ?? fallback;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Parses Accept-Language: q-ordered (stable ties), canonicalized, no wildcard, no q=0. */
|
|
54
|
+
export function parseAcceptLanguage(header: string | null | undefined): string[] {
|
|
55
|
+
if (header === null || header === undefined || header.trim() === "") return [];
|
|
56
|
+
|
|
57
|
+
const ranked: { tag: string; q: number; index: number }[] = [];
|
|
58
|
+
header.split(",").forEach((part, index) => {
|
|
59
|
+
const [rawTag, ...parameters] = part.split(";");
|
|
60
|
+
const tag = rawTag?.trim() ?? "";
|
|
61
|
+
// "*" means "anything else": it cannot resolve to a concrete language.
|
|
62
|
+
if (tag === "" || tag === "*") return;
|
|
63
|
+
|
|
64
|
+
let q = 1;
|
|
65
|
+
for (const parameter of parameters) {
|
|
66
|
+
const [name, rawValue] = parameter.split("=");
|
|
67
|
+
if (name?.trim().toLowerCase() !== "q") continue;
|
|
68
|
+
const parsed = Number.parseFloat(rawValue ?? "");
|
|
69
|
+
q = Number.isNaN(parsed) ? 1 : Math.min(Math.max(parsed, 0), 1);
|
|
70
|
+
}
|
|
71
|
+
if (q <= 0) return;
|
|
72
|
+
|
|
73
|
+
const normalized = normalizeLocale(tag);
|
|
74
|
+
if (normalized === null) return;
|
|
75
|
+
ranked.push({ tag: normalized, q, index });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const ordered = ranked
|
|
79
|
+
.toSorted((left, right) => right.q - left.q || left.index - right.index)
|
|
80
|
+
.map((entry) => entry.tag);
|
|
81
|
+
return ordered.filter((tag, index) => ordered.indexOf(tag) === index);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ResolveUserLocaleOptions {
|
|
85
|
+
/** Saved preference (e.g. a profile column); wins over the request header. */
|
|
86
|
+
readonly userLocale?: string | null;
|
|
87
|
+
/** Raw Accept-Language header; second in precedence. */
|
|
88
|
+
readonly acceptLanguage?: string | null;
|
|
89
|
+
/** Application locales to filter candidates through; omitted/empty means no filter. */
|
|
90
|
+
readonly supported?: readonly string[];
|
|
91
|
+
/** Last resort; defaults to "en". */
|
|
92
|
+
readonly defaultLocale?: string;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Resolves the language a request should be served in: saved preference, then
|
|
97
|
+
* Accept-Language tags in q-order, then the default — each filtered by `supported`.
|
|
98
|
+
* Storing the preference is the app's job; this only resolves it.
|
|
99
|
+
*/
|
|
100
|
+
export function resolveUserLocale(options: ResolveUserLocaleOptions): string {
|
|
101
|
+
const { userLocale, acceptLanguage, supported, defaultLocale = "en" } = options;
|
|
102
|
+
const candidates = supported !== undefined && supported.length > 0 ? supported : null;
|
|
103
|
+
|
|
104
|
+
for (const tag of [userLocale ?? undefined, ...parseAcceptLanguage(acceptLanguage)]) {
|
|
105
|
+
if (tag === undefined) continue;
|
|
106
|
+
const resolved =
|
|
107
|
+
candidates === null
|
|
108
|
+
? (normalizeLocale(tag) ?? undefined)
|
|
109
|
+
: findSupportedLocale(tag, candidates);
|
|
110
|
+
if (resolved !== undefined) return resolved;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return candidates === null
|
|
114
|
+
? (normalizeLocale(defaultLocale) ?? defaultLocale)
|
|
115
|
+
: (findSupportedLocale(defaultLocale, candidates) ?? defaultLocale);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Serves only one language: exact locale, then its base language (`it-CH` -> `it`), then
|
|
120
|
+
* `fallbackLocale`, then the first non-empty value, else "". Tolerates null/malformed row
|
|
121
|
+
* data (it can predate the schema) and never throws.
|
|
122
|
+
*/
|
|
123
|
+
export function resolveTranslation(
|
|
124
|
+
value: Translations | null | undefined,
|
|
125
|
+
locale: string,
|
|
126
|
+
fallbackLocale = "en",
|
|
127
|
+
): string {
|
|
128
|
+
const source: unknown = value;
|
|
129
|
+
if (typeof source !== "object" || source === null || Array.isArray(source)) return "";
|
|
130
|
+
|
|
131
|
+
const entries: [string, string][] = [];
|
|
132
|
+
for (const [key, rawValue] of Object.entries(source)) {
|
|
133
|
+
if (typeof rawValue === "string") entries.push([key, rawValue]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const exact = lookupLocale(entries, locale);
|
|
137
|
+
if (exact !== undefined) return exact;
|
|
138
|
+
|
|
139
|
+
const normalized = normalizeLocale(locale);
|
|
140
|
+
const base = normalized === null ? undefined : lookupLocale(entries, baseLanguage(normalized));
|
|
141
|
+
if (base !== undefined) return base;
|
|
142
|
+
|
|
143
|
+
const fallback = lookupLocale(entries, fallbackLocale);
|
|
144
|
+
if (fallback !== undefined) return fallback;
|
|
145
|
+
|
|
146
|
+
return entries.find(([, text]) => text.length > 0)?.[1] ?? "";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function findSupportedLocale(
|
|
150
|
+
locale: string | null | undefined,
|
|
151
|
+
supported: readonly string[],
|
|
152
|
+
): string | undefined {
|
|
153
|
+
if (locale === null || locale === undefined) return undefined;
|
|
154
|
+
const normalized = normalizeLocale(locale);
|
|
155
|
+
if (normalized === null) return undefined;
|
|
156
|
+
const base = baseLanguage(normalized);
|
|
157
|
+
|
|
158
|
+
const variants = supported.map((tag) => ({ tag, normalized: normalizeLocale(tag) }));
|
|
159
|
+
const exact = variants.find((entry) => entry.normalized === normalized);
|
|
160
|
+
if (exact !== undefined) return exact.tag;
|
|
161
|
+
const baseExact = variants.find((entry) => entry.normalized === base);
|
|
162
|
+
if (baseExact !== undefined) return baseExact.tag;
|
|
163
|
+
return variants.find((entry) => entry.normalized?.startsWith(`${base}-`))?.tag;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function lookupLocale(entries: readonly [string, string][], tag: string): string | undefined {
|
|
167
|
+
const normalized = normalizeLocale(tag);
|
|
168
|
+
if (normalized === null) return undefined;
|
|
169
|
+
for (const [key, text] of entries) {
|
|
170
|
+
// Empty values are treated as absent so the fallback chain can still serve something.
|
|
171
|
+
if (text.length > 0 && normalizeLocale(key) === normalized) return text;
|
|
172
|
+
}
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function baseLanguage(normalized: string): string {
|
|
177
|
+
return normalized.split("-")[0] ?? normalized;
|
|
178
|
+
}
|