@chipmobilesdk/rn-i18n 0.1.1 → 0.1.3
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/CHANGELOG.md +70 -45
- package/README.md +187 -187
- package/package.json +46 -49
- package/skills/sdk-rn-i18n/SKILL.md +1 -2
- package/src/config/types.ts +144 -152
- package/src/config/validateConfig.ts +156 -174
- package/src/format/currency.ts +27 -27
- package/src/format/date.ts +26 -26
- package/src/format/invalid.ts +13 -16
- package/src/format/number.ts +23 -25
- package/src/format/units.ts +141 -151
- package/src/i18n.ts +196 -222
- package/src/index.ts +50 -50
- package/src/locale/activeLocale.ts +46 -35
- package/src/locale/deviceLocale.ts +50 -54
- package/src/locale/persistence.ts +31 -37
- package/src/react/I18nProvider.tsx +31 -35
- package/src/react/hooks.ts +57 -61
- package/src/tooling/cli.ts +76 -78
- package/src/tooling/generateKeys.ts +62 -68
- package/src/tooling/index.ts +27 -32
- package/src/tooling/report.ts +109 -117
- package/src/tooling/runner.ts +111 -126
- package/src/tooling/scan.ts +193 -210
- package/src/tooling/sync.ts +193 -198
- package/src/tooling/types.ts +119 -119
- package/src/translation/interpolate.ts +23 -23
- package/src/translation/plurals.ts +62 -69
- package/src/translation/resolver.ts +140 -146
- package/src/translation/runtimeKeys.ts +27 -27
package/src/config/types.ts
CHANGED
|
@@ -1,152 +1,144 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Public configuration and contract types for @chipmobilesdk/rn-i18n.
|
|
3
|
-
*
|
|
4
|
-
* The package has zero runtime dependencies: formatting uses the Intl APIs
|
|
5
|
-
* built into Hermes (Intl.NumberFormat, Intl.DateTimeFormat), while plural
|
|
6
|
-
* rules and unit conversion are package-owned because Intl.PluralRules and
|
|
7
|
-
* Intl unit-style formatting are not reliably available in Hermes.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export type LocaleDirection = 'ltr' | 'rtl';
|
|
11
|
-
|
|
12
|
-
export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
|
|
13
|
-
|
|
14
|
-
export type PluralRule = (count: number) => PluralCategory;
|
|
15
|
-
|
|
16
|
-
export type UnitDimension = 'length' | 'mass' | 'volume' | 'temperature';
|
|
17
|
-
|
|
18
|
-
export type UnitId =
|
|
19
|
-
| 'millimeter'
|
|
20
|
-
| 'centimeter'
|
|
21
|
-
| 'meter'
|
|
22
|
-
| 'kilometer'
|
|
23
|
-
| 'inch'
|
|
24
|
-
| 'foot'
|
|
25
|
-
| 'mile'
|
|
26
|
-
| 'gram'
|
|
27
|
-
| 'kilogram'
|
|
28
|
-
| 'ounce'
|
|
29
|
-
| 'pound'
|
|
30
|
-
| 'milliliter'
|
|
31
|
-
| 'liter'
|
|
32
|
-
| 'gallon'
|
|
33
|
-
| 'celsius'
|
|
34
|
-
| 'fahrenheit'
|
|
35
|
-
| 'kelvin';
|
|
36
|
-
|
|
37
|
-
export interface LocaleConfig {
|
|
38
|
-
/** BCP-47 language-region code, e.g. `vi-VN`, `en-US`, `ja-JP`. */
|
|
39
|
-
code: string;
|
|
40
|
-
displayName: string;
|
|
41
|
-
direction: LocaleDirection;
|
|
42
|
-
/**
|
|
43
|
-
* Ordered locale codes consulted when a key is missing from this locale.
|
|
44
|
-
* Every entry must be another supported locale; cycles, duplicates, and
|
|
45
|
-
* self-references are rejected during setup validation.
|
|
46
|
-
*/
|
|
47
|
-
fallbackChain: string[];
|
|
48
|
-
/** ISO-4217 currency code used when no explicit currency is requested. */
|
|
49
|
-
defaultCurrency: string;
|
|
50
|
-
defaultUnit: UnitId;
|
|
51
|
-
/**
|
|
52
|
-
* Default Intl.DateTimeFormat options for this locale. Restricted to the
|
|
53
|
-
* component options supported by Hermes on both Android and iOS.
|
|
54
|
-
*/
|
|
55
|
-
defaultDateFormat: Intl.DateTimeFormatOptions;
|
|
56
|
-
/** Default Intl.NumberFormat options for this locale. */
|
|
57
|
-
defaultNumberFormat: Intl.NumberFormatOptions;
|
|
58
|
-
/** Optional plural rule override; defaults to the built-in language rule. */
|
|
59
|
-
pluralRule?: PluralRule;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export type TranslationNode = string | { [key: string]: TranslationNode };
|
|
63
|
-
|
|
64
|
-
export type TranslationNamespace = { [key: string]: TranslationNode };
|
|
65
|
-
|
|
66
|
-
/** App-owned translation resources keyed by locale code. */
|
|
67
|
-
export type TranslationResources = { [localeCode: string]: TranslationNamespace };
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* A translation key intentionally resolved at runtime and therefore not
|
|
71
|
-
* discoverable by static source scanning. Declared entries are protected
|
|
72
|
-
* from removal during locale JSON synchronization.
|
|
73
|
-
*/
|
|
74
|
-
export interface RuntimeTranslationEntry {
|
|
75
|
-
key: string;
|
|
76
|
-
/** Template text used when no locale resource provides a value. */
|
|
77
|
-
defaultText: string;
|
|
78
|
-
/**
|
|
79
|
-
* Synchronization policy when the key is missing from a locale file:
|
|
80
|
-
* `add` writes the default text, `report` only lists it. Default: `report`.
|
|
81
|
-
*/
|
|
82
|
-
policy?: 'add' | 'report';
|
|
83
|
-
/** Owning feature note surfaced in the extraction report. */
|
|
84
|
-
description?: string;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export type MissingTranslationPolicy =
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
export
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
readonly issues: readonly string[];
|
|
146
|
-
|
|
147
|
-
constructor(issues: readonly string[]) {
|
|
148
|
-
super(`Invalid i18n configuration:\n- ${issues.join('\n- ')}`);
|
|
149
|
-
this.name = 'I18nConfigError';
|
|
150
|
-
this.issues = issues;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Public configuration and contract types for @chipmobilesdk/rn-i18n.
|
|
3
|
+
*
|
|
4
|
+
* The package has zero runtime dependencies: formatting uses the Intl APIs
|
|
5
|
+
* built into Hermes (Intl.NumberFormat, Intl.DateTimeFormat), while plural
|
|
6
|
+
* rules and unit conversion are package-owned because Intl.PluralRules and
|
|
7
|
+
* Intl unit-style formatting are not reliably available in Hermes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type LocaleDirection = 'ltr' | 'rtl';
|
|
11
|
+
|
|
12
|
+
export type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
|
|
13
|
+
|
|
14
|
+
export type PluralRule = (count: number) => PluralCategory;
|
|
15
|
+
|
|
16
|
+
export type UnitDimension = 'length' | 'mass' | 'volume' | 'temperature';
|
|
17
|
+
|
|
18
|
+
export type UnitId =
|
|
19
|
+
| 'millimeter'
|
|
20
|
+
| 'centimeter'
|
|
21
|
+
| 'meter'
|
|
22
|
+
| 'kilometer'
|
|
23
|
+
| 'inch'
|
|
24
|
+
| 'foot'
|
|
25
|
+
| 'mile'
|
|
26
|
+
| 'gram'
|
|
27
|
+
| 'kilogram'
|
|
28
|
+
| 'ounce'
|
|
29
|
+
| 'pound'
|
|
30
|
+
| 'milliliter'
|
|
31
|
+
| 'liter'
|
|
32
|
+
| 'gallon'
|
|
33
|
+
| 'celsius'
|
|
34
|
+
| 'fahrenheit'
|
|
35
|
+
| 'kelvin';
|
|
36
|
+
|
|
37
|
+
export interface LocaleConfig {
|
|
38
|
+
/** BCP-47 language-region code, e.g. `vi-VN`, `en-US`, `ja-JP`. */
|
|
39
|
+
code: string;
|
|
40
|
+
displayName: string;
|
|
41
|
+
direction: LocaleDirection;
|
|
42
|
+
/**
|
|
43
|
+
* Ordered locale codes consulted when a key is missing from this locale.
|
|
44
|
+
* Every entry must be another supported locale; cycles, duplicates, and
|
|
45
|
+
* self-references are rejected during setup validation.
|
|
46
|
+
*/
|
|
47
|
+
fallbackChain: string[];
|
|
48
|
+
/** ISO-4217 currency code used when no explicit currency is requested. */
|
|
49
|
+
defaultCurrency: string;
|
|
50
|
+
defaultUnit: UnitId;
|
|
51
|
+
/**
|
|
52
|
+
* Default Intl.DateTimeFormat options for this locale. Restricted to the
|
|
53
|
+
* component options supported by Hermes on both Android and iOS.
|
|
54
|
+
*/
|
|
55
|
+
defaultDateFormat: Intl.DateTimeFormatOptions;
|
|
56
|
+
/** Default Intl.NumberFormat options for this locale. */
|
|
57
|
+
defaultNumberFormat: Intl.NumberFormatOptions;
|
|
58
|
+
/** Optional plural rule override; defaults to the built-in language rule. */
|
|
59
|
+
pluralRule?: PluralRule;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type TranslationNode = string | { [key: string]: TranslationNode };
|
|
63
|
+
|
|
64
|
+
export type TranslationNamespace = { [key: string]: TranslationNode };
|
|
65
|
+
|
|
66
|
+
/** App-owned translation resources keyed by locale code. */
|
|
67
|
+
export type TranslationResources = { [localeCode: string]: TranslationNamespace };
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A translation key intentionally resolved at runtime and therefore not
|
|
71
|
+
* discoverable by static source scanning. Declared entries are protected
|
|
72
|
+
* from removal during locale JSON synchronization.
|
|
73
|
+
*/
|
|
74
|
+
export interface RuntimeTranslationEntry {
|
|
75
|
+
key: string;
|
|
76
|
+
/** Template text used when no locale resource provides a value. */
|
|
77
|
+
defaultText: string;
|
|
78
|
+
/**
|
|
79
|
+
* Synchronization policy when the key is missing from a locale file:
|
|
80
|
+
* `add` writes the default text, `report` only lists it. Default: `report`.
|
|
81
|
+
*/
|
|
82
|
+
policy?: 'add' | 'report';
|
|
83
|
+
/** Owning feature note surfaced in the extraction report. */
|
|
84
|
+
description?: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type MissingTranslationPolicy = { mode: 'key' } | { mode: 'placeholder'; text: string };
|
|
88
|
+
|
|
89
|
+
export type InvalidFormatPolicy = { mode: 'placeholder'; text: string } | { mode: 'error' };
|
|
90
|
+
|
|
91
|
+
/** App-injected persistence contract. The package never bundles a backend. */
|
|
92
|
+
export interface LocaleStorageAdapter {
|
|
93
|
+
getItem(key: string): Promise<string | null>;
|
|
94
|
+
setItem(key: string, value: string): Promise<void>;
|
|
95
|
+
removeItem(key: string): Promise<void>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Returns the device locale, or undefined when detection is unavailable. */
|
|
99
|
+
export type DeviceLocaleProvider = () => string | undefined;
|
|
100
|
+
|
|
101
|
+
export interface I18nConfig {
|
|
102
|
+
locales: LocaleConfig[];
|
|
103
|
+
/** Recovery locale when persistence or device detection cannot decide. */
|
|
104
|
+
primaryLocale: string;
|
|
105
|
+
resources: TranslationResources;
|
|
106
|
+
runtimeEntries?: RuntimeTranslationEntry[];
|
|
107
|
+
/** Persist the selected locale across launches. Default: true. */
|
|
108
|
+
persistLocale?: boolean;
|
|
109
|
+
/** Required when `persistLocale` is not disabled. */
|
|
110
|
+
storage?: LocaleStorageAdapter;
|
|
111
|
+
deviceLocale?: DeviceLocaleProvider;
|
|
112
|
+
/** Default: `{ mode: 'key' }`. */
|
|
113
|
+
missingTranslation?: MissingTranslationPolicy;
|
|
114
|
+
/** Default: `{ mode: 'placeholder', text: '—' }`. */
|
|
115
|
+
invalidFormat?: InvalidFormatPolicy;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type LocaleSelectionSource =
|
|
119
|
+
'persisted' | 'device' | 'languageMatch' | 'primaryFallback' | 'runtimeSwitch';
|
|
120
|
+
|
|
121
|
+
export interface ActiveLocalePreference {
|
|
122
|
+
code: string;
|
|
123
|
+
source: LocaleSelectionSource;
|
|
124
|
+
/** True when an unsupported persisted locale was discarded. */
|
|
125
|
+
recovered: boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type TranslateVars = Record<string, string | number>;
|
|
129
|
+
|
|
130
|
+
export interface TranslateOptions {
|
|
131
|
+
/** Selects the plural variant and is exposed as the `count` variable. */
|
|
132
|
+
count?: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Configuration error listing every detected violation. */
|
|
136
|
+
export class I18nConfigError extends Error {
|
|
137
|
+
readonly issues: readonly string[];
|
|
138
|
+
|
|
139
|
+
constructor(issues: readonly string[]) {
|
|
140
|
+
super(`Invalid i18n configuration:\n- ${issues.join('\n- ')}`);
|
|
141
|
+
this.name = 'I18nConfigError';
|
|
142
|
+
this.issues = issues;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -1,174 +1,156 @@
|
|
|
1
|
-
import type { I18nConfig, LocaleConfig } from './types';
|
|
2
|
-
import { I18nConfigError } from './types';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* Validates an I18nConfig before any localized content is served (FR-003,
|
|
6
|
-
* FR-046). Collects every violation and throws a single I18nConfigError so
|
|
7
|
-
* apps see the complete picture instead of fixing issues one at a time.
|
|
8
|
-
*/
|
|
9
|
-
export function validateConfig(
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (
|
|
84
|
-
issues.push(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
(
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
states.set(code, 'visiting');
|
|
158
|
-
for (const next of chains.get(code) ?? []) {
|
|
159
|
-
if (visit(next, [...path, code])) {
|
|
160
|
-
states.set(code, 'done');
|
|
161
|
-
return true;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
states.set(code, 'done');
|
|
165
|
-
return false;
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
for (const locale of locales) {
|
|
169
|
-
if (visit(locale.code, [])) {
|
|
170
|
-
// Report the first cycle only; one clear cycle message is actionable.
|
|
171
|
-
return;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
1
|
+
import type { I18nConfig, LocaleConfig } from './types';
|
|
2
|
+
import { I18nConfigError } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validates an I18nConfig before any localized content is served (FR-003,
|
|
6
|
+
* FR-046). Collects every violation and throws a single I18nConfigError so
|
|
7
|
+
* apps see the complete picture instead of fixing issues one at a time.
|
|
8
|
+
*/
|
|
9
|
+
export function validateConfig(config: I18nConfig, knownUnits: ReadonlySet<string>): void {
|
|
10
|
+
const issues: string[] = [];
|
|
11
|
+
const locales = Array.isArray(config.locales) ? config.locales : [];
|
|
12
|
+
|
|
13
|
+
if (locales.length === 0) {
|
|
14
|
+
issues.push('At least one supported locale must be declared.');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const codes = new Set<string>();
|
|
18
|
+
for (const locale of locales) {
|
|
19
|
+
if (codes.has(locale.code)) {
|
|
20
|
+
issues.push(`Duplicate locale code "${locale.code}".`);
|
|
21
|
+
}
|
|
22
|
+
codes.add(locale.code);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const locale of locales) {
|
|
26
|
+
validateLocale(locale, codes, knownUnits, issues);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
detectFallbackCycles(locales, codes, issues);
|
|
30
|
+
|
|
31
|
+
if (locales.length > 0 && !codes.has(config.primaryLocale)) {
|
|
32
|
+
issues.push(`Primary locale "${config.primaryLocale}" is not a supported locale.`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
for (const code of codes) {
|
|
36
|
+
const resource = config.resources?.[code];
|
|
37
|
+
if (resource === undefined || resource === null) {
|
|
38
|
+
issues.push(`Locale "${code}" has no translation resource.`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (config.persistLocale !== false && !config.storage) {
|
|
43
|
+
issues.push(
|
|
44
|
+
'Locale persistence is enabled but no storage adapter was injected. ' +
|
|
45
|
+
'Provide `storage` or set `persistLocale: false`.',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const runtimeKeys = new Set<string>();
|
|
50
|
+
for (const entry of config.runtimeEntries ?? []) {
|
|
51
|
+
if (!entry.key) {
|
|
52
|
+
issues.push('Runtime-only entries must declare a non-empty key.');
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (runtimeKeys.has(entry.key)) {
|
|
56
|
+
issues.push(`Duplicate runtime-only entry "${entry.key}".`);
|
|
57
|
+
}
|
|
58
|
+
runtimeKeys.add(entry.key);
|
|
59
|
+
if (typeof entry.defaultText !== 'string') {
|
|
60
|
+
issues.push(`Runtime-only entry "${entry.key}" must declare default text.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (issues.length > 0) {
|
|
65
|
+
throw new I18nConfigError(issues);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function validateLocale(
|
|
70
|
+
locale: LocaleConfig,
|
|
71
|
+
codes: ReadonlySet<string>,
|
|
72
|
+
knownUnits: ReadonlySet<string>,
|
|
73
|
+
issues: string[],
|
|
74
|
+
): void {
|
|
75
|
+
const code = locale.code;
|
|
76
|
+
if (!code) {
|
|
77
|
+
issues.push('Every locale must declare a non-empty code.');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (!locale.displayName) {
|
|
81
|
+
issues.push(`Locale "${code}" must declare a display name.`);
|
|
82
|
+
}
|
|
83
|
+
if (locale.direction !== 'ltr' && locale.direction !== 'rtl') {
|
|
84
|
+
issues.push(`Locale "${code}" must declare direction "ltr" or "rtl".`);
|
|
85
|
+
}
|
|
86
|
+
if (!locale.defaultCurrency) {
|
|
87
|
+
issues.push(`Locale "${code}" must declare a default currency.`);
|
|
88
|
+
}
|
|
89
|
+
if (!knownUnits.has(locale.defaultUnit)) {
|
|
90
|
+
issues.push(`Locale "${code}" declares unknown default unit "${locale.defaultUnit}".`);
|
|
91
|
+
}
|
|
92
|
+
if (typeof locale.defaultDateFormat !== 'object' || !locale.defaultDateFormat) {
|
|
93
|
+
issues.push(`Locale "${code}" must declare a default date format.`);
|
|
94
|
+
}
|
|
95
|
+
if (typeof locale.defaultNumberFormat !== 'object' || !locale.defaultNumberFormat) {
|
|
96
|
+
issues.push(`Locale "${code}" must declare a default number format.`);
|
|
97
|
+
}
|
|
98
|
+
if (!Array.isArray(locale.fallbackChain)) {
|
|
99
|
+
issues.push(`Locale "${code}" must declare a fallback chain array.`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const seen = new Set<string>();
|
|
103
|
+
for (const entry of locale.fallbackChain) {
|
|
104
|
+
if (entry === code) {
|
|
105
|
+
issues.push(`Locale "${code}" must not include itself in its fallback chain.`);
|
|
106
|
+
}
|
|
107
|
+
if (seen.has(entry)) {
|
|
108
|
+
issues.push(`Locale "${code}" lists duplicate fallback-chain entry "${entry}".`);
|
|
109
|
+
}
|
|
110
|
+
seen.add(entry);
|
|
111
|
+
if (!codes.has(entry)) {
|
|
112
|
+
issues.push(`Locale "${code}" references unsupported fallback locale "${entry}".`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function detectFallbackCycles(
|
|
118
|
+
locales: readonly LocaleConfig[],
|
|
119
|
+
codes: ReadonlySet<string>,
|
|
120
|
+
issues: string[],
|
|
121
|
+
): void {
|
|
122
|
+
const chains = new Map(
|
|
123
|
+
locales.map(locale => [
|
|
124
|
+
locale.code,
|
|
125
|
+
(locale.fallbackChain ?? []).filter(entry => codes.has(entry)),
|
|
126
|
+
]),
|
|
127
|
+
);
|
|
128
|
+
const states = new Map<string, 'visiting' | 'done'>();
|
|
129
|
+
|
|
130
|
+
const visit = (code: string, path: string[]): boolean => {
|
|
131
|
+
const state = states.get(code);
|
|
132
|
+
if (state === 'done') {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
if (state === 'visiting') {
|
|
136
|
+
issues.push(`Fallback cycle detected: ${[...path, code].join(' -> ')}.`);
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
states.set(code, 'visiting');
|
|
140
|
+
for (const next of chains.get(code) ?? []) {
|
|
141
|
+
if (visit(next, [...path, code])) {
|
|
142
|
+
states.set(code, 'done');
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
states.set(code, 'done');
|
|
147
|
+
return false;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
for (const locale of locales) {
|
|
151
|
+
if (visit(locale.code, [])) {
|
|
152
|
+
// Report the first cycle only; one clear cycle message is actionable.
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|