@gabrielbourget/i18n-foundation 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.
Files changed (99) hide show
  1. package/README.md +159 -0
  2. package/dist/assets/index.d.ts +35 -0
  3. package/dist/assets/index.d.ts.map +1 -0
  4. package/dist/assets/index.js +102 -0
  5. package/dist/common/index.d.ts +18 -0
  6. package/dist/common/index.d.ts.map +1 -0
  7. package/dist/common/index.js +27 -0
  8. package/dist/core/client.d.ts +22 -0
  9. package/dist/core/client.d.ts.map +1 -0
  10. package/dist/core/client.js +89 -0
  11. package/dist/core/configuration.d.ts +18 -0
  12. package/dist/core/configuration.d.ts.map +1 -0
  13. package/dist/core/configuration.js +36 -0
  14. package/dist/core/createIsolatedI18nInstance.d.ts +13 -0
  15. package/dist/core/createIsolatedI18nInstance.d.ts.map +1 -0
  16. package/dist/core/createIsolatedI18nInstance.js +16 -0
  17. package/dist/core/i18nStrictMode.d.ts +11 -0
  18. package/dist/core/i18nStrictMode.d.ts.map +1 -0
  19. package/dist/core/i18nStrictMode.js +19 -0
  20. package/dist/core/index.d.ts +7 -0
  21. package/dist/core/index.d.ts.map +1 -0
  22. package/dist/core/index.js +6 -0
  23. package/dist/core/initCommon.d.ts +15 -0
  24. package/dist/core/initCommon.d.ts.map +1 -0
  25. package/dist/core/initCommon.js +51 -0
  26. package/dist/core/resourceLoader.d.ts +9 -0
  27. package/dist/core/resourceLoader.d.ts.map +1 -0
  28. package/dist/core/resourceLoader.js +56 -0
  29. package/dist/core/server.d.ts +21 -0
  30. package/dist/core/server.d.ts.map +1 -0
  31. package/dist/core/server.js +20 -0
  32. package/dist/core/types.d.ts +20 -0
  33. package/dist/core/types.d.ts.map +1 -0
  34. package/dist/core/types.js +1 -0
  35. package/dist/env/client.d.ts +29 -0
  36. package/dist/env/client.d.ts.map +1 -0
  37. package/dist/env/client.js +42 -0
  38. package/dist/env/index.d.ts +4 -0
  39. package/dist/env/index.d.ts.map +1 -0
  40. package/dist/env/index.js +3 -0
  41. package/dist/env/server.d.ts +30 -0
  42. package/dist/env/server.d.ts.map +1 -0
  43. package/dist/env/server.js +45 -0
  44. package/dist/env/utils.d.ts +23 -0
  45. package/dist/env/utils.d.ts.map +1 -0
  46. package/dist/env/utils.js +65 -0
  47. package/dist/index.d.ts +6 -0
  48. package/dist/index.d.ts.map +1 -0
  49. package/dist/index.js +5 -0
  50. package/dist/manifest/index.d.ts +37 -0
  51. package/dist/manifest/index.d.ts.map +1 -0
  52. package/dist/manifest/index.js +46 -0
  53. package/dist/pseudolocalization/index.d.ts +89 -0
  54. package/dist/pseudolocalization/index.d.ts.map +1 -0
  55. package/dist/pseudolocalization/index.js +182 -0
  56. package/dist/verifier/domainContracts.d.ts +43 -0
  57. package/dist/verifier/domainContracts.d.ts.map +1 -0
  58. package/dist/verifier/domainContracts.js +324 -0
  59. package/dist/verifier/fileSystem.d.ts +9 -0
  60. package/dist/verifier/fileSystem.d.ts.map +1 -0
  61. package/dist/verifier/fileSystem.js +42 -0
  62. package/dist/verifier/icuContracts.d.ts +13 -0
  63. package/dist/verifier/icuContracts.d.ts.map +1 -0
  64. package/dist/verifier/icuContracts.js +109 -0
  65. package/dist/verifier/index.d.ts +7 -0
  66. package/dist/verifier/index.d.ts.map +1 -0
  67. package/dist/verifier/index.js +6 -0
  68. package/dist/verifier/keyShape.d.ts +12 -0
  69. package/dist/verifier/keyShape.d.ts.map +1 -0
  70. package/dist/verifier/keyShape.js +73 -0
  71. package/dist/verifier/sheetOrdering.d.ts +2 -0
  72. package/dist/verifier/sheetOrdering.d.ts.map +1 -0
  73. package/dist/verifier/sheetOrdering.js +161 -0
  74. package/dist/verifier/verifyI18nSource.d.ts +25 -0
  75. package/dist/verifier/verifyI18nSource.d.ts.map +1 -0
  76. package/dist/verifier/verifyI18nSource.js +162 -0
  77. package/locales/en/common--carousel.json +30 -0
  78. package/locales/en/common--delete-entity-modal.json +15 -0
  79. package/locales/en/common--file-upload.json +88 -0
  80. package/locales/en/common--form-rules.json +12 -0
  81. package/locales/en/common--form-validation.json +19 -0
  82. package/locales/en/common--pagination.json +36 -0
  83. package/locales/en/common--saved-views.json +37 -0
  84. package/locales/en/common--searching.json +14 -0
  85. package/locales/en/common--sort-and-filter-panel.json +135 -0
  86. package/locales/en/common--theme.json +9 -0
  87. package/locales/en/common--ui.json +52 -0
  88. package/locales/fr/common--carousel.json +30 -0
  89. package/locales/fr/common--delete-entity-modal.json +15 -0
  90. package/locales/fr/common--file-upload.json +88 -0
  91. package/locales/fr/common--form-rules.json +12 -0
  92. package/locales/fr/common--form-validation.json +19 -0
  93. package/locales/fr/common--pagination.json +36 -0
  94. package/locales/fr/common--saved-views.json +37 -0
  95. package/locales/fr/common--searching.json +14 -0
  96. package/locales/fr/common--sort-and-filter-panel.json +135 -0
  97. package/locales/fr/common--theme.json +9 -0
  98. package/locales/fr/common--ui.json +52 -0
  99. package/package.json +103 -0
@@ -0,0 +1,42 @@
1
+ import fs from "node:fs/promises";
2
+ export const listDirNames = async (directoryPath) => {
3
+ const entries = await fs.readdir(directoryPath, { withFileTypes: true });
4
+ return entries
5
+ .filter((entry) => entry.isDirectory())
6
+ .map((entry) => entry.name)
7
+ .sort();
8
+ };
9
+ export const listFilesRecursive = async (directoryPath) => {
10
+ const entries = await fs.readdir(directoryPath, { withFileTypes: true });
11
+ const files = [];
12
+ for (const entry of entries) {
13
+ const entryPath = `${directoryPath}/${entry.name}`;
14
+ if (entry.isDirectory()) {
15
+ files.push(...(await listFilesRecursive(entryPath)));
16
+ continue;
17
+ }
18
+ if (entry.isFile()) {
19
+ files.push(entryPath);
20
+ }
21
+ }
22
+ return files.sort();
23
+ };
24
+ export const listJSONFiles = async (directoryPath) => {
25
+ const entries = await fs.readdir(directoryPath, { withFileTypes: true });
26
+ return entries
27
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
28
+ .map((entry) => entry.name)
29
+ .sort();
30
+ };
31
+ export const readJSONFileOrThrow = async (filePath, errorPrefix = "[i18n verify]") => {
32
+ const raw = await fs.readFile(filePath, "utf8");
33
+ try {
34
+ return {
35
+ json: JSON.parse(raw),
36
+ raw,
37
+ };
38
+ }
39
+ catch (error) {
40
+ throw new Error(`${errorPrefix} Invalid JSON: ${filePath}\n${String(error)}`);
41
+ }
42
+ };
@@ -0,0 +1,13 @@
1
+ export type TTranslationStringEntry = {
2
+ keyPath: string;
3
+ value: string;
4
+ };
5
+ export type TICUContract = {
6
+ contractSignatures: readonly string[];
7
+ interpolationTokens: readonly string[];
8
+ };
9
+ export declare const getTranslationStringEntries: (value: unknown, pathSegments?: readonly string[]) => TTranslationStringEntry[];
10
+ export declare const parseICUContract: (message: string, locale: string, context: string, errors: string[]) => TICUContract | null;
11
+ export declare const pushContractDiffs: (errors: string[], context: string, referenceContext: string, label: string, referenceItems: readonly string[], localeItems: readonly string[]) => void;
12
+ export declare const getDeclaredInterpolationTokens: (sheet: unknown, keyPath: string) => string[];
13
+ //# sourceMappingURL=icuContracts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"icuContracts.d.ts","sourceRoot":"","sources":["../../src/verifier/icuContracts.ts"],"names":[],"mappings":"AAuBA,MAAM,MAAM,uBAAuB,GAAG;IACpC,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,MAAM,CAAA;CACd,CAAA;AAED,MAAM,MAAM,YAAY,GAAG;IACzB,kBAAkB,EAAE,SAAS,MAAM,EAAE,CAAA;IACrC,mBAAmB,EAAE,SAAS,MAAM,EAAE,CAAA;CACvC,CAAA;AAaD,eAAO,MAAM,2BAA2B,GACtC,OAAO,OAAO,EACd,eAAc,SAAS,MAAM,EAAO,KACnC,uBAAuB,EAUzB,CAAA;AA+DD,eAAO,MAAM,gBAAgB,GAC3B,SAAS,MAAM,EACf,QAAQ,MAAM,EACd,SAAS,MAAM,EACf,QAAQ,MAAM,EAAE,KACf,YAAY,GAAG,IAWjB,CAAA;AAED,eAAO,MAAM,iBAAiB,GAC5B,QAAQ,MAAM,EAAE,EAChB,SAAS,MAAM,EACf,kBAAkB,MAAM,EACxB,OAAO,MAAM,EACb,gBAAgB,SAAS,MAAM,EAAE,EACjC,aAAa,SAAS,MAAM,EAAE,KAC7B,IAaF,CAAA;AAED,eAAO,MAAM,8BAA8B,GAAI,OAAO,OAAO,EAAE,SAAS,MAAM,KAAG,MAAM,EAUtF,CAAA"}
@@ -0,0 +1,109 @@
1
+ import { IntlMessageFormat } from "intl-messageformat";
2
+ import { isPlainObject } from "./keyShape.js";
3
+ const MESSAGE_ELEMENT_TYPES = Object.freeze({
4
+ argument: 1,
5
+ date: 3,
6
+ literal: 0,
7
+ number: 2,
8
+ plural: 6,
9
+ pound: 7,
10
+ select: 5,
11
+ tag: 8,
12
+ time: 4,
13
+ });
14
+ const SIMPLE_ARGUMENT_MESSAGE_TYPES = new Map([
15
+ [MESSAGE_ELEMENT_TYPES.argument, "argument"],
16
+ [MESSAGE_ELEMENT_TYPES.number, "number"],
17
+ [MESSAGE_ELEMENT_TYPES.date, "date"],
18
+ [MESSAGE_ELEMENT_TYPES.time, "time"],
19
+ ]);
20
+ const sortUnique = (items) => [...new Set(items)].sort();
21
+ export const getTranslationStringEntries = (value, pathSegments = []) => {
22
+ if (pathSegments[0] === "_meta")
23
+ return [];
24
+ if (typeof value === "string") {
25
+ return [{ keyPath: pathSegments.join("."), value }];
26
+ }
27
+ if (!isPlainObject(value))
28
+ return [];
29
+ return Object.entries(value).flatMap(([key, child]) => getTranslationStringEntries(child, [...pathSegments, key]));
30
+ };
31
+ const getSortedOptionKeys = (options) => Object.keys(options ?? {}).sort();
32
+ const collectMessageContract = (elements) => {
33
+ const interpolationTokens = [];
34
+ const contractSignatures = [];
35
+ const visit = (messageElements) => {
36
+ for (const element of messageElements) {
37
+ const simpleArgumentKind = SIMPLE_ARGUMENT_MESSAGE_TYPES.get(element.type);
38
+ if (simpleArgumentKind && element.value) {
39
+ interpolationTokens.push(element.value);
40
+ contractSignatures.push(`${simpleArgumentKind}(${element.value})`);
41
+ continue;
42
+ }
43
+ if (element.type === MESSAGE_ELEMENT_TYPES.select && element.value) {
44
+ const optionKeys = getSortedOptionKeys(element.options);
45
+ interpolationTokens.push(element.value);
46
+ contractSignatures.push(`select(${element.value}, options=${optionKeys.join("|")})`);
47
+ for (const optionKey of optionKeys) {
48
+ visit(element.options?.[optionKey]?.value ?? []);
49
+ }
50
+ continue;
51
+ }
52
+ if (element.type === MESSAGE_ELEMENT_TYPES.plural && element.value) {
53
+ const optionKeys = getSortedOptionKeys(element.options);
54
+ const pluralType = element.pluralType ?? "cardinal";
55
+ const offset = element.offset ?? 0;
56
+ interpolationTokens.push(element.value);
57
+ contractSignatures.push(`plural(${element.value}, type=${pluralType}, offset=${offset}, options=${optionKeys.join("|")})`);
58
+ for (const optionKey of optionKeys) {
59
+ visit(element.options?.[optionKey]?.value ?? []);
60
+ }
61
+ continue;
62
+ }
63
+ if (element.type === MESSAGE_ELEMENT_TYPES.tag && element.value) {
64
+ contractSignatures.push(`tag(${element.value})`);
65
+ visit(element.children ?? []);
66
+ }
67
+ }
68
+ };
69
+ visit(elements);
70
+ return {
71
+ contractSignatures: sortUnique(contractSignatures),
72
+ interpolationTokens: sortUnique(interpolationTokens),
73
+ };
74
+ };
75
+ export const parseICUContract = (message, locale, context, errors) => {
76
+ try {
77
+ const ast = new IntlMessageFormat(message, locale).getAst();
78
+ return collectMessageContract(ast);
79
+ }
80
+ catch (error) {
81
+ const messageText = error instanceof Error ? error.message : String(error);
82
+ errors.push(`${context} has invalid ICU syntax: ${messageText}`);
83
+ return null;
84
+ }
85
+ };
86
+ export const pushContractDiffs = (errors, context, referenceContext, label, referenceItems, localeItems) => {
87
+ const referenceItemSet = new Set(referenceItems);
88
+ const localeItemSet = new Set(localeItems);
89
+ const missingItems = referenceItems.filter((item) => !localeItemSet.has(item));
90
+ const extraItems = localeItems.filter((item) => !referenceItemSet.has(item));
91
+ if (missingItems.length > 0) {
92
+ errors.push(`${context} is missing ICU ${label} from ${referenceContext}: ${missingItems.join(", ")}`);
93
+ }
94
+ if (extraItems.length > 0) {
95
+ errors.push(`${context} has extra ICU ${label} not present in ${referenceContext}: ${extraItems.join(", ")}`);
96
+ }
97
+ };
98
+ export const getDeclaredInterpolationTokens = (sheet, keyPath) => {
99
+ if (!isPlainObject(sheet))
100
+ return [];
101
+ if (!isPlainObject(sheet._meta))
102
+ return [];
103
+ if (!isPlainObject(sheet._meta.interpolation))
104
+ return [];
105
+ const declaredTokens = sheet._meta.interpolation[keyPath];
106
+ return Array.isArray(declaredTokens) && declaredTokens.every((token) => typeof token === "string")
107
+ ? sortUnique(declaredTokens)
108
+ : [];
109
+ };
@@ -0,0 +1,7 @@
1
+ export * from "./domainContracts.js";
2
+ export * from "./fileSystem.js";
3
+ export * from "./icuContracts.js";
4
+ export * from "./keyShape.js";
5
+ export * from "./sheetOrdering.js";
6
+ export * from "./verifyI18nSource.js";
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/verifier/index.ts"],"names":[],"mappings":"AAAA,cAAc,sBAAsB,CAAA;AACpC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,mBAAmB,CAAA;AACjC,cAAc,eAAe,CAAA;AAC7B,cAAc,oBAAoB,CAAA;AAClC,cAAc,uBAAuB,CAAA"}
@@ -0,0 +1,6 @@
1
+ export * from "./domainContracts.js";
2
+ export * from "./fileSystem.js";
3
+ export * from "./icuContracts.js";
4
+ export * from "./keyShape.js";
5
+ export * from "./sheetOrdering.js";
6
+ export * from "./verifyI18nSource.js";
@@ -0,0 +1,12 @@
1
+ export type TLeafShape = {
2
+ keyPath: string;
3
+ kind: string;
4
+ };
5
+ export type TValidateKeyConventionsOptions = {
6
+ structuredNamespaceKeyConventionExceptions?: ReadonlySet<string> | readonly string[] | undefined;
7
+ };
8
+ export declare const isPlainObject: (value: unknown) => value is Record<string, unknown>;
9
+ export declare const getLeafShapes: (value: unknown, pathSegments?: readonly string[]) => TLeafShape[];
10
+ export declare const normalizeShapeEntries: (entries: readonly TLeafShape[]) => string[];
11
+ export declare const validateKeyConventions: (sheet: unknown, context: string, namespace: string, options?: TValidateKeyConventionsOptions) => string[];
12
+ //# sourceMappingURL=keyShape.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"keyShape.d.ts","sourceRoot":"","sources":["../../src/verifier/keyShape.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,UAAU,GAAG;IACvB,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;CACb,CAAA;AAED,MAAM,MAAM,8BAA8B,GAAG;IAC3C,0CAA0C,CAAC,EAAE,WAAW,CAAC,MAAM,CAAC,GAAG,SAAS,MAAM,EAAE,GAAG,SAAS,CAAA;CACjG,CAAA;AAID,eAAO,MAAM,aAAa,GAAI,OAAO,OAAO,KAAG,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CACR,CAAA;AAQtE,eAAO,MAAM,aAAa,GAAI,OAAO,OAAO,EAAE,eAAc,SAAS,MAAM,EAAO,KAAG,UAAU,EAM9F,CAAA;AAED,eAAO,MAAM,qBAAqB,GAAI,SAAS,SAAS,UAAU,EAAE,KAAG,MAAM,EACZ,CAAA;AA6DjE,eAAO,MAAM,sBAAsB,GACjC,OAAO,OAAO,EACd,SAAS,MAAM,EACf,WAAW,MAAM,EACjB,UAAS,8BAAmC,KAC3C,MAAM,EAgBR,CAAA"}
@@ -0,0 +1,73 @@
1
+ const TRANSLATION_KEY_SEGMENT_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
2
+ export const isPlainObject = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
3
+ const getValueKind = (value) => {
4
+ if (Array.isArray(value))
5
+ return "array";
6
+ if (isPlainObject(value))
7
+ return "object";
8
+ return typeof value;
9
+ };
10
+ export const getLeafShapes = (value, pathSegments = []) => {
11
+ if (!isPlainObject(value)) {
12
+ return [{ keyPath: pathSegments.join("."), kind: getValueKind(value) }];
13
+ }
14
+ return Object.entries(value).flatMap(([key, child]) => getLeafShapes(child, [...pathSegments, key]));
15
+ };
16
+ export const normalizeShapeEntries = (entries) => entries.map(({ keyPath, kind }) => `${keyPath}:${kind}`).sort();
17
+ const validateTranslationKey = (keyPath, context, errors) => {
18
+ const segments = keyPath.split(".");
19
+ const invalidSegments = segments.filter((segment) => !TRANSLATION_KEY_SEGMENT_PATTERN.test(segment));
20
+ if (invalidSegments.length === 0)
21
+ return;
22
+ errors.push(`${context} has non-kebab-case key segment(s): ${invalidSegments.join(", ")} in "${keyPath}"`);
23
+ };
24
+ const validateMeta = (sheet, context) => {
25
+ const errors = [];
26
+ if (!isPlainObject(sheet)) {
27
+ return [`${context} must contain a JSON object`];
28
+ }
29
+ const meta = sheet._meta;
30
+ if (!isPlainObject(meta)) {
31
+ return [`${context} must define root _meta as an object`];
32
+ }
33
+ if (!isPlainObject(meta.interpolation)) {
34
+ errors.push(`${context} _meta.interpolation must be an object`);
35
+ }
36
+ if (typeof meta.lastModified !== "string") {
37
+ errors.push(`${context} _meta.lastModified must be a string`);
38
+ }
39
+ const unexpectedMetaKeys = Object.keys(meta).filter((key) => key !== "interpolation" && key !== "lastModified");
40
+ for (const key of unexpectedMetaKeys) {
41
+ errors.push(`${context} _meta has unexpected key "${key}"`);
42
+ }
43
+ if (isPlainObject(meta.interpolation)) {
44
+ for (const [key, placeholders] of Object.entries(meta.interpolation)) {
45
+ validateTranslationKey(key, `${context} _meta.interpolation`, errors);
46
+ if (!Array.isArray(placeholders) || placeholders.some((placeholder) => typeof placeholder !== "string")) {
47
+ errors.push(`${context} _meta.interpolation.${key} must be an array of strings`);
48
+ }
49
+ }
50
+ }
51
+ return errors;
52
+ };
53
+ const hasStructuredNamespaceException = (namespace, exceptions) => {
54
+ if (!exceptions)
55
+ return false;
56
+ if ("has" in exceptions && typeof exceptions.has === "function")
57
+ return exceptions.has(namespace);
58
+ return exceptions.includes(namespace);
59
+ };
60
+ export const validateKeyConventions = (sheet, context, namespace, options = {}) => {
61
+ const errors = validateMeta(sheet, context);
62
+ if (!isPlainObject(sheet))
63
+ return errors;
64
+ if (hasStructuredNamespaceException(namespace, options.structuredNamespaceKeyConventionExceptions)) {
65
+ return errors;
66
+ }
67
+ for (const key of Object.keys(sheet)) {
68
+ if (key === "_meta")
69
+ continue;
70
+ validateTranslationKey(key, context, errors);
71
+ }
72
+ return errors;
73
+ };
@@ -0,0 +1,2 @@
1
+ export declare const formatI18nSheet: (sheet: unknown) => string;
2
+ //# sourceMappingURL=sheetOrdering.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sheetOrdering.d.ts","sourceRoot":"","sources":["../../src/verifier/sheetOrdering.ts"],"names":[],"mappings":"AAmMA,eAAO,MAAM,eAAe,GAAI,OAAO,OAAO,KAAG,MAA0C,CAAA"}
@@ -0,0 +1,161 @@
1
+ import { isPlainObject } from "./keyShape.js";
2
+ const CANONICAL_ROOT_KEY_ORDER = [
3
+ "title",
4
+ "page",
5
+ "card",
6
+ "section",
7
+ "sheet",
8
+ "panel",
9
+ "nav",
10
+ "navbar",
11
+ "menu-button",
12
+ "popover-button",
13
+ "tabs",
14
+ "labels",
15
+ "locale",
16
+ "theme",
17
+ "display-mode",
18
+ "role",
19
+ "institutions",
20
+ "digital-destinations",
21
+ "link-destinations",
22
+ "ticket-link-destinations",
23
+ "types",
24
+ "statuses",
25
+ "entities",
26
+ "query-controls",
27
+ "sort-and-filter-panel",
28
+ "saved-views",
29
+ "search",
30
+ "search-panel",
31
+ "prompts",
32
+ "form",
33
+ "current-sorts",
34
+ "sort-entry",
35
+ "current-filters",
36
+ "filter-entry",
37
+ "table",
38
+ "pagination",
39
+ "page-counter",
40
+ "page-input",
41
+ "items-per-page",
42
+ "primary-controls",
43
+ "actions",
44
+ "rules",
45
+ "import",
46
+ "messages",
47
+ "errors",
48
+ "aria-label",
49
+ ];
50
+ const CANONICAL_NESTED_KEY_ORDER = [
51
+ "interpolation",
52
+ "lastModified",
53
+ "display",
54
+ "grammar",
55
+ "singular",
56
+ "plural",
57
+ "articleForm",
58
+ "gender",
59
+ "elision",
60
+ "countable",
61
+ ];
62
+ const ROOT_KEY_RANKS = new Map(CANONICAL_ROOT_KEY_ORDER.map((key, index) => [key, index]));
63
+ const NESTED_KEY_RANKS = new Map(CANONICAL_NESTED_KEY_ORDER.map((key, index) => [key, index]));
64
+ const INDENT = " ";
65
+ const UNKNOWN_KEY_RANK = 10_000;
66
+ const compareStrings = (left, right) => {
67
+ if (left === right)
68
+ return 0;
69
+ return left < right ? -1 : 1;
70
+ };
71
+ const getRootKey = (key) => key.split(".")[0] ?? key;
72
+ const getRootKeyRank = (key) => ROOT_KEY_RANKS.get(getRootKey(key)) ?? UNKNOWN_KEY_RANK;
73
+ const getNestedKeyRank = (key) => NESTED_KEY_RANKS.get(key) ?? UNKNOWN_KEY_RANK;
74
+ const compareRootKeys = (left, right) => {
75
+ if (left === "_meta")
76
+ return -1;
77
+ if (right === "_meta")
78
+ return 1;
79
+ const rankDiff = getRootKeyRank(left) - getRootKeyRank(right);
80
+ if (rankDiff !== 0)
81
+ return rankDiff;
82
+ return compareStrings(left, right);
83
+ };
84
+ const compareNestedKeys = (left, right) => {
85
+ if (left === "_meta")
86
+ return -1;
87
+ if (right === "_meta")
88
+ return 1;
89
+ const rankDiff = getNestedKeyRank(left) - getNestedKeyRank(right);
90
+ if (rankDiff !== 0)
91
+ return rankDiff;
92
+ return compareStrings(left, right);
93
+ };
94
+ const isInterpolationObjectPath = (pathSegments) => pathSegments.length === 2 && pathSegments[0] === "_meta" && pathSegments[1] === "interpolation";
95
+ const isInterpolationTokenArrayPath = (pathSegments) => pathSegments.length === 3 && pathSegments[0] === "_meta" && pathSegments[1] === "interpolation";
96
+ const compareKeysForPath = (left, right, pathSegments) => pathSegments.length === 0 || isInterpolationObjectPath(pathSegments)
97
+ ? compareRootKeys(left, right)
98
+ : compareNestedKeys(left, right);
99
+ const getTopLevelFormattingGroup = (key) => {
100
+ if (key === "_meta")
101
+ return "_meta";
102
+ return getRootKey(key);
103
+ };
104
+ const formatPrimitive = (value) => JSON.stringify(value);
105
+ const formatArray = (value, pathSegments, depth) => {
106
+ const normalizedValue = isInterpolationTokenArrayPath(pathSegments) && value.every((item) => typeof item === "string")
107
+ ? [...value].sort(compareStrings)
108
+ : value;
109
+ if (normalizedValue.length === 0)
110
+ return "[]";
111
+ if (normalizedValue.every((item) => !isPlainObject(item) && !Array.isArray(item))) {
112
+ return `[${normalizedValue.map(formatPrimitive).join(", ")}]`;
113
+ }
114
+ const childIndent = INDENT.repeat(depth + 1);
115
+ const closingIndent = INDENT.repeat(depth);
116
+ const lines = ["["];
117
+ normalizedValue.forEach((item, index) => {
118
+ const valueText = formatValue(item, [...pathSegments, String(index)], depth + 1);
119
+ const valueLines = `${childIndent}${valueText}`.split("\n");
120
+ if (index < normalizedValue.length - 1) {
121
+ const lastIndex = valueLines.length - 1;
122
+ valueLines[lastIndex] = `${valueLines[lastIndex] ?? ""},`;
123
+ }
124
+ lines.push(...valueLines);
125
+ });
126
+ lines.push(`${closingIndent}]`);
127
+ return lines.join("\n");
128
+ };
129
+ const formatObject = (value, pathSegments, depth) => {
130
+ const keys = Object.keys(value).sort((left, right) => compareKeysForPath(left, right, pathSegments));
131
+ if (keys.length === 0)
132
+ return "{}";
133
+ const childIndent = INDENT.repeat(depth + 1);
134
+ const closingIndent = INDENT.repeat(depth);
135
+ const lines = ["{"];
136
+ keys.forEach((key, index) => {
137
+ const valueText = formatValue(value[key], [...pathSegments, key], depth + 1);
138
+ const propertyLines = `${childIndent}${JSON.stringify(key)}: ${valueText}`.split("\n");
139
+ const nextKey = keys[index + 1];
140
+ if (index < keys.length - 1) {
141
+ const lastIndex = propertyLines.length - 1;
142
+ propertyLines[lastIndex] = `${propertyLines[lastIndex] ?? ""},`;
143
+ }
144
+ lines.push(...propertyLines);
145
+ if (pathSegments.length === 0 &&
146
+ nextKey &&
147
+ getTopLevelFormattingGroup(key) !== getTopLevelFormattingGroup(nextKey)) {
148
+ lines.push("");
149
+ }
150
+ });
151
+ lines.push(`${closingIndent}}`);
152
+ return lines.join("\n");
153
+ };
154
+ const formatValue = (value, pathSegments, depth) => {
155
+ if (Array.isArray(value))
156
+ return formatArray(value, pathSegments, depth);
157
+ if (isPlainObject(value))
158
+ return formatObject(value, pathSegments, depth);
159
+ return formatPrimitive(value);
160
+ };
161
+ export const formatI18nSheet = (sheet) => `${formatValue(sheet, [], 0)}\n`;
@@ -0,0 +1,25 @@
1
+ import { type TDomainSeedSourceContract, type TDomainTranslationContract } from "./domainContracts.js";
2
+ export type TVerifyI18nSourceArgs<Locale extends string = string, Namespace extends string = string> = {
3
+ localesRoot: string;
4
+ availableNamespaces: readonly Namespace[];
5
+ declaredLocales?: readonly Locale[] | undefined;
6
+ defaultLocale: Locale;
7
+ initialLoadNamespaces?: readonly Namespace[] | undefined;
8
+ repoRoot?: string | undefined;
9
+ domainTranslationContracts?: readonly TDomainTranslationContract[] | undefined;
10
+ domainSeedSourceContracts?: readonly TDomainSeedSourceContract[] | undefined;
11
+ structuredNamespaceKeyConventionExceptions?: readonly Namespace[] | undefined;
12
+ };
13
+ export type TVerifyI18nSourceResult = {
14
+ sheetCount: number;
15
+ icuMessageCount: number;
16
+ localeCount: number;
17
+ registeredNamespaceCount: number;
18
+ keyTreeCount: number;
19
+ domainTranslationContractCount: number;
20
+ domainTranslationSourceCodeCount: number;
21
+ domainSeedSourceContractCount: number;
22
+ domainSeedSourceCodeCount: number;
23
+ };
24
+ export declare const verifyI18nSource: <Locale extends string, Namespace extends string>({ localesRoot, availableNamespaces, declaredLocales, defaultLocale, initialLoadNamespaces, repoRoot, domainTranslationContracts, domainSeedSourceContracts, structuredNamespaceKeyConventionExceptions, }: TVerifyI18nSourceArgs<Locale, Namespace>) => Promise<TVerifyI18nSourceResult>;
25
+ //# sourceMappingURL=verifyI18nSource.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verifyI18nSource.d.ts","sourceRoot":"","sources":["../../src/verifier/verifyI18nSource.ts"],"names":[],"mappings":"AAWA,OAAO,EAGL,KAAK,yBAAyB,EAC9B,KAAK,0BAA0B,EAChC,MAAM,sBAAsB,CAAA;AAE7B,MAAM,MAAM,qBAAqB,CAAC,MAAM,SAAS,MAAM,GAAG,MAAM,EAAE,SAAS,SAAS,MAAM,GAAG,MAAM,IAAI;IACrG,WAAW,EAAE,MAAM,CAAA;IACnB,mBAAmB,EAAE,SAAS,SAAS,EAAE,CAAA;IACzC,eAAe,CAAC,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,CAAA;IAC/C,aAAa,EAAE,MAAM,CAAA;IACrB,qBAAqB,CAAC,EAAE,SAAS,SAAS,EAAE,GAAG,SAAS,CAAA;IACxD,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IAC7B,0BAA0B,CAAC,EAAE,SAAS,0BAA0B,EAAE,GAAG,SAAS,CAAA;IAC9E,yBAAyB,CAAC,EAAE,SAAS,yBAAyB,EAAE,GAAG,SAAS,CAAA;IAC5E,0CAA0C,CAAC,EAAE,SAAS,SAAS,EAAE,GAAG,SAAS,CAAA;CAC9E,CAAA;AAED,MAAM,MAAM,uBAAuB,GAAG;IACpC,UAAU,EAAE,MAAM,CAAA;IAClB,eAAe,EAAE,MAAM,CAAA;IACvB,WAAW,EAAE,MAAM,CAAA;IACnB,wBAAwB,EAAE,MAAM,CAAA;IAChC,YAAY,EAAE,MAAM,CAAA;IACpB,8BAA8B,EAAE,MAAM,CAAA;IACtC,gCAAgC,EAAE,MAAM,CAAA;IACxC,6BAA6B,EAAE,MAAM,CAAA;IACrC,yBAAyB,EAAE,MAAM,CAAA;CAClC,CAAA;AAUD,eAAO,MAAM,gBAAgB,GAAU,MAAM,SAAS,MAAM,EAAE,SAAS,SAAS,MAAM,EAAE,2MAUrF,qBAAqB,CAAC,MAAM,EAAE,SAAS,CAAC,KAAG,OAAO,CAAC,uBAAuB,CAsO5E,CAAA"}
@@ -0,0 +1,162 @@
1
+ import path from "node:path";
2
+ import { getDeclaredInterpolationTokens, getTranslationStringEntries, parseICUContract, pushContractDiffs, } from "./icuContracts.js";
3
+ import { getLeafShapes, normalizeShapeEntries, validateKeyConventions } from "./keyShape.js";
4
+ import { listDirNames, listJSONFiles, readJSONFileOrThrow } from "./fileSystem.js";
5
+ import { formatI18nSheet } from "./sheetOrdering.js";
6
+ import { verifyDomainSeedSourceContracts, verifyDomainTranslationContracts, } from "./domainContracts.js";
7
+ const formatList = (items) => items.map((item) => ` - ${item}`).join("\n");
8
+ const assertNoItems = (label, items) => {
9
+ if (items.length === 0)
10
+ return;
11
+ throw new Error(`[i18n verify] ${label}\n${formatList(items)}`);
12
+ };
13
+ export const verifyI18nSource = async ({ localesRoot, availableNamespaces, declaredLocales, defaultLocale, initialLoadNamespaces = [], repoRoot, domainTranslationContracts = [], domainSeedSourceContracts = [], structuredNamespaceKeyConventionExceptions = [], }) => {
14
+ if (availableNamespaces.length === 0) {
15
+ throw new Error("[i18n verify] availableNamespaces must contain at least one namespace");
16
+ }
17
+ const locales = declaredLocales ? [...declaredLocales] : (await listDirNames(localesRoot));
18
+ if (locales.length === 0) {
19
+ throw new Error(`[i18n verify] No locale directories found at ${localesRoot}`);
20
+ }
21
+ if (!locales.includes(defaultLocale)) {
22
+ throw new Error(`[i18n verify] Reference locale "${defaultLocale}" was not found at ${localesRoot}`);
23
+ }
24
+ const registeredNamespaceSet = new Set(availableNamespaces);
25
+ const initialNamespacesOutsideRegistry = initialLoadNamespaces.filter((namespace) => !registeredNamespaceSet.has(namespace));
26
+ assertNoItems("Initial load namespaces are missing from the available namespace registry:", initialNamespacesOutsideRegistry);
27
+ const discoveredLocaleDirectories = await listDirNames(localesRoot);
28
+ assertNoItems("Declared locales are missing locale directories:", locales.filter((locale) => !discoveredLocaleDirectories.includes(locale)));
29
+ assertNoItems("Locale directories are missing from declared locales:", discoveredLocaleDirectories.filter((locale) => !locales.includes(locale)));
30
+ let sheetCount = 0;
31
+ const namespacesByLocale = new Map();
32
+ const sheetsByLocale = new Map();
33
+ const sheetOrderingErrors = [];
34
+ const namespaceUnion = new Set();
35
+ for (const locale of locales) {
36
+ const localeDir = path.join(localesRoot, locale);
37
+ const files = await listJSONFiles(localeDir);
38
+ const namespaces = files.map((file) => file.replace(/\.json$/, ""));
39
+ if (files.length === 0) {
40
+ throw new Error(`[i18n verify] No translation sheets found for locale=${locale}`);
41
+ }
42
+ namespacesByLocale.set(locale, namespaces);
43
+ sheetsByLocale.set(locale, new Map());
44
+ namespaces.forEach((namespace) => namespaceUnion.add(namespace));
45
+ for (const file of files) {
46
+ const namespace = file.replace(/\.json$/, "");
47
+ const filePath = path.join(localeDir, file);
48
+ const { json: sheet, raw } = await readJSONFileOrThrow(filePath, "[i18n verify]");
49
+ const canonicalSheet = formatI18nSheet(sheet);
50
+ if (raw !== canonicalSheet) {
51
+ sheetOrderingErrors.push(`${locale}/${file}`);
52
+ }
53
+ sheetsByLocale.get(locale)?.set(namespace, sheet);
54
+ sheetCount += 1;
55
+ }
56
+ }
57
+ const sourceNamespaces = [...namespaceUnion].sort();
58
+ for (const locale of locales) {
59
+ const localeNamespaceSet = new Set(namespacesByLocale.get(locale));
60
+ const missingNamespaces = sourceNamespaces.filter((namespace) => !localeNamespaceSet.has(namespace));
61
+ assertNoItems(`Locale ${locale} is missing translation sheets present in another locale:`, missingNamespaces);
62
+ }
63
+ assertNoItems("Registered namespaces are missing source translation sheets:", availableNamespaces.filter((namespace) => !namespaceUnion.has(namespace)));
64
+ assertNoItems("Source translation sheets are missing from the namespace registry:", sourceNamespaces.filter((namespace) => !registeredNamespaceSet.has(namespace)));
65
+ assertNoItems("Translation sheets are not canonically ordered. Run the app facade formatter:", sheetOrderingErrors);
66
+ const keyShapeErrors = [];
67
+ for (const namespace of sourceNamespaces) {
68
+ const referenceSheet = sheetsByLocale.get(defaultLocale)?.get(namespace);
69
+ const referenceShapes = normalizeShapeEntries(getLeafShapes(referenceSheet));
70
+ const referenceShapeSet = new Set(referenceShapes);
71
+ for (const locale of locales) {
72
+ const sheet = sheetsByLocale.get(locale)?.get(namespace);
73
+ const context = `${locale}/${namespace}.json`;
74
+ keyShapeErrors.push(...validateKeyConventions(sheet, context, namespace, {
75
+ structuredNamespaceKeyConventionExceptions,
76
+ }));
77
+ if (locale === defaultLocale)
78
+ continue;
79
+ const localeShapes = normalizeShapeEntries(getLeafShapes(sheet));
80
+ const localeShapeSet = new Set(localeShapes);
81
+ const missingShapes = referenceShapes.filter((shape) => !localeShapeSet.has(shape));
82
+ const extraShapes = localeShapes.filter((shape) => !referenceShapeSet.has(shape));
83
+ for (const shape of missingShapes) {
84
+ keyShapeErrors.push(`${context} is missing key shape from ${defaultLocale}/${namespace}.json: ${shape}`);
85
+ }
86
+ for (const shape of extraShapes) {
87
+ keyShapeErrors.push(`${context} has extra key shape not present in ${defaultLocale}/${namespace}.json: ${shape}`);
88
+ }
89
+ }
90
+ }
91
+ assertNoItems("Translation key shape violations:", keyShapeErrors);
92
+ const domainTranslationContractResult = repoRoot && domainTranslationContracts.length > 0
93
+ ? await verifyDomainTranslationContracts({
94
+ contracts: domainTranslationContracts,
95
+ locales,
96
+ repoRoot,
97
+ sheetsByLocale,
98
+ })
99
+ : {
100
+ checkedCodeCount: 0,
101
+ checkedContractCount: 0,
102
+ errors: [],
103
+ };
104
+ assertNoItems("Domain-to-i18n contract violations:", domainTranslationContractResult.errors);
105
+ const domainSeedSourceContractResult = repoRoot && domainSeedSourceContracts.length > 0
106
+ ? await verifyDomainSeedSourceContracts({
107
+ contracts: domainSeedSourceContracts,
108
+ repoRoot,
109
+ })
110
+ : {
111
+ checkedContractCount: 0,
112
+ checkedSourceCodeCount: 0,
113
+ errors: [],
114
+ };
115
+ assertNoItems("Domain seed source contract violations:", domainSeedSourceContractResult.errors);
116
+ let icuMessageCount = 0;
117
+ const icuContractErrors = [];
118
+ for (const namespace of sourceNamespaces) {
119
+ const referenceSheet = sheetsByLocale.get(defaultLocale)?.get(namespace);
120
+ const referenceContractsByKeyPath = new Map();
121
+ for (const { keyPath, value } of getTranslationStringEntries(referenceSheet)) {
122
+ const referenceContext = `${defaultLocale}/${namespace}.json ${keyPath}`;
123
+ const referenceContract = parseICUContract(value, defaultLocale, referenceContext, icuContractErrors);
124
+ if (referenceContract) {
125
+ referenceContractsByKeyPath.set(keyPath, referenceContract);
126
+ icuMessageCount += 1;
127
+ }
128
+ }
129
+ for (const locale of locales) {
130
+ const sheet = sheetsByLocale.get(locale)?.get(namespace);
131
+ for (const { keyPath, value } of getTranslationStringEntries(sheet)) {
132
+ const context = `${locale}/${namespace}.json ${keyPath}`;
133
+ const contract = locale === defaultLocale
134
+ ? referenceContractsByKeyPath.get(keyPath)
135
+ : parseICUContract(value, locale, context, icuContractErrors);
136
+ if (!contract)
137
+ continue;
138
+ if (locale !== defaultLocale)
139
+ icuMessageCount += 1;
140
+ pushContractDiffs(icuContractErrors, `${context} _meta.interpolation`, context, "interpolation token(s)", contract.interpolationTokens, getDeclaredInterpolationTokens(sheet, keyPath));
141
+ if (locale === defaultLocale)
142
+ continue;
143
+ const referenceContract = referenceContractsByKeyPath.get(keyPath);
144
+ if (!referenceContract)
145
+ continue;
146
+ pushContractDiffs(icuContractErrors, context, `${defaultLocale}/${namespace}.json ${keyPath}`, "contract signature(s)", referenceContract.contractSignatures, contract.contractSignatures);
147
+ }
148
+ }
149
+ }
150
+ assertNoItems("ICU message contract violations:", icuContractErrors);
151
+ return {
152
+ domainSeedSourceCodeCount: domainSeedSourceContractResult.checkedSourceCodeCount,
153
+ domainSeedSourceContractCount: domainSeedSourceContractResult.checkedContractCount,
154
+ domainTranslationContractCount: domainTranslationContractResult.checkedContractCount,
155
+ domainTranslationSourceCodeCount: domainTranslationContractResult.checkedCodeCount,
156
+ icuMessageCount,
157
+ keyTreeCount: sourceNamespaces.length,
158
+ localeCount: locales.length,
159
+ registeredNamespaceCount: availableNamespaces.length,
160
+ sheetCount,
161
+ };
162
+ };