@marimo-team/frontend 0.23.16-dev2 → 0.23.16-dev21
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/assets/{CellStatus-BalYv0pK.js → CellStatus-DwV4gbV3.js} +1 -1
- package/dist/assets/{ConnectedDataExplorerComponent-DI7mV-Gw.js → ConnectedDataExplorerComponent-CMaZapRd.js} +1 -1
- package/dist/assets/{ErrorBoundary-C7_5jjWi.js → ErrorBoundary-DzTD8LVq.js} +1 -1
- package/dist/assets/{ImperativeModal-BIavOiUv.js → ImperativeModal-fY-ZfT0b.js} +1 -1
- package/dist/assets/JsonOutput-BoDVlAbV.js +53 -0
- package/dist/assets/{LazyAnyLanguageCodeMirror-sVm_hz8A.js → LazyAnyLanguageCodeMirror-B2JvSXP-.js} +2 -2
- package/dist/assets/{MarimoErrorOutput-DO2k2fA5.js → MarimoErrorOutput-C2HrF0bD.js} +5 -5
- package/dist/assets/{RSPContexts-CgC81f0I.js → RSPContexts-WiCgGm__.js} +1 -1
- package/dist/assets/RenderHTML-DoCFSir5.js +1 -0
- package/dist/assets/{RunButton-BKRupqr7.js → RunButton-COtmJuup.js} +1 -1
- package/dist/assets/{add-cell-with-ai-CEZtaZCK.js → add-cell-with-ai-96GHR2R7.js} +16 -16
- package/dist/assets/add-connection-dialog-wLF3on0N.js +170 -0
- package/dist/assets/{agent-panel-By4A-Cqr.js → agent-panel-xLPKpIwr.js} +5 -5
- package/dist/assets/ai-model-dropdown-CR8HQGYf.js +5 -0
- package/dist/assets/{alert-dialog-BXegNC0I.js → alert-dialog-Bb8Lz8ko.js} +1 -1
- package/dist/assets/{any-language-editor-DCpZRrpx.js → any-language-editor--tRHs8C_.js} +1 -1
- package/dist/assets/{app-config-button-NrM9ywXA.js → app-config-button-Ch-fmU-u.js} +1 -1
- package/dist/assets/{button-eIihcbUw.js → button-DDGiD1NW.js} +1 -1
- package/dist/assets/{cache-panel-wllSTlbM.js → cache-panel-DcQUeUpg.js} +1 -1
- package/dist/assets/{capabilities-B09j1Eor.js → capabilities-DoEg0XwL.js} +1 -1
- package/dist/assets/{cell-editor-DdofA9oQ.js → cell-editor-CxP3SFwW.js} +10 -10
- package/dist/assets/cell-link-D1nZ5myN.js +1 -0
- package/dist/assets/cells-D9tCNe6o.js +231 -0
- package/dist/assets/{chat-display-BG08ltQ3.js → chat-display-PstK5lnL.js} +1 -1
- package/dist/assets/{chat-panel-P2CzCtXJ.js → chat-panel-XuGXjT-8.js} +3 -3
- package/dist/assets/{chat-ui-fAJZAoTD.js → chat-ui-Cme86OAK.js} +1 -1
- package/dist/assets/{column-preview-BgZLUEOj.js → column-preview-BYiduGzU.js} +1 -1
- package/dist/assets/{command-CAJLPEBq.js → command-Dqxie4gq.js} +1 -1
- package/dist/assets/{command-palette-DrI24FoL.js → command-palette-C7NlQPUx.js} +1 -1
- package/dist/assets/{common-CD6Mj8n6.js → common-XBd1qRDk.js} +1 -1
- package/dist/assets/components-D-aPO6md.js +1 -0
- package/dist/assets/config-B-Q7J_6d.js +1 -0
- package/dist/assets/{config-COUOIPEq.js → config-BJVx3eBW.js} +1 -1
- package/dist/assets/{copy-BtTOqPTS.js → copy-Bxb1_Gtw.js} +1 -1
- package/dist/assets/{copy-icon-C2pG0ZNK.js → copy-icon-neDQUo4Q.js} +1 -1
- package/dist/assets/{createReducer-C7kZ8zEe.js → createReducer-DGx5Af-C.js} +1 -1
- package/dist/assets/{datasource-ySAiC9gr.js → datasource-Pl4KKOUa.js} +2 -2
- package/dist/assets/{dates-D-kIKDYA.js → dates-C2LWzsCZ.js} +1 -1
- package/dist/assets/{dependency-graph-panel-BF23EwcD.js → dependency-graph-panel-98TlJ5y7.js} +4 -4
- package/dist/assets/{dialog-D1DEsRZ2.js → dialog-BCVepWLJ.js} +1 -1
- package/dist/assets/{dist-ByVeMWkr.js → dist-BLN8PNJV.js} +1 -1
- package/dist/assets/dist-CiVShZKM.js +2 -0
- package/dist/assets/documentation-panel-DSOMQtKU.js +1 -0
- package/dist/assets/{download-BomYt3nl.js → download-CnYLah74.js} +3 -3
- package/dist/assets/{dropdown-menu-BFZLnWeO.js → dropdown-menu-dHmZR8bI.js} +1 -1
- package/dist/assets/{edit-page-C37w54dQ.js → edit-page-CchyCkOl.js} +6 -6
- package/dist/assets/{error-banner-B6eIlxzg.js → error-banner-BSxEOIn2.js} +1 -1
- package/dist/assets/{error-entries-Birw-8VX.js → error-entries-CmfGHGq1.js} +3 -3
- package/dist/assets/{error-panel-DdsUL6b7.js → error-panel-BKb_UksO.js} +1 -1
- package/dist/assets/{es-1D83cgiu.js → es-gYgfF8y5.js} +1 -1
- package/dist/assets/{esm-BL7jQmzn.js → esm-B30N1GS0.js} +1 -1
- package/dist/assets/{field-BeZbVzXA.js → field-BsxD35VE.js} +1 -1
- package/dist/assets/file-explorer-panel-8_ps5wdU.js +26 -0
- package/dist/assets/{file-icons-Cb54mIL8.js → file-icons-Czm7CWoc.js} +1 -1
- package/dist/assets/{file-name-input-BWFcK1ey.js → file-name-input-CjWG0FWT.js} +1 -1
- package/dist/assets/floating-outline-BWk7h_Uf.js +1 -0
- package/dist/assets/focus-DTmSq3hN.js +1 -0
- package/dist/assets/{form-CgWNb44_.js → form-B5UJtZJr.js} +1 -1
- package/dist/assets/{formats-ClEFGPy-.js → formats-DzmLpc9A.js} +1 -1
- package/dist/assets/{formatting-B_41gSF5.js → formatting-CDf6w1Kt.js} +1 -1
- package/dist/assets/{fullscreen-dUjPYOmm.js → fullscreen-CTDqNQr6.js} +1 -1
- package/dist/assets/{gallery-page-CWQQAYpa.js → gallery-page-CJ3ms1Hn.js} +1 -1
- package/dist/assets/{glide-data-editor-lMARFWHS.js → glide-data-editor-j2igs9Y_.js} +1 -1
- package/dist/assets/home-page-cBU6sDBU.js +4 -0
- package/dist/assets/{html-to-image-CwmX28T9.js → html-to-image-1vflBYah.js} +2 -2
- package/dist/assets/index-B8eK-daS.js +38 -0
- package/dist/assets/index-xs-zSSrX.css +2 -0
- package/dist/assets/{input-CqrUjynb.js → input-JxFwu-3G.js} +1 -1
- package/dist/assets/kiosk-mode-BumOFEtY.js +1 -0
- package/dist/assets/{label-BElwWzf0.js → label-CqjrpwRC.js} +1 -1
- package/dist/assets/{layout-BOwE7yok.js → layout-DkpUSblm.js} +5 -5
- package/dist/assets/{links-CToBjIIj.js → links-CZr5bjvo.js} +1 -1
- package/dist/assets/{logs-panel-iFV4ZJ9b.js → logs-panel-BojuPimp.js} +1 -1
- package/dist/assets/{markdown-renderer-QvXG0CRk.js → markdown-renderer-C9vdWclk.js} +2 -2
- package/dist/assets/{menu-items-DbSLBfXR.js → menu-items-CCi6aOPm.js} +1 -1
- package/dist/assets/{mermaid-Bh0flEIb.js → mermaid-CYN0wOyG.js} +1 -1
- package/dist/assets/name-cell-input-BSFsnuTp.js +1 -0
- package/dist/assets/node-sql-parser-Si4oZ2P-.js +68 -0
- package/dist/assets/{numbers-CeeaFKqu.js → numbers-rmawRqQP.js} +1 -1
- package/dist/assets/outline-panel-B1f5a2yI.js +1 -0
- package/dist/assets/{packages-panel-BzQF60rG.js → packages-panel-BKuXx1gi.js} +1 -1
- package/dist/assets/{pair-with-agent-modal-CKOYwOYw.js → pair-with-agent-modal-nwd26aKZ.js} +1 -1
- package/dist/assets/panels-Cvyb3kny.js +4 -0
- package/dist/assets/{popover-DrHQ3KEt.js → popover-Bcz1Zikb.js} +1 -1
- package/dist/assets/process-output-Csd6PmBH.js +1 -0
- package/dist/assets/{radio-group-CLBjGkt5.js → radio-group-CdR34W0K.js} +1 -1
- package/dist/assets/readonly-code-display-XBAHhCYR.js +1 -0
- package/dist/assets/{readonly-python-code-CW_G5e4E.js → readonly-python-code-BqcMp63O.js} +1 -1
- package/dist/assets/{renderShortcut-BZLr8D7H.js → renderShortcut-Bl8_FVn8.js} +1 -1
- package/dist/assets/{reveal-component-DqMRxmUr.js → reveal-component-BvsuzhVi.js} +57 -57
- package/dist/assets/{run-page-s6Mf8p78.js → run-page-B9jHpo8w.js} +1 -1
- package/dist/assets/{runs-D51Xwrsk.js → runs-DBTXqJHS.js} +1 -1
- package/dist/assets/scratchpad-panel-D3slJkhR.js +1 -0
- package/dist/assets/{scroll-to-bottom-button-Cgr10kaJ.js → scroll-to-bottom-button-C8yfT4wq.js} +1 -1
- package/dist/assets/{secrets-panel-Cc5KDBk6.js → secrets-panel-6J6wcuVR.js} +1 -1
- package/dist/assets/{select-B5TFNjmU.js → select-BP75_5jJ.js} +1 -1
- package/dist/assets/{session-CEFnN9Cq.js → session-BmukRjSi.js} +1 -1
- package/dist/assets/{session-panel-CmHetqAb.js → session-panel-DO-4U6Ry.js} +1 -1
- package/dist/assets/{share-CYpcX4N4.js → share-852X_cqy.js} +1 -1
- package/dist/assets/{skeleton-B_4CZVEm.js → skeleton-C3fwwBky.js} +1 -1
- package/dist/assets/{snippets-panel-CXpGflMO.js → snippets-panel-ACMhA8oL.js} +1 -1
- package/dist/assets/{spec-CzqcmYQk.js → spec-C7opZZRp.js} +1 -1
- package/dist/assets/{state-BvdK8eiw.js → state-D10U2MwA.js} +1 -1
- package/dist/assets/{state-BXbiRXZk.js → state-D2ccy8VQ.js} +3 -3
- package/dist/assets/{state-WBgc7MiA.js → state-DDag0B_B.js} +1 -1
- package/dist/assets/{state-DBzEQtWz.js → state-N0U-K9F6.js} +1 -1
- package/dist/assets/{strings-Djnbwmfl.js → strings--BknHX9C.js} +1 -1
- package/dist/assets/{swiper-component-DInvCxhI.js → swiper-component-Che9MNs4.js} +1 -1
- package/dist/assets/{switch-CvweaCXq.js → switch-Drt3XC71.js} +1 -1
- package/dist/assets/{terminal-D3IQBBYo.js → terminal-217O8hlZ.js} +1 -1
- package/dist/assets/{textarea-CpFfEUet.js → textarea-BQE_pb7P.js} +1 -1
- package/dist/assets/{tooltip-CFX4fYHU.js → tooltip-4bZlU7jl.js} +1 -1
- package/dist/assets/{tooltip-HLb6RxVp.js → tooltip-B7U4xkIN.js} +1 -1
- package/dist/assets/{tracing-CKn3gWHw.js → tracing-CuJfR83E.js} +1 -1
- package/dist/assets/{tracing-panel-BU-o_LCd.js → tracing-panel-CmMG0R-H.js} +2 -2
- package/dist/assets/{tree-actions-CsC2XmGo.js → tree-actions-DJTGQWz0.js} +1 -1
- package/dist/assets/{useBoolean-D89u30n-.js → useBoolean-CFj86iac.js} +1 -1
- package/dist/assets/useCellActionButton-BhCYWOKS.js +1 -0
- package/dist/assets/{useDeleteCell-EPH4kcG7.js → useDeleteCell-DsWH8wmn.js} +1 -1
- package/dist/assets/{useDependencyPanelTab-CZnK7N2L.js → useDependencyPanelTab-CAI77Oxj.js} +1 -1
- package/dist/assets/useEventListener-C0195JsF.js +1 -0
- package/dist/assets/{useHotkey-DIuRH8Wu.js → useHotkey-DLZvrRZT.js} +1 -1
- package/dist/assets/{useIframeCapabilities-Ccb4fvKc.js → useIframeCapabilities-j6fR_dBA.js} +1 -1
- package/dist/assets/{useInstallPackage-BRbUAUFn.js → useInstallPackage-WiYBwhUO.js} +1 -1
- package/dist/assets/{useInterval-CC0vKpWm.js → useInterval-CWBTUy5W.js} +1 -1
- package/dist/assets/{useNotebookActions-DJKDd0gM.js → useNotebookActions-9T9wvd8s.js} +4 -4
- package/dist/assets/useRunCells-BpMmDGqa.js +1 -0
- package/dist/assets/{useSplitCell-9Qz93yPZ.js → useSplitCell-CapBPXO1.js} +1 -1
- package/dist/assets/{useTheme-KXqAyDPC.js → useTheme-Bxv9B31g.js} +1 -1
- package/dist/assets/{utils-Z80SV4_d.js → utils-BWBBpCkT.js} +1 -1
- package/dist/assets/{vega-component-Cie3ag_T.js → vega-component-Db8KjZJG.js} +1 -1
- package/dist/assets/{write-secret-modal-DEEn7sZV.js → write-secret-modal-3-v-dHVj.js} +1 -1
- package/dist/index.html +68 -68
- package/package.json +2 -2
- package/src/components/data-table/column-formatting/feature.ts +2 -1
- package/src/components/editor/connections/storage/__tests__/__snapshots__/as-code.test.ts.snap +50 -0
- package/src/components/editor/connections/storage/__tests__/as-code.test.ts +87 -8
- package/src/components/editor/connections/storage/as-code.ts +40 -21
- package/src/components/editor/connections/storage/schemas.ts +83 -42
- package/src/components/editor/file-tree/__tests__/file-render-mode.test.ts +27 -0
- package/src/components/editor/file-tree/__tests__/file-viewer.test.tsx +150 -0
- package/src/components/editor/file-tree/file-preview-metadata.tsx +51 -0
- package/src/components/editor/file-tree/file-render-mode.ts +32 -0
- package/src/components/editor/file-tree/file-viewer.tsx +76 -50
- package/src/components/slides/__tests__/slide-scroll-hint.test.tsx +198 -0
- package/src/components/slides/reveal-component.tsx +3 -2
- package/src/components/slides/slide-scroll-hint.tsx +161 -0
- package/src/components/ui/input.tsx +1 -1
- package/src/core/codemirror/keymaps/__tests__/keymaps.test.ts +31 -2
- package/src/core/codemirror/keymaps/keymaps.ts +12 -0
- package/src/core/codemirror/language/__tests__/sql.test.ts +443 -61
- package/src/core/codemirror/language/languages/sql/completion-store.ts +5 -1
- package/src/core/codemirror/language/languages/sql/sql.ts +210 -3
- package/src/core/hotkeys/hotkeys.ts +0 -2
- 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
- package/src/core/islands/__tests__/bridge.test.ts +107 -0
- package/src/core/islands/bridge.ts +15 -0
- package/src/core/mode.ts +10 -0
- package/src/core/network/__tests__/connection.test.ts +45 -0
- package/src/core/network/__tests__/requests-network.test.ts +13 -0
- package/src/core/network/connection.ts +15 -0
- package/src/core/network/requests-network.ts +6 -3
- package/src/core/network/types.ts +3 -1
- package/src/plugins/impl/FormPlugin.tsx +1 -1
- package/src/plugins/impl/__tests__/FormPlugin.test.tsx +162 -0
- package/dist/assets/JsonOutput-DnGSkLeP.js +0 -53
- package/dist/assets/RenderHTML-BCvU9dYz.js +0 -1
- package/dist/assets/add-connection-dialog-CEp3SJR2.js +0 -170
- package/dist/assets/ai-model-dropdown-DXgLphFa.js +0 -5
- package/dist/assets/cell-link-DqBkyAFe.js +0 -1
- package/dist/assets/cells-Cf7bHHq3.js +0 -229
- package/dist/assets/components-BH6gDVv6.js +0 -1
- package/dist/assets/config-BbOTTCYY.js +0 -1
- package/dist/assets/dist-BCMBu0rO.js +0 -2
- package/dist/assets/documentation-panel-e9Fi9R7e.js +0 -1
- package/dist/assets/file-explorer-panel-DbdoJbbF.js +0 -26
- package/dist/assets/floating-outline-wz-WSRgP.js +0 -1
- package/dist/assets/focus-DiEyfiAG.js +0 -1
- package/dist/assets/home-page-DJ4jJsZj.js +0 -4
- package/dist/assets/index-A71LAt6h.css +0 -2
- package/dist/assets/index-Cl0lNhQU.js +0 -38
- package/dist/assets/kiosk-mode-ABm3oYgq.js +0 -1
- package/dist/assets/name-cell-input-C6cukQ9k.js +0 -1
- package/dist/assets/node-sql-parser-BWgUjcfV.js +0 -68
- package/dist/assets/outline-panel-BPlg9_CE.js +0 -1
- package/dist/assets/panels-CW-PUlv2.js +0 -4
- package/dist/assets/process-output-BEhIGfkO.js +0 -1
- package/dist/assets/readonly-code-display-D63oRtBP.js +0 -1
- package/dist/assets/scratchpad-panel-CXsxdMB-.js +0 -1
- package/dist/assets/useCellActionButton-CRfIEVB5.js +0 -1
- package/dist/assets/useEventListener-B_w3A283.js +0 -1
- package/dist/assets/useRunCells-CSSF_mJ0.js +0 -1
|
@@ -12,12 +12,17 @@ import {
|
|
|
12
12
|
type ViewUpdate,
|
|
13
13
|
} from "@codemirror/view";
|
|
14
14
|
import {
|
|
15
|
+
aliasColumnCompletionSource,
|
|
16
|
+
createCteCompletionSource,
|
|
15
17
|
defaultSqlHoverTheme,
|
|
16
18
|
NodeSqlParser,
|
|
17
19
|
type NodeSqlParserResult,
|
|
20
|
+
QueryContextAnalyzer,
|
|
18
21
|
type SupportedDialects as ParserDialects,
|
|
19
22
|
type SqlParseError,
|
|
20
23
|
sqlExtension,
|
|
24
|
+
SqlStructureAnalyzer,
|
|
25
|
+
unqualifiedColumnCompletionSource,
|
|
21
26
|
} from "@marimo-team/codemirror-sql";
|
|
22
27
|
import { DuckDBDialect } from "@marimo-team/codemirror-sql/dialects";
|
|
23
28
|
import { type SQLMetadata, SQLParser } from "@marimo-team/smart-cells";
|
|
@@ -142,6 +147,7 @@ export class SQLLanguageAdapter implements LanguageAdapter<SQLLanguageAdapterMet
|
|
|
142
147
|
_placeholderType: PlaceholderType,
|
|
143
148
|
lspConfig: LSPConfig & { diagnostics: DiagnosticsConfig },
|
|
144
149
|
): Extension[] {
|
|
150
|
+
const analysis = createSQLAnalysis();
|
|
145
151
|
const extensions = [
|
|
146
152
|
// This can be updated with a dispatch effect
|
|
147
153
|
sqlConfigCompartment.of(sql({ dialect: DEFAULT_DIALECT })),
|
|
@@ -167,6 +173,10 @@ export class SQLLanguageAdapter implements LanguageAdapter<SQLLanguageAdapterMet
|
|
|
167
173
|
variableCompletionSource,
|
|
168
174
|
// Completions for dialect keywords
|
|
169
175
|
customKeywordCompletionSource(),
|
|
176
|
+
// Completions based on the current query
|
|
177
|
+
analysis.cteCompletionSource,
|
|
178
|
+
analysis.aliasCompletionSource,
|
|
179
|
+
analysis.columnCompletionSource,
|
|
170
180
|
],
|
|
171
181
|
}),
|
|
172
182
|
];
|
|
@@ -185,10 +195,23 @@ export class SQLLanguageAdapter implements LanguageAdapter<SQLLanguageAdapterMet
|
|
|
185
195
|
|
|
186
196
|
extensions.push(
|
|
187
197
|
sqlExtension({
|
|
198
|
+
schema: getSchema,
|
|
188
199
|
enableLinting: true,
|
|
189
200
|
linterConfig: {
|
|
190
201
|
delay: 250, // Delay before running validation
|
|
191
202
|
parser: parser,
|
|
203
|
+
// CustomSqlParser performs backend DuckDB validation in validateSql.
|
|
204
|
+
perStatement: false,
|
|
205
|
+
},
|
|
206
|
+
enableSemanticLinting: true,
|
|
207
|
+
semanticLinterConfig: {
|
|
208
|
+
parser: analysis.parser,
|
|
209
|
+
structureAnalyzer: analysis.structureAnalyzer,
|
|
210
|
+
severity: {
|
|
211
|
+
unknownTable: "off",
|
|
212
|
+
unknownColumn: "warning",
|
|
213
|
+
ambiguousColumn: "warning",
|
|
214
|
+
},
|
|
192
215
|
},
|
|
193
216
|
enableGutterMarkers: true,
|
|
194
217
|
gutterConfig: {
|
|
@@ -198,14 +221,21 @@ export class SQLLanguageAdapter implements LanguageAdapter<SQLLanguageAdapterMet
|
|
|
198
221
|
parser: parser,
|
|
199
222
|
},
|
|
200
223
|
hoverConfig: {
|
|
201
|
-
schema: getSchema, // Use the same schema as autocomplete
|
|
202
224
|
hoverTime: 300, // 300ms hover delay
|
|
203
225
|
enableKeywords: true, // Show keyword information
|
|
204
226
|
enableTables: true, // Show table information
|
|
205
227
|
enableColumns: true, // Show column information
|
|
206
|
-
parser: parser,
|
|
228
|
+
parser: analysis.parser,
|
|
229
|
+
contextAnalyzer: analysis.contextAnalyzer,
|
|
207
230
|
theme: defaultSqlHoverTheme(theme),
|
|
208
231
|
},
|
|
232
|
+
enableNavigation: true,
|
|
233
|
+
navigationConfig: {
|
|
234
|
+
contextAnalyzer: analysis.contextAnalyzer,
|
|
235
|
+
keymap: false,
|
|
236
|
+
parser: analysis.parser,
|
|
237
|
+
structureAnalyzer: analysis.structureAnalyzer,
|
|
238
|
+
},
|
|
209
239
|
}),
|
|
210
240
|
EditorView.updateListener.of((update) => {
|
|
211
241
|
if (update.focusChanged) {
|
|
@@ -223,6 +253,123 @@ export class SQLLanguageAdapter implements LanguageAdapter<SQLLanguageAdapterMet
|
|
|
223
253
|
}
|
|
224
254
|
}
|
|
225
255
|
|
|
256
|
+
function createSQLAnalysis() {
|
|
257
|
+
const parser = new NodeSqlParser({
|
|
258
|
+
getParserOptions: (state: EditorState) => {
|
|
259
|
+
return {
|
|
260
|
+
database: getAnalysisDialect(state),
|
|
261
|
+
};
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
const contextAnalyzer = new DialectAwareQueryContextAnalyzer(parser);
|
|
265
|
+
const structureAnalyzer = new DialectAwareSqlStructureAnalyzer(parser);
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
parser,
|
|
269
|
+
contextAnalyzer,
|
|
270
|
+
structureAnalyzer,
|
|
271
|
+
cteCompletionSource: createCteCompletionSource({
|
|
272
|
+
parser,
|
|
273
|
+
contextAnalyzer,
|
|
274
|
+
structureAnalyzer,
|
|
275
|
+
}),
|
|
276
|
+
aliasCompletionSource: aliasColumnCompletionSource({
|
|
277
|
+
schema: getSchema,
|
|
278
|
+
parser,
|
|
279
|
+
contextAnalyzer,
|
|
280
|
+
}),
|
|
281
|
+
columnCompletionSource: unqualifiedColumnCompletionSource({
|
|
282
|
+
schema: getSchema,
|
|
283
|
+
parser,
|
|
284
|
+
contextAnalyzer,
|
|
285
|
+
}),
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function getAnalysisDialect(state: EditorState): ParserDialects {
|
|
290
|
+
return guessParserDialect(state) ?? DEFAULT_PARSER_DIALECT;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
class DialectAwareQueryContextAnalyzer extends QueryContextAnalyzer {
|
|
294
|
+
private readonly analyzers = new Map<ParserDialects, QueryContextAnalyzer>();
|
|
295
|
+
private readonly analysisParser: NodeSqlParser;
|
|
296
|
+
|
|
297
|
+
constructor(analysisParser: NodeSqlParser) {
|
|
298
|
+
super(analysisParser);
|
|
299
|
+
this.analysisParser = analysisParser;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
override getContext(
|
|
303
|
+
sql: string,
|
|
304
|
+
opts: { state: EditorState },
|
|
305
|
+
): ReturnType<QueryContextAnalyzer["getContext"]> {
|
|
306
|
+
return this.getAnalyzer(opts.state).getContext(sql, opts);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
override clearCache(): void {
|
|
310
|
+
for (const analyzer of this.analyzers.values()) {
|
|
311
|
+
analyzer.clearCache();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private getAnalyzer(state: EditorState): QueryContextAnalyzer {
|
|
316
|
+
const dialect = getAnalysisDialect(state);
|
|
317
|
+
let analyzer = this.analyzers.get(dialect);
|
|
318
|
+
if (!analyzer) {
|
|
319
|
+
analyzer = new QueryContextAnalyzer(this.analysisParser);
|
|
320
|
+
this.analyzers.set(dialect, analyzer);
|
|
321
|
+
}
|
|
322
|
+
return analyzer;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
class DialectAwareSqlStructureAnalyzer extends SqlStructureAnalyzer {
|
|
327
|
+
private readonly analyzers = new Map<ParserDialects, SqlStructureAnalyzer>();
|
|
328
|
+
private readonly analysisParser: NodeSqlParser;
|
|
329
|
+
|
|
330
|
+
constructor(analysisParser: NodeSqlParser) {
|
|
331
|
+
super(analysisParser);
|
|
332
|
+
this.analysisParser = analysisParser;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
override analyzeDocument(
|
|
336
|
+
state: EditorState,
|
|
337
|
+
): ReturnType<SqlStructureAnalyzer["analyzeDocument"]> {
|
|
338
|
+
return this.getAnalyzer(state).analyzeDocument(state);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
override getStatementAtPosition(
|
|
342
|
+
state: EditorState,
|
|
343
|
+
position: number,
|
|
344
|
+
): ReturnType<SqlStructureAnalyzer["getStatementAtPosition"]> {
|
|
345
|
+
return this.getAnalyzer(state).getStatementAtPosition(state, position);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
override getStatementsInRange(
|
|
349
|
+
state: EditorState,
|
|
350
|
+
from: number,
|
|
351
|
+
to: number,
|
|
352
|
+
): ReturnType<SqlStructureAnalyzer["getStatementsInRange"]> {
|
|
353
|
+
return this.getAnalyzer(state).getStatementsInRange(state, from, to);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
override clearCache(): void {
|
|
357
|
+
for (const analyzer of this.analyzers.values()) {
|
|
358
|
+
analyzer.clearCache();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private getAnalyzer(state: EditorState): SqlStructureAnalyzer {
|
|
363
|
+
const dialect = getAnalysisDialect(state);
|
|
364
|
+
let analyzer = this.analyzers.get(dialect);
|
|
365
|
+
if (!analyzer) {
|
|
366
|
+
analyzer = new SqlStructureAnalyzer(this.analysisParser);
|
|
367
|
+
this.analyzers.set(dialect, analyzer);
|
|
368
|
+
}
|
|
369
|
+
return analyzer;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
226
373
|
class CustomSqlParser extends NodeSqlParser {
|
|
227
374
|
private validationTimeout: number | null = null;
|
|
228
375
|
private readonly VALIDATION_DELAY_MS = 300; // Wait 300ms after user stops typing
|
|
@@ -344,7 +491,61 @@ function getSchema(view: EditorView): SQLNamespace {
|
|
|
344
491
|
return {};
|
|
345
492
|
}
|
|
346
493
|
|
|
347
|
-
|
|
494
|
+
const schema = config.schema;
|
|
495
|
+
if (!isNamespaceMap(schema)) {
|
|
496
|
+
return schema;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const defaultSchema = config.defaultSchema;
|
|
500
|
+
const defaultSchemaNamespace = defaultSchema
|
|
501
|
+
? schema[defaultSchema]
|
|
502
|
+
: undefined;
|
|
503
|
+
|
|
504
|
+
// The completion schema contains both default-schema and fully qualified
|
|
505
|
+
// paths. Promote default-schema tables so unqualified references resolve to
|
|
506
|
+
// one exact match instead of appearing ambiguous.
|
|
507
|
+
if (
|
|
508
|
+
defaultSchemaNamespace &&
|
|
509
|
+
!Array.isArray(defaultSchemaNamespace) &&
|
|
510
|
+
typeof defaultSchemaNamespace === "object" &&
|
|
511
|
+
"children" in defaultSchemaNamespace &&
|
|
512
|
+
isNamespaceMap(defaultSchemaNamespace.children)
|
|
513
|
+
) {
|
|
514
|
+
return {
|
|
515
|
+
...schema,
|
|
516
|
+
...Object.fromEntries(
|
|
517
|
+
Object.entries(defaultSchemaNamespace.children).filter(
|
|
518
|
+
([name]) => !Object.hasOwn(schema, name),
|
|
519
|
+
),
|
|
520
|
+
),
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return schema;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function isNamespaceMap(
|
|
528
|
+
namespace: SQLNamespace,
|
|
529
|
+
): namespace is Record<string, SQLNamespace> {
|
|
530
|
+
if (Array.isArray(namespace)) {
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
if (!("self" in namespace) || !("children" in namespace)) {
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// A self/children wrapper stores a Completion in `self`. A table or schema
|
|
539
|
+
// literally named "self" stores another namespace there.
|
|
540
|
+
const self = namespace.self;
|
|
541
|
+
const isSelfChildrenWrapper =
|
|
542
|
+
typeof self === "object" &&
|
|
543
|
+
self !== null &&
|
|
544
|
+
!Array.isArray(self) &&
|
|
545
|
+
"label" in self &&
|
|
546
|
+
typeof self.label === "string";
|
|
547
|
+
|
|
548
|
+
return !isSelfChildrenWrapper;
|
|
348
549
|
}
|
|
349
550
|
|
|
350
551
|
function guessParserDialect(state: EditorState): ParserDialects | null {
|
|
@@ -529,3 +730,9 @@ async function validateSQL(
|
|
|
529
730
|
}
|
|
530
731
|
return result;
|
|
531
732
|
}
|
|
733
|
+
|
|
734
|
+
export const exportedForTesting = {
|
|
735
|
+
createSQLAnalysis,
|
|
736
|
+
CustomSqlParser,
|
|
737
|
+
getSchema,
|
|
738
|
+
};
|
|
@@ -256,13 +256,11 @@ const DEFAULT_HOT_KEY = {
|
|
|
256
256
|
name: "Copy line(s) up",
|
|
257
257
|
group: "Editing",
|
|
258
258
|
key: "Alt-Shift-ArrowUp",
|
|
259
|
-
editable: false,
|
|
260
259
|
},
|
|
261
260
|
"cell.copyLineDown": {
|
|
262
261
|
name: "Copy line(s) down",
|
|
263
262
|
group: "Editing",
|
|
264
263
|
key: "Alt-Shift-ArrowDown",
|
|
265
|
-
editable: false,
|
|
266
264
|
},
|
|
267
265
|
|
|
268
266
|
// Markdown
|
|
@@ -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
|
-
}
|