@hoardodile/i18n 0.0.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 +674 -0
- package/README.md +81 -0
- package/dist/catalogs/ui.d.ts +2 -0
- package/dist/catalogs/ui.js +449 -0
- package/dist/catalogs/ui.js.map +1 -0
- package/dist/catalogs/workbench.d.ts +291 -0
- package/dist/catalogs/workbench.js +250 -0
- package/dist/catalogs/workbench.js.map +1 -0
- package/dist/catalogs-BF4px4Mm.d.ts +12986 -0
- package/dist/catalogs.d.ts +2 -0
- package/dist/catalogs.js +10794 -0
- package/dist/catalogs.js.map +1 -0
- package/dist/core.d.ts +32 -0
- package/dist/core.js +14 -0
- package/dist/core.js.map +1 -0
- package/dist/create-i18n.d.ts +35 -0
- package/dist/create-i18n.js +30 -0
- package/dist/create-i18n.js.map +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +11284 -0
- package/dist/index.js.map +1 -0
- package/dist/react.d.ts +1 -0
- package/dist/react.js +3 -0
- package/dist/react.js.map +1 -0
- package/dist/ui-CJPMCZT7.d.ts +548 -0
- package/package.json +81 -0
- package/src/catalogs/de.json +2155 -0
- package/src/catalogs/en.json +2155 -0
- package/src/catalogs/es.json +2155 -0
- package/src/catalogs/ja.json +2155 -0
- package/src/catalogs/ui.ts +30 -0
- package/src/catalogs/workbench.ts +29 -0
- package/src/catalogs/zh.json +2155 -0
- package/src/catalogs.ts +31 -0
- package/src/core.ts +40 -0
- package/src/create-i18n.ts +52 -0
- package/src/index.ts +66 -0
- package/src/language.test.ts +70 -0
- package/src/parity.test.ts +175 -0
- package/src/react.ts +25 -0
- package/src/ui/de.json +86 -0
- package/src/ui/en.json +86 -0
- package/src/ui/es.json +86 -0
- package/src/ui/ja.json +86 -0
- package/src/ui/zh.json +86 -0
- package/src/workbench/de.json +45 -0
- package/src/workbench/en.json +45 -0
- package/src/workbench/es.json +45 -0
- package/src/workbench/ja.json +45 -0
- package/src/workbench/zh.json +45 -0
package/src/catalogs.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app catalog registry: every shipped language's complete JSON
|
|
3
|
+
* catalog plus the single resolution point the Electron shell (main
|
|
4
|
+
* process, wizard, error pages) uses to pick translations.
|
|
5
|
+
*
|
|
6
|
+
* Split from `./core.ts` (pure helpers, no catalogs) so the sandboxed
|
|
7
|
+
* preload and any other bundle that only needs `isSupportedLanguage` /
|
|
8
|
+
* `resolveSystemLanguage` never loads the catalogs.
|
|
9
|
+
*
|
|
10
|
+
* `Record<SupportedLanguage, typeof en>` makes any key drift between
|
|
11
|
+
* catalogs a **compile error** (the five JSON modules are structurally
|
|
12
|
+
* typed); `parity.test.ts` enforces the rest (placeholders, plural
|
|
13
|
+
* pairs, markup tags, ellipsis).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import de from "./catalogs/de.json"
|
|
17
|
+
import en from "./catalogs/en.json"
|
|
18
|
+
import es from "./catalogs/es.json"
|
|
19
|
+
import ja from "./catalogs/ja.json"
|
|
20
|
+
import zh from "./catalogs/zh.json"
|
|
21
|
+
import type { SupportedLanguage } from "./core.ts"
|
|
22
|
+
|
|
23
|
+
export const CATALOGS = { en, zh, ja, de, es } as const satisfies Record<
|
|
24
|
+
SupportedLanguage,
|
|
25
|
+
typeof en
|
|
26
|
+
>
|
|
27
|
+
|
|
28
|
+
/** Resolve the app catalog for the active language; pre-SPA (undefined) → English. */
|
|
29
|
+
export function catalogFor(language: SupportedLanguage | undefined): typeof en {
|
|
30
|
+
return language === undefined ? en : CATALOGS[language]
|
|
31
|
+
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locale plumbing shared by the web SPA, the Electron shell and the plugin
|
|
3
|
+
* SDK: the supported-language set and pure helpers. **This module must
|
|
4
|
+
* stay free of catalog imports** — the sandboxed preload imports
|
|
5
|
+
* `isSupportedLanguage` from here, and the catalogs (which weigh far
|
|
6
|
+
* more than this file) live in `./catalogs.ts` (`CATALOGS`,
|
|
7
|
+
* `catalogFor`), `./catalogs/ui.ts` (`UI_CATALOGS`, `uiCatalogFor`) plus
|
|
8
|
+
* the per-language JSON modules.
|
|
9
|
+
*
|
|
10
|
+
* `catalogs/*.json` (the app `translation` namespace) and `ui/*.json`
|
|
11
|
+
* (the shared `ui` namespace consumed by `@hoardodile/ui` and the plugin
|
|
12
|
+
* SDK iframes) must stay in lockstep internally: identical flat key
|
|
13
|
+
* sets, matching interpolation placeholders, and complete `_one`/`_other`
|
|
14
|
+
* pairs. `src/parity.test.ts` enforces this — run it after touching any
|
|
15
|
+
* catalog. `CATALOGS: Record<SupportedLanguage, typeof en>` and
|
|
16
|
+
* `UI_CATALOGS: Record<SupportedLanguage, typeof uiEn>` additionally
|
|
17
|
+
* make any key drift a compile error in `tsc`.
|
|
18
|
+
*/
|
|
19
|
+
export const SUPPORTED_LANGUAGES = ["en", "zh", "ja", "de", "es"] as const
|
|
20
|
+
export type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]
|
|
21
|
+
|
|
22
|
+
export function isSupportedLanguage(value: string): value is SupportedLanguage {
|
|
23
|
+
return (SUPPORTED_LANGUAGES as readonly string[]).includes(value)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Map a BCP-47 locale string (e.g. `navigator.language`,
|
|
28
|
+
* `app.getLocale()`, a stored pref) onto the supported set, taking the
|
|
29
|
+
* base code: `"ja-JP"` → `"ja"`, `"de-DE"` → `"de"`, `"es-MX"` → `"es"`,
|
|
30
|
+
* `"zh-CN"`/`"zh-TW"` → `"zh"` (any Chinese base maps to the `zh`
|
|
31
|
+
* catalog, preserving historical behavior). Unknown or missing values
|
|
32
|
+
* fall back to `"en"`.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveSystemLanguage(
|
|
35
|
+
raw: string | undefined,
|
|
36
|
+
): SupportedLanguage {
|
|
37
|
+
const base = raw?.toLowerCase().split("-")[0]
|
|
38
|
+
if (base !== undefined && isSupportedLanguage(base)) return base
|
|
39
|
+
return "en"
|
|
40
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The i18next instance factory every React root uses: the web SPA, the
|
|
3
|
+
* desktop wizard/shell pages, the workbench and the plugin SDK iframes
|
|
4
|
+
* all boot their instance with the same options so catalog behavior
|
|
5
|
+
* (fallback, plurals, interpolation, type safety) never diverges between
|
|
6
|
+
* surfaces.
|
|
7
|
+
*
|
|
8
|
+
* This module is deliberately free of catalog imports — plugin iframe
|
|
9
|
+
* bundles import the factory through this subpath and ship only the
|
|
10
|
+
* small `ui`/`plugin` namespaces (the full app catalog would otherwise
|
|
11
|
+
* be pulled into every bundle). Host surfaces use `createI18n` from
|
|
12
|
+
* `@hoardodile/i18n`, which wraps this factory with the shared catalogs.
|
|
13
|
+
*
|
|
14
|
+
* React-free on purpose: the Electron main process and the sandboxed
|
|
15
|
+
* preload keep using `catalogFor` / `uiCatalogFor` directly, and each
|
|
16
|
+
* React root adds its own `react-i18next` binding (e.g.
|
|
17
|
+
* `setI18n(instance)`).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import i18next, { type i18n as I18nInstance, type InitOptions } from "i18next"
|
|
21
|
+
import { SUPPORTED_LANGUAGES } from "./core.ts"
|
|
22
|
+
|
|
23
|
+
export type CreateI18nOptions = Omit<InitOptions, "lng" | "resources"> & {
|
|
24
|
+
/** Initial language; undefined lets i18next resolve its default (fallback → "en"). */
|
|
25
|
+
readonly lng?: string
|
|
26
|
+
/** Resource map — the shared catalogs (or the smaller ui/plugin set). */
|
|
27
|
+
readonly resources: InitOptions["resources"]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Create an i18next instance with the canonical option set. Callers
|
|
32
|
+
* supply the resources explicitly (hosts via the `@hoardodile/i18n`
|
|
33
|
+
* wrapper, plugins via the SDK).
|
|
34
|
+
*/
|
|
35
|
+
export function createI18n(options: CreateI18nOptions): I18nInstance {
|
|
36
|
+
const { lng, resources, ...rest } = options
|
|
37
|
+
const instance = i18next.createInstance()
|
|
38
|
+
void instance.init({
|
|
39
|
+
resources,
|
|
40
|
+
lng,
|
|
41
|
+
fallbackLng: "en",
|
|
42
|
+
supportedLngs: [...SUPPORTED_LANGUAGES],
|
|
43
|
+
nonExplicitSupportedLngs: true,
|
|
44
|
+
interpolation: { escapeValue: false },
|
|
45
|
+
returnNull: false,
|
|
46
|
+
returnEmptyString: false,
|
|
47
|
+
compatibilityJSON: "v4",
|
|
48
|
+
pluralSeparator: "_",
|
|
49
|
+
...rest,
|
|
50
|
+
} satisfies InitOptions)
|
|
51
|
+
return instance
|
|
52
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type en from "./catalogs/en.json"
|
|
2
|
+
import type uiEn from "./ui/en.json"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Type the `t()` keys of every consumer against the catalogs: a wrong key
|
|
6
|
+
* (typo or a key added to one catalog but not the others) becomes a
|
|
7
|
+
* compile error instead of a silently rendered key name. Declared once
|
|
8
|
+
* here so the web SPA, the desktop surfaces, the workbench and the plugin
|
|
9
|
+
* SDK all resolve the same resources. The `plugin` namespace is typed
|
|
10
|
+
* loosely on purpose — its strings are authored per plugin (their
|
|
11
|
+
* bundles vary), and `@hoardodile/sdk-react`'s `useTranslation` wrapper
|
|
12
|
+
* returns that loose signature to plugin code.
|
|
13
|
+
*/
|
|
14
|
+
declare module "i18next" {
|
|
15
|
+
interface CustomTypeOptions {
|
|
16
|
+
defaultNS: "translation"
|
|
17
|
+
resources: {
|
|
18
|
+
translation: typeof en
|
|
19
|
+
ui: typeof uiEn
|
|
20
|
+
plugin: Record<string, string>
|
|
21
|
+
}
|
|
22
|
+
returnNull: false
|
|
23
|
+
returnEmptyString: false
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
import type { i18n as I18nInstance } from "i18next"
|
|
28
|
+
import { UI_CATALOGS } from "./catalogs/ui.ts"
|
|
29
|
+
import { CATALOGS } from "./catalogs.ts"
|
|
30
|
+
import { SUPPORTED_LANGUAGES } from "./core.ts"
|
|
31
|
+
import {
|
|
32
|
+
type CreateI18nOptions,
|
|
33
|
+
createI18n as createI18nCore,
|
|
34
|
+
} from "./create-i18n.ts"
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Host-surface factory: an i18next instance preloaded with the shared
|
|
38
|
+
* catalogs (`translation` + `ui`, all five languages). React roots boot
|
|
39
|
+
* it with the same options everywhere. Plugin iframes use the `createI18n`
|
|
40
|
+
* factory from `@hoardodile/i18n/create-i18n` instead with a resource
|
|
41
|
+
* subset (ui + plugin namespaces) so the full app catalog never enters
|
|
42
|
+
* their bundles.
|
|
43
|
+
*/
|
|
44
|
+
export function createI18n(
|
|
45
|
+
options?: Omit<CreateI18nOptions, "resources">,
|
|
46
|
+
): I18nInstance {
|
|
47
|
+
return createI18nCore({
|
|
48
|
+
resources: Object.fromEntries(
|
|
49
|
+
SUPPORTED_LANGUAGES.map((language) => [
|
|
50
|
+
language,
|
|
51
|
+
{ translation: CATALOGS[language], ui: UI_CATALOGS[language] },
|
|
52
|
+
]),
|
|
53
|
+
),
|
|
54
|
+
...options,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export { UI_CATALOGS, uiCatalogFor } from "./catalogs/ui.ts"
|
|
59
|
+
export { CATALOGS, catalogFor } from "./catalogs.ts"
|
|
60
|
+
export {
|
|
61
|
+
isSupportedLanguage,
|
|
62
|
+
resolveSystemLanguage,
|
|
63
|
+
SUPPORTED_LANGUAGES,
|
|
64
|
+
type SupportedLanguage,
|
|
65
|
+
} from "./core.ts"
|
|
66
|
+
export type { CreateI18nOptions } from "./create-i18n.ts"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from "vitest"
|
|
5
|
+
import { UI_CATALOGS, uiCatalogFor } from "./catalogs/ui.ts"
|
|
6
|
+
import { CATALOGS, catalogFor } from "./catalogs.ts"
|
|
7
|
+
import {
|
|
8
|
+
isSupportedLanguage,
|
|
9
|
+
resolveSystemLanguage,
|
|
10
|
+
SUPPORTED_LANGUAGES,
|
|
11
|
+
} from "./core.ts"
|
|
12
|
+
|
|
13
|
+
describe("resolveSystemLanguage", () => {
|
|
14
|
+
it("maps base codes onto the supported set", () => {
|
|
15
|
+
expect(resolveSystemLanguage("ja-JP")).toBe("ja")
|
|
16
|
+
expect(resolveSystemLanguage("de-DE")).toBe("de")
|
|
17
|
+
expect(resolveSystemLanguage("es-MX")).toBe("es")
|
|
18
|
+
expect(resolveSystemLanguage("es")).toBe("es")
|
|
19
|
+
// Any Chinese base maps to the `zh` catalog, preserving history.
|
|
20
|
+
expect(resolveSystemLanguage("zh-CN")).toBe("zh")
|
|
21
|
+
expect(resolveSystemLanguage("zh-TW")).toBe("zh")
|
|
22
|
+
expect(resolveSystemLanguage("ZH-hant")).toBe("zh")
|
|
23
|
+
expect(resolveSystemLanguage("en-US")).toBe("en")
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it("falls back to English for unknown or missing values", () => {
|
|
27
|
+
expect(resolveSystemLanguage("fr")).toBe("en")
|
|
28
|
+
expect(resolveSystemLanguage("")).toBe("en")
|
|
29
|
+
expect(resolveSystemLanguage(undefined)).toBe("en")
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe("catalogFor", () => {
|
|
34
|
+
it("returns the matching catalog for every supported language", () => {
|
|
35
|
+
for (const code of SUPPORTED_LANGUAGES) {
|
|
36
|
+
expect(catalogFor(code), `catalogFor(${code})`).toBe(
|
|
37
|
+
CATALOGS[code as (typeof SUPPORTED_LANGUAGES)[number]],
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("falls back to English when no language is pushed yet", () => {
|
|
43
|
+
expect(catalogFor(undefined)).toBe(CATALOGS.en)
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe("uiCatalogFor", () => {
|
|
48
|
+
it("returns the matching ui catalog for every supported language", () => {
|
|
49
|
+
for (const code of SUPPORTED_LANGUAGES) {
|
|
50
|
+
expect(uiCatalogFor(code), `uiCatalogFor(${code})`).toBe(
|
|
51
|
+
UI_CATALOGS[code as (typeof SUPPORTED_LANGUAGES)[number]],
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("falls back to English when no language is pushed yet", () => {
|
|
57
|
+
expect(uiCatalogFor(undefined)).toBe(UI_CATALOGS.en)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe("isSupportedLanguage", () => {
|
|
62
|
+
it("accepts exactly the supported base codes", () => {
|
|
63
|
+
for (const code of SUPPORTED_LANGUAGES) {
|
|
64
|
+
expect(isSupportedLanguage(code)).toBe(true)
|
|
65
|
+
}
|
|
66
|
+
expect(isSupportedLanguage("de-DE")).toBe(false)
|
|
67
|
+
expect(isSupportedLanguage("fr")).toBe(false)
|
|
68
|
+
expect(isSupportedLanguage("")).toBe(false)
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment node
|
|
3
|
+
*
|
|
4
|
+
* Guardrails that keep the i18n catalogs in lockstep — the app
|
|
5
|
+
* `translation` namespace (`src/catalogs/*.json`), the shared `ui`
|
|
6
|
+
* namespace (`src/ui/*.json`) and the workbench namespace
|
|
7
|
+
* (`src/workbench/*.json`). These caught real regressions
|
|
8
|
+
* historically (dead `_plural` keys under i18next v4, a missing zh
|
|
9
|
+
* mirror, and the desktop shell's hardcoded ternaries), so they are
|
|
10
|
+
* intentionally strict: a new key must be registered in every language
|
|
11
|
+
* before it can ship.
|
|
12
|
+
*
|
|
13
|
+
* Structural rules only (key parity, placeholders, plural pairs, markup
|
|
14
|
+
* tags, ellipsis). Key *naming* is intentionally not enforced — see the
|
|
15
|
+
* documented conventions in `src/core.ts` and the catalog registries.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, it } from "vitest"
|
|
18
|
+
import { UI_CATALOGS } from "./catalogs/ui.ts"
|
|
19
|
+
import { WORKBENCH_CATALOGS } from "./catalogs/workbench.ts"
|
|
20
|
+
import { CATALOGS } from "./catalogs.ts"
|
|
21
|
+
|
|
22
|
+
type FlatEntry = { key: string; value: string }
|
|
23
|
+
|
|
24
|
+
function flatten(
|
|
25
|
+
obj: Record<string, unknown>,
|
|
26
|
+
path: string[] = [],
|
|
27
|
+
out: FlatEntry[] = [],
|
|
28
|
+
): FlatEntry[] {
|
|
29
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
30
|
+
if (typeof v === "object" && v !== null) {
|
|
31
|
+
flatten(v as Record<string, unknown>, [...path, k], out)
|
|
32
|
+
} else {
|
|
33
|
+
out.push({ key: [...path, k].join("."), value: String(v) })
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function vars(value: string): string {
|
|
40
|
+
return (value.match(/\{\{[^}]+\}\}/g) ?? [])
|
|
41
|
+
.map((m) => m.slice(2, -2))
|
|
42
|
+
.sort()
|
|
43
|
+
.join(",")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Markup tags (`<name>`, `</name>`, …) — translations must keep them. */
|
|
47
|
+
function tags(value: string): string {
|
|
48
|
+
return (value.match(/<\/?[a-zA-Z][a-zA-Z0-9-]*>/g) ?? []).sort().join(",")
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Keys that legitimately keep `{{count}}` without a plural pair. */
|
|
52
|
+
const NO_PLURAL_PAIR_ALLOWLIST = new Set([
|
|
53
|
+
"search.sectionCount", // "({{count}})" parenthetical
|
|
54
|
+
"search.viewAll", // "View all ({{count}})"
|
|
55
|
+
"me.custom.unusedCount", // "{{count}} unused" (adjective)
|
|
56
|
+
"me.desktop.lan.moreAddresses", // "Other addresses ({{count}})" parenthetical
|
|
57
|
+
"me.trash.view", // "Review trash ({{count}})"
|
|
58
|
+
"plugins.countInstalled", // "{{count}} installed" (adjective)
|
|
59
|
+
"usage.leaderboard.associatedSessionsShort", // "{{count}} associated" (adjective)
|
|
60
|
+
"categories.panel.dependencyResources", // "{{count}} res" (abbreviation)
|
|
61
|
+
"categories.panel.dependencyCharacters", // "{{count}} char" (abbreviation)
|
|
62
|
+
"categories.panel.tagCharacterCount", // "char {{count}}"
|
|
63
|
+
"categories.panel.tagResourceCount", // "res {{count}}"
|
|
64
|
+
"deleteEntity.usageMessage", // usage noun is passed in (singular when count=1)
|
|
65
|
+
"documents.statusBar.charCount", // "{{count}} / {{max}} chars" (range)
|
|
66
|
+
"sync.banner.overdueDescription", // "{{count}}-day reminder" (compound)
|
|
67
|
+
"trace.overview.moreThanPrev", // "{{count}} more than the previous period"
|
|
68
|
+
"trace.overview.lessThanPrev", // "{{count}} less than the previous period"
|
|
69
|
+
"characters.bulk.toolbarCount", // "{{count}} selected" (adjective)
|
|
70
|
+
"resources.bulk.toolbarCount", // "{{count}} selected" (adjective)
|
|
71
|
+
"characters.bulk.toastAllFailed", // "({{count}} failed)" parenthetical
|
|
72
|
+
"resources.bulk.toastAllFailed", // "({{count}} failed)" parenthetical
|
|
73
|
+
"characters.selectorDialog.confirmCount", // "Confirm ({{count}})"
|
|
74
|
+
"messages.viewReplies", // "View replies ({{count}})"
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
/** Keys that keep ASCII `...` in the value (URL placeholders). */
|
|
78
|
+
const ASCII_ELLIPSIS_ALLOWLIST = new Set([
|
|
79
|
+
"resources.new.sourceUrlPlaceholder",
|
|
80
|
+
"resources.editDialog.sourceUrlPlaceholder",
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
function checkCatalogLockstep(
|
|
84
|
+
catalogs: Readonly<Record<string, Readonly<Record<string, unknown>>>>,
|
|
85
|
+
): void {
|
|
86
|
+
const EN = catalogs.en as Record<string, unknown>
|
|
87
|
+
const enFlat = flatten(EN)
|
|
88
|
+
const enKeys = new Set(enFlat.map((r) => r.key))
|
|
89
|
+
const enByKey = new Map(enFlat.map((r) => [r.key, r.value]))
|
|
90
|
+
|
|
91
|
+
for (const [name, catalog] of Object.entries(catalogs)) {
|
|
92
|
+
const flat = flatten(catalog as unknown as Record<string, unknown>)
|
|
93
|
+
const keys = new Set(flat.map((r) => r.key))
|
|
94
|
+
const byKey = new Map(flat.map((r) => [r.key, r.value]))
|
|
95
|
+
|
|
96
|
+
it(`${name} has identical flat key sets`, () => {
|
|
97
|
+
expect(
|
|
98
|
+
[...enKeys].filter((k) => !keys.has(k)),
|
|
99
|
+
`keys only in en (missing from ${name})`,
|
|
100
|
+
).toEqual([])
|
|
101
|
+
expect(
|
|
102
|
+
[...keys].filter((k) => !enKeys.has(k)),
|
|
103
|
+
`keys only in ${name}`,
|
|
104
|
+
).toEqual([])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it(`${name} uses the same interpolation placeholders per key`, () => {
|
|
108
|
+
const mismatched: string[] = []
|
|
109
|
+
for (const key of enKeys) {
|
|
110
|
+
const value = byKey.get(key)
|
|
111
|
+
if (
|
|
112
|
+
value !== undefined &&
|
|
113
|
+
vars(enByKey.get(key) ?? "") !== vars(value)
|
|
114
|
+
) {
|
|
115
|
+
mismatched.push(key)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
expect(mismatched, "placeholder mismatch").toEqual([])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it(`${name} keeps <name> markup tags identical per key`, () => {
|
|
122
|
+
const mismatched: string[] = []
|
|
123
|
+
for (const key of enKeys) {
|
|
124
|
+
const value = byKey.get(key)
|
|
125
|
+
if (
|
|
126
|
+
value !== undefined &&
|
|
127
|
+
tags(enByKey.get(key) ?? "") !== tags(value)
|
|
128
|
+
) {
|
|
129
|
+
mismatched.push(key)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
expect(mismatched, "markup tag mismatch").toEqual([])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it(`${name} has complete plural pairs and no legacy suffixes`, () => {
|
|
136
|
+
const bad: string[] = []
|
|
137
|
+
for (const key of keys) {
|
|
138
|
+
if (/_(plural|singular)$/.test(key)) bad.push(`${key} (legacy suffix)`)
|
|
139
|
+
}
|
|
140
|
+
for (const key of keys) {
|
|
141
|
+
const base = key.replace(/_(one|other|few|many|zero)$/, "")
|
|
142
|
+
if (base === key) continue
|
|
143
|
+
if (!keys.has(`${base}_one`) || !keys.has(`${base}_other`)) {
|
|
144
|
+
bad.push(`${key} (incomplete pair)`)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
expect(bad, "plural violations").toEqual([])
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it(`${name} uses U+2026 ellipsis everywhere except URL placeholders`, () => {
|
|
151
|
+
const violating = flat
|
|
152
|
+
.filter((r) => r.value.includes("..."))
|
|
153
|
+
.map((r) => r.key)
|
|
154
|
+
.filter((k) => !ASCII_ELLIPSIS_ALLOWLIST.has(k))
|
|
155
|
+
expect(violating, "ASCII ellipsis outside allowlist").toEqual([])
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
it("never leaves a count key without a plural pair unless allowlisted", () => {
|
|
160
|
+
const violating: string[] = []
|
|
161
|
+
for (const [key, value] of enByKey) {
|
|
162
|
+
if (!value.includes("{{count}}")) continue
|
|
163
|
+
if (/_(one|other|few|many|zero)$/.test(key)) continue
|
|
164
|
+
if (NO_PLURAL_PAIR_ALLOWLIST.has(key)) continue
|
|
165
|
+
violating.push(key)
|
|
166
|
+
}
|
|
167
|
+
expect(violating, "count key without plural pair").toEqual([])
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
describe("i18n catalog parity", () => {
|
|
172
|
+
checkCatalogLockstep(CATALOGS)
|
|
173
|
+
checkCatalogLockstep(UI_CATALOGS)
|
|
174
|
+
checkCatalogLockstep(WORKBENCH_CATALOGS)
|
|
175
|
+
})
|
package/src/react.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { I18nextProvider, setI18n } from "react-i18next"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* React binding shared by every host surface. Import the provider and
|
|
5
|
+
* `setI18n` from HERE (not from `react-i18next`) so the provider and
|
|
6
|
+
* `@hoardodile/ui` components resolve the SAME react-i18next context
|
|
7
|
+
* instance: the workspace pins two typescript toolchains and pnpm
|
|
8
|
+
* therefore keeps two physical copies of react-i18next (keyed by peer
|
|
9
|
+
* context), so react-i18next's module-global default instance must NOT
|
|
10
|
+
* be relied on across package boundaries — hosts pass the instance
|
|
11
|
+
* explicitly.
|
|
12
|
+
*
|
|
13
|
+
* The `@hoardodile/ui` components call `useTranslation("ui")` against
|
|
14
|
+
* this context; host roots wrap their tree with the provider:
|
|
15
|
+
*
|
|
16
|
+
* ```tsx
|
|
17
|
+
* <I18nProvider i18n={i18n}>
|
|
18
|
+
* <App />
|
|
19
|
+
* </I18nProvider>
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Test setups that cannot wrap a provider bind the same instance here so
|
|
23
|
+
* ui components still resolve it.
|
|
24
|
+
*/
|
|
25
|
+
export { I18nextProvider as I18nProvider, setI18n }
|
package/src/ui/de.json
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"caption": {
|
|
3
|
+
"back": "Zurück",
|
|
4
|
+
"forward": "Vorwärts",
|
|
5
|
+
"reload": "Neu laden",
|
|
6
|
+
"minimize": "Minimieren",
|
|
7
|
+
"maximize": "Maximieren",
|
|
8
|
+
"restore": "Wiederherstellen",
|
|
9
|
+
"close": "Schließen",
|
|
10
|
+
"devtools": "Entwicklertools"
|
|
11
|
+
},
|
|
12
|
+
"closeConfirm": {
|
|
13
|
+
"title": "hoardodile schließen?",
|
|
14
|
+
"description": "Die App läuft im Infobereich weiter, außer du beendest sie.",
|
|
15
|
+
"tray": "Im Infobereich ausblenden",
|
|
16
|
+
"quit": "App beenden",
|
|
17
|
+
"cancel": "Abbrechen",
|
|
18
|
+
"remember": "Meine Auswahl merken"
|
|
19
|
+
},
|
|
20
|
+
"pluginDownload": {
|
|
21
|
+
"eyebrow": "Plugin-Download",
|
|
22
|
+
"title": "Diese Datei herunterladen?",
|
|
23
|
+
"description": "{{pluginName}} möchte eine Datei in seinen eigenen Speicherordner herunterladen. Die Datei wird von der unten stehenden URL geladen und nur innerhalb des Plugin-Ordners gespeichert.",
|
|
24
|
+
"urlLabel": "URL",
|
|
25
|
+
"destLabel": "Ziel",
|
|
26
|
+
"sizeLabel": "Größe",
|
|
27
|
+
"unknownSize": "unbekannt",
|
|
28
|
+
"deny": "Ablehnen",
|
|
29
|
+
"allow": "Erlauben",
|
|
30
|
+
"remember": "Für diese Sitzung merken (keine weiteren Rückfragen dieses Plugins)"
|
|
31
|
+
},
|
|
32
|
+
"pagination": {
|
|
33
|
+
"region": "Seitennavigation",
|
|
34
|
+
"prev": "Zurück",
|
|
35
|
+
"next": "Weiter",
|
|
36
|
+
"previous": "Vorherige",
|
|
37
|
+
"goTo": "Gehe zu",
|
|
38
|
+
"jumpToPage": "Seite",
|
|
39
|
+
"morePages": "Weitere Seiten"
|
|
40
|
+
},
|
|
41
|
+
"aria": {
|
|
42
|
+
"loading": "Wird geladen",
|
|
43
|
+
"closeToast": "Benachrichtigung schließen",
|
|
44
|
+
"close": "Schließen",
|
|
45
|
+
"more": "Mehr",
|
|
46
|
+
"toggleSidebar": "Seitenleiste umschalten",
|
|
47
|
+
"mobileSidebarDescription": "Zeigt die mobile Seitenleiste.",
|
|
48
|
+
"sidebar": "Seitenleiste",
|
|
49
|
+
"breadcrumb": "Breadcrumb"
|
|
50
|
+
},
|
|
51
|
+
"dialog": {
|
|
52
|
+
"cancel": "Abbrechen"
|
|
53
|
+
},
|
|
54
|
+
"confirmByTyping": {
|
|
55
|
+
"prompt": "Tippe „<name>{{name}}</name>“ ein, um zu bestätigen"
|
|
56
|
+
},
|
|
57
|
+
"colorPicker": {
|
|
58
|
+
"clear": "Farbe entfernen",
|
|
59
|
+
"addPreset": "Zu meinen Presets hinzufügen",
|
|
60
|
+
"removePresetAria": "Preset {{color}} entfernen",
|
|
61
|
+
"customSwatch": "Benutzerdefinierte Farbe wählen"
|
|
62
|
+
},
|
|
63
|
+
"fontPicker": {
|
|
64
|
+
"inherit": "Globale Schriftart übernehmen",
|
|
65
|
+
"inheritedHint": "Übernimmt derzeit die globale Schriftarteinstellung",
|
|
66
|
+
"addCustom": "Schriftart per Name hinzufügen, Eingabetaste drücken…",
|
|
67
|
+
"selected": "Fallback-Reihenfolge — zum Sortieren ziehen",
|
|
68
|
+
"description": "Oberfläche und Listen — Dokumente behalten ihre Lesestimme."
|
|
69
|
+
},
|
|
70
|
+
"imageCrop": {
|
|
71
|
+
"saving": "Speichern…",
|
|
72
|
+
"remove": "Entfernen",
|
|
73
|
+
"save": "Speichern",
|
|
74
|
+
"pickHint": "Klicken oder ablegen",
|
|
75
|
+
"reselect": "Erneut hochladen",
|
|
76
|
+
"previewLabel": "Vorschau",
|
|
77
|
+
"previewAlt": "Zugeschnittene Vorschau",
|
|
78
|
+
"showPreview": "Vorschau anzeigen",
|
|
79
|
+
"saveFailed": "Speichern fehlgeschlagen"
|
|
80
|
+
},
|
|
81
|
+
"panelToolbar": {
|
|
82
|
+
"add": "Hinzufügen",
|
|
83
|
+
"unused": "Ungenutzt",
|
|
84
|
+
"reorder": "Neu ordnen"
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/ui/en.json
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"caption": {
|
|
3
|
+
"back": "Back",
|
|
4
|
+
"forward": "Forward",
|
|
5
|
+
"reload": "Reload",
|
|
6
|
+
"minimize": "Minimize",
|
|
7
|
+
"maximize": "Maximize",
|
|
8
|
+
"restore": "Restore",
|
|
9
|
+
"close": "Close",
|
|
10
|
+
"devtools": "Developer tools"
|
|
11
|
+
},
|
|
12
|
+
"closeConfirm": {
|
|
13
|
+
"title": "Close hoardodile?",
|
|
14
|
+
"description": "The app keeps running in the tray unless you quit it.",
|
|
15
|
+
"tray": "Hide to tray",
|
|
16
|
+
"quit": "Quit the app",
|
|
17
|
+
"cancel": "Cancel",
|
|
18
|
+
"remember": "Remember my choice"
|
|
19
|
+
},
|
|
20
|
+
"pluginDownload": {
|
|
21
|
+
"eyebrow": "Plugin download",
|
|
22
|
+
"title": "Download this file?",
|
|
23
|
+
"description": "{{pluginName}} wants to download a file into its own storage folder. The file is fetched from the URL below and stored only inside the plugin's folder.",
|
|
24
|
+
"urlLabel": "URL",
|
|
25
|
+
"destLabel": "Destination",
|
|
26
|
+
"sizeLabel": "Size",
|
|
27
|
+
"unknownSize": "unknown",
|
|
28
|
+
"deny": "Deny",
|
|
29
|
+
"allow": "Allow",
|
|
30
|
+
"remember": "Remember for this session (no more prompts from this plugin)"
|
|
31
|
+
},
|
|
32
|
+
"pagination": {
|
|
33
|
+
"region": "pagination",
|
|
34
|
+
"prev": "Prev",
|
|
35
|
+
"next": "Next",
|
|
36
|
+
"previous": "Previous",
|
|
37
|
+
"goTo": "Go to",
|
|
38
|
+
"jumpToPage": "Page",
|
|
39
|
+
"morePages": "More pages"
|
|
40
|
+
},
|
|
41
|
+
"aria": {
|
|
42
|
+
"loading": "Loading",
|
|
43
|
+
"closeToast": "Close toast",
|
|
44
|
+
"close": "Close",
|
|
45
|
+
"more": "More",
|
|
46
|
+
"toggleSidebar": "Toggle Sidebar",
|
|
47
|
+
"mobileSidebarDescription": "Displays the mobile sidebar.",
|
|
48
|
+
"sidebar": "Sidebar",
|
|
49
|
+
"breadcrumb": "breadcrumb"
|
|
50
|
+
},
|
|
51
|
+
"dialog": {
|
|
52
|
+
"cancel": "Cancel"
|
|
53
|
+
},
|
|
54
|
+
"confirmByTyping": {
|
|
55
|
+
"prompt": "Type \"<name>{{name}}</name>\" to confirm"
|
|
56
|
+
},
|
|
57
|
+
"colorPicker": {
|
|
58
|
+
"clear": "Clear color",
|
|
59
|
+
"addPreset": "Add to my presets",
|
|
60
|
+
"removePresetAria": "Remove preset {{color}}",
|
|
61
|
+
"customSwatch": "Pick a custom color"
|
|
62
|
+
},
|
|
63
|
+
"fontPicker": {
|
|
64
|
+
"inherit": "Inherit global font",
|
|
65
|
+
"inheritedHint": "Currently inheriting the global font setting",
|
|
66
|
+
"addCustom": "Add a font by name, press Enter…",
|
|
67
|
+
"selected": "Fallback order — drag to rearrange",
|
|
68
|
+
"description": "Chrome and lists — documents keep their reading voice."
|
|
69
|
+
},
|
|
70
|
+
"imageCrop": {
|
|
71
|
+
"saving": "Saving…",
|
|
72
|
+
"remove": "Remove",
|
|
73
|
+
"save": "Save",
|
|
74
|
+
"pickHint": "Click or drop",
|
|
75
|
+
"reselect": "Re-upload",
|
|
76
|
+
"previewLabel": "Preview",
|
|
77
|
+
"previewAlt": "Cropped preview",
|
|
78
|
+
"showPreview": "Show preview",
|
|
79
|
+
"saveFailed": "Save failed"
|
|
80
|
+
},
|
|
81
|
+
"panelToolbar": {
|
|
82
|
+
"add": "Add",
|
|
83
|
+
"unused": "Unused",
|
|
84
|
+
"reorder": "Reorder"
|
|
85
|
+
}
|
|
86
|
+
}
|