@magicvr/schema-ui-lib 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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;
@@ -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
+ }
@@ -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
+ }
@@ -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
2
  * @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
@@ -0,0 +1,5 @@
1
+ import { clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+ export function cn(...inputs) {
4
+ return twMerge(clsx(inputs));
5
+ }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@magicvr/schema-ui-lib",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
- "description": "schema-ui-core 包面(VP-023 R3 六包化)",
6
- "main": "index.js",
7
- "types": "./lib/index.d.ts",
5
+ "description": "schema-ui-core 包面(lib)",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
8
  "exports": {
9
9
  ".": {
10
- "types": "./lib/index.d.ts",
10
+ "types": "lib/index.d.ts",
11
11
  "import": "./index.js"
12
12
  },
13
13
  "./*": "./*"