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:
@@ -0,0 +1,31 @@
|
||||
export type DevtoolAction = "compile" | "extract"
|
||||
|
||||
interface ApiError {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function requestDevtoolJson<T>(
|
||||
url: string,
|
||||
init?: RequestInit
|
||||
): Promise<T> {
|
||||
const response = await fetch(url, init)
|
||||
const body = (await response.json()) as T & ApiError
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(body.error ?? `Request failed with ${response.status}.`)
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
export async function runDevtoolAction(
|
||||
action: DevtoolAction,
|
||||
baseUrl = "/__i18n"
|
||||
) {
|
||||
const normalizedBaseUrl = baseUrl.replace(/\/+$/, "")
|
||||
|
||||
return requestDevtoolJson<{ output: string }>(
|
||||
`${normalizedBaseUrl}/actions/${action}`,
|
||||
{ method: "POST" }
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from "react"
|
||||
|
||||
import type { I18nProjectConfig } from "../../config/lingui-config"
|
||||
import { I18nProvider } from "../../runtime"
|
||||
import { requestDevtoolJson } from "../api-client"
|
||||
import { I18nDevtool } from "../i18n-devtool"
|
||||
|
||||
type ProjectState =
|
||||
| { status: "loading" }
|
||||
| { error: Error; status: "error" }
|
||||
| { project: I18nProjectConfig; status: "ready" }
|
||||
|
||||
export function DevtoolApp() {
|
||||
const [state, setState] = React.useState<ProjectState>({ status: "loading" })
|
||||
|
||||
React.useEffect(() => {
|
||||
requestDevtoolJson<I18nProjectConfig>("/__i18n/project").then(
|
||||
(project) => setState({ project, status: "ready" }),
|
||||
(reason) =>
|
||||
setState({
|
||||
error:
|
||||
reason instanceof Error
|
||||
? reason
|
||||
: new Error("Unable to load the i18n project."),
|
||||
status: "error",
|
||||
})
|
||||
)
|
||||
}, [])
|
||||
|
||||
if (state.status !== "ready") {
|
||||
return (
|
||||
<main
|
||||
className="devtool-app__state"
|
||||
role={state.status === "error" ? "alert" : "status"}
|
||||
>
|
||||
{state.status === "error"
|
||||
? state.error.message
|
||||
: "Loading I18n Devtool…"}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const locale = state.project.locales[0]
|
||||
|
||||
if (!locale) {
|
||||
return (
|
||||
<main className="devtool-app__state">
|
||||
Add a locale with <code>workspace-i18n new <locale></code>.
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<I18nProvider locale={locale} locales={state.project.locales}>
|
||||
<I18nDevtool defaultOpen mode="standalone" />
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0"
|
||||
/>
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>Message Studio</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
|
||||
import { DevtoolApp } from "./app"
|
||||
import "./styles.css"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
|
||||
if (!root) {
|
||||
throw new Error("Missing #root element.")
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<DevtoolApp />
|
||||
</StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 20rem;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.devtool-app__state {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
padding: 2rem;
|
||||
color: color-mix(in srgb, currentColor 60%, transparent);
|
||||
text-align: center;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type {
|
||||
DevtoolMessage,
|
||||
MessageRepository,
|
||||
UpdateMessageInput,
|
||||
} from "./types"
|
||||
|
||||
interface ApiError {
|
||||
error?: string
|
||||
}
|
||||
|
||||
async function readResponse<T>(response: Response): Promise<T> {
|
||||
if (response.ok) {
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
let message = `Request failed with status ${response.status}`
|
||||
|
||||
try {
|
||||
const body = (await response.json()) as ApiError
|
||||
message = body.error ?? message
|
||||
} catch {
|
||||
// Keep the status-based error when the response is not JSON.
|
||||
}
|
||||
|
||||
throw new Error(message)
|
||||
}
|
||||
|
||||
export function createHttpMessageRepository(
|
||||
baseUrl = "/__i18n"
|
||||
): MessageRepository {
|
||||
const normalizedBaseUrl = baseUrl.replace(/\/+$/, "")
|
||||
|
||||
return {
|
||||
async getMessages(locale) {
|
||||
const response = await fetch(
|
||||
`${normalizedBaseUrl}/messages?locale=${encodeURIComponent(locale)}`
|
||||
)
|
||||
|
||||
return readResponse<readonly DevtoolMessage[]>(response)
|
||||
},
|
||||
async updateMessage(input: UpdateMessageInput) {
|
||||
const response = await fetch(`${normalizedBaseUrl}/messages`, {
|
||||
body: JSON.stringify(input),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
method: "PUT",
|
||||
})
|
||||
|
||||
return readResponse<DevtoolMessage>(response)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
.i18n-devtool {
|
||||
--i18n-devtool-background: #f7f7f8;
|
||||
--i18n-devtool-border: rgb(15 23 42 / 14%);
|
||||
--i18n-devtool-foreground: #18181b;
|
||||
--i18n-devtool-muted: #71717a;
|
||||
--i18n-devtool-panel: #fff;
|
||||
--i18n-devtool-primary: #2563eb;
|
||||
position: fixed;
|
||||
z-index: 2147483647;
|
||||
color: var(--i18n-devtool-foreground);
|
||||
color-scheme: light;
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.i18n-devtool[data-dark] {
|
||||
--i18n-devtool-background: #111318;
|
||||
--i18n-devtool-border: rgb(255 255 255 / 14%);
|
||||
--i18n-devtool-foreground: #f4f4f5;
|
||||
--i18n-devtool-muted: #a1a1aa;
|
||||
--i18n-devtool-panel: #181a20;
|
||||
--i18n-devtool-primary: #60a5fa;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.i18n-devtool *,
|
||||
.i18n-devtool *::before,
|
||||
.i18n-devtool *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.i18n-devtool button,
|
||||
.i18n-devtool select {
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.i18n-devtool__trigger {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
display: inline-flex;
|
||||
height: 2.75rem;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0 0.85rem;
|
||||
border: 1px solid var(--i18n-devtool-border);
|
||||
border-radius: 999px;
|
||||
background: var(--i18n-devtool-panel);
|
||||
box-shadow: 0 12px 30px rgb(0 0 0 / 18%);
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.i18n-devtool__trigger span:first-child {
|
||||
display: grid;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
place-items: center;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--i18n-devtool-primary);
|
||||
color: white;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.i18n-devtool__panel {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
bottom: 1rem;
|
||||
display: grid;
|
||||
width: min(54rem, calc(100vw - 2rem));
|
||||
overflow: hidden;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
border: 1px solid var(--i18n-devtool-border);
|
||||
border-radius: 1rem;
|
||||
outline: none;
|
||||
background: var(--i18n-devtool-background);
|
||||
box-shadow: 0 24px 70px rgb(0 0 0 / 28%);
|
||||
}
|
||||
|
||||
.i18n-devtool[data-mode="standalone"] {
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.i18n-devtool[data-mode="standalone"] .i18n-devtool__panel {
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.i18n-devtool__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid var(--i18n-devtool-border);
|
||||
background: var(--i18n-devtool-panel);
|
||||
}
|
||||
|
||||
.i18n-devtool__header h2,
|
||||
.i18n-devtool__eyebrow {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.i18n-devtool__header h2 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.i18n-devtool__eyebrow {
|
||||
margin-bottom: 0.1rem;
|
||||
color: var(--i18n-devtool-muted);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.i18n-devtool__controls {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.i18n-devtool__controls label {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.i18n-devtool__controls label span {
|
||||
color: var(--i18n-devtool-muted);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 650;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.i18n-devtool__controls select,
|
||||
.i18n-devtool__controls button {
|
||||
height: 2.25rem;
|
||||
padding: 0 0.7rem;
|
||||
border: 1px solid var(--i18n-devtool-border);
|
||||
border-radius: 0.55rem;
|
||||
background: var(--i18n-devtool-panel);
|
||||
}
|
||||
|
||||
.i18n-devtool__controls button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.i18n-devtool__controls button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.i18n-devtool__controls .i18n-devtool__close {
|
||||
width: 2.25rem;
|
||||
padding: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.i18n-devtool__output {
|
||||
max-height: 10rem;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 0.65rem 1rem;
|
||||
border-bottom: 1px solid var(--i18n-devtool-border);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--i18n-devtool-primary) 10%,
|
||||
var(--i18n-devtool-panel)
|
||||
);
|
||||
font-size: 0.72rem;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.i18n-devtool__output[data-error] {
|
||||
background: color-mix(in srgb, #ef4444 12%, var(--i18n-devtool-panel));
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.i18n-devtool__main {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.i18n-devtool .i18n-message-panel {
|
||||
--i18n-border: var(--i18n-devtool-border);
|
||||
--i18n-muted: var(--i18n-devtool-muted);
|
||||
--i18n-surface: var(--i18n-devtool-panel);
|
||||
}
|
||||
|
||||
.i18n-devtool .i18n-message-panel__toolbar {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--i18n-devtool-background) 90%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.i18n-devtool .i18n-message-panel input[type="search"],
|
||||
.i18n-devtool .i18n-message-panel textarea,
|
||||
.i18n-devtool .i18n-message-panel button {
|
||||
background: var(--i18n-devtool-panel);
|
||||
}
|
||||
|
||||
@media (max-width: 42rem) {
|
||||
.i18n-devtool__panel {
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.i18n-devtool__header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.i18n-devtool__controls {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.i18n-devtool__controls label {
|
||||
min-width: 10rem;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import * as React from "react"
|
||||
import { act, render, screen, waitFor } from "@testing-library/react"
|
||||
import { afterEach, describe, expect, it } from "vitest"
|
||||
|
||||
import { I18nProvider } from "../runtime"
|
||||
import { I18nDevtool } from "./i18n-devtool"
|
||||
|
||||
afterEach(() => {
|
||||
document.documentElement.classList.remove("ui:dark")
|
||||
})
|
||||
|
||||
describe("I18nDevtool", () => {
|
||||
it("tracks the configured document dark-mode class", async () => {
|
||||
render(
|
||||
<I18nProvider
|
||||
catalogs={{ en: {} }}
|
||||
locale="en"
|
||||
locales={["en", "zh-Hans"]}
|
||||
>
|
||||
<I18nDevtool dark={"ui\\:dark"} />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
const devtool = await screen
|
||||
.findByText("i18n")
|
||||
.then((element) =>
|
||||
element.closest<HTMLElement>('[data-slot="i18n-devtool"]')
|
||||
)
|
||||
|
||||
expect(devtool?.hasAttribute("data-dark")).toBe(false)
|
||||
|
||||
act(() => {
|
||||
document.documentElement.classList.add("ui:dark")
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(devtool?.hasAttribute("data-dark")).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,240 @@
|
||||
import * as React from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
|
||||
import { useLocale, useLocales } from "../runtime"
|
||||
import { runDevtoolAction, type DevtoolAction } from "./api-client"
|
||||
import { createHttpMessageRepository } from "./http-message-repository"
|
||||
import { MessagePanel } from "./message-panel"
|
||||
import { MessageRepositoryProvider } from "./message-repository"
|
||||
import "./i18n-devtool.css"
|
||||
|
||||
export interface I18nDevtoolProps {
|
||||
apiBaseUrl?: string
|
||||
className?: string
|
||||
dark?: string
|
||||
defaultOpen?: boolean
|
||||
mode?: "floating" | "standalone"
|
||||
}
|
||||
|
||||
function matchesDarkSelector(element: Element, dark: string) {
|
||||
const normalizedDark = dark.replace(/\\:/g, ":")
|
||||
|
||||
if (
|
||||
dark.startsWith(".") ||
|
||||
dark.startsWith("#") ||
|
||||
dark.startsWith("[") ||
|
||||
dark.startsWith(":")
|
||||
) {
|
||||
try {
|
||||
return element.matches(dark)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return element.classList.contains(normalizedDark)
|
||||
}
|
||||
|
||||
function useDarkMode(dark: string | undefined) {
|
||||
const [isDark, setDark] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!dark) {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
const updateFromSystem = () => setDark(mediaQuery.matches)
|
||||
|
||||
updateFromSystem()
|
||||
mediaQuery.addEventListener("change", updateFromSystem)
|
||||
|
||||
return () => mediaQuery.removeEventListener("change", updateFromSystem)
|
||||
}
|
||||
|
||||
const updateFromDocument = () => {
|
||||
setDark(
|
||||
matchesDarkSelector(document.documentElement, dark) ||
|
||||
matchesDarkSelector(document.body, dark)
|
||||
)
|
||||
}
|
||||
|
||||
updateFromDocument()
|
||||
|
||||
const observer = new MutationObserver(updateFromDocument)
|
||||
observer.observe(document.documentElement, { attributes: true })
|
||||
observer.observe(document.body, { attributes: true })
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [dark])
|
||||
|
||||
return isDark
|
||||
}
|
||||
|
||||
export function I18nDevtool({
|
||||
apiBaseUrl = "/__i18n",
|
||||
className,
|
||||
dark,
|
||||
defaultOpen = false,
|
||||
mode = "floating",
|
||||
}: I18nDevtoolProps) {
|
||||
const activeLocale = useLocale()
|
||||
const locales = useLocales()
|
||||
const [portalTarget, setPortalTarget] = React.useState<HTMLElement | null>(
|
||||
null
|
||||
)
|
||||
const [isOpen, setOpen] = React.useState(defaultOpen || mode === "standalone")
|
||||
const [locale, setLocale] = React.useState(activeLocale)
|
||||
const [reloadKey, setReloadKey] = React.useState(0)
|
||||
const [actionState, setActionState] = React.useState<DevtoolAction | "idle">(
|
||||
"idle"
|
||||
)
|
||||
const [actionOutput, setActionOutput] = React.useState("")
|
||||
const [error, setError] = React.useState("")
|
||||
const isDark = useDarkMode(dark)
|
||||
const repository = React.useMemo(
|
||||
() => createHttpMessageRepository(apiBaseUrl),
|
||||
[apiBaseUrl]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
setPortalTarget(document.body)
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (locales.some((definition) => definition.locale === locale)) {
|
||||
return
|
||||
}
|
||||
|
||||
setLocale(activeLocale)
|
||||
}, [activeLocale, locale, locales])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen || mode === "standalone") {
|
||||
return
|
||||
}
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown)
|
||||
return () => window.removeEventListener("keydown", handleKeyDown)
|
||||
}, [isOpen, mode])
|
||||
|
||||
const runAction = async (action: DevtoolAction) => {
|
||||
setActionState(action)
|
||||
setActionOutput("")
|
||||
setError("")
|
||||
|
||||
try {
|
||||
const result = await runDevtoolAction(action, apiBaseUrl)
|
||||
setActionOutput(result.output.trim() || `${action} completed.`)
|
||||
setReloadKey((current) => current + 1)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : `${action} failed.`)
|
||||
} finally {
|
||||
setActionState("idle")
|
||||
}
|
||||
}
|
||||
|
||||
if (!portalTarget) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rootClassName = ["i18n-devtool", className].filter(Boolean).join(" ")
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={rootClassName}
|
||||
data-dark={isDark || undefined}
|
||||
data-mode={mode}
|
||||
data-slot="i18n-devtool"
|
||||
>
|
||||
{mode === "floating" && !isOpen && (
|
||||
<button
|
||||
type="button"
|
||||
aria-controls="i18n-devtool-panel"
|
||||
aria-expanded="false"
|
||||
className="i18n-devtool__trigger"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<span aria-hidden="true">文</span>
|
||||
<span>i18n</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isOpen && (
|
||||
<section
|
||||
id="i18n-devtool-panel"
|
||||
aria-label="I18n Devtool"
|
||||
className="i18n-devtool__panel"
|
||||
role="dialog"
|
||||
>
|
||||
<header className="i18n-devtool__header">
|
||||
<div>
|
||||
<p className="i18n-devtool__eyebrow">Lingui</p>
|
||||
<h2>I18n Devtool</h2>
|
||||
</div>
|
||||
|
||||
<div className="i18n-devtool__controls">
|
||||
<label>
|
||||
<span>Locale</span>
|
||||
<select
|
||||
aria-label="Devtool locale"
|
||||
value={locale}
|
||||
onChange={(event) => setLocale(event.target.value)}
|
||||
>
|
||||
{locales.map((definition) => (
|
||||
<option key={definition.locale} value={definition.locale}>
|
||||
{definition.label ?? definition.locale}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionState !== "idle"}
|
||||
onClick={() => void runAction("extract")}
|
||||
>
|
||||
{actionState === "extract" ? "Extracting…" : "Extract"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={actionState !== "idle"}
|
||||
onClick={() => void runAction("compile")}
|
||||
>
|
||||
{actionState === "compile" ? "Compiling…" : "Compile"}
|
||||
</button>
|
||||
{mode === "floating" && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close I18n Devtool"
|
||||
className="i18n-devtool__close"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{(error || actionOutput) && (
|
||||
<pre
|
||||
className="i18n-devtool__output"
|
||||
data-error={error ? "" : undefined}
|
||||
>
|
||||
{error || actionOutput}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
<main className="i18n-devtool__main">
|
||||
<MessageRepositoryProvider repository={repository}>
|
||||
<MessagePanel key={`${locale}:${reloadKey}`} locale={locale} />
|
||||
</MessageRepositoryProvider>
|
||||
</main>
|
||||
</section>
|
||||
)}
|
||||
</div>,
|
||||
portalTarget
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export { createHttpMessageRepository } from "./http-message-repository"
|
||||
export { I18nDevtool, type I18nDevtoolProps } from "./i18n-devtool"
|
||||
export { MessagePanel, type MessagePanelProps } from "./message-panel"
|
||||
export {
|
||||
MessageRepositoryProvider,
|
||||
type MessageRepositoryProviderProps,
|
||||
useMessageRepository,
|
||||
} from "./message-repository"
|
||||
export type {
|
||||
DevtoolMessage,
|
||||
MessageOrigin,
|
||||
MessageRepository,
|
||||
UpdateMessageInput,
|
||||
} from "./types"
|
||||
@@ -0,0 +1,218 @@
|
||||
.i18n-message-panel {
|
||||
--i18n-border: color-mix(in srgb, currentColor 14%, transparent);
|
||||
--i18n-muted: color-mix(in srgb, currentColor 62%, transparent);
|
||||
--i18n-surface: color-mix(in srgb, Canvas 96%, currentColor 4%);
|
||||
color: CanvasText;
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
.i18n-message-panel *,
|
||||
.i18n-message-panel *::before,
|
||||
.i18n-message-panel *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.i18n-message-panel__toolbar {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--i18n-border);
|
||||
background: color-mix(in srgb, Canvas 90%, transparent);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.i18n-message-panel__search {
|
||||
min-width: 12rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.i18n-message-panel input,
|
||||
.i18n-message-panel textarea,
|
||||
.i18n-message-panel button {
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.i18n-message-panel input[type="search"],
|
||||
.i18n-message-panel textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--i18n-border);
|
||||
border-radius: 0.625rem;
|
||||
outline: none;
|
||||
background: Canvas;
|
||||
}
|
||||
|
||||
.i18n-message-panel input[type="search"] {
|
||||
height: 2.5rem;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
|
||||
.i18n-message-panel textarea {
|
||||
min-height: 5.5rem;
|
||||
resize: vertical;
|
||||
padding: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.i18n-message-panel input[type="search"]:focus,
|
||||
.i18n-message-panel textarea:focus {
|
||||
border-color: AccentColor;
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, AccentColor 18%, transparent);
|
||||
}
|
||||
|
||||
.i18n-message-panel__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
white-space: nowrap;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.i18n-message-panel__count {
|
||||
color: var(--i18n-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.i18n-message-panel__list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.i18n-message {
|
||||
display: grid;
|
||||
gap: 0.875rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--i18n-border);
|
||||
border-radius: 0.875rem;
|
||||
background: var(--i18n-surface);
|
||||
}
|
||||
|
||||
.i18n-message[data-missing] {
|
||||
border-color: color-mix(in srgb, #e8a317 55%, var(--i18n-border));
|
||||
}
|
||||
|
||||
.i18n-message[data-obsolete] {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.i18n-message__header,
|
||||
.i18n-message__actions,
|
||||
.i18n-message__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.i18n-message__header {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.i18n-message__id {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.i18n-message__catalog {
|
||||
color: var(--i18n-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.i18n-message__source p {
|
||||
margin: 0.25rem 0 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.i18n-message__field {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.i18n-message__label {
|
||||
color: var(--i18n-muted);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.i18n-message__meta {
|
||||
flex-wrap: wrap;
|
||||
color: var(--i18n-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.i18n-message__actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.i18n-message__status {
|
||||
margin-inline-end: auto;
|
||||
color: var(--i18n-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.i18n-message-panel button {
|
||||
min-height: 2.25rem;
|
||||
padding: 0 0.8rem;
|
||||
border: 1px solid var(--i18n-border);
|
||||
border-radius: 0.55rem;
|
||||
background: Canvas;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.i18n-message-panel button:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, Canvas 90%, currentColor 10%);
|
||||
}
|
||||
|
||||
.i18n-message-panel button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.i18n-message-panel__state {
|
||||
display: grid;
|
||||
min-height: 14rem;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem;
|
||||
color: var(--i18n-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.i18n-message-panel__state p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.i18n-visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.i18n-message-panel__toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.i18n-message-panel__search {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import * as React from "react"
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { MessagePanel } from "./message-panel"
|
||||
import type { DevtoolMessage, MessageRepository } from "./types"
|
||||
|
||||
const message: DevtoolMessage = {
|
||||
catalog: "messages",
|
||||
comments: ["Navigation title"],
|
||||
id: "navigation.home",
|
||||
missing: true,
|
||||
obsolete: false,
|
||||
origins: [{ file: "src/navigation.tsx", line: 12 }],
|
||||
source: "Home",
|
||||
translation: "",
|
||||
}
|
||||
|
||||
describe("MessagePanel", () => {
|
||||
it("loads, filters, and saves messages through the repository", async () => {
|
||||
const updateMessage = vi.fn(async (input) => ({
|
||||
...message,
|
||||
missing: false,
|
||||
translation: input.translation,
|
||||
}))
|
||||
const repository: MessageRepository = {
|
||||
getMessages: vi.fn(async () => [message]),
|
||||
updateMessage,
|
||||
}
|
||||
|
||||
render(<MessagePanel locale="zh-Hans" repository={repository} />)
|
||||
|
||||
expect(await screen.findByText("navigation.home")).toBeTruthy()
|
||||
|
||||
const textarea = screen.getByLabelText(
|
||||
"navigation.home translation for zh-Hans"
|
||||
)
|
||||
fireEvent.change(textarea, { target: { value: "首页" } })
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save" }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateMessage).toHaveBeenCalledWith({
|
||||
catalog: "messages",
|
||||
id: "navigation.home",
|
||||
locale: "zh-Hans",
|
||||
translation: "首页",
|
||||
})
|
||||
})
|
||||
expect(await screen.findByText("Saved")).toBeTruthy()
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText("Search messages"), {
|
||||
target: { value: "does not exist" },
|
||||
})
|
||||
expect(
|
||||
screen.getByText("No messages match the current filters.")
|
||||
).toBeTruthy()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from "react"
|
||||
|
||||
import type { MessageRepository } from "./types"
|
||||
|
||||
const MessageRepositoryContext = React.createContext<MessageRepository | null>(
|
||||
null
|
||||
)
|
||||
|
||||
export interface MessageRepositoryProviderProps {
|
||||
children: React.ReactNode
|
||||
repository: MessageRepository
|
||||
}
|
||||
|
||||
export function MessageRepositoryProvider({
|
||||
children,
|
||||
repository,
|
||||
}: MessageRepositoryProviderProps) {
|
||||
return (
|
||||
<MessageRepositoryContext.Provider value={repository}>
|
||||
{children}
|
||||
</MessageRepositoryContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useMessageRepository() {
|
||||
const repository = React.useContext(MessageRepositoryContext)
|
||||
|
||||
if (!repository) {
|
||||
throw new Error(
|
||||
"MessagePanel requires a MessageRepositoryProvider or a repository prop."
|
||||
)
|
||||
}
|
||||
|
||||
return repository
|
||||
}
|
||||
|
||||
export function useOptionalMessageRepository() {
|
||||
return React.useContext(MessageRepositoryContext)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export interface MessageOrigin {
|
||||
file: string
|
||||
line?: number
|
||||
}
|
||||
|
||||
export interface DevtoolMessage {
|
||||
catalog: string
|
||||
comments: readonly string[]
|
||||
id: string
|
||||
missing: boolean
|
||||
obsolete: boolean
|
||||
origins: readonly MessageOrigin[]
|
||||
source: string
|
||||
translation: string
|
||||
}
|
||||
|
||||
export interface UpdateMessageInput {
|
||||
catalog: string
|
||||
id: string
|
||||
locale: string
|
||||
translation: string
|
||||
}
|
||||
|
||||
export interface MessageRepository {
|
||||
getMessages(locale: string): Promise<readonly DevtoolMessage[]>
|
||||
updateMessage(input: UpdateMessageInput): Promise<DevtoolMessage>
|
||||
}
|
||||
Reference in New Issue
Block a user