@markcom/i18n 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/dist/i18next.d.mts +4 -0
- package/dist/i18next.mjs +16 -0
- package/dist/locales.d.mts +24 -0
- package/dist/locales.mjs +50 -0
- package/dist/react-router.d.mts +27 -0
- package/dist/react-router.mjs +22 -0
- package/dist/vite.d.mts +12 -0
- package/dist/vite.mjs +104 -0
- package/package.json +92 -4
- package/README.md +0 -3
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { LocaleRecord, LocaleSet } from "./locales.mjs";
|
|
2
|
+
import { i18n } from "i18next";
|
|
3
|
+
/** A ready i18next instance serving every locale, starting in the default language. */
|
|
4
|
+
export declare function createI18n<Lang extends string, Locale extends LocaleRecord>(locales: LocaleSet<Lang, Locale>): Promise<i18n>;
|
package/dist/i18next.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import i18next from "i18next";
|
|
2
|
+
import { initReactI18next } from "react-i18next";
|
|
3
|
+
// src/i18next.ts
|
|
4
|
+
/** A ready i18next instance serving every locale, starting in the default language. */
|
|
5
|
+
async function createI18n(locales) {
|
|
6
|
+
const instance = i18next.createInstance();
|
|
7
|
+
const resources = Object.fromEntries(locales.entries.map(([lang, translation]) => [lang, { translation }]));
|
|
8
|
+
await instance.use(initReactI18next).init({
|
|
9
|
+
resources,
|
|
10
|
+
lng: locales.defaultLang,
|
|
11
|
+
fallbackLng: locales.defaultLang,
|
|
12
|
+
interpolation: { escapeValue: false }
|
|
13
|
+
});
|
|
14
|
+
return instance;
|
|
15
|
+
}
|
|
16
|
+
export { createI18n };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ParseKeys } from "i18next";
|
|
2
|
+
export type LocaleValue = string | LocaleRecord;
|
|
3
|
+
export type LocaleRecord = {
|
|
4
|
+
readonly [key: string]: LocaleValue;
|
|
5
|
+
};
|
|
6
|
+
/** The string at a dotted key path; throws if missing or not a string. */
|
|
7
|
+
export declare function lookupString(locale: LocaleRecord, key: string): string;
|
|
8
|
+
/** Every dotted key path that ends in a string. */
|
|
9
|
+
export declare function leafKeys(locale: LocaleRecord): string[];
|
|
10
|
+
/** A site's languages and their locale files; the default locale holds the full key set. */
|
|
11
|
+
export declare class LocaleSet<Lang extends string, Locale extends LocaleRecord> {
|
|
12
|
+
readonly defaultLang: Lang;
|
|
13
|
+
readonly defaultLocale: Locale;
|
|
14
|
+
readonly langs: readonly Lang[];
|
|
15
|
+
private readonly locales;
|
|
16
|
+
constructor(locales: Readonly<Record<Lang, Locale>>, defaultLang: NoInfer<Lang>);
|
|
17
|
+
get entries(): readonly (readonly [Lang, Locale])[];
|
|
18
|
+
localeFor(lang: Lang): Locale;
|
|
19
|
+
isSupportedLang(lang: string | undefined): lang is Lang;
|
|
20
|
+
/** The best supported match for the browser's preferences, else the default. */
|
|
21
|
+
detectLang(preferred: readonly string[]): Lang;
|
|
22
|
+
/** True for a key of the default locale. ViewModels hold these; views turn them into text. */
|
|
23
|
+
isLocaleKey(key: string): key is ParseKeys;
|
|
24
|
+
}
|
package/dist/locales.mjs
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { match } from "@formatjs/intl-localematcher";
|
|
2
|
+
import { flatten } from "flat";
|
|
3
|
+
// src/locales.ts
|
|
4
|
+
/** Every dotted key path that ends in a string, with its string. */
|
|
5
|
+
function flattenLocale(locale) {
|
|
6
|
+
return new Map(Object.entries(flatten(locale)));
|
|
7
|
+
}
|
|
8
|
+
/** The string at a dotted key path; throws if missing or not a string. */
|
|
9
|
+
function lookupString(locale, key) {
|
|
10
|
+
const value = flattenLocale(locale).get(key);
|
|
11
|
+
if (value === void 0) throw new Error(`Locale key is missing or not a string: ${key}`);
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
/** Every dotted key path that ends in a string. */
|
|
15
|
+
function leafKeys(locale) {
|
|
16
|
+
return [...flattenLocale(locale).keys()];
|
|
17
|
+
}
|
|
18
|
+
/** A site's languages and their locale files; the default locale holds the full key set. */
|
|
19
|
+
var LocaleSet = class {
|
|
20
|
+
defaultLang;
|
|
21
|
+
defaultLocale;
|
|
22
|
+
langs;
|
|
23
|
+
locales;
|
|
24
|
+
constructor(locales, defaultLang) {
|
|
25
|
+
this.locales = new Map(Object.entries(locales).filter((entry) => Object.hasOwn(locales, entry[0])));
|
|
26
|
+
this.langs = [...this.locales.keys()];
|
|
27
|
+
this.defaultLang = defaultLang;
|
|
28
|
+
this.defaultLocale = this.localeFor(defaultLang);
|
|
29
|
+
}
|
|
30
|
+
get entries() {
|
|
31
|
+
return [...this.locales.entries()];
|
|
32
|
+
}
|
|
33
|
+
localeFor(lang) {
|
|
34
|
+
const locale = this.locales.get(lang);
|
|
35
|
+
if (locale === void 0) throw new Error(`No locale for language: ${lang}`);
|
|
36
|
+
return locale;
|
|
37
|
+
}
|
|
38
|
+
isSupportedLang(lang) {
|
|
39
|
+
return this.langs.some((supported) => supported === lang);
|
|
40
|
+
}
|
|
41
|
+
/** The best supported match for the browser's preferences, else the default. */
|
|
42
|
+
detectLang(preferred) {
|
|
43
|
+
return match(preferred, this.langs, this.defaultLang);
|
|
44
|
+
}
|
|
45
|
+
/** True for a key of the default locale. ViewModels hold these; views turn them into text. */
|
|
46
|
+
isLocaleKey(key) {
|
|
47
|
+
return leafKeys(this.defaultLocale).includes(key);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
export { LocaleSet, leafKeys, lookupString };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { LocaleRecord, LocaleSet } from "./locales.mjs";
|
|
2
|
+
import { RouteObject } from "react-router";
|
|
3
|
+
import { ComponentType } from "react";
|
|
4
|
+
/** The one thing routing needs from i18next, so tests can pass a fake. */
|
|
5
|
+
export interface LanguageSwitcher {
|
|
6
|
+
changeLanguage(lang: string): Promise<unknown>;
|
|
7
|
+
}
|
|
8
|
+
export type LanguageRoutesOptions<Lang extends string, Locale extends LocaleRecord> = {
|
|
9
|
+
locales: LocaleSet<Lang, Locale>;
|
|
10
|
+
i18n: LanguageSwitcher;
|
|
11
|
+
/** The site's root path, usually `/`. */
|
|
12
|
+
rootPath: string;
|
|
13
|
+
/** The browser's language preferences: `() => navigator.languages`. */
|
|
14
|
+
preferredLanguages: () => readonly string[];
|
|
15
|
+
/** Gets the page language: `document.documentElement`. */
|
|
16
|
+
documentElement: {
|
|
17
|
+
lang: string;
|
|
18
|
+
};
|
|
19
|
+
/** Renders around every page of a language; its Outlet shows the children. */
|
|
20
|
+
layout: ComponentType | null;
|
|
21
|
+
children: RouteObject[];
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* The `/<lang>/…` route: a supported language switches i18next and the page language before
|
|
25
|
+
* rendering; no language or an unsupported one redirects to the one detectLang picks.
|
|
26
|
+
*/
|
|
27
|
+
export declare function languageRoutes<Lang extends string, Locale extends LocaleRecord>(options: LanguageRoutesOptions<Lang, Locale>): RouteObject[];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { redirect } from "react-router";
|
|
2
|
+
// src/react-router.ts
|
|
3
|
+
/**
|
|
4
|
+
* The `/<lang>/…` route: a supported language switches i18next and the page language before
|
|
5
|
+
* rendering; no language or an unsupported one redirects to the one detectLang picks.
|
|
6
|
+
*/
|
|
7
|
+
function languageRoutes(options) {
|
|
8
|
+
const { locales, rootPath } = options;
|
|
9
|
+
return [{
|
|
10
|
+
path: `${rootPath}:lang?`,
|
|
11
|
+
Component: options.layout,
|
|
12
|
+
loader: async ({ params }) => {
|
|
13
|
+
const { lang } = params;
|
|
14
|
+
if (!locales.isSupportedLang(lang)) return redirect(`${rootPath}${locales.detectLang(options.preferredLanguages())}`);
|
|
15
|
+
await options.i18n.changeLanguage(lang);
|
|
16
|
+
options.documentElement.lang = lang;
|
|
17
|
+
return null;
|
|
18
|
+
},
|
|
19
|
+
children: options.children
|
|
20
|
+
}];
|
|
21
|
+
}
|
|
22
|
+
export { languageRoutes };
|
package/dist/vite.d.mts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { LocaleRecord, LocaleSet } from "./locales.mjs";
|
|
2
|
+
import { Plugin } from "vite";
|
|
3
|
+
export type LocaleHtmlOptions<Lang extends string, Locale extends LocaleRecord> = {
|
|
4
|
+
locales: LocaleSet<Lang, Locale>;
|
|
5
|
+
/** Values for `{{name}}` placeholders, such as the theme colour. `{{lang}}` is filled in. */
|
|
6
|
+
values: ReadonlyMap<string, string>;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Fills index.html from the default locale, and stops `vite` and `vite build` when a locale
|
|
10
|
+
* file is incomplete or index.html has text or colours that do not come from a placeholder.
|
|
11
|
+
*/
|
|
12
|
+
export declare function localeHtmlPlugin<Lang extends string, Locale extends LocaleRecord>(options: LocaleHtmlOptions<Lang, Locale>): Plugin;
|
package/dist/vite.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { leafKeys, lookupString } from "./locales.mjs";
|
|
2
|
+
import { flatten } from "flat";
|
|
3
|
+
import { checkInvalidTranslations, checkMissingTranslations } from "@lingual/i18n-check";
|
|
4
|
+
import escapeHtml from "escape-html";
|
|
5
|
+
// src/vite.ts
|
|
6
|
+
/** index.html placeholders filled by localeHtmlPlugin. */
|
|
7
|
+
const HTML_PLACEHOLDER = /\{\{[\w:.]+\}\}/g;
|
|
8
|
+
const TEXT_BETWEEN_TAGS = /(?<=>)[^<>]+(?=<)/g;
|
|
9
|
+
const TEXT_ATTRIBUTE = /(?<= (?:content|title|alt|aria-label|aria-description|placeholder)=")[^"]*(?=")/g;
|
|
10
|
+
const HEX_COLOUR = /#[0-9a-f]{3,8}\b/gi;
|
|
11
|
+
const CHECK_OPTIONS = { format: "i18next" };
|
|
12
|
+
/** Meta content that is configuration, not text a person reads. */
|
|
13
|
+
const ALLOWED_META_CONTENT = /* @__PURE__ */ new Set(["width=device-width, initial-scale=1"]);
|
|
14
|
+
/** i18n-check compares flat files: one dotted key per string. */
|
|
15
|
+
function flat(locale) {
|
|
16
|
+
return flatten(locale);
|
|
17
|
+
}
|
|
18
|
+
function withoutPlaceholders(text) {
|
|
19
|
+
return text.replace(HTML_PLACEHOLDER, "").trim();
|
|
20
|
+
}
|
|
21
|
+
/** Keys whose string is blank. */
|
|
22
|
+
function findBlankStrings(locale) {
|
|
23
|
+
return leafKeys(locale).filter((key) => lookupString(locale, key).trim() === "");
|
|
24
|
+
}
|
|
25
|
+
function keysMissingFrom(target, source) {
|
|
26
|
+
return Object.values(checkMissingTranslations(flat(source), { target: flat(target) }, CHECK_OPTIONS)).flat();
|
|
27
|
+
}
|
|
28
|
+
/** Keys the default locale has and this locale lacks. */
|
|
29
|
+
function findMissingKeys(defaultLocale, locale) {
|
|
30
|
+
return keysMissingFrom(locale, defaultLocale);
|
|
31
|
+
}
|
|
32
|
+
/** Keys this locale has and the default locale lacks. */
|
|
33
|
+
function findExtraKeys(defaultLocale, locale) {
|
|
34
|
+
return keysMissingFrom(defaultLocale, locale);
|
|
35
|
+
}
|
|
36
|
+
/** Keys whose interpolation placeholders or nesting differ from the default locale. */
|
|
37
|
+
function findInvalidTranslations(defaultLocale, locale) {
|
|
38
|
+
return Object.values(checkInvalidTranslations(flat(defaultLocale), { locale: flat(locale) }, CHECK_OPTIONS)).flat().map(({ key }) => key);
|
|
39
|
+
}
|
|
40
|
+
/** Text in index.html a person would read that does not come from a locale placeholder. */
|
|
41
|
+
function findLiteralHtmlText(html) {
|
|
42
|
+
const betweenTags = [...html.matchAll(TEXT_BETWEEN_TAGS)].map(([text]) => text);
|
|
43
|
+
const inAttributes = [...html.matchAll(TEXT_ATTRIBUTE)].map(([value]) => value).filter((value) => !ALLOWED_META_CONTENT.has(value));
|
|
44
|
+
return [...betweenTags, ...inAttributes].map(withoutPlaceholders).filter((text) => text !== "");
|
|
45
|
+
}
|
|
46
|
+
/** Colours written into index.html instead of coming from the tokens. */
|
|
47
|
+
function findHtmlColours(html) {
|
|
48
|
+
return [...html.matchAll(HEX_COLOUR)].map(([colour]) => colour);
|
|
49
|
+
}
|
|
50
|
+
/** Every problem in a site's locale files, one message each; empty when they are all fine. */
|
|
51
|
+
function findLocaleProblems(locales) {
|
|
52
|
+
const defaultFile = `${locales.defaultLang}.json`;
|
|
53
|
+
const blank = findBlankStrings(locales.defaultLocale).map((key) => `${defaultFile}: ${key} is blank`);
|
|
54
|
+
const translations = locales.langs.filter((lang) => lang !== locales.defaultLang).flatMap((lang) => {
|
|
55
|
+
const locale = locales.localeFor(lang);
|
|
56
|
+
const file = `${lang}.json`;
|
|
57
|
+
return [
|
|
58
|
+
...findMissingKeys(locales.defaultLocale, locale).map((key) => `${file}: ${key} is missing`),
|
|
59
|
+
...findExtraKeys(locales.defaultLocale, locale).map((key) => `${file}: ${key} is not in ${defaultFile}`),
|
|
60
|
+
...findInvalidTranslations(locales.defaultLocale, locale).map((key) => `${file}: ${key} has different placeholders from ${defaultFile}`)
|
|
61
|
+
];
|
|
62
|
+
});
|
|
63
|
+
return [...blank, ...translations];
|
|
64
|
+
}
|
|
65
|
+
/** Every problem in a site's index.html, one message each; empty when it is fine. */
|
|
66
|
+
function findIndexHtmlProblems(html) {
|
|
67
|
+
return [...findLiteralHtmlText(html).map((text) => `index.html: "${text}" is literal text; use {{t:key}}`), ...findHtmlColours(html).map((colour) => `index.html: ${colour} is a hard-coded colour; use {{name}}`)];
|
|
68
|
+
}
|
|
69
|
+
/** `{{t:meta.title}}` inserts a locale string; `{{lang}}` inserts a named value. */
|
|
70
|
+
const PLACEHOLDER = /\{\{(t:)?([\w.]+)\}\}/g;
|
|
71
|
+
const PLUGIN_NAME = "markcom-locale-html";
|
|
72
|
+
function resolveValue(values, name) {
|
|
73
|
+
const value = values.get(name);
|
|
74
|
+
if (value === void 0) throw new Error(`index.html placeholder {{${name}}} has no value`);
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
function failOnProblems(problems) {
|
|
78
|
+
if (problems.length > 0) throw new Error(`Locale problems:\n${problems.join("\n")}`);
|
|
79
|
+
}
|
|
80
|
+
/** Replaces every placeholder in index.html, HTML-escaped; fails on an unknown key or value. */
|
|
81
|
+
function renderLocaleHtml(html, locale, values) {
|
|
82
|
+
return html.replace(PLACEHOLDER, (_match, localePrefix, key) => escapeHtml(localePrefix === void 0 ? resolveValue(values, key) : lookupString(locale, key)));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Fills index.html from the default locale, and stops `vite` and `vite build` when a locale
|
|
86
|
+
* file is incomplete or index.html has text or colours that do not come from a placeholder.
|
|
87
|
+
*/
|
|
88
|
+
function localeHtmlPlugin(options) {
|
|
89
|
+
const values = new Map([["lang", options.locales.defaultLang], ...options.values]);
|
|
90
|
+
return {
|
|
91
|
+
name: PLUGIN_NAME,
|
|
92
|
+
buildStart: () => {
|
|
93
|
+
failOnProblems(findLocaleProblems(options.locales));
|
|
94
|
+
},
|
|
95
|
+
transformIndexHtml: {
|
|
96
|
+
order: "pre",
|
|
97
|
+
handler: (html) => {
|
|
98
|
+
failOnProblems(findIndexHtmlProblems(html));
|
|
99
|
+
return renderLocaleHtml(html, options.locales.defaultLocale, values);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
export { localeHtmlPlugin };
|
package/package.json
CHANGED
|
@@ -1,6 +1,94 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markcom/i18n",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Locale checks, language detection and routing, and i18next set-up for the react-ui websites.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/MarkIsMyNames/ClaudeSkills.git",
|
|
9
|
+
"directory": "libraries/markcom-i18n"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=24"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/locales.d.mts",
|
|
21
|
+
"default": "./dist/locales.mjs"
|
|
22
|
+
},
|
|
23
|
+
"./i18next": {
|
|
24
|
+
"types": "./dist/i18next.d.mts",
|
|
25
|
+
"default": "./dist/i18next.mjs"
|
|
26
|
+
},
|
|
27
|
+
"./vite": {
|
|
28
|
+
"types": "./dist/vite.d.mts",
|
|
29
|
+
"default": "./dist/vite.mjs"
|
|
30
|
+
},
|
|
31
|
+
"./react-router": {
|
|
32
|
+
"types": "./dist/react-router.d.mts",
|
|
33
|
+
"default": "./dist/react-router.mjs"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"prettier": "@markcom/web-config/prettier",
|
|
37
|
+
"scripts": {
|
|
38
|
+
"build": "tsdown src/locales.ts src/i18next.ts src/vite.ts src/react-router.ts --dts --format esm --clean",
|
|
39
|
+
"lint": "eslint --max-warnings=0 .",
|
|
40
|
+
"format": "prettier --ignore-path ../../.gitignore --write .",
|
|
41
|
+
"format:check": "prettier --ignore-path ../../.gitignore --check .",
|
|
42
|
+
"typecheck": "tsc",
|
|
43
|
+
"audit": "npm audit --audit-level=low",
|
|
44
|
+
"test": "vitest run --coverage",
|
|
45
|
+
"test:e2e": "playwright test"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@formatjs/intl-localematcher": "^0.10.1",
|
|
49
|
+
"escape-html": "^1.0.3",
|
|
50
|
+
"flat": "^6.0.1",
|
|
51
|
+
"@lingual/i18n-check": "^0.9.5"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"i18next": "^27.1.1",
|
|
55
|
+
"react-i18next": "^18.0.1",
|
|
56
|
+
"vite": "^8.3.7",
|
|
57
|
+
"react-router": "^8.5.1"
|
|
58
|
+
},
|
|
59
|
+
"peerDependenciesMeta": {
|
|
60
|
+
"vite": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"react-router": {
|
|
64
|
+
"optional": true
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@markcom/web-config": "file:../markcom-web-config",
|
|
69
|
+
"@playwright/test": "^1.65.0",
|
|
70
|
+
"@types/escape-html": "^1.0.4",
|
|
71
|
+
"@types/node": "^26.6.6",
|
|
72
|
+
"@types/react": "^19.3.0",
|
|
73
|
+
"@vitest/coverage-v8": "^5.1.0",
|
|
74
|
+
"eslint": "^10.14.0",
|
|
75
|
+
"i18next": "^27.1.1",
|
|
76
|
+
"jiti": "^2.7.0",
|
|
77
|
+
"prettier": "^3.9.10",
|
|
78
|
+
"react": "^19.3.1",
|
|
79
|
+
"react-i18next": "^18.0.1",
|
|
80
|
+
"typescript": "~6.0.3",
|
|
81
|
+
"vite": "^8.3.7",
|
|
82
|
+
"vitest": "^5.1.0",
|
|
83
|
+
"@tsconfig/strictest": "^2.0.8",
|
|
84
|
+
"tsdown": "^0.23.7",
|
|
85
|
+
"react-router": "^8.5.1",
|
|
86
|
+
"react-dom": "^19.3.1",
|
|
87
|
+
"@vitejs/plugin-react": "^6.3.0",
|
|
88
|
+
"@types/react-dom": "^19.3.0"
|
|
89
|
+
},
|
|
90
|
+
"overrides": {
|
|
91
|
+
"minimatch": "^11.0.5",
|
|
92
|
+
"glob": "^14.0.4"
|
|
93
|
+
}
|
|
94
|
+
}
|
package/README.md
DELETED