|
| 1 | +/** |
| 2 | + * ObjectUI |
| 3 | + * Copyright (c) 2024-present ObjectStack Inc. |
| 4 | + * |
| 5 | + * This source code is licensed under the MIT license found in the |
| 6 | + * LICENSE file in the root directory of this source tree. |
| 7 | + */ |
| 8 | + |
| 9 | +/** |
| 10 | + * @object-ui/i18n - Spec-aligned i18n utilities |
| 11 | + * |
| 12 | + * Runtime consumers for @objectstack/spec v2.0.7 i18n types: |
| 13 | + * - PluralRuleSchema → resolvePlural() |
| 14 | + * - DateFormatSchema → formatDateSpec() |
| 15 | + * - NumberFormatSchema → formatNumberSpec() |
| 16 | + * - LocaleConfigSchema → applyLocaleConfig() |
| 17 | + * |
| 18 | + * @module spec-formatters |
| 19 | + */ |
| 20 | + |
| 21 | +// ============================================================================ |
| 22 | +// PluralRuleSchema Consumer |
| 23 | +// ============================================================================ |
| 24 | + |
| 25 | +/** |
| 26 | + * Spec-aligned PluralRule (mirrors @objectstack/spec PluralRuleSchema). |
| 27 | + */ |
| 28 | +export interface SpecPluralRule { |
| 29 | + /** Translation key */ |
| 30 | + key: string; |
| 31 | + /** Form for zero items */ |
| 32 | + zero?: string; |
| 33 | + /** Form for exactly one item */ |
| 34 | + one?: string; |
| 35 | + /** Form for exactly two items */ |
| 36 | + two?: string; |
| 37 | + /** Form for few items (language-specific) */ |
| 38 | + few?: string; |
| 39 | + /** Form for many items (language-specific) */ |
| 40 | + many?: string; |
| 41 | + /** Default/fallback form (required) */ |
| 42 | + other: string; |
| 43 | +} |
| 44 | + |
| 45 | +/** |
| 46 | + * Resolve a plural form based on count, following CLDR plural rules. |
| 47 | + * Uses the Intl.PluralRules API for correct locale-aware pluralization. |
| 48 | + * |
| 49 | + * @example |
| 50 | + * ```ts |
| 51 | + * const rule: SpecPluralRule = { |
| 52 | + * key: 'items.count', |
| 53 | + * zero: 'No items', |
| 54 | + * one: '{count} item', |
| 55 | + * other: '{count} items', |
| 56 | + * }; |
| 57 | + * resolvePlural(rule, 0, 'en'); // → 'No items' |
| 58 | + * resolvePlural(rule, 1, 'en'); // → '1 item' |
| 59 | + * resolvePlural(rule, 5, 'en'); // → '5 items' |
| 60 | + * ``` |
| 61 | + */ |
| 62 | +export function resolvePlural( |
| 63 | + rule: SpecPluralRule, |
| 64 | + count: number, |
| 65 | + locale = 'en', |
| 66 | +): string { |
| 67 | + const pr = new Intl.PluralRules(locale); |
| 68 | + const category = pr.select(count); |
| 69 | + |
| 70 | + // Prefer the explicit form, fall back to 'other' |
| 71 | + const template = |
| 72 | + (category === 'zero' && rule.zero) || |
| 73 | + (category === 'one' && rule.one) || |
| 74 | + (category === 'two' && rule.two) || |
| 75 | + (category === 'few' && rule.few) || |
| 76 | + (category === 'many' && rule.many) || |
| 77 | + rule.other; |
| 78 | + |
| 79 | + // Replace {count} placeholder |
| 80 | + return template.replace(/\{count\}/g, String(count)); |
| 81 | +} |
| 82 | + |
| 83 | +// ============================================================================ |
| 84 | +// DateFormatSchema Consumer |
| 85 | +// ============================================================================ |
| 86 | + |
| 87 | +/** |
| 88 | + * Spec-aligned DateFormat (mirrors @objectstack/spec DateFormatSchema). |
| 89 | + */ |
| 90 | +export interface SpecDateFormat { |
| 91 | + dateStyle?: 'full' | 'long' | 'medium' | 'short'; |
| 92 | + timeStyle?: 'full' | 'long' | 'medium' | 'short'; |
| 93 | + timeZone?: string; |
| 94 | + hour12?: boolean; |
| 95 | +} |
| 96 | + |
| 97 | +/** |
| 98 | + * Format a date using @objectstack/spec DateFormatSchema configuration. |
| 99 | + * |
| 100 | + * @example |
| 101 | + * ```ts |
| 102 | + * formatDateSpec(new Date(), { |
| 103 | + * dateStyle: 'medium', |
| 104 | + * timeStyle: 'short', |
| 105 | + * timeZone: 'America/New_York', |
| 106 | + * }, 'en-US'); |
| 107 | + * ``` |
| 108 | + */ |
| 109 | +export function formatDateSpec( |
| 110 | + date: Date | string | number, |
| 111 | + format: SpecDateFormat, |
| 112 | + locale = 'en', |
| 113 | +): string { |
| 114 | + const d = date instanceof Date ? date : new Date(date); |
| 115 | + if (isNaN(d.getTime())) return String(date); |
| 116 | + |
| 117 | + const options: Intl.DateTimeFormatOptions = {}; |
| 118 | + if (format.dateStyle) options.dateStyle = format.dateStyle; |
| 119 | + if (format.timeStyle) options.timeStyle = format.timeStyle; |
| 120 | + if (format.timeZone) options.timeZone = format.timeZone; |
| 121 | + if (format.hour12 !== undefined) options.hour12 = format.hour12; |
| 122 | + |
| 123 | + return new Intl.DateTimeFormat(locale, options).format(d); |
| 124 | +} |
| 125 | + |
| 126 | +// ============================================================================ |
| 127 | +// NumberFormatSchema Consumer |
| 128 | +// ============================================================================ |
| 129 | + |
| 130 | +/** |
| 131 | + * Spec-aligned NumberFormat (mirrors @objectstack/spec NumberFormatSchema). |
| 132 | + */ |
| 133 | +export interface SpecNumberFormat { |
| 134 | + style?: 'currency' | 'percent' | 'decimal' | 'unit'; |
| 135 | + currency?: string; |
| 136 | + unit?: string; |
| 137 | + minimumFractionDigits?: number; |
| 138 | + maximumFractionDigits?: number; |
| 139 | + useGrouping?: boolean; |
| 140 | +} |
| 141 | + |
| 142 | +/** |
| 143 | + * Format a number using @objectstack/spec NumberFormatSchema configuration. |
| 144 | + * |
| 145 | + * @example |
| 146 | + * ```ts |
| 147 | + * formatNumberSpec(1234.56, { |
| 148 | + * style: 'currency', |
| 149 | + * currency: 'USD', |
| 150 | + * maximumFractionDigits: 2, |
| 151 | + * }, 'en-US'); // → '$1,234.56' |
| 152 | + * ``` |
| 153 | + */ |
| 154 | +export function formatNumberSpec( |
| 155 | + value: number, |
| 156 | + format: SpecNumberFormat, |
| 157 | + locale = 'en', |
| 158 | +): string { |
| 159 | + const options: Intl.NumberFormatOptions = { |
| 160 | + style: format.style || 'decimal', |
| 161 | + }; |
| 162 | + |
| 163 | + if (format.currency) options.currency = format.currency; |
| 164 | + if (format.unit) options.unit = format.unit; |
| 165 | + if (format.minimumFractionDigits !== undefined) options.minimumFractionDigits = format.minimumFractionDigits; |
| 166 | + if (format.maximumFractionDigits !== undefined) options.maximumFractionDigits = format.maximumFractionDigits; |
| 167 | + if (format.useGrouping !== undefined) options.useGrouping = format.useGrouping; |
| 168 | + |
| 169 | + return new Intl.NumberFormat(locale, options).format(value); |
| 170 | +} |
| 171 | + |
| 172 | +// ============================================================================ |
| 173 | +// LocaleConfigSchema Consumer |
| 174 | +// ============================================================================ |
| 175 | + |
| 176 | +/** |
| 177 | + * Spec-aligned LocaleConfig (mirrors @objectstack/spec LocaleConfigSchema). |
| 178 | + */ |
| 179 | +export interface SpecLocaleConfig { |
| 180 | + /** BCP 47 language code (e.g., 'en-US', 'zh-CN') */ |
| 181 | + code: string; |
| 182 | + /** Fallback locale chain */ |
| 183 | + fallbackChain?: string[]; |
| 184 | + /** Text direction */ |
| 185 | + direction?: 'ltr' | 'rtl'; |
| 186 | + /** Number formatting defaults */ |
| 187 | + numberFormat?: SpecNumberFormat; |
| 188 | + /** Date formatting defaults */ |
| 189 | + dateFormat?: SpecDateFormat; |
| 190 | +} |
| 191 | + |
| 192 | +/** |
| 193 | + * Apply a LocaleConfigSchema to configure i18n formatting defaults. |
| 194 | + * Returns resolved formatting functions bound to the locale config. |
| 195 | + * |
| 196 | + * @example |
| 197 | + * ```ts |
| 198 | + * const locale = applyLocaleConfig({ |
| 199 | + * code: 'zh-CN', |
| 200 | + * direction: 'ltr', |
| 201 | + * numberFormat: { style: 'decimal', useGrouping: true }, |
| 202 | + * dateFormat: { dateStyle: 'medium' }, |
| 203 | + * }); |
| 204 | + * locale.formatDate(new Date()); // Chinese medium date |
| 205 | + * locale.formatNumber(1234.5); // 1,234.5 |
| 206 | + * ``` |
| 207 | + */ |
| 208 | +export function applyLocaleConfig(config: SpecLocaleConfig) { |
| 209 | + return { |
| 210 | + code: config.code, |
| 211 | + direction: config.direction || 'ltr', |
| 212 | + fallbackChain: config.fallbackChain || [], |
| 213 | + formatDate: (date: Date | string | number, overrides?: Partial<SpecDateFormat>) => |
| 214 | + formatDateSpec(date, { ...config.dateFormat, ...overrides }, config.code), |
| 215 | + formatNumber: (value: number, overrides?: Partial<SpecNumberFormat>) => |
| 216 | + formatNumberSpec(value, { ...config.numberFormat, ...overrides }, config.code), |
| 217 | + resolvePlural: (rule: SpecPluralRule, count: number) => |
| 218 | + resolvePlural(rule, count, config.code), |
| 219 | + }; |
| 220 | +} |
0 commit comments