@vouchington/localization 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 +21 -0
- package/README.md +9 -0
- package/dist/bounds.d.mts +9 -0
- package/dist/bounds.mjs +9 -0
- package/dist/catalog.d.mts +3 -0
- package/dist/catalog.mjs +34 -0
- package/dist/compare.d.mts +1 -0
- package/dist/compare.mjs +3 -0
- package/dist/consumers.d.mts +6 -0
- package/dist/consumers.mjs +26 -0
- package/dist/descriptors.d.mts +6 -0
- package/dist/descriptors.mjs +81 -0
- package/dist/index.d.mts +14 -0
- package/dist/index.mjs +13 -0
- package/dist/locales.d.mts +5 -0
- package/dist/locales.mjs +38 -0
- package/dist/placeholders.d.mts +3 -0
- package/dist/placeholders.mjs +13 -0
- package/dist/request.d.mts +4 -0
- package/dist/request.mjs +38 -0
- package/dist/selection.d.mts +4 -0
- package/dist/selection.mjs +53 -0
- package/dist/selectors.d.mts +6 -0
- package/dist/selectors.mjs +52 -0
- package/dist/serialize.d.mts +1 -0
- package/dist/serialize.mjs +29 -0
- package/dist/types.d.mts +70 -0
- package/dist/types.mjs +6 -0
- package/dist/wire.d.mts +5 -0
- package/dist/wire.mjs +33 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jonathan Ong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN ANY ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# @vouchington/localization
|
|
2
|
+
|
|
3
|
+
Browser-safe localization contracts for Node 24+ and browsers. The package owns locale
|
|
4
|
+
normalization (`en` aliases `en-US`), exact and terminal-prefix selector validation, ordered
|
|
5
|
+
fallback, consumer membership, and deterministic catalog serialization. It does not load catalogs,
|
|
6
|
+
open SQLite, or interpolate message text.
|
|
7
|
+
|
|
8
|
+
`@vouchington/localization-compiler` compiles namespace-sharded JSON into an immutable SQLite
|
|
9
|
+
artifact and resolves the same selectors locally.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const DEFAULT_LOCALIZATION_BOUNDS: {
|
|
2
|
+
readonly maxLocales: 8;
|
|
3
|
+
readonly maxSelectors: 32;
|
|
4
|
+
readonly maxMessages: 2000;
|
|
5
|
+
readonly maxBytes: number;
|
|
6
|
+
};
|
|
7
|
+
export declare class LocalizationBoundError extends RangeError {
|
|
8
|
+
readonly code = "LOCALIZATION_BOUNDS";
|
|
9
|
+
}
|
package/dist/bounds.mjs
ADDED
package/dist/catalog.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { uniqueConsumers } from './consumers.mjs';
|
|
2
|
+
import { parseDescriptor } from './descriptors.mjs';
|
|
3
|
+
import { canonicalJson } from './serialize.mjs';
|
|
4
|
+
import { isMessageId } from './selectors.mjs';
|
|
5
|
+
export function serializeCatalogMessages(messages) {
|
|
6
|
+
return canonicalJson([...messages]
|
|
7
|
+
.toSorted((left, right) => (left.id < right.id ? -1 : 1))
|
|
8
|
+
.map((message) => ({
|
|
9
|
+
consumers: uniqueConsumers(message.consumers),
|
|
10
|
+
descriptor: message.descriptor,
|
|
11
|
+
id: message.id,
|
|
12
|
+
translations: message.translations,
|
|
13
|
+
})));
|
|
14
|
+
}
|
|
15
|
+
export function catalogMessageFromRecord(value) {
|
|
16
|
+
if (!isPlainObject(value) || typeof value.id !== 'string' || !isMessageId(value.id)) {
|
|
17
|
+
throw new TypeError('Catalog message is missing a valid id');
|
|
18
|
+
}
|
|
19
|
+
if (!Array.isArray(value.consumers) || value.consumers.length === 0) {
|
|
20
|
+
throw new TypeError(`Catalog message "${value.id}" must declare consumers`);
|
|
21
|
+
}
|
|
22
|
+
if (!isPlainObject(value.translations) || Object.keys(value.translations).length === 0) {
|
|
23
|
+
throw new TypeError(`Catalog message "${value.id}" must declare translations`);
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
id: value.id,
|
|
27
|
+
descriptor: parseDescriptor(value.descriptor ?? null),
|
|
28
|
+
consumers: uniqueConsumers(value.consumers.map(String)),
|
|
29
|
+
translations: value.translations,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function isPlainObject(value) {
|
|
33
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function compareCodePoints(left: string, right: string): number;
|
package/dist/compare.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type LocalizationConsumer, type PublicLocalizationConsumer } from './types.mts';
|
|
2
|
+
export declare function isLocalizationConsumer(value: string): value is LocalizationConsumer;
|
|
3
|
+
export declare function isPublicLocalizationConsumer(value: string): value is PublicLocalizationConsumer;
|
|
4
|
+
export declare function assertLocalizationConsumer(value: string): LocalizationConsumer;
|
|
5
|
+
export declare function assertPublicLocalizationConsumer(value: string): PublicLocalizationConsumer;
|
|
6
|
+
export declare function uniqueConsumers(values: readonly string[]): readonly LocalizationConsumer[];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { LOCALIZATION_CONSUMERS, PUBLIC_LOCALIZATION_CONSUMERS, } from './types.mjs';
|
|
2
|
+
export function isLocalizationConsumer(value) {
|
|
3
|
+
return LOCALIZATION_CONSUMERS.includes(value);
|
|
4
|
+
}
|
|
5
|
+
export function isPublicLocalizationConsumer(value) {
|
|
6
|
+
return PUBLIC_LOCALIZATION_CONSUMERS.includes(value);
|
|
7
|
+
}
|
|
8
|
+
export function assertLocalizationConsumer(value) {
|
|
9
|
+
if (!isLocalizationConsumer(value)) {
|
|
10
|
+
throw new TypeError(`Unknown localization consumer "${value}"`);
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
export function assertPublicLocalizationConsumer(value) {
|
|
15
|
+
const consumer = assertLocalizationConsumer(value);
|
|
16
|
+
if (!isPublicLocalizationConsumer(consumer)) {
|
|
17
|
+
throw new TypeError(`Localization consumer "${value}" is not public`);
|
|
18
|
+
}
|
|
19
|
+
return consumer;
|
|
20
|
+
}
|
|
21
|
+
export function uniqueConsumers(values) {
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
for (const value of values)
|
|
24
|
+
seen.add(assertLocalizationConsumer(value));
|
|
25
|
+
return LOCALIZATION_CONSUMERS.filter((consumer) => seen.has(consumer));
|
|
26
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type MessageDescriptor, type PluralForms, type TranslationValue } from './types.mts';
|
|
2
|
+
export declare function isPluralForms(value: unknown): value is PluralForms;
|
|
3
|
+
export declare function isSelectPluralCases(value: unknown): value is Readonly<Record<string, PluralForms>>;
|
|
4
|
+
export declare function isTranslationValue(value: unknown): value is TranslationValue;
|
|
5
|
+
export declare function descriptorSignature(descriptor: MessageDescriptor): string;
|
|
6
|
+
export declare function parseDescriptor(value: unknown): MessageDescriptor | null;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { compareCodePoints } from './compare.mjs';
|
|
2
|
+
import { PLURAL_CATEGORIES, } from './types.mjs';
|
|
3
|
+
const PLURAL_KEYS = new Set(PLURAL_CATEGORIES);
|
|
4
|
+
export function isPluralForms(value) {
|
|
5
|
+
if (!isPlainObject(value) || typeof value.other !== 'string')
|
|
6
|
+
return false;
|
|
7
|
+
return Object.entries(value).every(([key, form]) => PLURAL_KEYS.has(key) && typeof form === 'string');
|
|
8
|
+
}
|
|
9
|
+
export function isSelectPluralCases(value) {
|
|
10
|
+
return (isPlainObject(value) &&
|
|
11
|
+
Object.keys(value).length > 0 &&
|
|
12
|
+
Object.values(value).every(isPluralForms));
|
|
13
|
+
}
|
|
14
|
+
export function isTranslationValue(value) {
|
|
15
|
+
return typeof value === 'string' || isPluralForms(value) || isSelectPluralCases(value);
|
|
16
|
+
}
|
|
17
|
+
export function descriptorSignature(descriptor) {
|
|
18
|
+
return JSON.stringify({
|
|
19
|
+
kind: descriptor.kind,
|
|
20
|
+
valueParameter: descriptor.valueParameter,
|
|
21
|
+
selectParameter: descriptor.kind === 'select-plural' ? descriptor.selectParameter : null,
|
|
22
|
+
numberParameters: [...(descriptor.numberParameters ?? [])].toSorted(compareCodePoints),
|
|
23
|
+
cases: descriptor.kind === 'select-plural' ? [...descriptor.cases].toSorted(compareCodePoints) : [],
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export function parseDescriptor(value) {
|
|
27
|
+
if (value === null)
|
|
28
|
+
return null;
|
|
29
|
+
if (!isPlainObject(value) ||
|
|
30
|
+
typeof value.kind !== 'string' ||
|
|
31
|
+
typeof value.valueParameter !== 'string') {
|
|
32
|
+
throw new TypeError('Invalid message descriptor');
|
|
33
|
+
}
|
|
34
|
+
const numberParameters = optionalStringArray(value.numberParameters);
|
|
35
|
+
if (value.kind === 'plural') {
|
|
36
|
+
assertNoKeys(value, ['kind', 'valueParameter', 'numberParameters']);
|
|
37
|
+
return numberParameters === undefined
|
|
38
|
+
? { kind: 'plural', valueParameter: value.valueParameter }
|
|
39
|
+
: { kind: 'plural', valueParameter: value.valueParameter, numberParameters };
|
|
40
|
+
}
|
|
41
|
+
if (value.kind !== 'select-plural' || typeof value.selectParameter !== 'string') {
|
|
42
|
+
throw new TypeError('Invalid message descriptor');
|
|
43
|
+
}
|
|
44
|
+
const cases = value.cases;
|
|
45
|
+
if (!Array.isArray(cases) ||
|
|
46
|
+
cases.length === 0 ||
|
|
47
|
+
cases.some((item) => typeof item !== 'string')) {
|
|
48
|
+
throw new TypeError('Invalid select-plural descriptor cases');
|
|
49
|
+
}
|
|
50
|
+
assertNoKeys(value, ['kind', 'valueParameter', 'selectParameter', 'numberParameters', 'cases']);
|
|
51
|
+
return numberParameters === undefined
|
|
52
|
+
? {
|
|
53
|
+
kind: 'select-plural',
|
|
54
|
+
valueParameter: value.valueParameter,
|
|
55
|
+
selectParameter: value.selectParameter,
|
|
56
|
+
cases: [...cases],
|
|
57
|
+
}
|
|
58
|
+
: {
|
|
59
|
+
kind: 'select-plural',
|
|
60
|
+
valueParameter: value.valueParameter,
|
|
61
|
+
selectParameter: value.selectParameter,
|
|
62
|
+
numberParameters,
|
|
63
|
+
cases: [...cases],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function optionalStringArray(value) {
|
|
67
|
+
if (value === undefined)
|
|
68
|
+
return undefined;
|
|
69
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
|
70
|
+
throw new TypeError('Descriptor numberParameters must be a string array');
|
|
71
|
+
}
|
|
72
|
+
return [...value];
|
|
73
|
+
}
|
|
74
|
+
function assertNoKeys(value, allowed) {
|
|
75
|
+
if (Object.keys(value).some((key) => !allowed.includes(key))) {
|
|
76
|
+
throw new TypeError('Invalid message descriptor');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function isPlainObject(value) {
|
|
80
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
81
|
+
}
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type { CatalogMessage, ExactSelector, LocalizationBatch, LocalizationBounds, LocalizationConsumer, LocalizationLeaf, LocalizationRequest, LocalizationSelector, LocalizationWireContract, MessageDescriptor, NormalizedLocalizationRequest, PluralCategory, PluralDescriptor, PluralForms, PrefixSelector, PublicLocalizationConsumer, SelectPluralCases, SelectPluralDescriptor, TranslationValue, } from './types.mts';
|
|
2
|
+
export { CANONICAL_SOURCE_LOCALE, ENGLISH_LOCALE_ALIAS, LOCALIZATION_CONSUMERS, LOCALIZATION_WIRE_CONTRACT, PLURAL_CATEGORIES, PUBLIC_LOCALIZATION_CONSUMERS, } from './types.mts';
|
|
3
|
+
export { DEFAULT_LOCALIZATION_BOUNDS, LocalizationBoundError } from './bounds.mts';
|
|
4
|
+
export { assertLocalizationConsumer, assertPublicLocalizationConsumer, isLocalizationConsumer, isPublicLocalizationConsumer, uniqueConsumers, } from './consumers.mts';
|
|
5
|
+
export { aliasEnglishLocale, canonicalizeLocale, normalizeLocale, normalizeLocaleList, } from './locales.mts';
|
|
6
|
+
export { dedupeSelectors, isMessageId, parseSelector, prefixRange, selectorMatches, } from './selectors.mts';
|
|
7
|
+
export { assertMessageCount, assertPayloadBytes, normalizeLocalizationRequest } from './request.mts';
|
|
8
|
+
export { firstAvailableTranslation, leafForTranslation, selectedIds } from './selection.mts';
|
|
9
|
+
export { assertSamePlaceholders, placeholdersIn, uniquePlaceholders } from './placeholders.mts';
|
|
10
|
+
export { descriptorSignature, isPluralForms, isSelectPluralCases, isTranslationValue, parseDescriptor, } from './descriptors.mts';
|
|
11
|
+
export { canonicalJson } from './serialize.mts';
|
|
12
|
+
export { compareCodePoints } from './compare.mts';
|
|
13
|
+
export { catalogMessageFromRecord, serializeCatalogMessages } from './catalog.mts';
|
|
14
|
+
export { createLocalizationBatch, etagMatches, localizationEtag, serializeLocalizationBatch, } from './wire.mts';
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { CANONICAL_SOURCE_LOCALE, ENGLISH_LOCALE_ALIAS, LOCALIZATION_CONSUMERS, LOCALIZATION_WIRE_CONTRACT, PLURAL_CATEGORIES, PUBLIC_LOCALIZATION_CONSUMERS, } from './types.mjs';
|
|
2
|
+
export { DEFAULT_LOCALIZATION_BOUNDS, LocalizationBoundError } from './bounds.mjs';
|
|
3
|
+
export { assertLocalizationConsumer, assertPublicLocalizationConsumer, isLocalizationConsumer, isPublicLocalizationConsumer, uniqueConsumers, } from './consumers.mjs';
|
|
4
|
+
export { aliasEnglishLocale, canonicalizeLocale, normalizeLocale, normalizeLocaleList, } from './locales.mjs';
|
|
5
|
+
export { dedupeSelectors, isMessageId, parseSelector, prefixRange, selectorMatches, } from './selectors.mjs';
|
|
6
|
+
export { assertMessageCount, assertPayloadBytes, normalizeLocalizationRequest } from './request.mjs';
|
|
7
|
+
export { firstAvailableTranslation, leafForTranslation, selectedIds } from './selection.mjs';
|
|
8
|
+
export { assertSamePlaceholders, placeholdersIn, uniquePlaceholders } from './placeholders.mjs';
|
|
9
|
+
export { descriptorSignature, isPluralForms, isSelectPluralCases, isTranslationValue, parseDescriptor, } from './descriptors.mjs';
|
|
10
|
+
export { canonicalJson } from './serialize.mjs';
|
|
11
|
+
export { compareCodePoints } from './compare.mjs';
|
|
12
|
+
export { catalogMessageFromRecord, serializeCatalogMessages } from './catalog.mjs';
|
|
13
|
+
export { createLocalizationBatch, etagMatches, localizationEtag, serializeLocalizationBatch, } from './wire.mjs';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function canonicalizeLocale(value: string): string | null;
|
|
2
|
+
/** Maps `en` onto `en-US` after Unicode canonicalization. */
|
|
3
|
+
export declare function aliasEnglishLocale(value: string): string;
|
|
4
|
+
export declare function normalizeLocale(value: string): string | null;
|
|
5
|
+
export declare function normalizeLocaleList(values: readonly string[], available?: readonly string[] | null): string[];
|
package/dist/locales.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { CANONICAL_SOURCE_LOCALE, ENGLISH_LOCALE_ALIAS } from './types.mjs';
|
|
2
|
+
const LANGUAGE_TAG = /^[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*$/;
|
|
3
|
+
export function canonicalizeLocale(value) {
|
|
4
|
+
const trimmed = value.trim().replaceAll('_', '-');
|
|
5
|
+
if (!LANGUAGE_TAG.test(trimmed))
|
|
6
|
+
return null;
|
|
7
|
+
try {
|
|
8
|
+
return Intl.getCanonicalLocales(trimmed)[0] ?? null;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Maps `en` onto `en-US` after Unicode canonicalization. */
|
|
15
|
+
export function aliasEnglishLocale(value) {
|
|
16
|
+
return value === ENGLISH_LOCALE_ALIAS ? CANONICAL_SOURCE_LOCALE : value;
|
|
17
|
+
}
|
|
18
|
+
export function normalizeLocale(value) {
|
|
19
|
+
const canonical = canonicalizeLocale(value);
|
|
20
|
+
return canonical === null ? null : aliasEnglishLocale(canonical);
|
|
21
|
+
}
|
|
22
|
+
export function normalizeLocaleList(values, available = null) {
|
|
23
|
+
const allowed = available === null ? null : new Set(available.map(aliasEnglishLocale));
|
|
24
|
+
const locales = [];
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
for (const value of values) {
|
|
27
|
+
const locale = normalizeLocale(value);
|
|
28
|
+
if (locale === null)
|
|
29
|
+
throw new TypeError(`Invalid locale "${value}"`);
|
|
30
|
+
if (allowed !== null && !allowed.has(locale))
|
|
31
|
+
continue;
|
|
32
|
+
if (seen.has(locale))
|
|
33
|
+
continue;
|
|
34
|
+
seen.add(locale);
|
|
35
|
+
locales.push(locale);
|
|
36
|
+
}
|
|
37
|
+
return locales;
|
|
38
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { compareCodePoints } from './compare.mjs';
|
|
2
|
+
const PLACEHOLDER = /\{([\w.-]+)\}/g;
|
|
3
|
+
export function placeholdersIn(value) {
|
|
4
|
+
return [...value.matchAll(PLACEHOLDER)].map((match) => match[1]).toSorted(compareCodePoints);
|
|
5
|
+
}
|
|
6
|
+
export function uniquePlaceholders(values) {
|
|
7
|
+
return [...new Set(values.flatMap(placeholdersIn))].toSorted(compareCodePoints);
|
|
8
|
+
}
|
|
9
|
+
export function assertSamePlaceholders(canonical, other, path) {
|
|
10
|
+
if (canonical.join(',') !== other.join(',')) {
|
|
11
|
+
throw new TypeError(`Placeholder mismatch at "${path}"`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { LocalizationBounds, LocalizationRequest, NormalizedLocalizationRequest } from './types.mts';
|
|
2
|
+
export declare function normalizeLocalizationRequest(request: LocalizationRequest, availableLocales?: readonly string[] | null, bounds?: LocalizationBounds): NormalizedLocalizationRequest;
|
|
3
|
+
export declare function assertMessageCount(count: number, bounds: LocalizationBounds): void;
|
|
4
|
+
export declare function assertPayloadBytes(payload: string, bounds: LocalizationBounds): void;
|
package/dist/request.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { LocalizationBoundError, DEFAULT_LOCALIZATION_BOUNDS } from './bounds.mjs';
|
|
2
|
+
import { assertLocalizationConsumer } from './consumers.mjs';
|
|
3
|
+
import { normalizeLocaleList } from './locales.mjs';
|
|
4
|
+
import { dedupeSelectors, parseSelector } from './selectors.mjs';
|
|
5
|
+
export function normalizeLocalizationRequest(request, availableLocales = null, bounds = DEFAULT_LOCALIZATION_BOUNDS) {
|
|
6
|
+
const consumer = assertLocalizationConsumer(request.consumer);
|
|
7
|
+
if (request.locales.length === 0)
|
|
8
|
+
throw new TypeError('At least one locale is required');
|
|
9
|
+
if (request.locales.length > bounds.maxLocales) {
|
|
10
|
+
throw new LocalizationBoundError(`At most ${bounds.maxLocales} locales are allowed`);
|
|
11
|
+
}
|
|
12
|
+
if (request.selectors.length === 0)
|
|
13
|
+
throw new TypeError('At least one selector is required');
|
|
14
|
+
if (request.selectors.length > bounds.maxSelectors) {
|
|
15
|
+
throw new LocalizationBoundError(`At most ${bounds.maxSelectors} selectors are allowed`);
|
|
16
|
+
}
|
|
17
|
+
const locales = normalizeLocaleList(request.locales, availableLocales);
|
|
18
|
+
if (locales.length === 0)
|
|
19
|
+
throw new TypeError('No requested locales are available');
|
|
20
|
+
return {
|
|
21
|
+
consumer,
|
|
22
|
+
locales,
|
|
23
|
+
selectors: dedupeSelectors(request.selectors.map(parseSelector)),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function assertMessageCount(count, bounds) {
|
|
27
|
+
if (count > bounds.maxMessages) {
|
|
28
|
+
throw new LocalizationBoundError(`At most ${bounds.maxMessages} messages are allowed`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function assertPayloadBytes(payload, bounds) {
|
|
32
|
+
if (byteLength(payload) > bounds.maxBytes) {
|
|
33
|
+
throw new LocalizationBoundError(`Localization payload exceeds ${bounds.maxBytes} bytes`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function byteLength(value) {
|
|
37
|
+
return new TextEncoder().encode(value).byteLength;
|
|
38
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { LocalizationLeaf, LocalizationSelector, MessageDescriptor, TranslationValue } from './types.mts';
|
|
2
|
+
export declare function selectedIds(ids: readonly string[], selectors: readonly LocalizationSelector[]): string[];
|
|
3
|
+
export declare function firstAvailableTranslation(locales: readonly string[], translations: Readonly<Record<string, TranslationValue>>): TranslationValue | undefined;
|
|
4
|
+
export declare function leafForTranslation(descriptor: MessageDescriptor | null, value: TranslationValue): LocalizationLeaf;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { compareCodePoints } from './compare.mjs';
|
|
2
|
+
import { selectorMatches } from './selectors.mjs';
|
|
3
|
+
export function selectedIds(ids, selectors) {
|
|
4
|
+
return ids
|
|
5
|
+
.filter((id) => selectors.some((selector) => selectorMatches(selector, id)))
|
|
6
|
+
.toSorted(compareCodePoints);
|
|
7
|
+
}
|
|
8
|
+
export function firstAvailableTranslation(locales, translations) {
|
|
9
|
+
for (const locale of locales) {
|
|
10
|
+
if (Object.hasOwn(translations, locale))
|
|
11
|
+
return translations[locale];
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
export function leafForTranslation(descriptor, value) {
|
|
16
|
+
if (descriptor === null) {
|
|
17
|
+
if (typeof value !== 'string')
|
|
18
|
+
throw new TypeError('String messages require string translations');
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
if (descriptor.kind === 'plural') {
|
|
22
|
+
if (!isPluralForms(value))
|
|
23
|
+
throw new TypeError('Plural messages require plural-form translations');
|
|
24
|
+
return descriptor.numberParameters === undefined
|
|
25
|
+
? { kind: 'plural', valueParameter: descriptor.valueParameter, forms: value }
|
|
26
|
+
: {
|
|
27
|
+
kind: 'plural',
|
|
28
|
+
valueParameter: descriptor.valueParameter,
|
|
29
|
+
numberParameters: descriptor.numberParameters,
|
|
30
|
+
forms: value,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (typeof value === 'string' || isPluralForms(value)) {
|
|
34
|
+
throw new TypeError('Select-plural messages require cased translations');
|
|
35
|
+
}
|
|
36
|
+
return descriptor.numberParameters === undefined
|
|
37
|
+
? {
|
|
38
|
+
kind: 'select-plural',
|
|
39
|
+
valueParameter: descriptor.valueParameter,
|
|
40
|
+
selectParameter: descriptor.selectParameter,
|
|
41
|
+
cases: value,
|
|
42
|
+
}
|
|
43
|
+
: {
|
|
44
|
+
kind: 'select-plural',
|
|
45
|
+
valueParameter: descriptor.valueParameter,
|
|
46
|
+
selectParameter: descriptor.selectParameter,
|
|
47
|
+
numberParameters: descriptor.numberParameters,
|
|
48
|
+
cases: value,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function isPluralForms(value) {
|
|
52
|
+
return (typeof value === 'object' && Object.hasOwn(value, 'other') && typeof value.other === 'string');
|
|
53
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { LocalizationSelector } from './types.mts';
|
|
2
|
+
export declare function isMessageId(value: string): boolean;
|
|
3
|
+
export declare function parseSelector(value: string): LocalizationSelector;
|
|
4
|
+
export declare function selectorMatches(selector: LocalizationSelector, id: string): boolean;
|
|
5
|
+
export declare function prefixRange(prefix: string): readonly [string, string];
|
|
6
|
+
export declare function dedupeSelectors(selectors: readonly LocalizationSelector[]): LocalizationSelector[];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { compareCodePoints } from './compare.mjs';
|
|
2
|
+
const SEGMENT = '[A-Za-z0-9][A-Za-z0-9_-]*';
|
|
3
|
+
const MESSAGE_ID = new RegExp(`^${SEGMENT}(?:\\.${SEGMENT})+$`);
|
|
4
|
+
const PREFIX_SELECTOR = new RegExp(`^${SEGMENT}(?:\\.${SEGMENT})*\\.\\*$`);
|
|
5
|
+
export function isMessageId(value) {
|
|
6
|
+
return MESSAGE_ID.test(value);
|
|
7
|
+
}
|
|
8
|
+
export function parseSelector(value) {
|
|
9
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
10
|
+
throw new TypeError('Localization selector must be a non-empty string');
|
|
11
|
+
}
|
|
12
|
+
if (PREFIX_SELECTOR.test(value))
|
|
13
|
+
return { kind: 'prefix', prefix: value.slice(0, -2) };
|
|
14
|
+
if (MESSAGE_ID.test(value))
|
|
15
|
+
return { kind: 'exact', id: value };
|
|
16
|
+
throw new TypeError(`Invalid localization selector "${value}"`);
|
|
17
|
+
}
|
|
18
|
+
export function selectorMatches(selector, id) {
|
|
19
|
+
return selector.kind === 'exact' ? selector.id === id : id.startsWith(`${selector.prefix}.`);
|
|
20
|
+
}
|
|
21
|
+
export function prefixRange(prefix) {
|
|
22
|
+
return [`${prefix}.`, `${prefix}/`];
|
|
23
|
+
}
|
|
24
|
+
export function dedupeSelectors(selectors) {
|
|
25
|
+
const prefixes = uniquePrefixes(selectors.filter(isPrefix));
|
|
26
|
+
const exact = new Map();
|
|
27
|
+
for (const selector of selectors) {
|
|
28
|
+
if (selector.kind !== 'exact')
|
|
29
|
+
continue;
|
|
30
|
+
if (prefixes.some((prefix) => selectorMatches(prefix, selector.id)))
|
|
31
|
+
continue;
|
|
32
|
+
exact.set(selector.id, selector);
|
|
33
|
+
}
|
|
34
|
+
return [
|
|
35
|
+
...prefixes,
|
|
36
|
+
...[...exact.values()].toSorted((left, right) => compareCodePoints(left.id, right.id)),
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
function uniquePrefixes(selectors) {
|
|
40
|
+
const sorted = selectors.toSorted((left, right) => compareCodePoints(left.prefix, right.prefix));
|
|
41
|
+
const prefixes = [];
|
|
42
|
+
for (const selector of sorted) {
|
|
43
|
+
if (prefixes.some((prefix) => selector.prefix === prefix.prefix || selector.prefix.startsWith(`${prefix.prefix}.`))) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
prefixes.push(selector);
|
|
47
|
+
}
|
|
48
|
+
return prefixes;
|
|
49
|
+
}
|
|
50
|
+
function isPrefix(selector) {
|
|
51
|
+
return selector.kind === 'prefix';
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function canonicalJson(value: unknown): string;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { compareCodePoints } from './compare.mjs';
|
|
2
|
+
export function canonicalJson(value) {
|
|
3
|
+
return stringify(sort(value));
|
|
4
|
+
}
|
|
5
|
+
function sort(value) {
|
|
6
|
+
if (Array.isArray(value))
|
|
7
|
+
return value.map(sort);
|
|
8
|
+
if (value === null || typeof value !== 'object')
|
|
9
|
+
return value;
|
|
10
|
+
return Object.fromEntries(Object.entries(value)
|
|
11
|
+
.toSorted(([left], [right]) => compareCodePoints(left, right))
|
|
12
|
+
.map(([key, nested]) => [key, sort(nested)]));
|
|
13
|
+
}
|
|
14
|
+
function stringify(value) {
|
|
15
|
+
if (value === null)
|
|
16
|
+
return 'null';
|
|
17
|
+
if (typeof value === 'boolean' || typeof value === 'number')
|
|
18
|
+
return JSON.stringify(value);
|
|
19
|
+
if (typeof value === 'string')
|
|
20
|
+
return JSON.stringify(value);
|
|
21
|
+
if (Array.isArray(value))
|
|
22
|
+
return `[${value.map(stringify).join(',')}]`;
|
|
23
|
+
if (typeof value === 'object') {
|
|
24
|
+
return `{${Object.entries(value)
|
|
25
|
+
.map(([key, nested]) => `${JSON.stringify(key)}:${stringify(nested)}`)
|
|
26
|
+
.join(',')}}`;
|
|
27
|
+
}
|
|
28
|
+
throw new TypeError('Cannot serialize localization value');
|
|
29
|
+
}
|
package/dist/types.d.mts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export declare const LOCALIZATION_CONSUMERS: readonly ['web', 'swift', 'dotnet', 'email'];
|
|
2
|
+
export declare const PUBLIC_LOCALIZATION_CONSUMERS: readonly ['web', 'swift', 'dotnet'];
|
|
3
|
+
export declare const LOCALIZATION_WIRE_CONTRACT: 'v1';
|
|
4
|
+
export declare const CANONICAL_SOURCE_LOCALE: 'en-US';
|
|
5
|
+
export declare const ENGLISH_LOCALE_ALIAS: 'en';
|
|
6
|
+
export declare const PLURAL_CATEGORIES: readonly ['zero', 'one', 'two', 'few', 'many', 'other'];
|
|
7
|
+
export type LocalizationConsumer = (typeof LOCALIZATION_CONSUMERS)[number];
|
|
8
|
+
export type PublicLocalizationConsumer = (typeof PUBLIC_LOCALIZATION_CONSUMERS)[number];
|
|
9
|
+
export type LocalizationWireContract = typeof LOCALIZATION_WIRE_CONTRACT;
|
|
10
|
+
export type PluralCategory = (typeof PLURAL_CATEGORIES)[number];
|
|
11
|
+
export type PluralForms = Readonly<{
|
|
12
|
+
other: string;
|
|
13
|
+
} & Partial<Record<PluralCategory, string>>>;
|
|
14
|
+
export type SelectPluralCases = Readonly<Record<string, PluralForms>>;
|
|
15
|
+
export type TranslationValue = string | PluralForms | SelectPluralCases;
|
|
16
|
+
export type PluralDescriptor = Readonly<{
|
|
17
|
+
kind: 'plural';
|
|
18
|
+
valueParameter: string;
|
|
19
|
+
numberParameters?: readonly string[];
|
|
20
|
+
}>;
|
|
21
|
+
export type SelectPluralDescriptor = Readonly<{
|
|
22
|
+
kind: 'select-plural';
|
|
23
|
+
valueParameter: string;
|
|
24
|
+
selectParameter: string;
|
|
25
|
+
numberParameters?: readonly string[];
|
|
26
|
+
cases: readonly string[];
|
|
27
|
+
}>;
|
|
28
|
+
export type MessageDescriptor = PluralDescriptor | SelectPluralDescriptor;
|
|
29
|
+
export type ExactSelector = Readonly<{
|
|
30
|
+
kind: 'exact';
|
|
31
|
+
id: string;
|
|
32
|
+
}>;
|
|
33
|
+
export type PrefixSelector = Readonly<{
|
|
34
|
+
kind: 'prefix';
|
|
35
|
+
prefix: string;
|
|
36
|
+
}>;
|
|
37
|
+
export type LocalizationSelector = ExactSelector | PrefixSelector;
|
|
38
|
+
export type CatalogMessage = Readonly<{
|
|
39
|
+
id: string;
|
|
40
|
+
descriptor: MessageDescriptor | null;
|
|
41
|
+
consumers: readonly LocalizationConsumer[];
|
|
42
|
+
translations: Readonly<Record<string, TranslationValue>>;
|
|
43
|
+
}>;
|
|
44
|
+
export type LocalizationRequest = Readonly<{
|
|
45
|
+
consumer: LocalizationConsumer;
|
|
46
|
+
locales: readonly string[];
|
|
47
|
+
selectors: readonly string[];
|
|
48
|
+
}>;
|
|
49
|
+
export type NormalizedLocalizationRequest = Readonly<{
|
|
50
|
+
consumer: LocalizationConsumer;
|
|
51
|
+
locales: readonly string[];
|
|
52
|
+
selectors: readonly LocalizationSelector[];
|
|
53
|
+
}>;
|
|
54
|
+
export type LocalizationBounds = Readonly<{
|
|
55
|
+
maxLocales: number;
|
|
56
|
+
maxSelectors: number;
|
|
57
|
+
maxMessages: number;
|
|
58
|
+
maxBytes: number;
|
|
59
|
+
}>;
|
|
60
|
+
export type LocalizationLeaf = string | Readonly<PluralDescriptor & {
|
|
61
|
+
forms: PluralForms;
|
|
62
|
+
}> | Readonly<Omit<SelectPluralDescriptor, 'cases'> & {
|
|
63
|
+
cases: SelectPluralCases;
|
|
64
|
+
}>;
|
|
65
|
+
export type LocalizationBatch = Readonly<{
|
|
66
|
+
contract: LocalizationWireContract;
|
|
67
|
+
revision: string;
|
|
68
|
+
ttlSeconds: number;
|
|
69
|
+
messages: Readonly<Record<string, LocalizationLeaf>>;
|
|
70
|
+
}>;
|
package/dist/types.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export const LOCALIZATION_CONSUMERS = ['web', 'swift', 'dotnet', 'email'];
|
|
2
|
+
export const PUBLIC_LOCALIZATION_CONSUMERS = ['web', 'swift', 'dotnet'];
|
|
3
|
+
export const LOCALIZATION_WIRE_CONTRACT = 'v1';
|
|
4
|
+
export const CANONICAL_SOURCE_LOCALE = 'en-US';
|
|
5
|
+
export const ENGLISH_LOCALE_ALIAS = 'en';
|
|
6
|
+
export const PLURAL_CATEGORIES = ['zero', 'one', 'two', 'few', 'many', 'other'];
|
package/dist/wire.d.mts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { LocalizationBatch, LocalizationLeaf } from './types.mts';
|
|
2
|
+
export declare function serializeLocalizationBatch(batch: LocalizationBatch): string;
|
|
3
|
+
export declare function localizationEtag(revision: string): string;
|
|
4
|
+
export declare function etagMatches(header: string | null | undefined, revision: string): boolean;
|
|
5
|
+
export declare function createLocalizationBatch(revision: string, ttlSeconds: number, messages: Readonly<Record<string, LocalizationLeaf>>): LocalizationBatch;
|
package/dist/wire.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { compareCodePoints } from './compare.mjs';
|
|
2
|
+
import { LOCALIZATION_WIRE_CONTRACT } from './types.mjs';
|
|
3
|
+
import { canonicalJson } from './serialize.mjs';
|
|
4
|
+
export function serializeLocalizationBatch(batch) {
|
|
5
|
+
return canonicalJson({
|
|
6
|
+
contract: batch.contract,
|
|
7
|
+
messages: Object.fromEntries(Object.entries(batch.messages).toSorted(([left], [right]) => compareCodePoints(left, right))),
|
|
8
|
+
revision: batch.revision,
|
|
9
|
+
ttlSeconds: batch.ttlSeconds,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export function localizationEtag(revision) {
|
|
13
|
+
return `"${revision}"`;
|
|
14
|
+
}
|
|
15
|
+
export function etagMatches(header, revision) {
|
|
16
|
+
if (header == null || header.trim() === '')
|
|
17
|
+
return false;
|
|
18
|
+
const expected = localizationEtag(revision);
|
|
19
|
+
return header
|
|
20
|
+
.split(',')
|
|
21
|
+
.some((value) => value.trim() === expected || value.trim() === `W/${expected}`);
|
|
22
|
+
}
|
|
23
|
+
export function createLocalizationBatch(revision, ttlSeconds, messages) {
|
|
24
|
+
if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
|
|
25
|
+
throw new TypeError('ttlSeconds must be a positive integer');
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
contract: LOCALIZATION_WIRE_CONTRACT,
|
|
29
|
+
revision,
|
|
30
|
+
ttlSeconds,
|
|
31
|
+
messages: Object.fromEntries(Object.entries(messages).toSorted(([left], [right]) => compareCodePoints(left, right))),
|
|
32
|
+
};
|
|
33
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vouchington/localization",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Browser-safe localization catalog contracts, locale fallback, and selector validation.",
|
|
5
|
+
"homepage": "https://github.com/vouchington/vouchington-platform/tree/main/packages/localization#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/vouchington/vouchington-platform/issues"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"author": "Jonathan Ong",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/vouchington/vouchington-platform.git",
|
|
14
|
+
"directory": "packages/localization"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "./dist/index.mjs",
|
|
23
|
+
"types": "./dist/index.d.mts",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/index.d.mts",
|
|
27
|
+
"import": "./dist/index.mjs",
|
|
28
|
+
"default": "./dist/index.mjs"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc --project tsconfig.build.json",
|
|
36
|
+
"prepack": "pnpm run build"
|
|
37
|
+
},
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=24.0.0"
|
|
40
|
+
}
|
|
41
|
+
}
|