fix(i18n): keep rich grammar in shared APIs

This commit is contained in:
Hona
2026-09-27 17:32:23 +00:00
committed by OpenCode Agent
parent ae3c005f3c
commit 0e680ec4ac
13 changed files with 77 additions and 34 deletions
@@ -140,11 +140,13 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
<p class="text-14-regular text-text-base">
{language.t("provider.custom.description.prefix")}
<ExternalLink href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
{language.t("provider.custom.description.link")}
</ExternalLink>
{language.t("provider.custom.description.suffix")}
{language.rich("provider.custom.description", {
link: (
<ExternalLink href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
{language.t("provider.custom.description.link")}
</ExternalLink>
),
})}
</p>
<div class="flex flex-col gap-4">
+3
View File
@@ -285,6 +285,7 @@ export const dict = {
"provider.custom.description.prefix": "Configure an OpenAI-compatible provider. See the ",
"provider.custom.description.link": "provider config docs",
"provider.custom.description.suffix": ".",
"provider.custom.description": "Configure an OpenAI-compatible provider. See the {{link}}.",
"provider.custom.field.providerID.label": "Provider ID",
"provider.custom.field.providerID.placeholder": "myprovider",
"provider.custom.field.providerID.description": "Lowercase letters, numbers, hyphens, or underscores",
@@ -707,6 +708,8 @@ export const dict = {
"file.view.preview": "Preview",
"file.view.source": "Source",
"file.view.openInBrowser": "Open in browser",
"file.view.videoLabel": "Video: {{filename}}",
"file.view.audioLabel": "Audio: {{filename}}",
"file.view.binary": "Binary file · {{size}}",
"file.view.table.rows.one": "{{count}} row",
"file.view.table.rows.other": "{{count}} rows",
@@ -319,6 +319,7 @@ export function UiI18nBridge(props: { children?: JSX.Element }) {
t: language.t as UiI18n["t"],
plural: language.plural,
pluralForm: language.pluralForm,
rich: language.rich as UiI18n["rich"],
}}
>
{props.children}
+12 -3
View File
@@ -107,8 +107,11 @@ export const DialogServer: Component<{
>
<div class="flex w-full min-w-0 flex-col gap-6">
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label">{language.t("dialog.server.add.url")}</label>
<label for="dialog-server-url" class="settings-server-dialog-label">
{language.t("dialog.server.add.url")}
</label>
<TextInput
id="dialog-server-url"
type="text"
appearance="large"
class="!w-full self-stretch"
@@ -128,8 +131,11 @@ export const DialogServer: Component<{
</Show>
</div>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label">{language.t("dialog.server.add.name")}</label>
<label for="dialog-server-name" class="settings-server-dialog-label">
{language.t("dialog.server.add.name")}
</label>
<TextInput
id="dialog-server-name"
type="text"
appearance="large"
class="!w-full self-stretch"
@@ -141,8 +147,11 @@ export const DialogServer: Component<{
/>
</div>
<div class="flex w-full min-w-0 flex-col gap-2">
<label class="settings-server-dialog-label">{language.t("dialog.server.add.password")}</label>
<label for="dialog-server-password" class="settings-server-dialog-label">
{language.t("dialog.server.add.password")}
</label>
<TextInput
id="dialog-server-password"
type="password"
appearance="large"
class="!w-full self-stretch"
@@ -266,6 +266,7 @@ function ArtifactImage(props: MediaProps) {
}
function ArtifactVideo(props: MediaProps) {
const language = useLanguage()
const url = createBlobUrl(() => props.content)
return (
<div data-slot="artifact-stage" data-zoom="fit" class="relative min-h-0 flex-1 overflow-hidden">
@@ -274,6 +275,7 @@ function ArtifactVideo(props: MediaProps) {
data-slot="artifact-media"
class="w-full bg-black"
controls
aria-label={language.t("file.view.videoLabel", { filename: getFilename(props.path) })}
preload="metadata"
playsinline
onError={() => props.onError()}
@@ -289,6 +291,7 @@ function ArtifactVideo(props: MediaProps) {
}
function ArtifactAudio(props: MediaProps) {
const language = useLanguage()
const url = createBlobUrl(() => props.content)
return (
<div data-slot="artifact-stage" class="relative min-h-0 flex-1 overflow-auto">
@@ -302,6 +305,7 @@ function ArtifactAudio(props: MediaProps) {
class="w-full"
onError={() => props.onError()}
controls
aria-label={language.t("file.view.audioLabel", { filename: getFilename(props.path) })}
preload="metadata"
src={url()}
onLoadedMetadata={(event) => props.onInfo({ duration: event.currentTarget.duration })}
@@ -240,7 +240,13 @@ export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX
return (
<div class="flex justify-center bg-background-stronger px-6 py-4">
<audio class="w-full max-w-xl" controls preload="metadata" onLoadedMetadata={onLoad}>
<audio
class="w-full max-w-xl"
controls
preload="metadata"
aria-label={i18n.t("ui.fileMedia.audioLabel", { filename: cfg()?.path ?? "" })}
onLoadedMetadata={onLoad}
>
<source src={value()} type={audioMime()} />
</audio>
</div>
@@ -146,11 +146,7 @@ export const LineComment = (props: LineCommentProps) => {
<div data-slot="line-comment-tools">{split.actions}</div>
</Show>
</div>
<div data-slot="line-comment-label">
{i18n.t("ui.lineComment.label.prefix")}
{split.selection}
{i18n.t("ui.lineComment.label.suffix")}
</div>
<div data-slot="line-comment-label">{i18n.rich("ui.lineComment.label", { selection: split.selection })}</div>
</div>
</LineCommentAnchor>
)
@@ -378,9 +374,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
</Show>
<div data-slot="line-comment-actions">
<div data-slot="line-comment-editor-label">
{i18n.t("ui.lineComment.editorLabel.prefix")}
{split.selection}
{i18n.t("ui.lineComment.editorLabel.suffix")}
{i18n.rich("ui.lineComment.editorLabel", { selection: split.selection })}
</div>
<Show
when={!props.inline}
@@ -51,6 +51,7 @@ export function MessageNav(
data-active={message.id === local.current?.id || undefined}
role="button"
tabindex={0}
aria-label={local.getLabel?.(message) ?? i18n.t("ui.messageNav.newMessage")}
onClick={handleClick}
onKeyDown={handleKeyPress}
>
@@ -1032,7 +1032,7 @@ function FileTool(props: ToolProps & { title: string; count: number; children?:
rail={false}
trigger={{
title: props.title,
subtitle: props.count > 0 ? `${props.count} ${i18n.plural("ui.common.file", props.count)}` : "",
subtitle: props.count > 0 ? i18n.plural("ui.common.fileCount", props.count) : "",
}}
>
{props.children}
@@ -1273,12 +1273,12 @@ function toolErrorSubtitle(props: ToolProps, i18n: UiI18n) {
Array.isArray(props.metadata.files) ? props.metadata.files.filter(changedFileDiff).map((file) => file.file) : [],
).size
if (count === 0) return undefined
return `${count} ${i18n.plural("ui.common.file", count)}`
return i18n.plural("ui.common.fileCount", count)
}
if (props.tool === "question") {
const count = Array.isArray(props.input.questions) ? props.input.questions.filter(questionInfo).length : 0
if (count === 0) return undefined
return `${count} ${i18n.plural("ui.common.question", count)}`
return i18n.plural("ui.common.questionCount", count)
}
return undefined
}
@@ -2129,8 +2129,8 @@ ToolRegistry.register({
const subtitle = createMemo(() => {
const count = questions().length
if (count === 0) return ""
if (completed()) return i18n.t("ui.question.subtitle.answered", { count })
return `${count} ${i18n.plural("ui.common.question", count)}`
if (completed()) return i18n.plural("ui.question.subtitle.answeredCount", count)
return i18n.plural("ui.common.questionCount", count)
})
return (
+3 -10
View File
@@ -247,16 +247,9 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
const query = filter()
if (!query) return i18n.t("ui.list.empty")
const suffix = i18n.t("ui.list.emptyWithFilter.suffix")
return (
<>
<span>{i18n.t("ui.list.emptyWithFilter.prefix")}</span>
<span data-slot="list-filter">&quot;{query}&quot;</span>
<Show when={suffix}>
<span>{suffix}</span>
</Show>
</>
)
return i18n.rich("ui.list.emptyWithFilter", {
query: <span data-slot="list-filter">&quot;{query}&quot;</span>,
})
}
return (
+9 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { createUiI18n, localizedListSeparator, pluralCategory, type UiI18nSource } from "./i18n"
import { createUiI18n, localizedListSeparator, pluralCategory, richTemplateParts, type UiI18nSource } from "./i18n"
const i18n = (locale: string, translated: string) => {
const source: UiI18nSource = {
@@ -62,3 +62,11 @@ describe("localizedListSeparator", () => {
expect(i18n("de", "").list(["Lesen", "Suchen"])).toBe("Lesen und Suchen")
})
})
describe("richTemplateParts", () => {
test("lets each locale position a rich slot", () => {
const selection = "SELECTION"
expect(richTemplateParts("Comment on {{selection}}", { selection })).toEqual(["Comment on ", selection])
expect(richTemplateParts("{{selection}}에 댓글", { selection })).toEqual([selection, "에 댓글"])
})
})
+15 -1
View File
@@ -1,4 +1,4 @@
import { createContext, useContext, type Accessor, type ParentProps } from "solid-js"
import { createContext, useContext, type Accessor, type JSX, type ParentProps } from "solid-js"
import { I18nProvider } from "@kobalte/core/i18n"
import { dict as en } from "../i18n/en"
import type { Key, LocaleKey, PluralCategory, PluralKey, PluralLookupKey } from "../i18n/en"
@@ -20,6 +20,7 @@ export type UiI18nSource = {
t: UiTranslate
plural: (key: UiI18nPluralKey, count: number, params?: UiI18nParams) => string
pluralForm?: (key: UiI18nPluralKey, category: UiPluralCategory, params?: UiI18nParams) => string
rich?: (key: UiI18nOrdinaryKey, params: Record<string, JSX.Element>) => JSX.Element[]
}
export type UiI18n = UiI18nSource & {
@@ -27,6 +28,7 @@ export type UiI18n = UiI18nSource & {
tDynamic: (key: UiI18nOrdinaryKey, source: string, params?: UiI18nParams) => string
list: (items: readonly string[]) => string
listSeparator: (index: number, count: number) => string
rich: (key: UiI18nOrdinaryKey, params: Record<string, JSX.Element>) => JSX.Element[]
}
const rules = new Map<string, Intl.PluralRules>()
@@ -68,6 +70,17 @@ function resolveTemplate(text: string, params?: UiI18nParams) {
})
}
export function richTemplateParts(template: string, params: Record<string, JSX.Element>) {
return template
.split(/({{\s*[^}]+?\s*}})/g)
.filter(Boolean)
.map((part) => {
const match = part.match(/^{{\s*([^}]+?)\s*}}$/)
if (!match) return part
return params[match[1]] ?? ""
})
}
export function createUiI18n(source: UiI18nSource): UiI18n {
return {
...source,
@@ -84,6 +97,7 @@ export function createUiI18n(source: UiI18nSource): UiI18n {
? ","
: ""
: localizedListSeparator(source.locale(), index, count),
rich: source.rich ?? ((key, params) => richTemplateParts(source.t(key), params)),
}
}
+8
View File
@@ -50,6 +50,8 @@ const source = {
"ui.lineComment.label.suffix": "",
"ui.lineComment.editorLabel.prefix": "Commenting on ",
"ui.lineComment.editorLabel.suffix": "",
"ui.lineComment.label": "Comment on {{selection}}",
"ui.lineComment.editorLabel": "Commenting on {{selection}}",
"ui.lineComment.placeholder": "Add comment",
"ui.lineComment.contextPlaceholder": "Add context for this change",
"ui.lineComment.submit": "Comment",
@@ -135,6 +137,7 @@ const source = {
"ui.list.clearFilter": "Clear filter",
"ui.list.emptyWithFilter.prefix": "No results for",
"ui.list.emptyWithFilter.suffix": "",
"ui.list.emptyWithFilter": "No results for {{query}}",
"ui.fileSearch.placeholder": "Find",
"ui.fileSearch.previousMatch": "Previous match",
@@ -217,6 +220,8 @@ const source = {
"ui.common.fileCount.other": "{{count}} files",
"ui.common.question.one": "question",
"ui.common.question.other": "questions",
"ui.common.questionCount.one": "{{count}} question",
"ui.common.questionCount.other": "{{count}} questions",
"ui.common.add": "Add",
"ui.common.clear": "Clear",
@@ -267,6 +272,9 @@ const source = {
"ui.patch.action.patched": "Patched",
"ui.question.subtitle.answered": "{{count}} answered",
"ui.question.subtitle.answeredCount.one": "{{count}} answered",
"ui.question.subtitle.answeredCount.other": "{{count}} answered",
"ui.fileMedia.audioLabel": "Audio: {{filename}}",
"ui.question.answer.none": "(no answer)",
"ui.question.review.notAnswered": "(not answered)",
"ui.question.multiHint": "Select all answers that apply",