feat(i18n): localize hardcoded application copy (#40377)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
6c3299103c
commit
e85735028c
@@ -0,0 +1,7 @@
|
||||
## Localization
|
||||
|
||||
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, empty states, and displayed errors.
|
||||
- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change.
|
||||
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
|
||||
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
|
||||
- Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels.
|
||||
@@ -52,12 +52,12 @@ describe("message-file", () => {
|
||||
})
|
||||
|
||||
test("labels attachment types from the basename extension", () => {
|
||||
expect(typeLabel("list.md", "text/plain")).toBe("Markdown")
|
||||
expect(typeLabel("/repo/src/main.ts", "text/plain")).toBe("TypeScript")
|
||||
expect(typeLabel("/tmp/report.pdf", "application/pdf")).toBe("PDF")
|
||||
expect(typeLabel("notes.xyz", "text/plain")).toBe("XYZ")
|
||||
expect(typeLabel("/home/user/my.project/Makefile", "text/plain")).toBe("File")
|
||||
expect(typeLabel(".gitignore", "text/plain")).toBe("File")
|
||||
expect(typeLabel("/repo/.env", "text/plain")).toBe("File")
|
||||
expect(typeLabel("list.md", "text/plain", "File")).toBe("Markdown")
|
||||
expect(typeLabel("/repo/src/main.ts", "text/plain", "File")).toBe("TypeScript")
|
||||
expect(typeLabel("/tmp/report.pdf", "application/pdf", "File")).toBe("PDF")
|
||||
expect(typeLabel("notes.xyz", "text/plain", "File")).toBe("XYZ")
|
||||
expect(typeLabel("/home/user/my.project/Makefile", "text/plain", "File")).toBe("File")
|
||||
expect(typeLabel(".gitignore", "text/plain", "File")).toBe("File")
|
||||
expect(typeLabel("/repo/.env", "text/plain", "File")).toBe("File")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -23,12 +23,12 @@ const LANGUAGE_NAMES = new Map<string, string>(
|
||||
|
||||
// attachments carry text/plain for all text files, so the label comes from the extension;
|
||||
// filename may be an absolute path, so extract the basename before looking for one
|
||||
export function typeLabel(filename: string, mime: string) {
|
||||
export function typeLabel(filename: string, mime: string, fallback: string) {
|
||||
if (mime === "application/pdf") return "PDF"
|
||||
const base = getFilename(filename)
|
||||
// idx 0 is a dotfile like .gitignore, not an extension
|
||||
const idx = base.lastIndexOf(".")
|
||||
const suffix = idx <= 0 ? "" : base.slice(idx + 1).toLowerCase()
|
||||
if (!suffix) return "File"
|
||||
if (!suffix) return fallback
|
||||
return LANGUAGE_NAMES.get(suffix) ?? suffix.toUpperCase()
|
||||
}
|
||||
|
||||
@@ -459,10 +459,10 @@ function newLayout() {
|
||||
return typeof document !== "undefined" && document.body.hasAttribute("data-new-layout")
|
||||
}
|
||||
|
||||
function webSearchProviderLabel(provider: unknown) {
|
||||
if (provider === "parallel") return "Parallel Web Search"
|
||||
if (provider === "exa") return "Exa Web Search"
|
||||
return "Web Search"
|
||||
function webSearchProviderLabel(provider: unknown, i18n: ReturnType<typeof useI18n>) {
|
||||
const name = provider === "parallel" ? "Parallel" : provider === "exa" ? "Exa" : undefined
|
||||
if (name) return i18n.t("ui.tool.websearch.provider", { provider: name })
|
||||
return i18n.t("ui.tool.websearch")
|
||||
}
|
||||
|
||||
export function getToolInfo(
|
||||
@@ -505,7 +505,7 @@ export function getToolInfo(
|
||||
case "websearch":
|
||||
return {
|
||||
icon: "window-cursor",
|
||||
title: webSearchProviderLabel(metadata?.provider),
|
||||
title: webSearchProviderLabel(metadata?.provider, i18n),
|
||||
subtitle: input.query,
|
||||
}
|
||||
case "task": {
|
||||
@@ -1309,7 +1309,7 @@ export function UserMessageDisplay(props: {
|
||||
clickable={!!props.actions?.openAttachment}
|
||||
onClick={() => props.actions?.openAttachment?.(file)}
|
||||
>
|
||||
{typeLabel(name, file.mime)}
|
||||
{typeLabel(name, file.mime, i18n.t("ui.common.file"))}
|
||||
</AttachmentCardV2>
|
||||
</Show>
|
||||
)
|
||||
@@ -1594,7 +1594,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
|
||||
<ToolErrorCard
|
||||
tool={part().tool}
|
||||
error={error()}
|
||||
title={part().tool === "websearch" ? webSearchProviderLabel(partMetadata().provider) : undefined}
|
||||
title={
|
||||
part().tool === "websearch"
|
||||
? webSearchProviderLabel(partMetadata().provider, i18n)
|
||||
: undefined
|
||||
}
|
||||
defaultOpen={props.defaultOpen}
|
||||
open={controlledOpen()}
|
||||
onOpenChange={props.onToolOpenChange ? handleToolOpenChange : undefined}
|
||||
@@ -1956,12 +1960,13 @@ ToolRegistry.register({
|
||||
ToolRegistry.register({
|
||||
name: "websearch",
|
||||
render(props) {
|
||||
const i18n = useI18n()
|
||||
const query = createMemo(() => {
|
||||
const value = props.input.query
|
||||
if (typeof value !== "string") return ""
|
||||
return value
|
||||
})
|
||||
const title = createMemo(() => webSearchProviderLabel(props.metadata.provider))
|
||||
const title = createMemo(() => webSearchProviderLabel(props.metadata.provider, i18n))
|
||||
|
||||
return (
|
||||
<BasicTool
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { AnimatedNumber } from "@opencode-ai/ui/animated-number"
|
||||
import { pluralCategory, pluralKey, useI18n, type UiI18nPluralKey } from "@opencode-ai/ui/context"
|
||||
import { pluralCategory, pluralKey, useI18n, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n"
|
||||
|
||||
function split(text: string) {
|
||||
const match = /{{\s*count\s*}}/.exec(text)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { useI18n } from "@opencode-ai/ui/context/i18n"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
@@ -46,6 +47,7 @@ export type PromptInputV2Props = {
|
||||
}
|
||||
|
||||
export function PromptInputV2(props: PromptInputV2Props) {
|
||||
const i18n = useI18n()
|
||||
const state = props.controller.state
|
||||
const view = props.controller.view
|
||||
let editor: HTMLDivElement | undefined
|
||||
@@ -87,14 +89,14 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
/>
|
||||
<Show when={state.popover.type !== "closed"}>
|
||||
<PromptInputV2Popover
|
||||
emptyLabel="No matching items"
|
||||
emptyLabel={i18n.t("ui.promptInput.noMatchingItems")}
|
||||
items={props.controller.suggestions()}
|
||||
activeID={state.popover.type === "closed" ? undefined : state.popover.activeID}
|
||||
search={
|
||||
state.popover.type === "command-menu"
|
||||
? {
|
||||
value: state.popover.query,
|
||||
label: "Commands",
|
||||
label: i18n.t("ui.promptInput.commands"),
|
||||
placeholder: "/",
|
||||
onValueChange: props.controller.setQuery,
|
||||
onKeyDown: props.controller.onKeyDown,
|
||||
@@ -124,7 +126,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
>
|
||||
<Show when={state.drag === "active"}>
|
||||
<div class="pointer-events-none absolute inset-0 z-20 grid place-items-center rounded-xl bg-v2-background-bg-base/90 text-v2-text-text-base">
|
||||
Drop files to attach
|
||||
{i18n.t("ui.promptInput.dropFiles")}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -133,7 +135,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
attachments={props.controller.attachments()}
|
||||
comments={props.controller.comments()}
|
||||
activeCommentID={state.activeContextID}
|
||||
removeLabel="Remove attachment"
|
||||
removeLabel={i18n.t("ui.promptInput.removeAttachment")}
|
||||
onAttachmentClick={props.controller.openAttachment}
|
||||
onAttachmentRemove={(attachment) => props.controller.removeAttachment(attachment.id)}
|
||||
onCommentClick={(comment) => props.controller.toggleContext(comment.key)}
|
||||
@@ -151,7 +153,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
data-component="prompt-input"
|
||||
role="textbox"
|
||||
aria-multiline="true"
|
||||
aria-label="Prompt"
|
||||
aria-label={i18n.t("ui.promptInput.label")}
|
||||
contenteditable={!props.disabled && !props.readOnly}
|
||||
autocapitalize={state.mode === "normal" ? "sentences" : "off"}
|
||||
autocorrect={state.mode === "normal" ? "on" : "off"}
|
||||
@@ -186,7 +188,11 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
classList={{ "font-mono!": state.mode === "shell" }}
|
||||
>
|
||||
{view.placeholder?.() ??
|
||||
(state.mode === "shell" ? "Enter shell command..." : "Ask anything, / for commands, @ for context...")}
|
||||
(state.mode === "shell" ? (
|
||||
i18n.t("ui.promptInput.placeholder.shell")
|
||||
) : (
|
||||
i18n.t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" })
|
||||
))}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
@@ -200,13 +206,13 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
>
|
||||
<PromptInputV2AddMenu
|
||||
disabled={state.mode === "shell"}
|
||||
title="Add images and files"
|
||||
title={i18n.t("ui.promptInput.add")}
|
||||
keybind={props.attachKeybind ?? ["Mod", "U"]}
|
||||
attachLabel="Images and files"
|
||||
attachLabel={i18n.t("ui.promptInput.attachments")}
|
||||
attachShortcut={props.attachShortcut ?? "Mod+U"}
|
||||
commandsLabel="Commands"
|
||||
contextLabel="Context"
|
||||
shellLabel="Shell command"
|
||||
commandsLabel={i18n.t("ui.promptInput.commands")}
|
||||
contextLabel={i18n.t("ui.promptInput.context")}
|
||||
shellLabel={i18n.t("ui.promptInput.shell")}
|
||||
onAttach={props.controller.attach}
|
||||
onCommands={props.controller.openCommands}
|
||||
onContext={props.controller.openContext}
|
||||
@@ -214,7 +220,11 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
/>
|
||||
<Show when={view.agent} keyed>
|
||||
{(control) => (
|
||||
<PromptInputV2ConfiguredSelect title="Choose agent" keybind={["Mod", "."]} control={control} />
|
||||
<PromptInputV2ConfiguredSelect
|
||||
title={i18n.t("ui.promptInput.chooseAgent")}
|
||||
keybind={["Mod", "."]}
|
||||
control={control}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show
|
||||
@@ -223,7 +233,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
<Show when={view.model} keyed>
|
||||
{(control) => (
|
||||
<PromptInputV2ConfiguredSelect
|
||||
title="Choose model"
|
||||
title={i18n.t("ui.promptInput.chooseModel")}
|
||||
keybind={["Mod", "M"]}
|
||||
control={control}
|
||||
model
|
||||
@@ -238,7 +248,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
{(control) => (
|
||||
<Show when={control.options().length > 1}>
|
||||
<PromptInputV2ConfiguredSelect
|
||||
title="Choose model variant"
|
||||
title={i18n.t("ui.promptInput.chooseVariant")}
|
||||
keybind={["Shift", "Mod", "D"]}
|
||||
control={control}
|
||||
/>
|
||||
@@ -250,8 +260,8 @@ export function PromptInputV2(props: PromptInputV2Props) {
|
||||
mode={state.mode}
|
||||
stopping={view.submit.stopping()}
|
||||
disabled={!props.controller.canSubmit()}
|
||||
sendLabel="Send"
|
||||
stopLabel="Stop"
|
||||
sendLabel={i18n.t("ui.promptInput.send")}
|
||||
stopLabel={i18n.t("ui.promptInput.stop")}
|
||||
onSubmit={props.controller.submit}
|
||||
onStop={props.controller.stop}
|
||||
/>
|
||||
@@ -377,6 +387,7 @@ export function PromptInputV2Attachments(props: {
|
||||
onCommentClick?: (comment: PromptInputV2Comment) => void
|
||||
onCommentRemove?: (comment: PromptInputV2Comment) => void
|
||||
}) {
|
||||
const i18n = useI18n()
|
||||
return (
|
||||
<Show when={props.attachments.length > 0 || (props.comments?.length ?? 0) > 0}>
|
||||
<div data-component="prompt-input-v2-attachments" data-slot="prompt-attachments" class="relative">
|
||||
@@ -404,7 +415,7 @@ export function PromptInputV2Attachments(props: {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onCommentRemove?.(comment)}
|
||||
class="absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
|
||||
@@ -420,7 +431,7 @@ export function PromptInputV2Attachments(props: {
|
||||
when={attachment.mime.startsWith("image/")}
|
||||
fallback={
|
||||
<AttachmentCardV2 title={attachment.filename}>
|
||||
{typeLabel(attachment.filename, attachment.mime)}
|
||||
{typeLabel(attachment.filename, attachment.mime, i18n.t("ui.common.file"))}
|
||||
</AttachmentCardV2>
|
||||
}
|
||||
>
|
||||
@@ -436,7 +447,7 @@ export function PromptInputV2Attachments(props: {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onAttachmentRemove(attachment)}
|
||||
class="absolute -top-1 -right-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<IconV2 name="outline-xmark" class="text-v2-icon-icon-contrast" />
|
||||
@@ -447,11 +458,11 @@ export function PromptInputV2Attachments(props: {
|
||||
</div>
|
||||
<div
|
||||
data-slot="prompt-attachments-fade-left"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]"
|
||||
class="pointer-events-none absolute inset-y-0 start-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)] rtl:bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]"
|
||||
/>
|
||||
<div
|
||||
data-slot="prompt-attachments-fade-right"
|
||||
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)]"
|
||||
class="pointer-events-none absolute inset-y-0 end-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-base),transparent)] rtl:bg-[linear-gradient(to_right,var(--v2-background-bg-base),transparent)]"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
@@ -571,7 +582,7 @@ export function PromptInputV2Select(props: {
|
||||
<span class="truncate capitalize leading-5">
|
||||
{props.options.find((option) => option.id === props.current)?.label ?? props.current}
|
||||
</span>
|
||||
<span class="-ml-0.5 -mr-1 flex shrink-0">
|
||||
<span class="-ms-0.5 -me-1 flex shrink-0">
|
||||
<IconV2 name="chevron-down" />
|
||||
</span>
|
||||
</MenuV2.Trigger>
|
||||
@@ -637,7 +648,7 @@ export function PromptInputV2Popover(props: {
|
||||
<button
|
||||
type="button"
|
||||
data-suggestion-id={item.id}
|
||||
class="flex w-full items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-v2-overlay-simple-overlay-hover"
|
||||
class="flex w-full items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover"
|
||||
classList={{ "bg-v2-overlay-simple-overlay-hover": props.activeID === item.id }}
|
||||
onPointerMove={() => props.onActiveChange(item)}
|
||||
onClick={() => props.onSelect(item)}
|
||||
|
||||
Reference in New Issue
Block a user