@marimo-team/islands 0.23.16-dev2 → 0.23.16-dev3
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/dist/{code-visibility-mgBayy67.js → code-visibility-D-umBozt.js} +714 -691
- package/dist/main.js +934 -944
- package/dist/{reveal-component-BDdfO6U_.js → reveal-component-DALqCV7w.js} +6 -6
- package/package.json +1 -1
- package/src/components/data-table/column-formatting/feature.ts +2 -1
- package/src/core/i18n/__tests__/locale-provider.test.tsx +54 -118
- package/src/core/i18n/__tests__/locale.test.ts +113 -0
- package/src/core/i18n/locale-provider.tsx +1 -19
- package/src/core/i18n/locale.ts +71 -0
|
@@ -9,7 +9,7 @@ import { t as require_compiler_runtime } from "./compiler-runtime-CEbnTgxf.js";
|
|
|
9
9
|
import { dt as kioskModeAtom, ht as outputIsStale } from "./html-to-image-DDENewl2.js";
|
|
10
10
|
import "./chunk-5FQGJX7Z-B9WoXK1R.js";
|
|
11
11
|
import { u as createLucideIcon } from "./dist-B96tMAst.js";
|
|
12
|
-
import { a as DEFAULT_DECK_VERTICAL_ALIGN, c as SlideSidebar, cn as
|
|
12
|
+
import { a as DEFAULT_DECK_VERTICAL_ALIGN, c as SlideSidebar, cn as EyeOff, ct as Panel, fn as Code, i as DEFAULT_DECK_TRANSITION, l as Slide, ln as Expand, lt as PanelGroup, o as DEFAULT_SLIDE_TYPE, t as useNotebookCodeAvailable, ut as PanelResizeHandle } from "./code-visibility-D-umBozt.js";
|
|
13
13
|
import { X as useDebouncedCallback } from "./input-DStQIuqD.js";
|
|
14
14
|
import "./toDate-B3IRfHhw.js";
|
|
15
15
|
import "./react-dom-BTJzcVJ9.js";
|
|
@@ -7310,7 +7310,7 @@ var ParkedPreviewContent = (e5) => {
|
|
|
7310
7310
|
output: c,
|
|
7311
7311
|
stale: u
|
|
7312
7312
|
}), t[5] = n.id, t[6] = n.output, t[7] = n.status, t[8] = u, t[9] = f) : f = t[9], f;
|
|
7313
|
-
}, reveal_component_default = ({ slideCells: e5, layout: o, setLayout: s, noOutputIds: l, activeIndex: u, onSlideChange: f, mode: co, configWidth: lo, isEditable:
|
|
7313
|
+
}, reveal_component_default = ({ slideCells: e5, layout: o, setLayout: s, noOutputIds: l, activeIndex: u, onSlideChange: f, mode: co, configWidth: lo, isEditable: po = false }) => {
|
|
7314
7314
|
var _a2, _b2, _c;
|
|
7315
7315
|
let go = (0, import_react.useRef)(null), _o = (0, import_react.useRef)(null), { width: Co, height: wo } = useSlideDimensions(go), Eo = useFullScreenElement() != null, Do = useAtomValue(kioskModeAtom), Oo = (0, import_react.useMemo)(() => Do ? [] : [ce], [Do]), [ko, Ao] = (0, import_react.useState)(() => /* @__PURE__ */ new Set()), jo = useNotebookCodeAvailable(e5), Mo = !isIslands() && jo, No = u == null ? void 0 : e5[u], Po = No ?? e5.at(0), { parkedPreviewCell: Fo, isHeldEdit: Io, isNoOutputPreview: Lo, heldEditCellId: Ro, heldShowsCode: zo, toggleHeldShowsCode: Bo } = useParkedPreview({
|
|
7316
7316
|
activeCell: No,
|
|
@@ -7414,7 +7414,7 @@ var ParkedPreviewContent = (e5) => {
|
|
|
7414
7414
|
useEventListener(document, "keydown", is, { capture: true });
|
|
7415
7415
|
let os = Io ? null : Lo ? "Hidden as there is no output" : "Skipped in presentation", ss = Io ? zo : Vo(Fo == null ? void 0 : Fo.id), cs = parkedRendersSource({
|
|
7416
7416
|
isNoOutputPreview: Lo,
|
|
7417
|
-
isEditable:
|
|
7417
|
+
isEditable: po,
|
|
7418
7418
|
showCode: ss
|
|
7419
7419
|
}), ls = /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
7420
7420
|
ref: go,
|
|
@@ -7438,13 +7438,13 @@ var ParkedPreviewContent = (e5) => {
|
|
|
7438
7438
|
children: Go.stacks.map((e6, t) => e6.subslides.length === 1 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SubslideView, {
|
|
7439
7439
|
subslide: e6.subslides[0],
|
|
7440
7440
|
resolveShowCode: Vo,
|
|
7441
|
-
isEditable:
|
|
7441
|
+
isEditable: po,
|
|
7442
7442
|
slideConfigs: o.cells,
|
|
7443
7443
|
contentStyle: Yo
|
|
7444
7444
|
}, t) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ne, { children: e6.subslides.map((e7, t2) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SubslideView, {
|
|
7445
7445
|
subslide: e7,
|
|
7446
7446
|
resolveShowCode: Vo,
|
|
7447
|
-
isEditable:
|
|
7447
|
+
isEditable: po,
|
|
7448
7448
|
slideConfigs: o.cells,
|
|
7449
7449
|
contentStyle: Yo
|
|
7450
7450
|
}, t2)) }, t))
|
|
@@ -7463,7 +7463,7 @@ var ParkedPreviewContent = (e5) => {
|
|
|
7463
7463
|
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ParkedPreviewContent, {
|
|
7464
7464
|
cell: Fo,
|
|
7465
7465
|
isNoOutputPreview: Lo,
|
|
7466
|
-
isEditable:
|
|
7466
|
+
isEditable: po,
|
|
7467
7467
|
showCode: ss
|
|
7468
7468
|
})
|
|
7469
7469
|
})
|
package/package.json
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type TableFeature,
|
|
10
10
|
type Updater,
|
|
11
11
|
} from "@tanstack/react-table";
|
|
12
|
+
import { browserLocale } from "@/core/i18n/locale";
|
|
12
13
|
import type { DataType } from "@/core/kernel/messages";
|
|
13
14
|
import { logNever } from "@/utils/assertNever";
|
|
14
15
|
import {
|
|
@@ -40,7 +41,7 @@ export const ColumnFormattingFeature: TableFeature = {
|
|
|
40
41
|
return {
|
|
41
42
|
enableColumnFormatting: true,
|
|
42
43
|
onColumnFormattingChange: makeStateUpdater("columnFormatting", table),
|
|
43
|
-
locale:
|
|
44
|
+
locale: browserLocale(),
|
|
44
45
|
} as ColumnFormattingOptions;
|
|
45
46
|
},
|
|
46
47
|
|
|
@@ -8,20 +8,9 @@ import {
|
|
|
8
8
|
defaultUserConfig,
|
|
9
9
|
parseUserConfig,
|
|
10
10
|
} from "@/core/config/config-schema";
|
|
11
|
+
import { FALLBACK_LOCALE } from "../locale";
|
|
11
12
|
import { LocaleProvider } from "../locale-provider";
|
|
12
13
|
|
|
13
|
-
// Mock navigator.language with a getter
|
|
14
|
-
let mockNavigatorLanguage: string | undefined;
|
|
15
|
-
|
|
16
|
-
Object.defineProperty(window, "navigator", {
|
|
17
|
-
value: {
|
|
18
|
-
get language() {
|
|
19
|
-
return mockNavigatorLanguage;
|
|
20
|
-
},
|
|
21
|
-
},
|
|
22
|
-
writable: true,
|
|
23
|
-
});
|
|
24
|
-
|
|
25
14
|
// Mock react-aria-components I18nProvider
|
|
26
15
|
vi.mock("react-aria-components", () => ({
|
|
27
16
|
I18nProvider: ({
|
|
@@ -37,143 +26,90 @@ vi.mock("react-aria-components", () => ({
|
|
|
37
26
|
),
|
|
38
27
|
}));
|
|
39
28
|
|
|
29
|
+
function renderWithLocale(configLocale: string | null | undefined) {
|
|
30
|
+
const store = createStore();
|
|
31
|
+
store.set(
|
|
32
|
+
userConfigAtom,
|
|
33
|
+
parseUserConfig({ display: { locale: configLocale } }),
|
|
34
|
+
);
|
|
35
|
+
return render(
|
|
36
|
+
<Provider store={store}>
|
|
37
|
+
<LocaleProvider>
|
|
38
|
+
<div>Test content</div>
|
|
39
|
+
</LocaleProvider>
|
|
40
|
+
</Provider>,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
40
44
|
describe("LocaleProvider", () => {
|
|
41
45
|
beforeEach(() => {
|
|
42
|
-
|
|
43
|
-
mockNavigatorLanguage = undefined;
|
|
46
|
+
vi.stubGlobal("navigator", { language: undefined as string | undefined });
|
|
44
47
|
});
|
|
45
48
|
|
|
46
49
|
afterEach(() => {
|
|
47
50
|
cleanup();
|
|
48
|
-
|
|
49
|
-
mockNavigatorLanguage = undefined;
|
|
51
|
+
vi.unstubAllGlobals();
|
|
50
52
|
vi.clearAllMocks();
|
|
51
53
|
});
|
|
52
54
|
|
|
53
|
-
it("
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
store.set(userConfigAtom, config);
|
|
57
|
-
|
|
58
|
-
const { getByTestId } = render(
|
|
59
|
-
<Provider store={store}>
|
|
60
|
-
<LocaleProvider>
|
|
61
|
-
<div>Test content</div>
|
|
62
|
-
</LocaleProvider>
|
|
63
|
-
</Provider>,
|
|
64
|
-
);
|
|
65
|
-
|
|
66
|
-
const i18nProvider = getByTestId("i18n-provider");
|
|
67
|
-
expect(i18nProvider).toBeInTheDocument();
|
|
68
|
-
expect(i18nProvider.dataset.locale).toBe(undefined);
|
|
69
|
-
expect(i18nProvider).toHaveTextContent("Test content");
|
|
55
|
+
it("passes a valid configured locale through unchanged", () => {
|
|
56
|
+
const { getByTestId } = renderWithLocale("es-ES");
|
|
57
|
+
expect(getByTestId("i18n-provider").dataset.locale).toBe("es-ES");
|
|
70
58
|
});
|
|
71
59
|
|
|
72
|
-
it("
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const { getByTestId } = render(
|
|
78
|
-
<Provider store={store}>
|
|
79
|
-
<LocaleProvider>
|
|
80
|
-
<div>Test content</div>
|
|
81
|
-
</LocaleProvider>
|
|
82
|
-
</Provider>,
|
|
83
|
-
);
|
|
84
|
-
|
|
85
|
-
const i18nProvider = getByTestId("i18n-provider");
|
|
86
|
-
expect(i18nProvider).toBeInTheDocument();
|
|
87
|
-
expect(i18nProvider.dataset.locale).toBe(undefined);
|
|
88
|
-
expect(i18nProvider).toHaveTextContent("Test content");
|
|
60
|
+
it("prefers the browser locale when config is unset", () => {
|
|
61
|
+
vi.stubGlobal("navigator", { language: "de-DE" });
|
|
62
|
+
const { getByTestId } = renderWithLocale(null);
|
|
63
|
+
expect(getByTestId("i18n-provider").dataset.locale).toBe("de-DE");
|
|
89
64
|
});
|
|
90
65
|
|
|
91
|
-
it(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
<Provider store={store}>
|
|
99
|
-
<LocaleProvider>
|
|
100
|
-
<div>Test content</div>
|
|
101
|
-
</LocaleProvider>
|
|
102
|
-
</Provider>,
|
|
103
|
-
);
|
|
66
|
+
it.each([null, undefined])(
|
|
67
|
+
"falls back to %s config + unusable browser locale",
|
|
68
|
+
(configLocale) => {
|
|
69
|
+
const { getByTestId } = renderWithLocale(configLocale);
|
|
70
|
+
expect(getByTestId("i18n-provider").dataset.locale).toBe(FALLBACK_LOCALE);
|
|
71
|
+
},
|
|
72
|
+
);
|
|
104
73
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
expect(
|
|
74
|
+
it("sanitizes a POSIX-tagged browser locale (issue #9938)", () => {
|
|
75
|
+
vi.stubGlobal("navigator", { language: "en-US@posix" });
|
|
76
|
+
const { getByTestId } = renderWithLocale(null);
|
|
77
|
+
expect(getByTestId("i18n-provider").dataset.locale).toBe("en-US");
|
|
109
78
|
});
|
|
110
79
|
|
|
111
|
-
it("
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const store = createStore();
|
|
116
|
-
const config = parseUserConfig({ display: { locale } });
|
|
117
|
-
store.set(userConfigAtom, config);
|
|
118
|
-
|
|
119
|
-
const { getByTestId } = render(
|
|
120
|
-
<Provider store={store}>
|
|
121
|
-
<LocaleProvider>
|
|
122
|
-
<div>Test content for {locale}</div>
|
|
123
|
-
</LocaleProvider>
|
|
124
|
-
</Provider>,
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
const i18nProvider = getByTestId("i18n-provider");
|
|
128
|
-
expect(i18nProvider.dataset.locale).toBe(locale);
|
|
129
|
-
expect(i18nProvider).toHaveTextContent(`Test content for ${locale}`);
|
|
130
|
-
|
|
131
|
-
// Clean up after each iteration
|
|
132
|
-
cleanup();
|
|
133
|
-
});
|
|
80
|
+
it("falls back to the browser locale when the configured locale is unusable", () => {
|
|
81
|
+
vi.stubGlobal("navigator", { language: "de-DE" });
|
|
82
|
+
const { getByTestId } = renderWithLocale("@@@");
|
|
83
|
+
expect(getByTestId("i18n-provider").dataset.locale).toBe("de-DE");
|
|
134
84
|
});
|
|
135
85
|
|
|
136
|
-
it("
|
|
86
|
+
it("resolves a concrete locale for the default config", () => {
|
|
87
|
+
vi.stubGlobal("navigator", { language: "fr-FR" });
|
|
137
88
|
const store = createStore();
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const { getByText, getByRole } = render(
|
|
89
|
+
store.set(userConfigAtom, defaultUserConfig());
|
|
90
|
+
const { getByTestId } = render(
|
|
142
91
|
<Provider store={store}>
|
|
143
92
|
<LocaleProvider>
|
|
144
|
-
<div>
|
|
145
|
-
<h1>Test Heading</h1>
|
|
146
|
-
<p>Test paragraph</p>
|
|
147
|
-
<button type="button">Test Button</button>
|
|
148
|
-
</div>
|
|
93
|
+
<div>Default config test</div>
|
|
149
94
|
</LocaleProvider>
|
|
150
95
|
</Provider>,
|
|
151
96
|
);
|
|
152
|
-
|
|
153
|
-
expect(getByText("Test Heading")).toBeInTheDocument();
|
|
154
|
-
expect(getByText("Test paragraph")).toBeInTheDocument();
|
|
155
|
-
expect(getByRole("button", { name: "Test Button" })).toBeInTheDocument();
|
|
97
|
+
expect(getByTestId("i18n-provider").dataset.locale).toBe("fr-FR");
|
|
156
98
|
});
|
|
157
99
|
|
|
158
|
-
it("
|
|
159
|
-
mockNavigatorLanguage = "de-DE";
|
|
160
|
-
|
|
100
|
+
it("renders children", () => {
|
|
161
101
|
const store = createStore();
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
102
|
+
store.set(
|
|
103
|
+
userConfigAtom,
|
|
104
|
+
parseUserConfig({ display: { locale: "en-US" } }),
|
|
105
|
+
);
|
|
106
|
+
const { getByRole } = render(
|
|
166
107
|
<Provider store={store}>
|
|
167
108
|
<LocaleProvider>
|
|
168
|
-
<
|
|
109
|
+
<button type="button">Test Button</button>
|
|
169
110
|
</LocaleProvider>
|
|
170
111
|
</Provider>,
|
|
171
112
|
);
|
|
172
|
-
|
|
173
|
-
const i18nProvider = getByTestId("i18n-provider");
|
|
174
|
-
expect(i18nProvider).toBeInTheDocument();
|
|
175
|
-
// When no locale is specified in config, it should use navigator.language
|
|
176
|
-
expect(i18nProvider.dataset.locale).toBe("de-DE");
|
|
177
|
-
expect(i18nProvider).toHaveTextContent("Test content");
|
|
113
|
+
expect(getByRole("button", { name: "Test Button" })).toBeInTheDocument();
|
|
178
114
|
});
|
|
179
115
|
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
browserLocale,
|
|
6
|
+
FALLBACK_LOCALE,
|
|
7
|
+
isValidLocale,
|
|
8
|
+
normalizeLocale,
|
|
9
|
+
safeLocale,
|
|
10
|
+
} from "../locale";
|
|
11
|
+
|
|
12
|
+
describe("isValidLocale", () => {
|
|
13
|
+
it("accepts valid BCP 47 tags", () => {
|
|
14
|
+
expect(isValidLocale("en-US")).toBe(true);
|
|
15
|
+
expect(isValidLocale("de-DE")).toBe(true);
|
|
16
|
+
expect(isValidLocale("en")).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("rejects empty and modifier-tainted tags", () => {
|
|
20
|
+
expect(isValidLocale("")).toBe(false);
|
|
21
|
+
expect(isValidLocale("en-US@posix")).toBe(false);
|
|
22
|
+
expect(isValidLocale("en_US.UTF-8")).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("normalizeLocale", () => {
|
|
27
|
+
it("keeps valid tags unchanged, including script + region", () => {
|
|
28
|
+
expect(normalizeLocale("de-DE")).toBe("de-DE");
|
|
29
|
+
expect(normalizeLocale("zh-Hant-TW")).toBe("zh-Hant-TW");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("strips POSIX @modifiers (issue #9938)", () => {
|
|
33
|
+
expect(normalizeLocale("en-US@posix")).toBe("en-US");
|
|
34
|
+
expect(normalizeLocale("de-DE@euro")).toBe("de-DE");
|
|
35
|
+
// ICU-style unicode extension keywords are also dropped.
|
|
36
|
+
expect(normalizeLocale("de@collation=phonebook")).toBe("de");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("strips charset suffixes and maps underscores", () => {
|
|
40
|
+
expect(normalizeLocale("en_US.UTF-8")).toBe("en-US");
|
|
41
|
+
expect(normalizeLocale("en_US")).toBe("en-US");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("falls back to the language subtag when the full tag is invalid", () => {
|
|
45
|
+
// `-toolongsubtag` / `-oed` are structurally invalid, so `Intl.Locale`
|
|
46
|
+
// rejects the whole tag but accepts the leading `en`.
|
|
47
|
+
expect(normalizeLocale("en-toolongsubtag")).toBe("en");
|
|
48
|
+
expect(normalizeLocale("en-GB-oed")).toBe("en");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns null for empty or unrecoverable tags", () => {
|
|
52
|
+
expect(normalizeLocale(null)).toBeNull();
|
|
53
|
+
expect(normalizeLocale(undefined)).toBeNull();
|
|
54
|
+
expect(normalizeLocale("")).toBeNull();
|
|
55
|
+
expect(normalizeLocale("@@@")).toBeNull();
|
|
56
|
+
// POSIX `C` locale: single-letter tag is not a valid language subtag.
|
|
57
|
+
expect(normalizeLocale("C")).toBeNull();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("browserLocale", () => {
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
vi.unstubAllGlobals();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("normalizes navigator.language", () => {
|
|
67
|
+
vi.stubGlobal("navigator", { language: "en-US@posix" });
|
|
68
|
+
expect(browserLocale()).toBe("en-US");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("falls back when navigator.language is unusable", () => {
|
|
72
|
+
vi.stubGlobal("navigator", { language: "@@@" });
|
|
73
|
+
expect(browserLocale()).toBe(FALLBACK_LOCALE);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("falls back when navigator is unavailable", () => {
|
|
77
|
+
vi.stubGlobal("navigator", undefined);
|
|
78
|
+
expect(browserLocale()).toBe(FALLBACK_LOCALE);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("safeLocale", () => {
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
vi.unstubAllGlobals();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("prefers a valid configured locale", () => {
|
|
88
|
+
vi.stubGlobal("navigator", { language: "de-DE" });
|
|
89
|
+
expect(safeLocale("fr-FR")).toBe("fr-FR");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("normalizes configured locales before use", () => {
|
|
93
|
+
vi.stubGlobal("navigator", { language: "de-DE" });
|
|
94
|
+
expect(safeLocale("en_US")).toBe("en-US");
|
|
95
|
+
expect(safeLocale("en-US@posix")).toBe("en-US");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("falls back to the browser locale when config is unusable", () => {
|
|
99
|
+
vi.stubGlobal("navigator", { language: "de-DE" });
|
|
100
|
+
expect(safeLocale(null)).toBe("de-DE");
|
|
101
|
+
expect(safeLocale("@@@")).toBe("de-DE");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("normalizes the browser locale on the fallback path", () => {
|
|
105
|
+
vi.stubGlobal("navigator", { language: "en-US@posix" });
|
|
106
|
+
expect(safeLocale(null)).toBe("en-US");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("falls back to FALLBACK_LOCALE when nothing is usable", () => {
|
|
110
|
+
vi.stubGlobal("navigator", { language: "@@@" });
|
|
111
|
+
expect(safeLocale(null)).toBe(FALLBACK_LOCALE);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -4,6 +4,7 @@ import { useAtomValue } from "jotai";
|
|
|
4
4
|
import type { ReactNode } from "react";
|
|
5
5
|
import { I18nProvider } from "react-aria-components";
|
|
6
6
|
import { localeAtom } from "@/core/config/config";
|
|
7
|
+
import { safeLocale } from "./locale";
|
|
7
8
|
|
|
8
9
|
interface LocaleProviderProps {
|
|
9
10
|
children: ReactNode;
|
|
@@ -14,22 +15,3 @@ export const LocaleProvider = ({ children }: LocaleProviderProps) => {
|
|
|
14
15
|
|
|
15
16
|
return <I18nProvider locale={safeLocale(locale)}>{children}</I18nProvider>;
|
|
16
17
|
};
|
|
17
|
-
|
|
18
|
-
function safeLocale(locale: string | null | undefined) {
|
|
19
|
-
if (!locale) {
|
|
20
|
-
return navigator.language;
|
|
21
|
-
}
|
|
22
|
-
if (isValidLocale(locale)) {
|
|
23
|
-
return locale;
|
|
24
|
-
}
|
|
25
|
-
return navigator.language;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function isValidLocale(locale: string) {
|
|
29
|
-
try {
|
|
30
|
-
new Intl.NumberFormat(locale);
|
|
31
|
-
return true;
|
|
32
|
-
} catch {
|
|
33
|
-
return false;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Locale used when neither the configured locale nor the browser locale can be
|
|
5
|
+
* resolved to a valid BCP 47 tag.
|
|
6
|
+
*/
|
|
7
|
+
export const FALLBACK_LOCALE = "en-US";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether a tag is accepted by `Intl` (and therefore by react-aria's
|
|
11
|
+
* `I18nProvider`, which constructs `Intl.Locale` internally).
|
|
12
|
+
*/
|
|
13
|
+
export function isValidLocale(locale: string): boolean {
|
|
14
|
+
if (!locale) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
new Intl.Locale(locale);
|
|
19
|
+
return true;
|
|
20
|
+
} catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Coerce a browser or config locale tag into a valid
|
|
27
|
+
* [BCP 47](https://www.rfc-editor.org/info/rfc5646) tag, or `null` if nothing
|
|
28
|
+
* usable can be recovered.
|
|
29
|
+
*
|
|
30
|
+
* Some environments report POSIX-style tags that `Intl` rejects with
|
|
31
|
+
* `RangeError: Incorrect locale information provided`, crashing anything that
|
|
32
|
+
* constructs an `Intl.*` formatter (see issue #9938). Known offenders:
|
|
33
|
+
* - POSIX modifiers, e.g. Chromium under Playwright reporting `en-US@posix`
|
|
34
|
+
* - charset suffixes, e.g. `en_US.UTF-8`
|
|
35
|
+
* - underscore separators, e.g. `en_US`
|
|
36
|
+
*/
|
|
37
|
+
export function normalizeLocale(tag: string | null | undefined): string | null {
|
|
38
|
+
if (!tag) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const candidate = tag.split("@")[0].split(".")[0].trim().replaceAll("_", "-");
|
|
43
|
+
|
|
44
|
+
if (isValidLocale(candidate)) {
|
|
45
|
+
return candidate;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Fall back to the language subtag alone (e.g. `en` from a mangled `en-XX`).
|
|
49
|
+
const language = candidate.split("-")[0];
|
|
50
|
+
if (isValidLocale(language)) {
|
|
51
|
+
return language;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Resolve the browser's locale to a safe BCP 47 tag.
|
|
58
|
+
* See https://github.com/marimo-team/marimo/issues/9938 */
|
|
59
|
+
export function browserLocale(): string {
|
|
60
|
+
const language =
|
|
61
|
+
typeof navigator === "undefined" ? undefined : navigator.language;
|
|
62
|
+
return normalizeLocale(language) ?? FALLBACK_LOCALE;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve a configured locale to a safe BCP 47 tag, preferring the config and
|
|
67
|
+
* falling back to the browser locale, then to {@link FALLBACK_LOCALE}.
|
|
68
|
+
*/
|
|
69
|
+
export function safeLocale(locale: string | null | undefined): string {
|
|
70
|
+
return normalizeLocale(locale) ?? browserLocale();
|
|
71
|
+
}
|