mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(i18n): keep rich grammar in shared APIs
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">"{query}"</span>
|
||||
<Show when={suffix}>
|
||||
<span>{suffix}</span>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
return i18n.rich("ui.list.emptyWithFilter", {
|
||||
query: <span data-slot="list-filter">"{query}"</span>,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -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, "에 댓글"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user