@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/catalog.d.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  * observable via the `schema-ui:missing-translation` window event (deduped
13
13
  * per locale+key, so the first occurrence always reports).
14
14
  */
15
- import { type Locale } from "./locale";
15
+ import { type Locale } from "./locale.js";
16
16
  export type MessageParams = Record<string, string | number>;
17
17
  export interface MissingTranslationDetail {
18
18
  locale: Locale;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Translation catalog (S1 · C2/C3).
3
+ *
4
+ * Catalogs are pure data files under `messages/`; `en-US` is the canonical
5
+ * baseline. Resolution order for a key in locale L:
6
+ *
7
+ * catalog[L] → catalog[en-US] → observable missing-key event → key itself
8
+ *
9
+ * A key is "missing" only when neither the current catalog nor the en-US
10
+ * catalog has it; the en-US fallback is silent (designed behavior). Missing
11
+ * keys never render empty, never throw, and never block the flow; they are
12
+ * observable via the `schema-ui:missing-translation` window event (deduped
13
+ * per locale+key, so the first occurrence always reports).
14
+ */
15
+ import enUS from "./messages/en-US.json";
16
+ import zhCN from "./messages/zh-CN.json";
17
+ import { DEFAULT_LOCALE } from "./locale.js";
18
+ export const MISSING_TRANSLATION_EVENT = "schema-ui:missing-translation";
19
+ const catalogs = {
20
+ "en-US": enUS,
21
+ "zh-CN": zhCN,
22
+ };
23
+ const reportedMissing = new Set();
24
+ /** True when the key exists in the given locale catalog. */
25
+ export function hasTranslation(key, locale) {
26
+ return Object.prototype.hasOwnProperty.call(catalogs[locale], key);
27
+ }
28
+ /** Raw catalog text for a key, or null when the locale catalog lacks it. */
29
+ export function lookupTranslation(key, locale) {
30
+ if (hasTranslation(key, locale)) {
31
+ return catalogs[locale][key];
32
+ }
33
+ return null;
34
+ }
35
+ /** Replaces `{name}` placeholders with params; unknown placeholders stay. */
36
+ export function interpolate(template, params) {
37
+ if (params === undefined) {
38
+ return template;
39
+ }
40
+ return template.replace(/\{([a-zA-Z0-9_]+)\}/g, (match, name) => Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match);
41
+ }
42
+ /** Publishes a deduped missing-key report to the window event bus. */
43
+ export function reportMissingTranslation(detail) {
44
+ const dedupeKey = `${detail.locale}:${detail.key}`;
45
+ if (reportedMissing.has(dedupeKey)) {
46
+ return;
47
+ }
48
+ reportedMissing.add(dedupeKey);
49
+ if (typeof window !== "undefined") {
50
+ window.dispatchEvent(new CustomEvent(MISSING_TRANSLATION_EVENT, { detail }));
51
+ }
52
+ }
53
+ /** Resets the missing-key dedupe set (test seam). */
54
+ export function resetMissingTranslationReports() {
55
+ reportedMissing.clear();
56
+ }
57
+ /**
58
+ * Resolves a message key for a locale with the frozen fallback chain.
59
+ * Never throws, never returns an empty string for a missing key.
60
+ *
61
+ * Fallback order: catalog[locale] → catalog[en-US] → `literalFallback`
62
+ * (protocol literal text, when supplied) → key itself.
63
+ */
64
+ export function translate(key, params, locale = DEFAULT_LOCALE, path, literalFallback) {
65
+ const direct = lookupTranslation(key, locale);
66
+ if (direct !== null) {
67
+ return interpolate(direct, params);
68
+ }
69
+ const fallback = lookupTranslation(key, DEFAULT_LOCALE);
70
+ if (fallback !== null) {
71
+ return interpolate(fallback, params);
72
+ }
73
+ reportMissingTranslation({ locale, key, path });
74
+ return literalFallback !== undefined && literalFallback !== "" ? literalFallback : key;
75
+ }
76
+ /** Binds a locale (+ optional context path) to a translate function. */
77
+ export function createTranslator(locale, options) {
78
+ return (key, params, literalFallback) => translate(key, params, locale, options?.path, literalFallback);
79
+ }
80
+ /**
81
+ * Resolves a schema/manifest text prop pair — the `*Key` field wins over the
82
+ * literal protocol text, and the literal text is the last fallback before the
83
+ * key itself (frozen chain: 当前语种 → en-US → 字面文本 → key).
84
+ */
85
+ export function resolveTextProp(props, keyProp, literalProp, t, fallback = "") {
86
+ if (props === undefined) {
87
+ return fallback;
88
+ }
89
+ const key = props[keyProp];
90
+ if (typeof key === "string" && key !== "") {
91
+ const literal = typeof props[literalProp] === "string" ? props[literalProp] : undefined;
92
+ return t(key, undefined, literal);
93
+ }
94
+ const literal = props[literalProp];
95
+ return typeof literal === "string" ? literal : fallback;
96
+ }
package/i18n/format.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * Formatting is fail-safe: invalid inputs render empty, invalid timezones
7
7
  * degrade to the locale's default zone instead of throwing.
8
8
  */
9
- import { type Locale } from "./locale";
9
+ import { type Locale } from "./locale.js";
10
10
  export interface FormatOptions {
11
11
  /** IANA timezone name; omitted = the environment's default zone. */
12
12
  timeZone?: string;
package/i18n/format.js ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Locale-aware date/number formatting (S1 · C5).
3
+ *
4
+ * Formatting follows the effective locale through Intl.* — no custom format
5
+ * templates (VP-007: "首版不暴露任意日期/数字格式模板,随有效 locale").
6
+ * Formatting is fail-safe: invalid inputs render empty, invalid timezones
7
+ * degrade to the locale's default zone instead of throwing.
8
+ */
9
+ import { DEFAULT_LOCALE } from "./locale.js";
10
+ /** Formats a date value in the given locale. Returns "" for invalid input. */
11
+ export function formatDate(value, locale = DEFAULT_LOCALE, options = {}) {
12
+ const date = value instanceof Date ? value : new Date(value);
13
+ if (!Number.isFinite(date.getTime())) {
14
+ return "";
15
+ }
16
+ const timeZone = options.timeZone !== undefined && options.timeZone !== "" ? options.timeZone : undefined;
17
+ try {
18
+ return new Intl.DateTimeFormat(locale, {
19
+ dateStyle: "medium",
20
+ timeStyle: "short",
21
+ ...(timeZone === undefined ? {} : { timeZone }),
22
+ }).format(date);
23
+ }
24
+ catch {
25
+ // Invalid IANA name — degrade to the default zone, never throw.
26
+ return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(date);
27
+ }
28
+ }
29
+ /** Formats a finite number in the given locale. Returns "" for invalid input. */
30
+ export function formatNumber(value, locale = DEFAULT_LOCALE, options = {}) {
31
+ if (typeof value !== "number" || !Number.isFinite(value)) {
32
+ return "";
33
+ }
34
+ return new Intl.NumberFormat(locale, options).format(value);
35
+ }
package/i18n/locale.js ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Locale pure-logic unit (S1 · C1).
3
+ *
4
+ * `resolveLocale` is a side-effect-free function that computes the effective
5
+ * locale from the user's explicit choice, the system default, and the browser
6
+ * language preferences. Keeping the decision logic in a plain function lets
7
+ * vitest exercise every branch without a browser.
8
+ *
9
+ * Frozen priority (VP-007 / D-002 §I-L10N-002, user-confirmed 2026-08-09):
10
+ *
11
+ * user explicit choice → system default (non-auto) → browser preference
12
+ * (auto) → en-US safe fallback
13
+ */
14
+ export const SUPPORTED_LOCALES = ["zh-CN", "en-US"];
15
+ export const DEFAULT_LOCALE = "en-US";
16
+ export function isSupportedLocale(raw) {
17
+ return raw === "zh-CN" || raw === "en-US";
18
+ }
19
+ /**
20
+ * Normalizes a BCP 47-ish candidate to a supported locale.
21
+ * Accepts exact tags, case variants, underscore separators, and language-only
22
+ * prefixes ("zh", "zh-cn", "zh_CN", "en-US", "en", "en-us", "en-GB" → en-US).
23
+ * Returns null for anything else (including "auto").
24
+ */
25
+ export function normalizeLocaleCandidate(raw) {
26
+ if (raw === null || raw === undefined) {
27
+ return null;
28
+ }
29
+ const trimmed = raw.trim();
30
+ if (trimmed === "") {
31
+ return null;
32
+ }
33
+ const lower = trimmed.toLowerCase().replace(/_/g, "-");
34
+ if (lower === "zh-cn" || lower === "zh") {
35
+ return "zh-CN";
36
+ }
37
+ if (lower === "en-us" || lower === "en") {
38
+ return "en-US";
39
+ }
40
+ if (lower.startsWith("en-")) {
41
+ return "en-US";
42
+ }
43
+ return null;
44
+ }
45
+ /**
46
+ * Resolves the effective locale using the frozen priority. Pure — no I/O.
47
+ */
48
+ export function resolveLocale(input) {
49
+ const explicit = normalizeLocaleCandidate(input.stored);
50
+ if (explicit !== null) {
51
+ return explicit;
52
+ }
53
+ const system = normalizeLocaleCandidate(input.systemDefault);
54
+ if (system !== null) {
55
+ return system;
56
+ }
57
+ for (const candidate of input.browserLanguages ?? []) {
58
+ const normalized = normalizeLocaleCandidate(candidate);
59
+ if (normalized !== null) {
60
+ return normalized;
61
+ }
62
+ }
63
+ return DEFAULT_LOCALE;
64
+ }
65
+ /**
66
+ * Normalizes a raw stored value into a LocalePreference.
67
+ * Any value that is not a supported locale resolves to "auto".
68
+ */
69
+ export function normalizePreference(raw) {
70
+ const normalized = normalizeLocaleCandidate(raw);
71
+ return normalized === null ? "auto" : normalized;
72
+ }
73
+ /** Real browser inputs for the boot path (provider default). */
74
+ export function defaultBrowserLanguages() {
75
+ if (typeof navigator === "undefined" || typeof navigator.languages !== "object") {
76
+ return [];
77
+ }
78
+ return navigator.languages;
79
+ }