@vouchington/utils 0.1.0 → 0.3.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/README.md +18 -0
- package/dist/collections.d.mts +3 -0
- package/dist/collections.mjs +14 -0
- package/dist/feature-flags.d.mts +14 -0
- package/dist/feature-flags.mjs +48 -0
- package/dist/language-tags.d.mts +11 -0
- package/dist/language-tags.mjs +91 -0
- package/dist/message-catalog-validation.d.mts +4 -0
- package/dist/message-catalog-validation.mjs +103 -0
- package/dist/message-catalog.d.mts +36 -0
- package/dist/message-catalog.mjs +88 -0
- package/dist/urls.d.mts +2 -0
- package/dist/urls.mjs +13 -0
- package/package.json +16 -1
package/README.md
CHANGED
|
@@ -7,6 +7,8 @@ export: import the capability you use from an explicit subpath.
|
|
|
7
7
|
import { createTokenSecrets } from '@vouchington/utils/token-secrets'
|
|
8
8
|
import { createMoneyCatalog } from '@vouchington/utils/money'
|
|
9
9
|
import { serializeCookie } from '@vouchington/utils/cookies'
|
|
10
|
+
import { bestAcceptLanguageMatch } from '@vouchington/utils/language-tags'
|
|
11
|
+
import { createMessageTranslator } from '@vouchington/utils/message-catalog'
|
|
10
12
|
```
|
|
11
13
|
|
|
12
14
|
## Subpaths
|
|
@@ -27,6 +29,8 @@ import { serializeCookie } from '@vouchington/utils/cookies'
|
|
|
27
29
|
caller to supply every policy attribute. `SameSite=None` requires `Secure`.
|
|
28
30
|
- `collections`, `async`, and `stable-json`: collection deduplication/merging, bounded async mapping,
|
|
29
31
|
and deterministic JSON serialization.
|
|
32
|
+
- `feature-flags`: base64 JSON feature-flag cookie parsing and serialization with caller-injected
|
|
33
|
+
codecs, cookie names, and size policy.
|
|
30
34
|
- `dates`, `format`, `strings`, and `text-metrics`: UTC-day, duration, formatting, text normalization,
|
|
31
35
|
title casing, and word/sentence helpers.
|
|
32
36
|
- `gtin`, `bigint-ids`, and `validation`: GTIN predicates, canonical positive PostgreSQL bigint IDs,
|
|
@@ -35,6 +39,20 @@ import { serializeCookie } from '@vouchington/utils/cookies'
|
|
|
35
39
|
query-string serialization. Cursor parsing intentionally belongs to `@vouchington/pagination`.
|
|
36
40
|
- `slugs` and `urls`: ASCII-only slugification and URL/hostname primitives. URL hostname helpers
|
|
37
41
|
return `null` for invalid input; `normalizeAsciiHostname` names its ASCII-only contract directly.
|
|
42
|
+
`matchesHostnamePattern` supports exact and inclusive `*.` hostname patterns.
|
|
43
|
+
- `language-tags`: caller-configured locale normalization and strict `Accept-Language` parsing and
|
|
44
|
+
best matching. HTTP ranges reject malformed syntax and quality parameters; explicit `q=0` ranges
|
|
45
|
+
exclude a candidate when they tie for that candidate's most-specific matching range, so `en;q=0`
|
|
46
|
+
overrides an equal `en;q=1`, while `en-US;q=1` overrides broader `en;q=0` for supported `en-US`.
|
|
47
|
+
A truncated fallback (`en-US` to supported `en`) instead uses the ranges that match `en`, so `en;q=0`
|
|
48
|
+
or `*;q=0` excludes it. Matching compares an exact supported tag,
|
|
49
|
+
then truncates the _request_ (`en-US` can match supported `en`); it never expands `en` to an
|
|
50
|
+
arbitrary regional supported tag. Invalid configured tags are ignored. This subpath has no default
|
|
51
|
+
locale or supported-tag policy.
|
|
52
|
+
- `message-catalog`: typed nested catalog translation for string, plural, and select-plural JSON
|
|
53
|
+
descriptors. Catalog segments cannot contain dots because dots delimit nested keys. Plural values
|
|
54
|
+
and configured number parameters must be finite numbers; callers own catalog content, locale
|
|
55
|
+
choice, and number formatting.
|
|
38
56
|
- `observability`: SDK-free URL, request, event, and span scrubbing. Callers provide credential
|
|
39
57
|
headers and environment or spike policy; this package has no product defaults.
|
|
40
58
|
|
package/dist/collections.d.mts
CHANGED
|
@@ -3,6 +3,9 @@ export declare function dedupeByLast<T, K>(items: readonly T[], key: (item: T) =
|
|
|
3
3
|
export declare function dedupeById<T extends {
|
|
4
4
|
id: string;
|
|
5
5
|
}>(items: readonly T[]): T[];
|
|
6
|
+
export declare function indexById<T extends {
|
|
7
|
+
id: string;
|
|
8
|
+
}>(values: readonly (T | null | undefined)[]): Record<string, T>;
|
|
6
9
|
export declare function mergePageResultsById<T extends {
|
|
7
10
|
id: string;
|
|
8
11
|
}>(pages?: readonly ({
|
package/dist/collections.mjs
CHANGED
|
@@ -14,6 +14,20 @@ export function dedupeByLast(items, key) {
|
|
|
14
14
|
export function dedupeById(items) {
|
|
15
15
|
return dedupeBy(items, (item) => item.id);
|
|
16
16
|
}
|
|
17
|
+
export function indexById(values) {
|
|
18
|
+
const indexed = {};
|
|
19
|
+
for (const value of values) {
|
|
20
|
+
if (value === null || value === undefined)
|
|
21
|
+
continue;
|
|
22
|
+
Object.defineProperty(indexed, value.id, {
|
|
23
|
+
configurable: true,
|
|
24
|
+
enumerable: true,
|
|
25
|
+
value,
|
|
26
|
+
writable: true,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return indexed;
|
|
30
|
+
}
|
|
17
31
|
export function mergePageResultsById(pages) {
|
|
18
32
|
return dedupeById((pages ?? []).flatMap((page) => page?.results ?? []));
|
|
19
33
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type FeatureFlags = Record<string, boolean>;
|
|
2
|
+
export type FeatureFlagCookieCodec = {
|
|
3
|
+
encodeBase64(value: string): string;
|
|
4
|
+
decodeBase64(value: string): string;
|
|
5
|
+
};
|
|
6
|
+
export type FeatureFlagCookieOptions = {
|
|
7
|
+
maxCookieLength?: number;
|
|
8
|
+
};
|
|
9
|
+
export declare const DEFAULT_FEATURE_FLAG_COOKIE_MAX_LENGTH = 4096;
|
|
10
|
+
export declare function extractBooleanFeatureFlags(fields: Record<string, unknown>): FeatureFlags;
|
|
11
|
+
export declare function parseFeatureFlagCookie(cookieValue: string, codec: FeatureFlagCookieCodec, options?: FeatureFlagCookieOptions): FeatureFlags;
|
|
12
|
+
export declare function encodeFeatureFlagCookie(overrides: FeatureFlags, codec: FeatureFlagCookieCodec): string;
|
|
13
|
+
export declare function parseFeatureFlagCookieMaxLength(value: string | undefined, fallback?: number): number;
|
|
14
|
+
export declare function safeFeatureFlagCookiePart(cookieName: string, cookieValue: string | undefined, options?: FeatureFlagCookieOptions): string | null;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export const DEFAULT_FEATURE_FLAG_COOKIE_MAX_LENGTH = 4096;
|
|
2
|
+
const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
3
|
+
const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
|
|
4
|
+
export function extractBooleanFeatureFlags(fields) {
|
|
5
|
+
const flags = {};
|
|
6
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
7
|
+
if (typeof value === 'boolean') {
|
|
8
|
+
Object.defineProperty(flags, key, {
|
|
9
|
+
configurable: true,
|
|
10
|
+
enumerable: true,
|
|
11
|
+
value,
|
|
12
|
+
writable: true,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return flags;
|
|
17
|
+
}
|
|
18
|
+
export function parseFeatureFlagCookie(cookieValue, codec, options = {}) {
|
|
19
|
+
try {
|
|
20
|
+
if (!isSafeCookieValue(cookieValue, options))
|
|
21
|
+
return {};
|
|
22
|
+
const parsed = JSON.parse(codec.decodeBase64(cookieValue));
|
|
23
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
24
|
+
return {};
|
|
25
|
+
return extractBooleanFeatureFlags(parsed);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return {};
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function encodeFeatureFlagCookie(overrides, codec) {
|
|
32
|
+
return codec.encodeBase64(JSON.stringify(overrides));
|
|
33
|
+
}
|
|
34
|
+
export function parseFeatureFlagCookieMaxLength(value, fallback = DEFAULT_FEATURE_FLAG_COOKIE_MAX_LENGTH) {
|
|
35
|
+
const parsed = Number.parseInt(value ?? '', 10);
|
|
36
|
+
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
37
|
+
}
|
|
38
|
+
export function safeFeatureFlagCookiePart(cookieName, cookieValue, options = {}) {
|
|
39
|
+
return COOKIE_NAME.test(cookieName) && isSafeCookieValue(cookieValue, options)
|
|
40
|
+
? `${cookieName}=${cookieValue}`
|
|
41
|
+
: null;
|
|
42
|
+
}
|
|
43
|
+
function isSafeCookieValue(value, { maxCookieLength = DEFAULT_FEATURE_FLAG_COOKIE_MAX_LENGTH }) {
|
|
44
|
+
return (typeof value === 'string' &&
|
|
45
|
+
value.length > 0 &&
|
|
46
|
+
value.length <= maxCookieLength &&
|
|
47
|
+
BASE64.test(value));
|
|
48
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type ParsedLanguageRange = Readonly<{
|
|
2
|
+
range: string;
|
|
3
|
+
quality: number;
|
|
4
|
+
index: number;
|
|
5
|
+
}>;
|
|
6
|
+
/** Normalizes a locale against the caller's exact supported-tag catalog. */
|
|
7
|
+
export declare function normalizeLanguageTag<Supported extends string>(value: string | null | undefined, supported: readonly Supported[]): Supported | null;
|
|
8
|
+
/** Parses valid, non-zero Accept-Language ranges, highest quality first and stably for ties. */
|
|
9
|
+
export declare function parseAcceptLanguage(value: string | null | undefined): ParsedLanguageRange[];
|
|
10
|
+
/** Returns the caller-configured supported tag selected by strict Accept-Language lookup. */
|
|
11
|
+
export declare function bestAcceptLanguageMatch<Supported extends string>(value: string | null | undefined, supported: readonly Supported[]): Supported | null;
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
const HTTP_LANGUAGE_RANGE = /^(?:\*|[A-Za-z]{1,8}(?:-[A-Za-z0-9]{1,8})*)$/;
|
|
2
|
+
const QUALITY = /^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/;
|
|
3
|
+
/** Normalizes a locale against the caller's exact supported-tag catalog. */
|
|
4
|
+
export function normalizeLanguageTag(value, supported) {
|
|
5
|
+
if (!value)
|
|
6
|
+
return null;
|
|
7
|
+
const canonical = canonicalize(value.trim().replaceAll('_', '-'));
|
|
8
|
+
return canonical === null ? null : lookup(canonical, validSupported(supported));
|
|
9
|
+
}
|
|
10
|
+
/** Parses valid, non-zero Accept-Language ranges, highest quality first and stably for ties. */
|
|
11
|
+
export function parseAcceptLanguage(value) {
|
|
12
|
+
return parseLanguageRanges(value).filter((parsed) => parsed.quality > 0);
|
|
13
|
+
}
|
|
14
|
+
/** Returns the caller-configured supported tag selected by strict Accept-Language lookup. */
|
|
15
|
+
export function bestAcceptLanguageMatch(value, supported) {
|
|
16
|
+
const available = validSupported(supported);
|
|
17
|
+
const parsed = parseLanguageRanges(value);
|
|
18
|
+
for (const range of parsed) {
|
|
19
|
+
if (range.quality === 0)
|
|
20
|
+
continue;
|
|
21
|
+
if (range.range === '*')
|
|
22
|
+
return available.find((tag) => !isExcluded(tag.canonical, parsed))?.original ?? null;
|
|
23
|
+
const match = lookup(range.range, available, parsed);
|
|
24
|
+
if (match !== null)
|
|
25
|
+
return match;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
function parseLanguageRanges(value) {
|
|
30
|
+
if (!value)
|
|
31
|
+
return [];
|
|
32
|
+
return value
|
|
33
|
+
.split(',')
|
|
34
|
+
.flatMap((part, index) => {
|
|
35
|
+
const [range, ...parameters] = part
|
|
36
|
+
.trim()
|
|
37
|
+
.split(';')
|
|
38
|
+
.map((item) => item.trim());
|
|
39
|
+
if (!range || !HTTP_LANGUAGE_RANGE.test(range))
|
|
40
|
+
return [];
|
|
41
|
+
const quality = parameters.length === 0 ? 1 : parseQuality(parameters);
|
|
42
|
+
const canonical = range === '*' ? '*' : canonicalize(range);
|
|
43
|
+
return canonical === null || quality === null ? [] : [{ range: canonical, quality, index }];
|
|
44
|
+
})
|
|
45
|
+
.toSorted((left, right) => right.quality - left.quality || left.index - right.index);
|
|
46
|
+
}
|
|
47
|
+
function parseQuality(parameters) {
|
|
48
|
+
if (parameters.length !== 1)
|
|
49
|
+
return null;
|
|
50
|
+
const matched = /^q=(.+)$/i.exec(parameters[0]);
|
|
51
|
+
if (!matched || !QUALITY.test(matched[1]))
|
|
52
|
+
return null;
|
|
53
|
+
return Number(matched[1]);
|
|
54
|
+
}
|
|
55
|
+
function canonicalize(value) {
|
|
56
|
+
try {
|
|
57
|
+
return Intl.getCanonicalLocales(value)[0];
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function lookup(range, supported, parsed = []) {
|
|
64
|
+
let candidate = range;
|
|
65
|
+
while (candidate) {
|
|
66
|
+
for (const tag of supported) {
|
|
67
|
+
if (tag.canonical === candidate && !isExcluded(candidate, parsed))
|
|
68
|
+
return tag.original;
|
|
69
|
+
}
|
|
70
|
+
const separator = candidate.lastIndexOf('-');
|
|
71
|
+
candidate = separator > 1 ? candidate.slice(0, separator) : '';
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
function validSupported(supported) {
|
|
76
|
+
return supported.flatMap((original) => {
|
|
77
|
+
const canonical = canonicalize(original);
|
|
78
|
+
return canonical === null ? [] : [{ original, canonical }];
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
function isExcluded(canonical, parsed) {
|
|
82
|
+
const matching = parsed.filter((range) => matchesRange(range.range, canonical));
|
|
83
|
+
const specificity = Math.max(...matching.map((range) => rangeSpecificity(range.range)));
|
|
84
|
+
return matching.some((range) => range.quality === 0 && rangeSpecificity(range.range) === specificity);
|
|
85
|
+
}
|
|
86
|
+
function rangeSpecificity(range) {
|
|
87
|
+
return range === '*' ? 0 : range.split('-').length;
|
|
88
|
+
}
|
|
89
|
+
function matchesRange(range, candidate) {
|
|
90
|
+
return range === '*' || candidate === range || candidate.startsWith(`${range}-`);
|
|
91
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { MessageCatalog, MessageDescriptor } from './message-catalog.mjs';
|
|
2
|
+
export declare function isMessageDescriptor(value: unknown): value is MessageDescriptor;
|
|
3
|
+
export declare function assertCatalog(value: unknown, path?: string): asserts value is MessageCatalog;
|
|
4
|
+
export declare function isCatalogRecord(value: unknown): value is Record<string, unknown>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
const pluralCategories = ['zero', 'one', 'two', 'few', 'many', 'other'];
|
|
2
|
+
class CatalogValidationError extends TypeError {
|
|
3
|
+
}
|
|
4
|
+
export function isMessageDescriptor(value) {
|
|
5
|
+
try {
|
|
6
|
+
if (!isCatalogRecord(value) ||
|
|
7
|
+
!hasOwnString(value, 'kind') ||
|
|
8
|
+
!hasOwnString(value, 'valueParameter') ||
|
|
9
|
+
!isStringArray(value.numberParameters))
|
|
10
|
+
return false;
|
|
11
|
+
if (value.kind === 'plural')
|
|
12
|
+
return (hasOnlyKeys(value, ['kind', 'valueParameter', 'numberParameters', 'forms']) &&
|
|
13
|
+
Object.hasOwn(value, 'forms') &&
|
|
14
|
+
isPluralForms(value.forms));
|
|
15
|
+
return (value.kind === 'select-plural' &&
|
|
16
|
+
hasOnlyKeys(value, [
|
|
17
|
+
'kind',
|
|
18
|
+
'valueParameter',
|
|
19
|
+
'selectParameter',
|
|
20
|
+
'numberParameters',
|
|
21
|
+
'cases',
|
|
22
|
+
]) &&
|
|
23
|
+
hasOwnString(value, 'selectParameter') &&
|
|
24
|
+
Object.hasOwn(value, 'cases') &&
|
|
25
|
+
isCases(value.cases));
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function assertCatalog(value, path = 'catalog') {
|
|
32
|
+
assertCatalogValue(value, path, new WeakSet());
|
|
33
|
+
}
|
|
34
|
+
export function isCatalogRecord(value) {
|
|
35
|
+
try {
|
|
36
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
37
|
+
return false;
|
|
38
|
+
const prototype = Object.getPrototypeOf(value);
|
|
39
|
+
if ((prototype !== Object.prototype && prototype !== null) || Object.hasOwn(value, 'toJSON'))
|
|
40
|
+
return false;
|
|
41
|
+
return Object.values(Object.getOwnPropertyDescriptors(value)).every((descriptor) => descriptor.enumerable && 'value' in descriptor && typeof descriptor.value !== 'function');
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function assertCatalogValue(value, path, ancestors) {
|
|
48
|
+
if (!isCatalogRecord(value))
|
|
49
|
+
fail(`${path} must be a serializable catalog object`);
|
|
50
|
+
if (ancestors.has(value))
|
|
51
|
+
fail(`${path} contains a catalog cycle`);
|
|
52
|
+
ancestors.add(value);
|
|
53
|
+
try {
|
|
54
|
+
for (const [key, child] of Object.entries(value)) {
|
|
55
|
+
if (key.includes('.'))
|
|
56
|
+
fail(`segment "${key}" must not contain a dot`);
|
|
57
|
+
if (typeof child === 'string' || isMessageDescriptor(child))
|
|
58
|
+
continue;
|
|
59
|
+
if (isCatalogRecord(child) && child.kind === 'plural' && hasDescriptorFields(child))
|
|
60
|
+
fail(`Invalid plural message descriptor at "${key}"`);
|
|
61
|
+
if (isCatalogRecord(child) && child.kind === 'select-plural' && hasDescriptorFields(child))
|
|
62
|
+
fail(`Invalid select-plural message descriptor at "${key}"`);
|
|
63
|
+
assertCatalogValue(child, `${path}.${key}`, ancestors);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error instanceof CatalogValidationError)
|
|
68
|
+
throw error;
|
|
69
|
+
fail(`${path} must be a serializable catalog object`);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
ancestors.delete(value);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function isCases(value) {
|
|
76
|
+
return (isCatalogRecord(value) &&
|
|
77
|
+
Object.keys(value).length > 0 &&
|
|
78
|
+
Object.values(value).every(isPluralForms));
|
|
79
|
+
}
|
|
80
|
+
function isPluralForms(value) {
|
|
81
|
+
return (isCatalogRecord(value) &&
|
|
82
|
+
Object.hasOwn(value, 'other') &&
|
|
83
|
+
Object.keys(value).every((key) => pluralCategories.includes(key) &&
|
|
84
|
+
typeof value[key] === 'string'));
|
|
85
|
+
}
|
|
86
|
+
function hasOwnString(value, key) {
|
|
87
|
+
return Object.hasOwn(value, key) && typeof value[key] === 'string';
|
|
88
|
+
}
|
|
89
|
+
function hasOnlyKeys(value, allowed) {
|
|
90
|
+
return Object.keys(value).every((key) => allowed.includes(key));
|
|
91
|
+
}
|
|
92
|
+
function hasDescriptorFields(value) {
|
|
93
|
+
return ['valueParameter', 'numberParameters', 'forms', 'selectParameter', 'cases'].some((key) => Object.hasOwn(value, key));
|
|
94
|
+
}
|
|
95
|
+
function isStringArray(value) {
|
|
96
|
+
return (value === undefined ||
|
|
97
|
+
(Array.isArray(value) &&
|
|
98
|
+
!Object.hasOwn(value, 'toJSON') &&
|
|
99
|
+
Array.from(value).every((item) => typeof item === 'string')));
|
|
100
|
+
}
|
|
101
|
+
function fail(message) {
|
|
102
|
+
throw new CatalogValidationError(message);
|
|
103
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export { isCatalogRecord, isMessageDescriptor } from './message-catalog-validation.mjs';
|
|
2
|
+
type PluralCategory = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
|
|
3
|
+
export type PluralForms = Readonly<{
|
|
4
|
+
other: string;
|
|
5
|
+
} & Partial<Record<PluralCategory, string>>>;
|
|
6
|
+
export type PluralMessageDescriptor = Readonly<{
|
|
7
|
+
kind: 'plural';
|
|
8
|
+
valueParameter: string;
|
|
9
|
+
numberParameters?: readonly string[];
|
|
10
|
+
forms: PluralForms;
|
|
11
|
+
}>;
|
|
12
|
+
export type SelectPluralMessageDescriptor = Readonly<{
|
|
13
|
+
kind: 'select-plural';
|
|
14
|
+
valueParameter: string;
|
|
15
|
+
selectParameter: string;
|
|
16
|
+
numberParameters?: readonly string[];
|
|
17
|
+
cases: Readonly<Record<string, PluralForms>>;
|
|
18
|
+
}>;
|
|
19
|
+
export type MessageDescriptor = PluralMessageDescriptor | SelectPluralMessageDescriptor;
|
|
20
|
+
type CatalogLeaf = string | MessageDescriptor;
|
|
21
|
+
export type MessageCatalog = {
|
|
22
|
+
readonly [key: string]: CatalogLeaf | MessageCatalog;
|
|
23
|
+
};
|
|
24
|
+
type DottedCatalog<Catalog extends MessageCatalog> = {
|
|
25
|
+
[Key in keyof Catalog & string]: Key extends `${string}.${string}` ? true : Catalog[Key] extends MessageCatalog ? DottedCatalog<Catalog[Key]> : false;
|
|
26
|
+
}[keyof Catalog & string];
|
|
27
|
+
type ValidCatalog<Catalog extends MessageCatalog> = string extends keyof Catalog ? unknown : true extends DottedCatalog<Catalog> ? never : unknown;
|
|
28
|
+
export type MessageKey<Catalog extends MessageCatalog> = string extends keyof Catalog ? string : {
|
|
29
|
+
[Key in keyof Catalog & string]: Catalog[Key] extends CatalogLeaf ? Key : Catalog[Key] extends MessageCatalog ? `${Key}.${MessageKey<Catalog[Key]>}` : never;
|
|
30
|
+
}[keyof Catalog & string];
|
|
31
|
+
export type MessageTranslator<Catalog extends MessageCatalog> = <Key extends MessageKey<Catalog>>(key: Key, parameters?: Readonly<Record<string, unknown>>) => string;
|
|
32
|
+
export declare function plural(valueParameter: string, forms: PluralForms, numberParameters?: readonly string[]): PluralMessageDescriptor;
|
|
33
|
+
export declare function selectPlural(valueParameter: string, selectParameter: string, cases: Readonly<Record<string, PluralForms>>, numberParameters?: readonly string[]): SelectPluralMessageDescriptor;
|
|
34
|
+
export declare function createMessageTranslator<Catalog extends MessageCatalog>(locale: string, catalog: Catalog & ValidCatalog<Catalog>, options?: {
|
|
35
|
+
formatNumber?: (value: number, locale: string) => string;
|
|
36
|
+
}): MessageTranslator<Catalog>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { assertCatalog, isCatalogRecord, isMessageDescriptor, } from './message-catalog-validation.mjs';
|
|
2
|
+
export { isCatalogRecord, isMessageDescriptor } from './message-catalog-validation.mjs';
|
|
3
|
+
const pluralRules = new Map();
|
|
4
|
+
export function plural(valueParameter, forms, numberParameters) {
|
|
5
|
+
const descriptor = {
|
|
6
|
+
kind: 'plural',
|
|
7
|
+
valueParameter,
|
|
8
|
+
...(numberParameters && { numberParameters }),
|
|
9
|
+
forms,
|
|
10
|
+
};
|
|
11
|
+
if (!isMessageDescriptor(descriptor))
|
|
12
|
+
throw new TypeError('Invalid plural message descriptor');
|
|
13
|
+
return descriptor;
|
|
14
|
+
}
|
|
15
|
+
export function selectPlural(valueParameter, selectParameter, cases, numberParameters) {
|
|
16
|
+
const descriptor = {
|
|
17
|
+
kind: 'select-plural',
|
|
18
|
+
valueParameter,
|
|
19
|
+
selectParameter,
|
|
20
|
+
...(numberParameters && { numberParameters }),
|
|
21
|
+
cases,
|
|
22
|
+
};
|
|
23
|
+
if (!isMessageDescriptor(descriptor))
|
|
24
|
+
throw new TypeError('Invalid select-plural message descriptor');
|
|
25
|
+
return descriptor;
|
|
26
|
+
}
|
|
27
|
+
export function createMessageTranslator(locale, catalog, options = {}) {
|
|
28
|
+
assertCatalog(catalog);
|
|
29
|
+
return (key, parameters = {}) => {
|
|
30
|
+
const leaf = resolve(catalog, key);
|
|
31
|
+
if (typeof leaf === 'string')
|
|
32
|
+
return interpolate(leaf, parameters);
|
|
33
|
+
if (!isMessageDescriptor(leaf))
|
|
34
|
+
throw new Error(`Message key "${key}" resolves to a non-leaf value`);
|
|
35
|
+
const numeric = readNumericParameters(leaf, parameters);
|
|
36
|
+
const forms = leaf.kind === 'plural' ? leaf.forms : selectForms(leaf, parameters);
|
|
37
|
+
const category = getPluralRules(locale).select(numeric.get(leaf.valueParameter));
|
|
38
|
+
const template = Object.hasOwn(forms, category) ? forms[category] : forms.other;
|
|
39
|
+
const formatted = { ...parameters };
|
|
40
|
+
for (const parameter of leaf.numberParameters ?? [])
|
|
41
|
+
formatted[parameter] =
|
|
42
|
+
options.formatNumber?.(numeric.get(parameter), locale) ?? String(numeric.get(parameter));
|
|
43
|
+
return interpolate(template, formatted);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
function resolve(catalog, key) {
|
|
47
|
+
let value = catalog;
|
|
48
|
+
for (const segment of key.split('.')) {
|
|
49
|
+
if (!isCatalogRecord(value) || !Object.hasOwn(value, segment))
|
|
50
|
+
throw new Error(`Unresolvable message key "${key}" at "${segment}"`);
|
|
51
|
+
value = value[segment];
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
function readNumericParameters(descriptor, parameters) {
|
|
56
|
+
const numeric = new Map();
|
|
57
|
+
for (const parameter of new Set([
|
|
58
|
+
descriptor.valueParameter,
|
|
59
|
+
...(descriptor.numberParameters ?? []),
|
|
60
|
+
])) {
|
|
61
|
+
const value = parameters[parameter];
|
|
62
|
+
if (!Object.hasOwn(parameters, parameter) ||
|
|
63
|
+
typeof value !== 'number' ||
|
|
64
|
+
!Number.isFinite(value))
|
|
65
|
+
throw new TypeError(`Message parameter "${parameter}" must be a finite number`);
|
|
66
|
+
numeric.set(parameter, value);
|
|
67
|
+
}
|
|
68
|
+
return numeric;
|
|
69
|
+
}
|
|
70
|
+
function selectForms(descriptor, parameters) {
|
|
71
|
+
if (!Object.hasOwn(parameters, descriptor.selectParameter))
|
|
72
|
+
throw new TypeError(`Message parameter "${descriptor.selectParameter}" must be present`);
|
|
73
|
+
const selected = String(parameters[descriptor.selectParameter]);
|
|
74
|
+
if (!Object.hasOwn(descriptor.cases, selected))
|
|
75
|
+
throw new Error(`Unknown select-plural case "${selected}" for "${descriptor.selectParameter}"`);
|
|
76
|
+
return descriptor.cases[selected];
|
|
77
|
+
}
|
|
78
|
+
function interpolate(template, parameters) {
|
|
79
|
+
return template.replace(/\{([\w.-]+)\}/g, (match, name) => Object.hasOwn(parameters, name) ? String(parameters[name]) : match);
|
|
80
|
+
}
|
|
81
|
+
function getPluralRules(locale) {
|
|
82
|
+
const cached = pluralRules.get(locale);
|
|
83
|
+
if (cached)
|
|
84
|
+
return cached;
|
|
85
|
+
const rules = new Intl.PluralRules(locale);
|
|
86
|
+
pluralRules.set(locale, rules);
|
|
87
|
+
return rules;
|
|
88
|
+
}
|
package/dist/urls.d.mts
CHANGED
|
@@ -7,3 +7,5 @@ export declare function normalizeAsciiHostname(value: string): string | null;
|
|
|
7
7
|
export declare function extractUrlHostname(value: string): string | null;
|
|
8
8
|
export declare function getFirstPathSegment(pathname: string): string | null;
|
|
9
9
|
export declare function matchesPathnamePattern(pathname: string, pattern: string): boolean;
|
|
10
|
+
/** Matches an ASCII hostname against exact patterns or an inclusive `*.` suffix pattern. */
|
|
11
|
+
export declare function matchesHostnamePattern(hostname: string, patterns: string | readonly string[]): boolean;
|
package/dist/urls.mjs
CHANGED
|
@@ -91,3 +91,16 @@ export function matchesPathnamePattern(pathname, pattern) {
|
|
|
91
91
|
patternIndex++;
|
|
92
92
|
return patternIndex === pattern.length;
|
|
93
93
|
}
|
|
94
|
+
/** Matches an ASCII hostname against exact patterns or an inclusive `*.` suffix pattern. */
|
|
95
|
+
export function matchesHostnamePattern(hostname, patterns) {
|
|
96
|
+
const candidate = hostname.toLowerCase();
|
|
97
|
+
for (const configuredPattern of typeof patterns === 'string' ? [patterns] : patterns) {
|
|
98
|
+
const pattern = configuredPattern.toLowerCase();
|
|
99
|
+
if (pattern === candidate)
|
|
100
|
+
return true;
|
|
101
|
+
if (pattern.startsWith('*.') &&
|
|
102
|
+
(candidate === pattern.slice(2) || candidate.endsWith(pattern.slice(1))))
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vouchington/utils",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Dependency-free runtime utilities for Vouchington-style applications.",
|
|
5
5
|
"homepage": "https://github.com/vouchington/vouchington-platform/tree/main/packages/utils#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -60,6 +60,11 @@
|
|
|
60
60
|
"import": "./dist/collections.mjs",
|
|
61
61
|
"default": "./dist/collections.mjs"
|
|
62
62
|
},
|
|
63
|
+
"./feature-flags": {
|
|
64
|
+
"types": "./dist/feature-flags.d.mts",
|
|
65
|
+
"import": "./dist/feature-flags.mjs",
|
|
66
|
+
"default": "./dist/feature-flags.mjs"
|
|
67
|
+
},
|
|
63
68
|
"./dates": {
|
|
64
69
|
"types": "./dist/dates.d.mts",
|
|
65
70
|
"import": "./dist/dates.mjs",
|
|
@@ -110,6 +115,16 @@
|
|
|
110
115
|
"import": "./dist/urls.mjs",
|
|
111
116
|
"default": "./dist/urls.mjs"
|
|
112
117
|
},
|
|
118
|
+
"./language-tags": {
|
|
119
|
+
"types": "./dist/language-tags.d.mts",
|
|
120
|
+
"import": "./dist/language-tags.mjs",
|
|
121
|
+
"default": "./dist/language-tags.mjs"
|
|
122
|
+
},
|
|
123
|
+
"./message-catalog": {
|
|
124
|
+
"types": "./dist/message-catalog.d.mts",
|
|
125
|
+
"import": "./dist/message-catalog.mjs",
|
|
126
|
+
"default": "./dist/message-catalog.mjs"
|
|
127
|
+
},
|
|
113
128
|
"./validation": {
|
|
114
129
|
"types": "./dist/validation.d.mts",
|
|
115
130
|
"import": "./dist/validation.mjs",
|