@magicvr/schema-ui-lib 0.1.2 → 0.1.5
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/i18n/catalog.d.ts +1 -1
- package/i18n/catalog.js +96 -0
- package/i18n/format.d.ts +1 -1
- package/i18n/format.js +35 -0
- package/i18n/locale.js +79 -0
- package/i18n/messages/en-US.json +954 -0
- package/i18n/messages/zh-CN.json +954 -0
- package/i18n/money.d.ts +1 -1
- package/i18n/money.js +187 -0
- package/i18n/runtime.d.ts +3 -3
- package/i18n/runtime.js +184 -0
- package/i18n/timezone.js +117 -0
- package/lib/datetime.js +36 -0
- package/lib/fetch-timeout.js +43 -0
- package/lib/index.d.ts +10 -10
- package/lib/index.js +11 -0
- package/lib/utils.js +5 -0
- package/package.json +6 -6
- package/index.js +0 -5765
package/i18n/money.d.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* (amount in minor units); unparsable input → null (callers show a
|
|
13
13
|
* localized input error and must NOT submit the raw string).
|
|
14
14
|
*/
|
|
15
|
-
import { type Locale } from "./locale";
|
|
15
|
+
import { type Locale } from "./locale.js";
|
|
16
16
|
/** Site/machine default currency (ISO 4217). */
|
|
17
17
|
export declare const DEFAULT_CURRENCY = "USD";
|
|
18
18
|
/** Default minor-unit exponent per currency group (ISO 4217 minor units). */
|
package/i18n/money.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Money / number pure-logic unit (workspace-020 · R3 · C1/C2/C3/C5).
|
|
3
|
+
*
|
|
4
|
+
* Contract GOAL-002 D-001 §3 / §4.3 (user-confirmed I-002, 2026-08-26):
|
|
5
|
+
* - Display + input semantics live on the frontend; API stays a machine
|
|
6
|
+
* contract (amounts = int64 JSON in the smallest currency unit).
|
|
7
|
+
* - No custom format templates: symbols / positions / fraction digits are
|
|
8
|
+
* derived from `Intl.NumberFormat` (locale + currency).
|
|
9
|
+
* - Embedded default currency map (§4.3): zh-CN → CNY, en-US → USD,
|
|
10
|
+
* unknown locale → USD. Missing configuration never throws.
|
|
11
|
+
* - Input parsing normalizes localized strings back to machine values
|
|
12
|
+
* (amount in minor units); unparsable input → null (callers show a
|
|
13
|
+
* localized input error and must NOT submit the raw string).
|
|
14
|
+
*/
|
|
15
|
+
import { SUPPORTED_LOCALES } from "./locale.js";
|
|
16
|
+
/** Site/machine default currency (ISO 4217). */
|
|
17
|
+
export const DEFAULT_CURRENCY = "USD";
|
|
18
|
+
/** Default minor-unit exponent per currency group (ISO 4217 minor units). */
|
|
19
|
+
export const DEFAULT_MINOR_UNITS = 2;
|
|
20
|
+
/** Currency codes frozen in the contract's embedded default map (§4.3). */
|
|
21
|
+
const DEFAULT_CURRENCY_MAP = {
|
|
22
|
+
"zh-CN": "CNY",
|
|
23
|
+
"en-US": "USD",
|
|
24
|
+
};
|
|
25
|
+
/** Uppercase three-letter ISO 4217 code; null for anything else. */
|
|
26
|
+
export function normalizeCurrencyCode(raw) {
|
|
27
|
+
if (typeof raw !== "string") {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const trimmed = raw.trim();
|
|
31
|
+
return /^[A-Za-z]{3}$/.test(trimmed) ? trimmed.toUpperCase() : null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Embedded default currency for a locale (§4.3). Unknown locales fall back to
|
|
35
|
+
* USD; never throws.
|
|
36
|
+
*/
|
|
37
|
+
export function defaultCurrencyFor(locale) {
|
|
38
|
+
const key = SUPPORTED_LOCALES.includes(locale)
|
|
39
|
+
? locale
|
|
40
|
+
: null;
|
|
41
|
+
return key === null ? DEFAULT_CURRENCY : DEFAULT_CURRENCY_MAP[key];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Effective default currency: the explicit site default (branding
|
|
45
|
+
* `defaultCurrency`, ISO 4217) wins when set; otherwise the embedded
|
|
46
|
+
* per-locale map (§4.3) applies. Never throws.
|
|
47
|
+
*/
|
|
48
|
+
export function resolveEffectiveCurrency(locale, siteDefault) {
|
|
49
|
+
const site = normalizeCurrencyCode(siteDefault);
|
|
50
|
+
return site !== null ? site : defaultCurrencyFor(locale);
|
|
51
|
+
}
|
|
52
|
+
export function localeSeparators(locale) {
|
|
53
|
+
let group = ",";
|
|
54
|
+
let decimal = ".";
|
|
55
|
+
try {
|
|
56
|
+
for (const part of new Intl.NumberFormat(locale).formatToParts(1234567.89)) {
|
|
57
|
+
if (part.type === "group") {
|
|
58
|
+
group = part.value;
|
|
59
|
+
}
|
|
60
|
+
else if (part.type === "decimal") {
|
|
61
|
+
decimal = part.value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Unsupported locale edge — keep the en-US-ish defaults.
|
|
67
|
+
}
|
|
68
|
+
return { group, decimal };
|
|
69
|
+
}
|
|
70
|
+
/** Priority: explicit option → site default → embedded per-locale map. */
|
|
71
|
+
function resolveCurrency(locale, options) {
|
|
72
|
+
if (options.currency !== undefined) {
|
|
73
|
+
return normalizeCurrencyCode(options.currency);
|
|
74
|
+
}
|
|
75
|
+
return normalizeCurrencyCode(options.siteDefaultCurrency) ?? defaultCurrencyFor(locale);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Formats an amount given as machine value (minor units) into a
|
|
79
|
+
* locale+currency display string. Invalid input renders "" (fail-safe).
|
|
80
|
+
* R4 F-007: values beyond Number.MAX_SAFE_INTEGER render "" — the machine
|
|
81
|
+
* contract declares int64 minor units, which JS number cannot carry.
|
|
82
|
+
*/
|
|
83
|
+
export function formatMoney(minorValue, locale, options = {}) {
|
|
84
|
+
if (typeof minorValue !== "number" || !Number.isFinite(minorValue) || !Number.isSafeInteger(minorValue)) {
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
const currency = resolveCurrency(locale, options);
|
|
88
|
+
if (currency === null) {
|
|
89
|
+
return "";
|
|
90
|
+
}
|
|
91
|
+
const minorUnits = Number.isInteger(options.minorUnits) && options.minorUnits >= 0
|
|
92
|
+
? options.minorUnits
|
|
93
|
+
: DEFAULT_MINOR_UNITS;
|
|
94
|
+
const major = minorValue / Math.pow(10, minorUnits);
|
|
95
|
+
try {
|
|
96
|
+
return new Intl.NumberFormat(locale, {
|
|
97
|
+
style: "currency",
|
|
98
|
+
currency,
|
|
99
|
+
minimumFractionDigits: minorUnits,
|
|
100
|
+
maximumFractionDigits: minorUnits,
|
|
101
|
+
}).format(major);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return "";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Weakly parses a localized number (separators stripped) into a plain number.
|
|
109
|
+
* Returns null for anything without a parseable numeric core.
|
|
110
|
+
*/
|
|
111
|
+
function parseLocalizedNumberCore(raw, locale) {
|
|
112
|
+
if (typeof raw !== "string") {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const trimmed = raw.trim();
|
|
116
|
+
if (trimmed === "") {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const { group, decimal } = localeSeparators(locale);
|
|
120
|
+
let core = trimmed;
|
|
121
|
+
if (group !== "") {
|
|
122
|
+
// Strip the locale grouping separator wherever it appears. NOTE: group
|
|
123
|
+
// position correctness (e.g. "12,34.5") is intentionally NOT validated —
|
|
124
|
+
// out of the R3 verification scope (tolerance documented for R4 review).
|
|
125
|
+
core = core.split(group).join("");
|
|
126
|
+
}
|
|
127
|
+
if (decimal !== ".") {
|
|
128
|
+
core = core.split(decimal).join(".");
|
|
129
|
+
}
|
|
130
|
+
if ((core.match(/\./g) ?? []).length > 1) {
|
|
131
|
+
return null; // more than one decimal separator → not a number
|
|
132
|
+
}
|
|
133
|
+
if (!/^-?\d+(\.\d+)?$/.test(core)) {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
const value = Number(core);
|
|
137
|
+
return Number.isFinite(value) ? value : null;
|
|
138
|
+
}
|
|
139
|
+
/** Strips a locale+currency symbol (e.g. "¥", "$", "CN¥") and the code itself. */
|
|
140
|
+
function stripCurrencyAffixes(raw, locale, currency) {
|
|
141
|
+
let out = raw.trim();
|
|
142
|
+
try {
|
|
143
|
+
const parts = new Intl.NumberFormat(locale, {
|
|
144
|
+
style: "currency",
|
|
145
|
+
currency,
|
|
146
|
+
minimumFractionDigits: 2,
|
|
147
|
+
maximumFractionDigits: 2,
|
|
148
|
+
}).formatToParts(1.23);
|
|
149
|
+
const symbol = parts.find((part) => part.type === "currency")?.value;
|
|
150
|
+
if (symbol !== undefined && symbol !== "") {
|
|
151
|
+
out = out.split(symbol).join("");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
// Fall through with the raw string.
|
|
156
|
+
}
|
|
157
|
+
// Also tolerate the bare ISO code (e.g. "CNY 123.45").
|
|
158
|
+
return out.split(currency).join("").trim();
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Parses a localized money string into the machine value (minor-unit
|
|
162
|
+
* integer per contract §3.3). Returns null when the input is not a
|
|
163
|
+
* parseable amount — callers must NOT submit the raw string.
|
|
164
|
+
*/
|
|
165
|
+
export function parseLocalizedMoney(raw, locale, options = {}) {
|
|
166
|
+
const currency = resolveCurrency(locale, options);
|
|
167
|
+
if (currency === null) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const cleaned = stripCurrencyAffixes(raw, locale, currency);
|
|
171
|
+
const value = parseLocalizedNumberCore(cleaned, locale);
|
|
172
|
+
if (value === null) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const minorUnits = Number.isInteger(options.minorUnits) && options.minorUnits >= 0
|
|
176
|
+
? options.minorUnits
|
|
177
|
+
: DEFAULT_MINOR_UNITS;
|
|
178
|
+
const minor = Math.round(value * Math.pow(10, minorUnits));
|
|
179
|
+
// R4 F-007: the machine contract declares int64 minor units; JS number
|
|
180
|
+
// cannot represent values beyond MAX_SAFE_INTEGER — reject instead of
|
|
181
|
+
// silently losing precision.
|
|
182
|
+
return Number.isSafeInteger(minor) ? minor : null;
|
|
183
|
+
}
|
|
184
|
+
/** Parses a localized plain number into a machine number; null on failure. */
|
|
185
|
+
export function parseLocalizedNumber(raw, locale) {
|
|
186
|
+
return parseLocalizedNumberCore(raw, locale);
|
|
187
|
+
}
|
package/i18n/runtime.d.ts
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* - Exposes `t` / `formatDate` / `formatNumber` to components.
|
|
10
10
|
*/
|
|
11
11
|
import { type ReactNode } from "react";
|
|
12
|
-
import { type MessageParams } from "./catalog";
|
|
13
|
-
import { type Locale, type LocalePreference } from "./locale";
|
|
14
|
-
import { type TimezonePreference } from "./timezone";
|
|
12
|
+
import { type MessageParams } from "./catalog.js";
|
|
13
|
+
import { type Locale, type LocalePreference } from "./locale.js";
|
|
14
|
+
import { type TimezonePreference } from "./timezone.js";
|
|
15
15
|
export declare const LOCALE_STORAGE_KEY = "schema-ui:locale";
|
|
16
16
|
export declare function readStoredLocale(): string | null;
|
|
17
17
|
export declare function writeStoredLocale(preference: LocalePreference): void;
|
package/i18n/runtime.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* I18n React runtime (S1 · C4/C5).
|
|
4
|
+
*
|
|
5
|
+
* - Resolves the effective locale via `resolveLocale` (frozen priority).
|
|
6
|
+
* - Persists the user's explicit choice in localStorage["schema-ui:locale"]
|
|
7
|
+
* (single channel, same pattern as the theme mechanism; login/logout never
|
|
8
|
+
* clears it — D-002 §I-L10N-002).
|
|
9
|
+
* - Applies `document.documentElement.lang` on locale change.
|
|
10
|
+
* - Exposes `t` / `formatDate` / `formatNumber` to components.
|
|
11
|
+
*/
|
|
12
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState, } from "react";
|
|
13
|
+
import { createTranslator } from "./catalog.js";
|
|
14
|
+
import { formatDate as formatDateImpl, formatNumber as formatNumberImpl } from "./format.js";
|
|
15
|
+
import { defaultBrowserLanguages, DEFAULT_LOCALE, normalizePreference, resolveLocale, } from "./locale.js";
|
|
16
|
+
import { AUTO_TIMEZONE, detectBrowserTimezone, normalizeTimezonePreference, readStoredTimezone, resolveEffectiveTimezone, writeStoredTimezone, } from "./timezone.js";
|
|
17
|
+
export const LOCALE_STORAGE_KEY = "schema-ui:locale";
|
|
18
|
+
export function readStoredLocale() {
|
|
19
|
+
try {
|
|
20
|
+
if (typeof localStorage === "undefined") {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return localStorage.getItem(LOCALE_STORAGE_KEY);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Privacy mode / disabled storage throws SecurityError — same posture as
|
|
27
|
+
// tokens.ts / theme.ts. Never let locale boot white-screen the tree.
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export function writeStoredLocale(preference) {
|
|
32
|
+
try {
|
|
33
|
+
if (typeof localStorage === "undefined") {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (preference === "auto") {
|
|
37
|
+
localStorage.removeItem(LOCALE_STORAGE_KEY);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
localStorage.setItem(LOCALE_STORAGE_KEY, preference);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Best-effort persist; the in-memory preference still applies this page.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Applies the effective locale to <html lang>. No-op outside a browser. */
|
|
48
|
+
export function applyLocaleToDocument(locale) {
|
|
49
|
+
if (typeof document !== "undefined") {
|
|
50
|
+
document.documentElement.lang = locale;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// ── active-locale registry ────────────────────────────────────────────────────
|
|
54
|
+
// Module-level current locale for non-React consumers (API fetchers attach it
|
|
55
|
+
// as Accept-Language so the server negotiates the same language — VP-007 S4).
|
|
56
|
+
let activeLocale = "en-US";
|
|
57
|
+
/** Returns the provider's currently effective locale (defaults en-US). */
|
|
58
|
+
export function getActiveLocale() {
|
|
59
|
+
return activeLocale;
|
|
60
|
+
}
|
|
61
|
+
/** Internal: keeps the registry in sync with the provider's effective locale. */
|
|
62
|
+
export function setActiveLocale(locale) {
|
|
63
|
+
activeLocale = locale;
|
|
64
|
+
}
|
|
65
|
+
const I18nContext = createContext(null);
|
|
66
|
+
export function I18nProvider({ children, systemDefault = null, stored, browserLanguages, siteTimezone = null, storedTimezone, detectTimezone, siteDefaultCurrency = null, systemDefaultUrl, }) {
|
|
67
|
+
const [preference, setPreferenceState] = useState(() => {
|
|
68
|
+
const raw = stored !== undefined ? stored : readStoredLocale();
|
|
69
|
+
return normalizePreference(raw);
|
|
70
|
+
});
|
|
71
|
+
const [fetchedSystemDefault, setFetchedSystemDefault] = useState(null);
|
|
72
|
+
const [fetchedSiteTimezone, setFetchedSiteTimezone] = useState(null);
|
|
73
|
+
const [fetchedSiteDefaultCurrency, setFetchedSiteDefaultCurrency] = useState(null);
|
|
74
|
+
const [timezonePreference, setTimezonePreferenceState] = useState(() => {
|
|
75
|
+
const raw = storedTimezone !== undefined ? storedTimezone : readStoredTimezone();
|
|
76
|
+
return normalizeTimezonePreference(raw);
|
|
77
|
+
});
|
|
78
|
+
const browserList = browserLanguages !== undefined ? browserLanguages : defaultBrowserLanguages();
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (systemDefaultUrl === undefined) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
let cancelled = false;
|
|
84
|
+
fetch(systemDefaultUrl)
|
|
85
|
+
.then((response) => (response.ok ? response.json() : null))
|
|
86
|
+
.then((body) => {
|
|
87
|
+
if (cancelled) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const record = body;
|
|
91
|
+
const locale = typeof record?.defaultLocale === "string" ? record.defaultLocale : null;
|
|
92
|
+
setFetchedSystemDefault(locale === "auto" ? null : locale);
|
|
93
|
+
const zone = typeof record?.siteTimezone === "string" ? record.siteTimezone : null;
|
|
94
|
+
setFetchedSiteTimezone(zone === "auto" ? null : zone);
|
|
95
|
+
const currency = typeof record?.defaultCurrency === "string" ? record.defaultCurrency.trim().toUpperCase() : null;
|
|
96
|
+
setFetchedSiteDefaultCurrency(currency === "" || currency === null ? null : currency);
|
|
97
|
+
})
|
|
98
|
+
.catch(() => {
|
|
99
|
+
if (!cancelled) {
|
|
100
|
+
setFetchedSystemDefault(null);
|
|
101
|
+
setFetchedSiteTimezone(null);
|
|
102
|
+
setFetchedSiteDefaultCurrency(null);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
return () => {
|
|
106
|
+
cancelled = true;
|
|
107
|
+
};
|
|
108
|
+
}, [systemDefaultUrl]);
|
|
109
|
+
const effectiveSystemDefault = systemDefault ?? fetchedSystemDefault;
|
|
110
|
+
const effectiveSiteTimezone = siteTimezone ?? fetchedSiteTimezone;
|
|
111
|
+
const effectiveSiteDefaultCurrency = siteDefaultCurrency ?? fetchedSiteDefaultCurrency;
|
|
112
|
+
const locale = useMemo(() => resolveLocale({
|
|
113
|
+
stored: preference === "auto" ? null : preference,
|
|
114
|
+
systemDefault: effectiveSystemDefault,
|
|
115
|
+
browserLanguages: browserList,
|
|
116
|
+
}), [preference, effectiveSystemDefault, browserList]);
|
|
117
|
+
const timezone = useMemo(() => resolveEffectiveTimezone({
|
|
118
|
+
stored: timezonePreference === AUTO_TIMEZONE ? null : timezonePreference,
|
|
119
|
+
siteDefault: effectiveSiteTimezone,
|
|
120
|
+
detect: detectTimezone ?? detectBrowserTimezone,
|
|
121
|
+
}), [timezonePreference, effectiveSiteTimezone, detectTimezone]);
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
applyLocaleToDocument(locale);
|
|
124
|
+
setActiveLocale(locale);
|
|
125
|
+
}, [locale]);
|
|
126
|
+
const setPreference = useCallback((next) => {
|
|
127
|
+
writeStoredLocale(next);
|
|
128
|
+
setPreferenceState(next);
|
|
129
|
+
}, []);
|
|
130
|
+
const setTimezonePreference = useCallback((next) => {
|
|
131
|
+
writeStoredTimezone(next);
|
|
132
|
+
setTimezonePreferenceState(next);
|
|
133
|
+
}, []);
|
|
134
|
+
const t = useMemo(() => createTranslator(locale), [locale]);
|
|
135
|
+
const formatDate = useCallback((value, options = {}) => {
|
|
136
|
+
const requested = options.timeZone !== undefined && options.timeZone !== "" ? options.timeZone : null;
|
|
137
|
+
const zone = requested ?? (timezone === AUTO_TIMEZONE ? null : timezone);
|
|
138
|
+
return formatDateImpl(value, locale, zone === null ? {} : { timeZone: zone });
|
|
139
|
+
}, [locale, timezone]);
|
|
140
|
+
const formatNumber = useCallback((value, options) => formatNumberImpl(value, locale, options), [locale]);
|
|
141
|
+
const value = useMemo(() => ({
|
|
142
|
+
locale,
|
|
143
|
+
preference,
|
|
144
|
+
setPreference,
|
|
145
|
+
timezone,
|
|
146
|
+
timezonePreference,
|
|
147
|
+
setTimezonePreference,
|
|
148
|
+
defaultCurrency: effectiveSiteDefaultCurrency ?? "",
|
|
149
|
+
t,
|
|
150
|
+
formatDate,
|
|
151
|
+
formatNumber,
|
|
152
|
+
}), [
|
|
153
|
+
locale,
|
|
154
|
+
preference,
|
|
155
|
+
setPreference,
|
|
156
|
+
timezone,
|
|
157
|
+
timezonePreference,
|
|
158
|
+
setTimezonePreference,
|
|
159
|
+
effectiveSiteDefaultCurrency,
|
|
160
|
+
t,
|
|
161
|
+
formatDate,
|
|
162
|
+
formatNumber,
|
|
163
|
+
]);
|
|
164
|
+
return _jsx(I18nContext.Provider, { value: value, children: children });
|
|
165
|
+
}
|
|
166
|
+
export function useI18n() {
|
|
167
|
+
const value = useContext(I18nContext);
|
|
168
|
+
if (value === null) {
|
|
169
|
+
throw new Error("useI18n must be used within an I18nProvider");
|
|
170
|
+
}
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Tolerant translator hook for deep renderer internals.
|
|
175
|
+
*
|
|
176
|
+
* Returns the provider's translator, or a safe default (en-US resolution +
|
|
177
|
+
* missing-key observable fallback) when no provider is mounted. Production
|
|
178
|
+
* always mounts I18nProvider; bare component tests and pre-provider surfaces
|
|
179
|
+
* degrade to the documented safe fallback instead of throwing.
|
|
180
|
+
*/
|
|
181
|
+
export function useTranslate() {
|
|
182
|
+
const value = useContext(I18nContext);
|
|
183
|
+
return useMemo(() => value?.t ?? createTranslator(DEFAULT_LOCALE), [value]);
|
|
184
|
+
}
|
package/i18n/timezone.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timezone pure-logic unit (workspace-020 · R2 · C1).
|
|
3
|
+
*
|
|
4
|
+
* `resolveEffectiveTimezone` computes the effective timezone from the user's
|
|
5
|
+
* explicit override, the session probe, and the site default, per contract
|
|
6
|
+
* GOAL-002 D-001 §2 (user-confirmed I-001, 2026-08-26):
|
|
7
|
+
*
|
|
8
|
+
* L1 user override (localStorage "schema-ui:timezone") → L2 session probe
|
|
9
|
+
* (Intl) → L3 site default (siteTimezone) → L4 "auto" fallback
|
|
10
|
+
*
|
|
11
|
+
* Keeping the decision logic in plain functions lets vitest exercise every
|
|
12
|
+
* branch; the probe is injectable so tests do not depend on the host zone.
|
|
13
|
+
*/
|
|
14
|
+
export const TIMEZONE_STORAGE_KEY = "schema-ui:timezone";
|
|
15
|
+
export const AUTO_TIMEZONE = "auto";
|
|
16
|
+
/**
|
|
17
|
+
* Validates an IANA timezone name via Intl (RangeError on invalid names).
|
|
18
|
+
* Side-effect-free apart from the noexcept Intl probe; returns false for
|
|
19
|
+
* empty / non-string / unknown zones instead of throwing.
|
|
20
|
+
*/
|
|
21
|
+
export function isValidIanaTimeZone(raw) {
|
|
22
|
+
if (typeof raw !== "string") {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
const trimmed = raw.trim();
|
|
26
|
+
if (trimmed === "") {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
// The format call itself never renders; constructing with the option is
|
|
31
|
+
// the validation probe (RangeError for unknown "Foo/Bar" style names).
|
|
32
|
+
new Intl.DateTimeFormat("en-US", { timeZone: trimmed });
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Normalizes a raw stored/site value into a TimezonePreference.
|
|
41
|
+
* Any value that is not a valid IANA name resolves to "auto".
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeTimezonePreference(raw) {
|
|
44
|
+
if (typeof raw !== "string") {
|
|
45
|
+
return AUTO_TIMEZONE;
|
|
46
|
+
}
|
|
47
|
+
const trimmed = raw.trim();
|
|
48
|
+
if (trimmed === "" || trimmed.toLowerCase() === AUTO_TIMEZONE) {
|
|
49
|
+
return AUTO_TIMEZONE;
|
|
50
|
+
}
|
|
51
|
+
return isValidIanaTimeZone(trimmed) ? trimmed : AUTO_TIMEZONE;
|
|
52
|
+
}
|
|
53
|
+
/** Reads the stored user override; best-effort (privacy mode → null). */
|
|
54
|
+
export function readStoredTimezone() {
|
|
55
|
+
try {
|
|
56
|
+
if (typeof localStorage === "undefined") {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return localStorage.getItem(TIMEZONE_STORAGE_KEY);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/** Persists the user override; "auto" removes the key (single channel). */
|
|
66
|
+
export function writeStoredTimezone(preference) {
|
|
67
|
+
try {
|
|
68
|
+
if (typeof localStorage === "undefined") {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (preference === AUTO_TIMEZONE) {
|
|
72
|
+
localStorage.removeItem(TIMEZONE_STORAGE_KEY);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
localStorage.setItem(TIMEZONE_STORAGE_KEY, preference);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// Best-effort persist; the in-memory preference still applies this page.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Real session probe: the host zone from Intl.resolvedOptions(). */
|
|
83
|
+
export function detectBrowserTimezone() {
|
|
84
|
+
try {
|
|
85
|
+
const zone = new Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
86
|
+
return typeof zone === "string" ? zone : "";
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return "";
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Resolves the effective timezone per contract §2 (L1 → L2 → L3 → L4).
|
|
94
|
+
* Returns an IANA name, or "auto" when nothing is configured/detectable —
|
|
95
|
+
* consumers then fall back to the locale's default zone.
|
|
96
|
+
*/
|
|
97
|
+
export function resolveEffectiveTimezone(input) {
|
|
98
|
+
const stored = normalizeTimezonePreference(input.stored);
|
|
99
|
+
if (stored !== AUTO_TIMEZONE) {
|
|
100
|
+
return stored; // L1 · user override wins
|
|
101
|
+
}
|
|
102
|
+
let detected = "";
|
|
103
|
+
try {
|
|
104
|
+
detected = String(input.detect() ?? "");
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
detected = "";
|
|
108
|
+
}
|
|
109
|
+
if (isValidIanaTimeZone(detected)) {
|
|
110
|
+
return detected; // L2 · session probe
|
|
111
|
+
}
|
|
112
|
+
const site = normalizeTimezonePreference(input.siteDefault);
|
|
113
|
+
if (site !== AUTO_TIMEZONE) {
|
|
114
|
+
return site; // L3 · site default
|
|
115
|
+
}
|
|
116
|
+
return AUTO_TIMEZONE; // L4 · embedded default
|
|
117
|
+
}
|
package/lib/datetime.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display-time formatting (GOAL-011 table style): ISO-8601 timestamps are
|
|
3
|
+
* rendered in a human-readable local-time form ("2026-08-01 18:02") instead
|
|
4
|
+
* of the raw wire value ("2026-08-01T18:02:44.000Z").
|
|
5
|
+
*
|
|
6
|
+
* Returns null when the value is not a renderable timestamp so callers fall
|
|
7
|
+
* back to the raw text (numbers, booleans, plain strings, arbitrary dates
|
|
8
|
+
* with other shapes stay untouched).
|
|
9
|
+
*/
|
|
10
|
+
/** Matches the ISO-8601 timestamps shipped by the API (UTC Z or ±hh:mm). */
|
|
11
|
+
const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})$/;
|
|
12
|
+
function pad2(value) {
|
|
13
|
+
return String(value).padStart(2, "0");
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Formats an ISO-8601 timestamp string to local "YYYY-MM-DD HH:mm". Returns
|
|
17
|
+
* null for non-timestamp values, malformed dates, or non-string input.
|
|
18
|
+
*/
|
|
19
|
+
export function formatDisplayTime(value) {
|
|
20
|
+
if (typeof value !== "string" || !ISO_TIMESTAMP_PATTERN.test(value)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const date = new Date(value);
|
|
24
|
+
if (Number.isNaN(date.getTime())) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return (date.getFullYear() +
|
|
28
|
+
"-" +
|
|
29
|
+
pad2(date.getMonth() + 1) +
|
|
30
|
+
"-" +
|
|
31
|
+
pad2(date.getDate()) +
|
|
32
|
+
" " +
|
|
33
|
+
pad2(date.getHours()) +
|
|
34
|
+
":" +
|
|
35
|
+
pad2(date.getMinutes()));
|
|
36
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W10 F-002: fetch timeout wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Wraps a fetch implementation so a hung request (slow network, stalled
|
|
5
|
+
* server) aborts after a bounded window instead of pending forever. The
|
|
6
|
+
* returned function is drop-in compatible with `typeof fetch`; a caller-
|
|
7
|
+
* provided AbortSignal in `init` is composed with the timeout signal (either
|
|
8
|
+
* firing aborts the request). Timers are cleared once the request settles, so
|
|
9
|
+
* no handles leak on the happy path.
|
|
10
|
+
*/
|
|
11
|
+
/** Default ceiling for a single request (30s). */
|
|
12
|
+
export const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
|
|
13
|
+
export function withTimeout(fetchImpl, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
|
|
14
|
+
return async function fetchWithTimeout(input, init) {
|
|
15
|
+
// Resolved per call (not at wrap time) so a later global fetch stub —
|
|
16
|
+
// test doubles, service workers — is always honored.
|
|
17
|
+
const doFetch = fetchImpl ?? globalThis.fetch;
|
|
18
|
+
// RequestInit.signal is `AbortSignal | null | undefined`; normalize to
|
|
19
|
+
// undefined so the guards below stay simple.
|
|
20
|
+
const outer = init?.signal ?? undefined;
|
|
21
|
+
// An already-aborted caller signal must fail fast without touching the
|
|
22
|
+
// network (no fetch call, no timer).
|
|
23
|
+
if (outer?.aborted === true) {
|
|
24
|
+
throw new DOMException("The operation was aborted.", "AbortError");
|
|
25
|
+
}
|
|
26
|
+
const controller = new AbortController();
|
|
27
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
28
|
+
// Relay the caller signal onto our controller; the relay listener is
|
|
29
|
+
// removed once the request settles so a long-lived shared signal does not
|
|
30
|
+
// accumulate listeners across calls (A-003 recommended F-002).
|
|
31
|
+
const relayAbort = () => controller.abort();
|
|
32
|
+
if (outer !== undefined) {
|
|
33
|
+
outer.addEventListener("abort", relayAbort, { once: true });
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
return await doFetch(input, { ...init, signal: controller.signal });
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
outer?.removeEventListener("abort", relayAbort);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @schema-ui
|
|
2
|
+
* @magicvr/schema-ui-lib 聚合导出(R3 六包化):通用工具 + i18n 运行时面。
|
|
3
3
|
*/
|
|
4
|
-
export * from "./datetime";
|
|
5
|
-
export * from "./fetch-timeout";
|
|
6
|
-
export * from "./utils";
|
|
7
|
-
export * from "../i18n/runtime";
|
|
8
|
-
export * from "../i18n/catalog";
|
|
9
|
-
export type * from "../i18n/locale";
|
|
10
|
-
export * from "../i18n/format";
|
|
11
|
-
export * from "../i18n/money";
|
|
12
|
-
export * from "../i18n/timezone";
|
|
4
|
+
export * from "./datetime.js";
|
|
5
|
+
export * from "./fetch-timeout.js";
|
|
6
|
+
export * from "./utils.js";
|
|
7
|
+
export * from "../i18n/runtime.js";
|
|
8
|
+
export * from "../i18n/catalog.js";
|
|
9
|
+
export type * from "../i18n/locale.js";
|
|
10
|
+
export * from "../i18n/format.js";
|
|
11
|
+
export * from "../i18n/money.js";
|
|
12
|
+
export * from "../i18n/timezone.js";
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @magicvr/schema-ui-lib 聚合导出(R3 六包化):通用工具 + i18n 运行时面。
|
|
3
|
+
*/
|
|
4
|
+
export * from "./datetime.js";
|
|
5
|
+
export * from "./fetch-timeout.js";
|
|
6
|
+
export * from "./utils.js";
|
|
7
|
+
export * from "../i18n/runtime.js";
|
|
8
|
+
export * from "../i18n/catalog.js";
|
|
9
|
+
export * from "../i18n/format.js";
|
|
10
|
+
export * from "../i18n/money.js";
|
|
11
|
+
export * from "../i18n/timezone.js";
|
package/lib/utils.js
ADDED
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@magicvr/schema-ui-lib",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "schema-ui-core 包面(
|
|
6
|
-
"main": "index.js",
|
|
7
|
-
"types": "
|
|
5
|
+
"description": "schema-ui-core 包面(lib)",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
9
|
".": {
|
|
10
|
-
"types": "
|
|
11
|
-
"import": "
|
|
10
|
+
"types": "lib/index.d.ts",
|
|
11
|
+
"import": "lib/index.js"
|
|
12
12
|
},
|
|
13
13
|
"./*": "./*"
|
|
14
14
|
},
|