feat(ui): add i18n scaffolding
Adds a minimal i18n provider with locale preference support and migrates folder selection copy to message keys.
This commit is contained in:
104
packages/ui/src/lib/i18n/index.tsx
Normal file
104
packages/ui/src/lib/i18n/index.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { createContext, createMemo, createSignal, onMount, useContext } from "solid-js"
|
||||
import type { ParentComponent } from "solid-js"
|
||||
import { useConfig } from "../../stores/preferences"
|
||||
import { enMessages } from "./messages/en"
|
||||
|
||||
type Messages = Record<string, string>
|
||||
|
||||
export type Locale = "en"
|
||||
|
||||
const SUPPORTED_LOCALES: readonly Locale[] = ["en"] as const
|
||||
|
||||
const messagesByLocale: Record<Locale, Messages> = {
|
||||
en: enMessages,
|
||||
}
|
||||
|
||||
function normalizeLocaleTag(value: string): string {
|
||||
return value.trim().replace(/_/g, "-")
|
||||
}
|
||||
|
||||
function matchSupportedLocale(value: string | undefined): Locale | null {
|
||||
if (!value) return null
|
||||
|
||||
const normalized = normalizeLocaleTag(value)
|
||||
const lower = normalized.toLowerCase()
|
||||
const supportedLower = new Map(SUPPORTED_LOCALES.map((locale) => [locale.toLowerCase(), locale]))
|
||||
const exact = supportedLower.get(lower)
|
||||
if (exact) return exact
|
||||
|
||||
const base = lower.split("-")[0]
|
||||
if (!base) return null
|
||||
const baseMatch = supportedLower.get(base)
|
||||
return baseMatch ?? null
|
||||
}
|
||||
|
||||
function detectNavigatorLocale(): Locale | null {
|
||||
if (typeof navigator === "undefined") return null
|
||||
|
||||
const candidates = Array.isArray(navigator.languages) && navigator.languages.length > 0
|
||||
? navigator.languages
|
||||
: navigator.language
|
||||
? [navigator.language]
|
||||
: []
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const match = matchSupportedLocale(candidate)
|
||||
if (match) return match
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function interpolate(template: string, params?: Record<string, unknown>): string {
|
||||
if (!params) return template
|
||||
return template.replace(/\{(\w+)\}/g, (_match, key: string) => {
|
||||
const value = params[key]
|
||||
return value === undefined || value === null ? "" : String(value)
|
||||
})
|
||||
}
|
||||
|
||||
export interface I18nContextValue {
|
||||
locale: () => Locale
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nContextValue>()
|
||||
|
||||
export const I18nProvider: ParentComponent = (props) => {
|
||||
const { preferences } = useConfig()
|
||||
const [detectedLocale, setDetectedLocale] = createSignal<Locale>("en")
|
||||
|
||||
onMount(() => {
|
||||
const detected = detectNavigatorLocale()
|
||||
if (detected) setDetectedLocale(detected)
|
||||
})
|
||||
|
||||
const locale = createMemo<Locale>(() => {
|
||||
const configured = matchSupportedLocale(preferences().locale)
|
||||
return configured ?? detectedLocale() ?? "en"
|
||||
})
|
||||
|
||||
const messages = createMemo<Messages>(() => messagesByLocale[locale()])
|
||||
|
||||
function t(key: string, params?: Record<string, unknown>): string {
|
||||
const current = messages()[key]
|
||||
const fallback = enMessages[key as keyof typeof enMessages]
|
||||
const template = current ?? fallback ?? key
|
||||
return interpolate(template, params)
|
||||
}
|
||||
|
||||
const value: I18nContextValue = {
|
||||
locale,
|
||||
t,
|
||||
}
|
||||
|
||||
return <I18nContext.Provider value={value}>{props.children}</I18nContext.Provider>
|
||||
}
|
||||
|
||||
export function useI18n(): I18nContextValue {
|
||||
const context = useContext(I18nContext)
|
||||
if (!context) {
|
||||
throw new Error("useI18n must be used within I18nProvider")
|
||||
}
|
||||
return context
|
||||
}
|
||||
39
packages/ui/src/lib/i18n/messages/en.ts
Normal file
39
packages/ui/src/lib/i18n/messages/en.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export const enMessages = {
|
||||
"folderSelection.logoAlt": "CodeNomad logo",
|
||||
"folderSelection.tagline": "Select a folder to start coding with AI",
|
||||
|
||||
"folderSelection.links.github": "CodeNomad GitHub",
|
||||
"folderSelection.links.githubStars": "CodeNomad GitHub Stars",
|
||||
"folderSelection.links.discord": "CodeNomad Discord",
|
||||
|
||||
"folderSelection.empty.title": "No Recent Folders",
|
||||
"folderSelection.empty.description": "Browse for a folder to get started",
|
||||
|
||||
"folderSelection.recent.title": "Recent Folders",
|
||||
"folderSelection.recent.subtitle.one": "{count} folder available",
|
||||
"folderSelection.recent.subtitle.other": "{count} folders available",
|
||||
"folderSelection.recent.remove": "Remove from recent",
|
||||
|
||||
"folderSelection.browse.title": "Browse for Folder",
|
||||
"folderSelection.browse.subtitle": "Select any folder on your computer",
|
||||
"folderSelection.browse.button": "Browse Folders",
|
||||
"folderSelection.browse.buttonOpening": "Opening...",
|
||||
|
||||
"folderSelection.advancedSettings": "Advanced Settings",
|
||||
|
||||
"folderSelection.hints.navigate": "Navigate",
|
||||
"folderSelection.hints.select": "Select",
|
||||
"folderSelection.hints.remove": "Remove",
|
||||
"folderSelection.hints.browse": "Browse",
|
||||
|
||||
"folderSelection.loading.title": "Starting instance...",
|
||||
"folderSelection.loading.subtitle": "Hang tight while we prepare your workspace.",
|
||||
|
||||
"folderSelection.dialog.title": "Select Workspace",
|
||||
"folderSelection.dialog.description": "Select workspace to start coding.",
|
||||
|
||||
"time.relative.justNow": "just now",
|
||||
"time.relative.daysAgoShort": "{count}d ago",
|
||||
"time.relative.hoursAgoShort": "{count}h ago",
|
||||
"time.relative.minutesAgoShort": "{count}m ago",
|
||||
} as const
|
||||
Reference in New Issue
Block a user