@riducms/translations 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.
@@ -0,0 +1,114 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { ar } from "./languages/ar";
4
+ import { en } from "./languages/en";
5
+ import { fr } from "./languages/fr";
6
+ import {
7
+ createAdminI18n,
8
+ extendTranslationLanguage,
9
+ parseAcceptLanguage,
10
+ resolvePreferredLanguage,
11
+ validateLanguageCatalogs,
12
+ validatePluginMessageCatalog,
13
+ } from "./runtime";
14
+
15
+ describe("admin translations", () => {
16
+ test("interpolates, uses Intl plurals, and formats in one language and timezone", () => {
17
+ const i18n = createAdminI18n({ language: "en", timeZone: "UTC" });
18
+ expect(i18n.t("dashboard:welcome", { application: "Ridu" })).toBe("Welcome to Ridu");
19
+ expect(i18n.t("collections:selected", { count: 2 })).toBe("2 selected");
20
+ expect(i18n.t("collections:selected", { count: 1234 })).toBe("1,234 selected");
21
+ expect(i18n.formatNumber(1234.5)).toBe("1,234.5");
22
+ expect(
23
+ i18n.formatDate("2026-08-24T23:30:00Z", { dateStyle: "medium", timeStyle: "short" })
24
+ ).toContain("11:30");
25
+ });
26
+
27
+ test("continues through preferred languages and understands quality values", () => {
28
+ const preferences = parseAcceptLanguage("xx;q=1, fr-CA;q=0.9, en;q=0.8");
29
+ expect(resolvePreferredLanguage(preferences, ["en", "fr"], "en")).toBe("fr");
30
+ expect(resolvePreferredLanguage(["en"], ["en-US", "fr"], "fr")).toBe("en-US");
31
+ expect(resolvePreferredLanguage(["zh-Hant-HK"], ["zh-Hans", "zh-Hant"], "zh-Hans")).toBe(
32
+ "zh-Hant"
33
+ );
34
+ expect(resolvePreferredLanguage(["sr-Latn-RS"], ["sr-Cyrl", "sr-Latn"], "sr-Cyrl")).toBe(
35
+ "sr-Latn"
36
+ );
37
+ expect(parseAcceptLanguage("fr;q=1.5, en;q=0.8")).toEqual(["en"]);
38
+ });
39
+
40
+ test("translates, pluralizes, and formats the French admin", () => {
41
+ const i18n = createAdminI18n({ languages: [en, fr], language: "fr", timeZone: "Europe/Paris" });
42
+
43
+ expect(i18n.t("dashboard:heading")).toBe("Tableau de bord");
44
+ expect(i18n.t("dashboard:welcome", { application: "Ridu" })).toBe("Bienvenue dans Ridu");
45
+ expect(i18n.text("Posts", { fr: "Articles" })).toBe("Articles");
46
+ expect(i18n.text("Posts", { en: "Posts" })).toBe("Posts");
47
+ expect(i18n.t("collections:selected", { count: 1 })).toBe("1 élément sélectionné");
48
+ expect(i18n.t("collections:selected", { count: 2 })).toBe("2 éléments sélectionnés");
49
+ expect(i18n.t("collections:selected", { count: 1234 })).toBe(
50
+ "1\u202f234 éléments sélectionnés"
51
+ );
52
+ expect(i18n.formatNumber(1234.5)).toBe("1\u202f234,5");
53
+ expect(
54
+ i18n.formatDate("2026-08-24T22:30:00Z", {
55
+ dateStyle: "long",
56
+ timeStyle: "short",
57
+ })
58
+ ).toBe("25 août 2026 à 00:30");
59
+ });
60
+
61
+ test("provides a complete right-to-left Arabic catalog", () => {
62
+ const i18n = createAdminI18n({ languages: [en, ar], language: "ar" });
63
+
64
+ expect(i18n.language).toBe("ar");
65
+ expect(i18n.direction).toBe("rtl");
66
+ expect(i18n.t("dashboard:welcome", { application: "Ridu" })).toBe("مرحبًا بك في Ridu");
67
+ expect(i18n.t("collections:pageSummary", { page: 2, pages: 5 })).toBe("الصفحة 2 من 5");
68
+ });
69
+
70
+ test("selects all six Arabic plural categories", () => {
71
+ const i18n = createAdminI18n({ languages: [en, ar], language: "ar" });
72
+
73
+ expect(i18n.t("collections:selected", { count: 0 })).toBe("لم يتم تحديد أي عنصر (0)");
74
+ expect(i18n.t("collections:selected", { count: 1 })).toBe("تم تحديد عنصر واحد (1)");
75
+ expect(i18n.t("collections:selected", { count: 2 })).toBe("تم تحديد عنصرين (2)");
76
+ expect(i18n.t("collections:selected", { count: 3 })).toBe("تم تحديد 3 عناصر");
77
+ expect(i18n.t("collections:selected", { count: 11 })).toBe("تم تحديد 11 عنصرًا");
78
+ expect(i18n.t("collections:selected", { count: 100 })).toBe("تم تحديد 100 عنصر");
79
+ });
80
+
81
+ test("rejects missing variables and invalid plugin keys deterministically", () => {
82
+ const i18n = createAdminI18n({
83
+ languages: [en],
84
+ pluginMessages: { richtext: { fallback: { bold: "Bold" } } },
85
+ });
86
+ expect(() => i18n.t("dashboard:welcome")).toThrow("requires application");
87
+ expect(i18n.t("plugin.richtext:bold")).toBe("Bold");
88
+ expect(() => i18n.t("plugin.richtext:missing")).toThrow("Missing admin translation");
89
+ expect(() => validateLanguageCatalogs([{ ...en, code: "en-us" }])).toThrow(
90
+ "canonical BCP-47 casing"
91
+ );
92
+ expect(() => createAdminI18n({ timeZone: "+25:00" })).toThrow("Invalid admin timezone");
93
+ expect(() => createAdminI18n({ timeZone: "+05:30" })).not.toThrow();
94
+ expect(() =>
95
+ validatePluginMessageCatalog("broken", {
96
+ fallback: { count: { one: "One" } as never },
97
+ })
98
+ ).toThrow("requires other");
99
+ expect(() =>
100
+ validatePluginMessageCatalog("broken", {
101
+ fallback: { count: { other: "{count} items", invalid: "bad" } as never },
102
+ })
103
+ ).toThrow("invalid plural category");
104
+ });
105
+
106
+ test("supports typed project overrides without copying a whole catalog", () => {
107
+ const custom = extendTranslationLanguage(en, {
108
+ messages: { "dashboard:heading": "Control room" },
109
+ });
110
+ const i18n = createAdminI18n({ languages: [custom] });
111
+ expect(i18n.t("dashboard:heading")).toBe("Control room");
112
+ expect(i18n.t("general:save")).toBe("Save");
113
+ });
114
+ });
package/src/runtime.ts ADDED
@@ -0,0 +1,380 @@
1
+ import { en } from "./languages/en";
2
+ import type {
3
+ AdminI18n,
4
+ AdminTranslationKey,
5
+ CoreTranslationCatalog,
6
+ PluginMessageCatalog,
7
+ TranslationLanguage,
8
+ TranslationMessage,
9
+ TranslationVariables,
10
+ } from "./types";
11
+
12
+ export interface CreateAdminI18nOptions {
13
+ languages?: readonly TranslationLanguage[];
14
+ language?: string;
15
+ fallbackLanguage?: string;
16
+ timeZone?: string;
17
+ pluginMessages?: Readonly<Record<string, PluginMessageCatalog>>;
18
+ }
19
+
20
+ /** Defines and validates a complete statically bundled admin language. */
21
+ export function defineTranslationLanguage<const Language extends TranslationLanguage>(
22
+ language: Language
23
+ ): Language {
24
+ validateLanguageCatalogs([language]);
25
+ return language;
26
+ }
27
+
28
+ /** Creates a complete catalog from a built-in language and typed project overrides. */
29
+ export function extendTranslationLanguage(
30
+ base: TranslationLanguage,
31
+ options: {
32
+ code?: string;
33
+ label?: string;
34
+ rtl?: boolean;
35
+ messages: Readonly<Partial<Record<keyof CoreTranslationCatalog, TranslationMessage>>>;
36
+ }
37
+ ): TranslationLanguage {
38
+ const language: TranslationLanguage = {
39
+ ...base,
40
+ ...(options.code === undefined ? {} : { code: options.code }),
41
+ ...(options.label === undefined ? {} : { label: options.label }),
42
+ ...(options.rtl === undefined ? {} : { rtl: options.rtl }),
43
+ messages: { ...base.messages, ...options.messages },
44
+ };
45
+ validateLanguageCatalogs([language]);
46
+ return language;
47
+ }
48
+
49
+ export function createAdminI18n(options: CreateAdminI18nOptions = {}): AdminI18n {
50
+ const languages = options.languages?.length ? options.languages : [en];
51
+ validateLanguageCatalogs(languages);
52
+ if (options.timeZone !== undefined) validateTimeZone(options.timeZone);
53
+ const fallback = languageByCode(languages, options.fallbackLanguage) ?? languages[0];
54
+ if (fallback === undefined) throw new Error("Admin translations require at least one language.");
55
+ const active = languageByCode(languages, options.language) ?? fallback;
56
+ const pluralRules = new Intl.PluralRules(active.code);
57
+ const numberFormat = new Intl.NumberFormat(active.code);
58
+ const plugins = options.pluginMessages ?? {};
59
+ for (const [pluginKey, catalog] of Object.entries(plugins)) {
60
+ validatePluginMessageCatalog(pluginKey, catalog);
61
+ }
62
+ return {
63
+ language: active.code,
64
+ direction: active.rtl === true ? "rtl" : "ltr",
65
+ ...(options.timeZone === undefined ? {} : { timeZone: options.timeZone }),
66
+ text: (canonical, translations) =>
67
+ translations?.[active.code] ?? translations?.[fallback.code] ?? canonical,
68
+ t: (key, variables) => {
69
+ const message = key.startsWith("plugin.")
70
+ ? pluginMessage(plugins, key, active.code, fallback.code)
71
+ : (active.messages[key as keyof CoreTranslationCatalog] ??
72
+ fallback.messages[key as keyof CoreTranslationCatalog]);
73
+ if (message === undefined) throw new Error(`Missing admin translation ${key}.`);
74
+ return interpolate(
75
+ selectMessage(message, variables, pluralRules),
76
+ variables,
77
+ key,
78
+ numberFormat
79
+ );
80
+ },
81
+ formatDate: (value, formatOptions = {}) =>
82
+ new Intl.DateTimeFormat(active.code, {
83
+ ...(hasExplicitDateFormat(formatOptions) ? {} : { dateStyle: "medium" as const }),
84
+ ...(options.timeZone === undefined ? {} : { timeZone: options.timeZone }),
85
+ ...formatOptions,
86
+ }).format(normalizeDate(value)),
87
+ formatNumber: (value, formatOptions) =>
88
+ new Intl.NumberFormat(active.code, formatOptions).format(value),
89
+ formatRelativeTime: (value, unit) =>
90
+ new Intl.RelativeTimeFormat(active.code, { numeric: "auto" }).format(value, unit),
91
+ formatList: (values, formatOptions) =>
92
+ new Intl.ListFormat(active.code, formatOptions).format(values),
93
+ };
94
+ }
95
+
96
+ function validateTimeZone(timeZone: string) {
97
+ try {
98
+ new Intl.DateTimeFormat("en", { timeZone }).format(0);
99
+ } catch {
100
+ throw new Error(`Invalid admin timezone ${timeZone}.`);
101
+ }
102
+ }
103
+
104
+ export function resolvePreferredLanguage(
105
+ preferences: readonly string[],
106
+ supported: readonly string[],
107
+ fallback: string
108
+ ): string {
109
+ const exact = new Map(supported.map((code) => [code.toLowerCase(), code]));
110
+ for (const preference of preferences) {
111
+ const candidates = preferredLanguageCandidates(preference);
112
+ for (const candidate of candidates) {
113
+ const match = exact.get(candidate.toLowerCase());
114
+ if (match !== undefined) return match;
115
+ }
116
+ for (const candidate of candidates) {
117
+ const prefix = `${candidate.toLowerCase()}-`;
118
+ const match = supported.find((code) => code.toLowerCase().startsWith(prefix));
119
+ if (match !== undefined) return match;
120
+ }
121
+ }
122
+ return exact.get(fallback.toLowerCase()) ?? supported[0] ?? fallback;
123
+ }
124
+
125
+ function preferredLanguageCandidates(preference: string) {
126
+ let canonical: string;
127
+ try {
128
+ canonical = canonicalLanguageCode(preference.trim());
129
+ } catch {
130
+ return [];
131
+ }
132
+ const parts = canonical.split("-");
133
+ const candidates: string[] = [];
134
+ while (parts.length > 0) {
135
+ candidates.push(parts.join("-"));
136
+ parts.pop();
137
+ // RFC 4647 removes an extension singleton together with its extension.
138
+ while (parts.at(-1)?.length === 1) parts.pop();
139
+ }
140
+ return candidates;
141
+ }
142
+
143
+ export function parseAcceptLanguage(header: string): string[] {
144
+ return header
145
+ .split(",")
146
+ .map((part, index) => {
147
+ const [code = "", ...parameters] = part.trim().split(";");
148
+ const qualityParameter = parameters.find((parameter) => parameter.trim().startsWith("q="));
149
+ const parsedQuality =
150
+ qualityParameter === undefined ? 1 : Number(qualityParameter.trim().slice(2));
151
+ const quality =
152
+ Number.isFinite(parsedQuality) && parsedQuality >= 0 && parsedQuality <= 1
153
+ ? parsedQuality
154
+ : 0;
155
+ return { code, quality, index };
156
+ })
157
+ .filter(({ code, quality }) => code !== "*" && code !== "" && quality > 0)
158
+ .sort((left, right) => right.quality - left.quality || left.index - right.index)
159
+ .map(({ code }) => code);
160
+ }
161
+
162
+ export function validateLanguageCatalogs(languages: readonly TranslationLanguage[]): void {
163
+ const expectedKeys = Object.keys(en.messages).sort();
164
+ const seen = new Set<string>();
165
+ for (const language of languages) {
166
+ const canonicalCode = canonicalLanguageCode(language.code);
167
+ if (canonicalCode !== language.code) {
168
+ throw new Error(
169
+ `Admin language ${language.code} must use canonical BCP-47 casing (${canonicalCode}).`
170
+ );
171
+ }
172
+ if (language.label.trim() === "")
173
+ throw new Error(`Admin language ${language.code} has an empty label.`);
174
+ const identity = language.code.toLowerCase();
175
+ if (seen.has(identity)) throw new Error(`Duplicate admin language ${language.code}.`);
176
+ seen.add(identity);
177
+ const actualKeys = Object.keys(language.messages).sort();
178
+ if (actualKeys.join("\0") !== expectedKeys.join("\0")) {
179
+ throw new Error(
180
+ `Admin language ${language.code} does not exactly match the English catalog keys.`
181
+ );
182
+ }
183
+ for (const key of expectedKeys) {
184
+ const source = en.messages[key as keyof typeof en.messages];
185
+ const translated = language.messages[key as keyof CoreTranslationCatalog];
186
+ validateMessage(key, source, translated, language.code);
187
+ }
188
+ }
189
+ }
190
+
191
+ export function validatePluginMessageCatalog(
192
+ pluginKey: string,
193
+ catalog: PluginMessageCatalog
194
+ ): void {
195
+ if (!/^[a-z][a-z0-9-]*$/.test(pluginKey)) {
196
+ throw new Error(`Invalid admin plugin message namespace ${pluginKey}.`);
197
+ }
198
+ const fallbackKeys = Object.keys(catalog.fallback).sort();
199
+ if (fallbackKeys.length === 0) {
200
+ throw new Error(`Admin plugin ${pluginKey} must define at least one fallback message.`);
201
+ }
202
+ for (const key of fallbackKeys) {
203
+ if (!/^[A-Za-z][A-Za-z0-9._-]*$/.test(key) || key === "__proto__") {
204
+ throw new Error(`Admin plugin ${pluginKey} has invalid message key ${key}.`);
205
+ }
206
+ validateMessage(
207
+ `plugin.${pluginKey}:${key}`,
208
+ catalog.fallback[key]!,
209
+ catalog.fallback[key]!,
210
+ "fallback"
211
+ );
212
+ }
213
+ for (const [language, messages] of Object.entries(catalog.translations ?? {})) {
214
+ const canonicalCode = canonicalLanguageCode(language);
215
+ if (canonicalCode !== language) {
216
+ throw new Error(
217
+ `Admin plugin ${pluginKey} language ${language} must use canonical BCP-47 casing (${canonicalCode}).`
218
+ );
219
+ }
220
+ const keys = Object.keys(messages).sort();
221
+ if (keys.join("\0") !== fallbackKeys.join("\0")) {
222
+ throw new Error(
223
+ `Admin plugin ${pluginKey} language ${language} does not exactly match its fallback keys.`
224
+ );
225
+ }
226
+ for (const key of fallbackKeys) {
227
+ validateMessage(
228
+ `plugin.${pluginKey}:${key}`,
229
+ catalog.fallback[key]!,
230
+ messages[key]!,
231
+ language
232
+ );
233
+ }
234
+ }
235
+ }
236
+
237
+ function canonicalLanguageCode(code: string) {
238
+ try {
239
+ const [canonical] = Intl.getCanonicalLocales(code);
240
+ if (canonical === undefined) throw new RangeError();
241
+ return canonical;
242
+ } catch {
243
+ throw new Error(`Invalid admin language code ${code}.`);
244
+ }
245
+ }
246
+
247
+ function validateMessage(
248
+ key: string,
249
+ source: TranslationMessage,
250
+ translated: TranslationMessage,
251
+ language: string
252
+ ) {
253
+ validateMessageShape(key, source, language);
254
+ validateMessageShape(key, translated, language);
255
+ if ((typeof source === "string") !== (typeof translated === "string")) {
256
+ throw new Error(`Admin language ${language} message ${key} changes plural message shape.`);
257
+ }
258
+ const sourcePlaceholders = placeholders(source);
259
+ for (const text of messageVariants(translated)) {
260
+ if (text.trim() === "")
261
+ throw new Error(`Admin language ${language} has an empty ${key} message.`);
262
+ const translatedPlaceholders = [...extractPlaceholders(text)].sort();
263
+ if (translatedPlaceholders.join("\0") !== sourcePlaceholders.join("\0")) {
264
+ throw new Error(`Admin language ${language} message ${key} has different placeholders.`);
265
+ }
266
+ }
267
+ }
268
+
269
+ const pluralCategories = new Set<Intl.LDMLPluralRule>([
270
+ "zero",
271
+ "one",
272
+ "two",
273
+ "few",
274
+ "many",
275
+ "other",
276
+ ]);
277
+
278
+ function validateMessageShape(key: string, message: TranslationMessage, language: string) {
279
+ if (typeof message === "string") return;
280
+ if (message === null || typeof message !== "object" || Array.isArray(message)) {
281
+ throw new Error(`Admin language ${language} message ${key} has an invalid message shape.`);
282
+ }
283
+ for (const [category, text] of Object.entries(message)) {
284
+ if (!pluralCategories.has(category as Intl.LDMLPluralRule) || typeof text !== "string") {
285
+ throw new Error(`Admin language ${language} message ${key} has an invalid plural category.`);
286
+ }
287
+ }
288
+ if (typeof message.other !== "string") {
289
+ throw new Error(`Admin language ${language} plural message ${key} requires other.`);
290
+ }
291
+ }
292
+
293
+ function placeholders(message: TranslationMessage) {
294
+ const result = new Set<string>();
295
+ for (const text of messageVariants(message)) {
296
+ for (const placeholder of extractPlaceholders(text)) result.add(placeholder);
297
+ }
298
+ return [...result].sort();
299
+ }
300
+
301
+ function messageVariants(message: TranslationMessage): string[] {
302
+ return typeof message === "string"
303
+ ? [message]
304
+ : Object.values(message).filter((value): value is string => typeof value === "string");
305
+ }
306
+
307
+ function extractPlaceholders(message: string): Set<string> {
308
+ return new Set([...message.matchAll(/\{([A-Za-z][A-Za-z0-9_]*)\}/g)].map((match) => match[1]!));
309
+ }
310
+
311
+ function selectMessage(
312
+ message: TranslationMessage,
313
+ variables: TranslationVariables | undefined,
314
+ pluralRules: Intl.PluralRules
315
+ ) {
316
+ if (typeof message === "string") return message;
317
+ const count = variables?.count;
318
+ if (typeof count !== "number")
319
+ throw new Error("Plural admin translations require a numeric count.");
320
+ return message[pluralRules.select(count)] ?? message.other;
321
+ }
322
+
323
+ function interpolate(
324
+ message: string,
325
+ variables: TranslationVariables | undefined,
326
+ key: AdminTranslationKey,
327
+ numberFormat: Intl.NumberFormat
328
+ ) {
329
+ return message.replace(/\{([A-Za-z][A-Za-z0-9_]*)\}/g, (_, name: string) => {
330
+ const value = variables?.[name];
331
+ if (value === undefined) throw new Error(`Admin translation ${key} requires ${name}.`);
332
+ return typeof value === "number" ? numberFormat.format(value) : value;
333
+ });
334
+ }
335
+
336
+ function normalizeDate(value: Date | string | number) {
337
+ const date = value instanceof Date ? value : new Date(value);
338
+ if (Number.isNaN(date.valueOf())) throw new RangeError("Cannot format an invalid date.");
339
+ return date;
340
+ }
341
+
342
+ function hasExplicitDateFormat(options: Intl.DateTimeFormatOptions) {
343
+ return [
344
+ "dateStyle",
345
+ "timeStyle",
346
+ "weekday",
347
+ "era",
348
+ "year",
349
+ "month",
350
+ "day",
351
+ "dayPeriod",
352
+ "hour",
353
+ "minute",
354
+ "second",
355
+ "fractionalSecondDigits",
356
+ "timeZoneName",
357
+ ].some((key) => key in options);
358
+ }
359
+
360
+ function languageByCode(languages: readonly TranslationLanguage[], code: string | undefined) {
361
+ if (code === undefined) return undefined;
362
+ return languages.find((language) => language.code.toLowerCase() === code.toLowerCase());
363
+ }
364
+
365
+ function pluginMessage(
366
+ plugins: Readonly<Record<string, PluginMessageCatalog>>,
367
+ key: string,
368
+ language: string,
369
+ fallbackLanguage: string
370
+ ): TranslationMessage | undefined {
371
+ const separator = key.indexOf(":");
372
+ const namespace = key.slice("plugin.".length, separator);
373
+ const localKey = key.slice(separator + 1);
374
+ const catalog = plugins[namespace];
375
+ return (
376
+ catalog?.translations?.[language]?.[localKey] ??
377
+ catalog?.translations?.[fallbackLanguage]?.[localKey] ??
378
+ catalog?.fallback[localKey]
379
+ );
380
+ }
package/src/types.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { enMessages } from "./languages/en";
2
+
3
+ export type PluralCategory = Intl.LDMLPluralRule;
4
+ export type PluralMessage = Readonly<Partial<Record<PluralCategory, string>> & { other: string }>;
5
+ export type TranslationMessage = string | PluralMessage;
6
+ export type CoreTranslationKey = keyof typeof enMessages;
7
+ export type CoreTranslationCatalog = Readonly<Record<CoreTranslationKey, TranslationMessage>>;
8
+ export type TranslationVariables = Readonly<Record<string, string | number>>;
9
+ export type PluginTranslationKey = `plugin.${string}:${string}`;
10
+ export type AdminTranslationKey = CoreTranslationKey | PluginTranslationKey;
11
+
12
+ export interface TranslationLanguage {
13
+ code: string;
14
+ label: string;
15
+ rtl?: boolean;
16
+ messages: CoreTranslationCatalog;
17
+ }
18
+
19
+ export interface AdminI18n {
20
+ readonly language: string;
21
+ readonly direction: "ltr" | "rtl";
22
+ readonly timeZone?: string;
23
+ text: (canonical: string, translations?: Readonly<Record<string, string>>) => string;
24
+ t: (key: AdminTranslationKey, variables?: TranslationVariables) => string;
25
+ formatDate: (value: Date | string | number, options?: Intl.DateTimeFormatOptions) => string;
26
+ formatNumber: (value: number, options?: Intl.NumberFormatOptions) => string;
27
+ formatRelativeTime: (value: number, unit: Intl.RelativeTimeFormatUnit) => string;
28
+ formatList: (values: readonly string[], options?: Intl.ListFormatOptions) => string;
29
+ }
30
+
31
+ export interface PluginMessageCatalog {
32
+ fallback: Readonly<Record<string, TranslationMessage>>;
33
+ translations?: Readonly<Record<string, Readonly<Record<string, TranslationMessage>>>>;
34
+ }