diff --git a/packages/app/src/providers/credentials/dialog.tsx b/packages/app/src/providers/credentials/dialog.tsx index 1d1cda64b66..a6c8b6fcae2 100644 --- a/packages/app/src/providers/credentials/dialog.tsx +++ b/packages/app/src/providers/credentials/dialog.tsx @@ -140,11 +140,13 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {

- {language.t("provider.custom.description.prefix")} - - {language.t("provider.custom.description.link")} - - {language.t("provider.custom.description.suffix")} + {language.rich("provider.custom.description", { + link: ( + + {language.t("provider.custom.description.link")} + + ), + })}

diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 0c44e05dad1..a1955223d44 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -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", diff --git a/packages/app/src/runtime/i18n/language.tsx b/packages/app/src/runtime/i18n/language.tsx index 5805dad4ab5..2ec54cfbeb5 100644 --- a/packages/app/src/runtime/i18n/language.tsx +++ b/packages/app/src/runtime/i18n/language.tsx @@ -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} diff --git a/packages/app/src/servers/connect/dialog.tsx b/packages/app/src/servers/connect/dialog.tsx index 4324da29fd5..16635fdcb08 100644 --- a/packages/app/src/servers/connect/dialog.tsx +++ b/packages/app/src/servers/connect/dialog.tsx @@ -107,8 +107,11 @@ export const DialogServer: Component<{ >
- +
- +
- + props.content) return (
@@ -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 (
@@ -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 })} diff --git a/packages/session-ui/src/components/file-media.tsx b/packages/session-ui/src/components/file-media.tsx index 216873f1747..145b84c158c 100644 --- a/packages/session-ui/src/components/file-media.tsx +++ b/packages/session-ui/src/components/file-media.tsx @@ -240,7 +240,13 @@ export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX return (
-
diff --git a/packages/session-ui/src/components/line-comment.tsx b/packages/session-ui/src/components/line-comment.tsx index 6f4206770e0..eec565638e5 100644 --- a/packages/session-ui/src/components/line-comment.tsx +++ b/packages/session-ui/src/components/line-comment.tsx @@ -146,11 +146,7 @@ export const LineComment = (props: LineCommentProps) => {
{split.actions}
-
- {i18n.t("ui.lineComment.label.prefix")} - {split.selection} - {i18n.t("ui.lineComment.label.suffix")} -
+
{i18n.rich("ui.lineComment.label", { selection: split.selection })}
) @@ -378,9 +374,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
- {i18n.t("ui.lineComment.editorLabel.prefix")} - {split.selection} - {i18n.t("ui.lineComment.editorLabel.suffix")} + {i18n.rich("ui.lineComment.editorLabel", { selection: split.selection })}
diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index ba3f9084406..66bb1045443 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -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 ( diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index fc67346bfa5..d2c776b3c6c 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -247,16 +247,9 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) const query = filter() if (!query) return i18n.t("ui.list.empty") - const suffix = i18n.t("ui.list.emptyWithFilter.suffix") - return ( - <> - {i18n.t("ui.list.emptyWithFilter.prefix")} - "{query}" - - {suffix} - - - ) + return i18n.rich("ui.list.emptyWithFilter", { + query: "{query}", + }) } return ( diff --git a/packages/ui/src/context/i18n.test.ts b/packages/ui/src/context/i18n.test.ts index d3c86eea40e..eac9118c190 100644 --- a/packages/ui/src/context/i18n.test.ts +++ b/packages/ui/src/context/i18n.test.ts @@ -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, "에 댓글"]) + }) +}) diff --git a/packages/ui/src/context/i18n.tsx b/packages/ui/src/context/i18n.tsx index bcbf3960a42..380bf547b79 100644 --- a/packages/ui/src/context/i18n.tsx +++ b/packages/ui/src/context/i18n.tsx @@ -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) => 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) => JSX.Element[] } const rules = new Map() @@ -68,6 +70,17 @@ function resolveTemplate(text: string, params?: UiI18nParams) { }) } +export function richTemplateParts(template: string, params: Record) { + 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)), } } diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index f5f68b39d19..1c585975ab6 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -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",