feat(i18n): add Lingui runtime and development tooling

- add SSR-safe I18nProvider, Translate, locale hooks, catalog activation, and typed runtime APIs

- add consumer-owned Lingui configuration helpers and a CLI for locale creation, extraction, compilation, and project discovery

- add catalog read/write services plus a Vite development API plugin scoped to each consuming application

- add the portal-based I18nDevtool, message editor, dark-theme tracking, and standalone Message Studio

- document the consumer workflow and cover runtime, configuration, catalog, CLI, and Devtool behavior with tests
This commit is contained in:
Maofeng
2026-07-29 21:23:00 +08:00
parent 011e7d8115
commit 9e3f1d6b59
39 changed files with 2897 additions and 0 deletions
+307
View File
@@ -0,0 +1,307 @@
import * as React from "react"
import { useOptionalMessageRepository } from "./message-repository"
import type { DevtoolMessage, MessageRepository } from "./types"
import "./message-panel.css"
type LoadState =
| { status: "loading" }
| { error: Error; status: "error" }
| { messages: readonly DevtoolMessage[]; status: "ready" }
export interface MessagePanelProps {
className?: string
locale: string
repository?: MessageRepository
}
function getMessageSearchText(message: DevtoolMessage) {
return [
message.id,
message.source,
message.translation,
...message.comments,
...message.origins.map((origin) => origin.file),
]
.join("\n")
.toLocaleLowerCase()
}
function MessageEditor({
locale,
message,
onSave,
}: {
locale: string
message: DevtoolMessage
onSave: (translation: string) => Promise<void>
}) {
const [translation, setTranslation] = React.useState(message.translation)
const [saveState, setSaveState] = React.useState<
"idle" | "saving" | "saved" | "error"
>("idle")
const submittedTranslationRef = React.useRef<string | undefined>(undefined)
const isDirty = translation !== message.translation
React.useEffect(() => {
setTranslation(message.translation)
if (submittedTranslationRef.current === message.translation) {
submittedTranslationRef.current = undefined
return
}
setSaveState("idle")
}, [message.translation])
const handleSave = async () => {
if (!isDirty || saveState === "saving") {
return
}
setSaveState("saving")
submittedTranslationRef.current = translation
try {
await onSave(translation)
setSaveState("saved")
} catch {
submittedTranslationRef.current = undefined
setSaveState("error")
}
}
return (
<article
className="i18n-message"
data-missing={message.missing || undefined}
data-obsolete={message.obsolete || undefined}
>
<header className="i18n-message__header">
<code className="i18n-message__id">{message.id}</code>
<span className="i18n-message__catalog">{message.catalog}</span>
</header>
<div className="i18n-message__source">
<span className="i18n-message__label">Source</span>
<p>{message.source || message.id}</p>
</div>
<label className="i18n-message__field">
<span className="i18n-message__label">{locale}</span>
<textarea
aria-label={`${message.id} translation for ${locale}`}
value={translation}
onChange={(event) => {
submittedTranslationRef.current = undefined
setTranslation(event.target.value)
setSaveState("idle")
}}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault()
void handleSave()
}
}}
/>
</label>
{(message.comments.length > 0 || message.origins.length > 0) && (
<footer className="i18n-message__meta">
{message.comments.map((comment) => (
<span key={comment}>{comment}</span>
))}
{message.origins.map((origin) => (
<span key={`${origin.file}:${origin.line ?? ""}`}>
{origin.file}
{origin.line ? `:${origin.line}` : ""}
</span>
))}
</footer>
)}
<div className="i18n-message__actions">
<span aria-live="polite" className="i18n-message__status">
{saveState === "saving" && "Saving…"}
{saveState === "saved" && "Saved"}
{saveState === "error" && "Could not save"}
</span>
<button
type="button"
disabled={!isDirty || saveState === "saving"}
onClick={() => void handleSave()}
>
Save
</button>
</div>
</article>
)
}
export function MessagePanel({
className,
locale,
repository: repositoryProp,
}: MessagePanelProps) {
const repositoryFromContext = useOptionalMessageRepository()
const repository = repositoryProp ?? repositoryFromContext
const [state, setState] = React.useState<LoadState>({ status: "loading" })
const [query, setQuery] = React.useState("")
const [missingOnly, setMissingOnly] = React.useState(false)
const loadRequestRef = React.useRef(0)
const loadMessages = React.useCallback(async () => {
const requestId = loadRequestRef.current + 1
loadRequestRef.current = requestId
if (!repository) {
setState({
error: new Error(
"MessagePanel requires a MessageRepositoryProvider or a repository prop."
),
status: "error",
})
return
}
setState({ status: "loading" })
try {
const messages = await repository.getMessages(locale)
if (requestId !== loadRequestRef.current) {
return
}
setState({ messages, status: "ready" })
} catch (error) {
if (requestId !== loadRequestRef.current) {
return
}
setState({
error:
error instanceof Error
? error
: new Error("Unable to load messages."),
status: "error",
})
}
}, [locale, repository])
React.useEffect(() => {
void loadMessages()
}, [loadMessages])
const visibleMessages = React.useMemo(() => {
if (state.status !== "ready") {
return []
}
const normalizedQuery = query.trim().toLocaleLowerCase()
return state.messages.filter((message) => {
if (missingOnly && !message.missing) {
return false
}
return (
!normalizedQuery ||
getMessageSearchText(message).includes(normalizedQuery)
)
})
}, [missingOnly, query, state])
const updateMessage = React.useCallback(
async (message: DevtoolMessage, translation: string) => {
if (!repository) {
throw new Error("No message repository is available.")
}
const updatedMessage = await repository.updateMessage({
catalog: message.catalog,
id: message.id,
locale,
translation,
})
setState((current) => {
if (current.status !== "ready") {
return current
}
return {
messages: current.messages.map((item) =>
item.catalog === updatedMessage.catalog &&
item.id === updatedMessage.id
? updatedMessage
: item
),
status: "ready",
}
})
},
[locale, repository]
)
const rootClassName = ["i18n-message-panel", className]
.filter(Boolean)
.join(" ")
return (
<section className={rootClassName}>
<header className="i18n-message-panel__toolbar">
<label className="i18n-message-panel__search">
<span className="i18n-visually-hidden">Search messages</span>
<input
type="search"
placeholder="Search messages"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</label>
<label className="i18n-message-panel__toggle">
<input
type="checkbox"
checked={missingOnly}
onChange={(event) => setMissingOnly(event.target.checked)}
/>
Missing only
</label>
{state.status === "ready" && (
<output className="i18n-message-panel__count">
{visibleMessages.length} / {state.messages.length}
</output>
)}
</header>
{state.status === "loading" ? (
<div className="i18n-message-panel__state" role="status">
Loading {locale}
</div>
) : state.status === "error" ? (
<div className="i18n-message-panel__state" role="alert">
<p>{state.error.message}</p>
<button type="button" onClick={() => void loadMessages()}>
Retry
</button>
</div>
) : visibleMessages.length === 0 ? (
<div className="i18n-message-panel__state">
No messages match the current filters.
</div>
) : (
<div className="i18n-message-panel__list">
{visibleMessages.map((message) => (
<MessageEditor
key={`${message.catalog}:${message.id}`}
locale={locale}
message={message}
onSave={(translation) => updateMessage(message, translation)}
/>
))}
</div>
)}
</section>
)
}