diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.i18n.yaml new file mode 100644 index 0000000000..e17980cb32 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-09-17-multiline-display-dollar-math.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md +/agent-note-multi-line-display-dollar-math: + en: 8f57c77aa43fbd6c + zh: ef5192184e4923d5 +/agent-note-multi-line-display-dollar-math/problem: + en: e4b73644a982844c + zh: 48f86358242d6ccc +/agent-note-multi-line-display-dollar-math/decision: + en: d6b136b1f62285b7 + zh: aba323bfdb800162 +/agent-note-multi-line-display-dollar-math/alternatives-considered: + en: e0833b5674e648e7 + zh: b0472020b07099b9 +/agent-note-multi-line-display-dollar-math/consequences: + en: 70f16597bd640e93 + zh: 0e0c9005f331e514 +/agent-note-multi-line-display-dollar-math/testing: + en: f17a1453df91ce88 + zh: 4f15be0139e8b694 diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md new file mode 100644 index 0000000000..0362895314 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md @@ -0,0 +1,66 @@ +# Agent Note: Multi-line display-dollar math + +Status: implemented + +English | [中文](2026-09-17-multiline-display-dollar-math.zh.md) + +## Problem + +Authors and models write `$$` display math in three shapes. The settled grammar rendered two of them: `$$math$$` on one line, and an opening `$$` alone on its line closed by `$$` alone on its line. The third shape — math on the opening line, a closing `$$` at the end of the last math line — did not: + +```markdown +$$f(x)=x +\text{continued}$$ +``` + +`micromark-extension-math` treats text after an opening `$$` as fence meta and accepts a closing fence only on a line whose content is exactly `$$` (after optional whitespace). The [markdown compatibility layer](../../archived/feature/2026-07-23-web-assistant-markdown.md) claimed only the same-line shape, and the settled renderer passes `node.value` to KaTeX while ignoring `node.meta`. The block therefore never closed: it consumed the document text that followed, `value` retained the literal `$$`, KaTeX raised `Can't use function '$' in math mode`, and [katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx)'s error arm painted the TeX source plus the swallowed prose in red. + +In the session that surfaced this, one chat reply lost lines 22–55 of 55 to a single error span, and six note files lost every line after such a block (up to 500 lines) to one. A second defect shared the mechanism: when a later lone `$$` did close the flow, the opening line had already been assigned to `meta`, so the renderer silently dropped the first line of the formula. + +## Decision + +[mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) gains multi-line display-dollar blocks. Its dollar construct keeps the opening line's text in `mathFlowValue` instead of fence meta and closes at a trailing `$$` on any later content line, so `$$a+b` continued by `c+d$$` becomes one math node. + +A `$$` sequence that fails to close ends the candidate: immediately on a later line, and at that line's ending on the opening line (`$$x$$ trailing` fails there). Within the opening line the scan continues, so a later `$$` there still closes (`$$\verb*a$$a$$`). Scanning past either point would consume a later block's closing fence. A solitary `$` is content rather than a failed fence, so a formula that uses one — `$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$` — keeps the block open and closes normally wherever this flow owns the block. A trailing backslash escapes the line ending, not the next line's first character, so the escape run ends at the line ending instead of reaching into the next candidate. An unescaped `%` starts a TeX comment that runs to the line ending, so a `$$` at the end of a later line closes nothing when the comment holds text before it. A `%` whose text before the fence is empty or whitespace (`95%$$`, `95 %$$`) holds no comment text, and a block that opens and closes on one line keeps closing at that line's `$$`, as it did before this flow spanned lines. That state and its gate on the closing check belong to the dollar flow. The characters right after `\verb` or `\verb*` are that command's delimiter rather than a fence, so the delimiter and, when the region is empty, the `$` closing it are content; that tail is cleared at a line ending and after an escaped backslash (`\\verb` is a line break followed by letters), and a comment holds no commands. Otherwise verbatim regions are not parsed, which keeps `\verb`-bearing formulas on the same value path as every other input. + +Its paragraph-interruption check declines instead of consuming the line ending, for the dollar construct only. Consuming it there keeps the paragraph open across the line boundary, so the document layer keeps the container and counts the next line lazy for every construct tried afterwards; that made upstream's `mathFlow`, the construct tried after this one, end empty inside a list item. Declining also leaves the interruption decision to that upstream flow, whose own check short-circuits without consuming and claims only the fences it accepts, so a candidate neither construct accepts (`**before` followed by `$$a\$b`) leaves its paragraph intact. The backslash construct has no competing upstream construct and keeps consuming there. + +[parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts) registers `mathCompatibility()` after `math()`: micromark dispatches the extension registered last first, so the dollar flow above takes precedence over upstream's `mathFlow` while `math()` still owns inline `$…$`. + +## Alternatives considered + +**Keep the compatibility layer at same-line `$$…$$`.** The multi-line shape is what GitHub, KaTeX's auto-render, VS Code, and Typora accept, and the form models emit for multi-case formulas; leaving it unparsed means a red error span in place of the formula plus the rest of the document. + +**Repair the parsed tree afterwards.** The flow consumes the swallowed text into `mathFlowValue`, where it is indistinguishable from math source; `meta` and `value` cannot be reassembled into the intended block, and the following blocks were never parsed at all. + +**Rewrite `$$` fences in the source text before parsing.** The compatibility layer is a grammar extension by design; a text pass would need to locate code spans, fences, and escapes itself, and would change author text rather than parse it. + +**Continue the dollar flow after a failed close.** That is what a plain `multiline` flag produces: `$$x$$ trailing\n\n$$y$$` then scans to the second block's `$$` and renders one math node holding both lines' text, where upstream inline math renders `x`, the prose, and `y` separately. Existing behavior for `$$x$$ trailing` already pinned the fallback, so the flow declines the candidate instead. Declining on any `$` rather than a full `$$` sequence also broke formulas that use a solitary `$` inside `\text{}`. + +**End the attempt as soon as one line resists parsing.** Declining every failed same-line close swallows nothing but turns `$$a$b` lines into a quadratic scan and drops the solitary-`$` formulas above. The condition is the full `$$` sequence, not the character. + +**Claim the paragraph interruption in the check.** Returning success there, as upstream's flow does for the fences it accepts, prevents the lazy bookkeeping that broke the list case, but it also ends the paragraph for candidates that later fail (`**before\n$$a\$b\nafter**` split in two and exposed its `**`). Declining instead lets upstream's own check decide. + +**Apply the interruption guard to the backslash construct too.** The guard exists to protect the construct tried after this one; `\[`/`\(` have no second construct, and an unconditional guard dropped their multi-line blocks (`Prose line` followed by `\[`). + +## Consequences + +The three shapes now produce the same math node, so formulas stop truncating at the opening line and stop swallowing the prose after them. `$$` fence meta is no longer honored: `$$asciimath$$` parses as a formula whose value is `asciimath`, which upstream's flow treats as fence meta and drops. That spelling has no user in this repository, and the renderer never read `meta` anyway. + +Six inputs still reach upstream's flow or stay in the surrounding paragraph, because the compatibility flow declines them. Trailing text after a closing `$$` (`$$x$$ trailing`, or a CJK quote mark closing a line of prose) resolves through upstream inline math, as the existing `markdown.client.spec.tsx` case pins — including when a later `$$` block follows. A `$$` sequence the line does not end after declines the block: immediately on a later line, and at that line's ending on the opening line. A `$$` block left open runs to the next lone `$$` or to EOF exactly as it did before this change, because the upstream flow closes at EOF. + +The fourth is a multi-line block whose opening line carries a solitary `$` and that must interrupt a paragraph, with no blank line before it. Both flows decline the interruption there — this one by design, upstream because its fence meta stops at the `$` — so the block stays inside the paragraph and renders as inline math or not at all. Separating the block from the paragraph with a blank line makes this flow own it and render the display block. + +The fifth follows from the comment gate: a block whose only closing fence sits behind a comment that holds text keeps scanning instead of closing there, so when nothing else closes it the block reaches the upstream flow as the shapes above do. A fence behind a whitespace-only comment closes the block there, which leaves a following lone `$$` as its own empty block where the flow it replaced kept one node. + +The sixth is a multi-line block inside a container whose continuation line falls back to the container's indentation. `- Define:` followed by `$$f(x)=x` at the item's indentation and then a de-indented continuation line gives upstream's flow a fence meta and no value, so the reader sees an empty display block and the continuation as prose — the opening line is gone. Keeping the continuation at the same indentation, or moving the block out of the list, renders it normally. Both flow behaviors match the grammar this replaced; only the multi-line blocks this flow owns are new. + +One further limitation is not a fallback: the flow reads the six characters before a fence and does not parse verbatim regions, so a fence inside an unterminated region (`\verb|$$`) closes the block there, and a region whose text ends in `\verb` (`\verb**\verb*$$`) has its fence read as that text's delimiter. The six characters before either fence are locally identical, so no rule that reads only that tail separates them. + +Repeated unclosed candidates keep linear parser work: an open candidate declines at the next line that starts with a full `$$` it cannot close, rather than scanning each candidate to EOF, which would make 9.6 KB of `$$a$b` lines take ~4 s instead of ~16 ms. KaTeX-level failures in a formula's own source, such as `\arccot`, remain errors by design. + +## Testing + +`packages/client/ui-primitives/tests/markdown.client.spec.tsx` adds the multi-line case — opening-line math closed on a content line, closed by a lone fence, after a paragraph, inside a list item, and at column zero after a list item — and that case fails without the change. The same file also pins a multi-line formula that uses a solitary `$`, which like the case above renders only with this change. Further cases pin one condition each: a trailing-text candidate ahead of a later block (the flow must decline a full `$$` the line does not end after), a solitary `$` inside a single-line display formula (the flow must not decline on a single `$`), an escaped-dollar candidate that leaves its paragraph intact (the interruption check must not claim the paragraph), an unclosed `\[` candidate that leaves its paragraph intact (the guard must not apply to the backslash construct), a candidate ending in an odd backslash (the escape run must end at the line ending), a closing fence inside a TeX comment, a `%` the fence follows immediately or after whitespace, a second `%` inside an open comment, a `%` inside `\verb|…|`, the delimiter right after `\verb` and `\verb*` together with its empty region, an escaped backslash before `verb`, a verb tail that crosses no line ending, a comment ending in `verb`, a later-line trailing fence whose following prose upstream takes, a failed fence inside a comment, and bounded-work cases for repeated candidates with that backslash, without it, and ending in a verb tail. + +The DOM compatibility corpus adds `math-multiline-display` with its `.settled.txt` and `.streaming.txt` fixtures, which also pin that a column-zero `$$` after a list item stays outside the list item. `apps/web/tests/math-rendering.e2e.ts` renders the multi-line shape in the browser lane. diff --git a/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md new file mode 100644 index 0000000000..ed0ef3f46a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md @@ -0,0 +1,66 @@ +# Agent Note: Multi-line display-dollar math + +Status: implemented + +[English](2026-09-17-multiline-display-dollar-math.md) | 中文 + +## Problem + +作者和模型写 `$$` 行间公式时有三种写法。定稿语法只渲染其中两种:一行内开闭的 `$$math$$`,以及 `$$` 独占一行、由独占一行的 `$$` 闭合。第三种——开围栏同一行就带公式内容,闭合 `$$` 落在最后一行公式行行尾——不渲染: + +```markdown +$$f(x)=x +\text{continued}$$ +``` + +`micromark-extension-math` 把开围栏 `$$` 之后的同一行文本当作围栏 meta,并且只接受内容恰为 `$$`(后接可选空白)的行作为闭合围栏。[markdown 兼容层](../../archived/feature/2026-07-23-web-assistant-markdown.md)只认同一行开闭这一种形状,而定稿渲染器把 `node.value` 交给 KaTeX,不读 `node.meta`。于是该块始终不闭合:它吞掉后续正文,`value` 里残留字面 `$$`,KaTeX 抛出 `Can't use function '$' in math mode`,[katex.tsx](../../../../packages/client/ui-primitives/src/markdown/katex.tsx) 的错误分支把 TeX 原文连同被吞掉的正文一起显示为红色。 + +在暴露该问题的会话中,一条聊天回复 55 行里有 34 行(第 22–55 行)变成同一个错误块,六个笔记文件从出错点起的所有行(最多约 500 行)同样变成一个错误块。同一机制还带出第二个缺陷:当更靠后的独占行 `$$` 恰好闭合了这个流时,开围栏那一行已经归给 `meta`,渲染器于是静默丢掉公式的第一行。 + +## Decision + +[mathCompatibility.ts](../../../../packages/client/ui-primitives/src/markdown/mathCompatibility.ts) 现在支持跨行的显示美元块。dollar 构造把开围栏同一行的文本留在 `mathFlowValue` 里而不是当作围栏 meta,并在后续任意内容行行尾的 `$$` 处闭合,于是 `$$a+b` 接 `c+d$$` 成为一个 math 节点。 + +闭合失败的 `$$` 序列会结束该候选:出现在后续行时立即结束;出现在开围栏行时在该行行尾结束(`$$x$$ trailing` 即在此放弃)。在开围栏行内扫描继续,因此同一行更靠后的 `$$` 仍能闭合(`$$\verb*a$$a$$`)。越过这两点继续扫描都会吞掉更靠后一个块的闭合围栏。孤立的一个 `$` 属于公式内容而不是失败的围栏,所以凡是本 flow 拥有该块的场合,用到它的公式——`$$P = \begin{cases} 1 & \text{if $x>0$} \end{cases}$$`——保持块打开并正常闭合。行尾反斜杠转义的是换行本身,不是下一行的第一个字符,因此转义状态在换行处结束,不会延续到下一个候选。未转义的 `%` 会开启延续到行尾的 TeX 注释:当注释在围栏之前还有文本时,块后续行行尾的 `$$` 不闭合任何东西;而围栏之前只有空白或什么都没有的 `%`(`95%$$`、`95 %$$`)不含注释文本,且在一行内开闭的块仍在该行的 `$$` 处闭合,与它跨行之前的行为一致。该状态及其对闭合判定的门控属于 dollar flow。`\verb` 或 `\verb*` 之后的那个字符是该命令的分隔符而不是围栏,因此该分隔符、以及区域为空时紧随其后的那个 `$` 都属于内容;这段尾部状态在换行处、以及在被转义的反斜杠之后清空(`\\verb` 是换行加字母,不是该命令),注释里也不识别命令。除此之外不解析原样文本区域,使含 `\verb` 的公式与其它输入走同一条 value 路径。 + +它的段落打断检查改为「不认领」,且只作用于 dollar 构造。在那里消耗换行会让段落跨行边界保持打开,document 层因此保留容器并把下一行对随后尝试的所有构造计为 lazy;这正是上游 `mathFlow`(本构造之后被尝试的构造)在列表项内以空块结束的原因。不认领同时把打断决定交给上游 flow:它自己的检查不消耗换行就短路返回,并且只认领它接受的围栏,因此两个构造都不接受的候选(`**before` 后接 `$$a\$b`)保持原段落。反斜杠构造没有竞争的上游构造,仍然照旧消耗换行。 + +[parse.ts](../../../../packages/client/ui-primitives/src/markdown/parse.ts) 把 `mathCompatibility()` 注册在 `math()` 之后:micromark 先派发最后注册的扩展,上面的 dollar flow 因此优先于上游 `mathFlow`,而行内 `$…$` 仍由 `math()` 负责。 + +## Alternatives considered + +**兼容层维持只处理同一行 `$$…$$`。** 多行写法是 GitHub、KaTeX auto-render、VS Code、Typora 都接受的形状,也是模型写多分支公式时实际采用的形状;不解析它意味着公式位置显示为红色错误块,并连带吞掉后文。 + +**解析后修补语法树。** 被吞掉的正文已经进入 `mathFlowValue`,与数学源码无法区分;`meta` 和 `value` 无法还原成原意的块,而且后面的块根本没有被解析。 + +**解析前用文本替换改写 `$$` 围栏。** 兼容层的设计就是语法扩展;文本替换需要自己定位代码 span、围栏和转义,而且改的是作者原文而不是解析它。 + +**闭合失败后继续扫描。** 这正是单纯打开 `multiline` 会得到的行为:`$$x$$ trailing\n\n$$y$$` 会一路扫到第二个块的 `$$`,渲染成一个同时含着两行文本的 math 节点;而上游行内数学会把 `x`、正文和 `y` 分开渲染。`$$x$$ trailing` 的既有行为已经固定了这条回退,因此该构造放弃这个候选。对任意一个 `$`(而不是完整的 `$$` 序列)就放弃,还会破坏在 `\text{}` 内使用单个 `$` 的公式。 + +**只要有一行解析不下去就结束尝试。** 对每次同一行闭合失败都放弃虽然不再吞文,却会让 `$$a$b` 这类行变成平方级扫描,并丢掉上面那些含单个 `$` 的公式。判据是完整的 `$$` 序列,不是那个字符。 + +**在检查阶段认领段落打断。** 像上游 flow 那样返回成功可以避免破坏列表用例的 lazy 记账,但也会让最终失败的候选提前结束段落(`**before\n$$a\$b\nafter**` 被拆成两段并暴露 `**`)。改为不认领,把决定交给上游自己的检查。 + +**把打断守卫也用在反斜杠构造上。** 这个守卫是为了保护随后被尝试的构造;`\[`/`\(` 没有第二个构造,无条件套用会丢掉它们的跨行块(`Prose line` 后接 `\[`)。 + +## Consequences + +三种写法现在产生同一个 math 节点,公式不再在开围栏那一行被截断,也不再吞掉其后的正文。`$$` 围栏 meta 不再生效:`$$asciimath$$` 会解析成 value 为 `asciimath` 的公式,而上游 flow 会把该段当作 fence meta 丢弃。这种写法在本仓库没有使用者,且渲染器本来就从不读 `meta`。 + +有六种输入仍会走到上游 flow,或留在所属段落里,因为兼容 flow 主动放弃它们。闭合 `$$` 之后还有文本时(`$$x$$ trailing`,或用中文引号收尾的散文行)走上游行内数学,这一点由 `markdown.client.spec.tsx` 中已有用例固定,后面还有别的 `$$` 块时同样如此。「后面不是行尾」的 `$$` 序列会让块放弃:出现在后续行时立即放弃,出现在开围栏行时在该行行尾放弃。未闭合的 `$$` 块仍像本次改动之前那样延伸到下一个独占行 `$$` 或文件结尾,因为上游 flow 在 EOF 处闭合。 + +第四种是「开围栏行含孤立 `$`、且需要打断段落、前面又没有空行」的多行块。两个 flow 都不认领这次打断——本 flow 出于设计,上游则因为它的围栏 meta 在 `$` 处即停止——该块因此留在段落里,渲染成行内数学或根本不渲染。用空行把块与段落分开,本 flow 就会拥有它并渲染 display。 + +第五种来自注释门控:当块唯一的闭合围栏位于「带文本的注释」之后时,块继续扫描而不是在那里闭合,若再没有别的围栏闭合它,就会像上面几种一样走到上游 flow。若围栏之前只有空白,则块在该处闭合,此后单独一行的 `$$` 会成为它自己的空块——而被替换掉的语法那时只给出一个节点。 + +第六种是容器内续行退回容器缩进的多行块。`- Define:` 之后以列表项缩进写 `$$f(x)=x`,再接一行不缩进的续行,上游 flow 会得到有 meta、无 value 的结果:读者看到一个空的 display 块加一行散文,公式首行无声消失。续行保持同样缩进,或把整块移到列表之外,即可正常渲染。这两种 flow 行为都与本次替换掉的语法一致;真正新增的只是「本 flow 拥有的多行块」。 + +另有一项不属于回退的限制:flow 只读取围栏之前的六个字符、不解析原样文本区域,因此未闭合区域内部的围栏(`\verb|$$`)会在那里闭合块,而内容以文本 `\verb` 结尾的区域(`\verb**\verb*$$`)其围栏会被读成该文本的分隔符。两种情况下围栏前的六个字符在局部完全相同,仅凭这段尾部无法区分。 + +重复的未闭合候选保持线性解析开销:未闭合的候选会在下一行遇到无法闭合的完整 `$$` 时放弃,而不是把每个候选都扫到 EOF——否则 9.6 KB 的 `$$a$b` 行会花约 4 秒而不是约 16 毫秒。公式自身源码层面的 KaTeX 失败,例如 `\arccot`,仍按设计报错。 + +## Testing + +`packages/client/ui-primitives/tests/markdown.client.spec.tsx` 新增多行用例——开围栏行内容由内容行行尾 `$$` 闭合、由独占行 `$$` 闭合、出现在段落之后、出现在列表项内,以及列表项之后的零缩进围栏——该用例去掉改动即失败。同一文件还固定了「使用单个 `$` 的多行公式」,它与上面的多行用例一样只有本次改动才会渲染成 display。另有一组用例各自对应一个条件:后面还有块时的尾随文本候选(flow 必须放弃「后面不是行尾」的完整 `$$`)、单行显示公式内的单个 `$`(不得因为一个 `$` 就放弃)、保持段落完整性的转义美元候选(打断检查不得认领段落)、保持段落完整性的未闭合 `\[` 候选(守卫不得作用于反斜杠构造)、以奇数个反斜杠结尾的候选(转义状态必须在换行处结束)、TeX 注释内的闭合围栏、紧邻围栏或只隔空白的 `%`、已开启注释内的第二个 `%`、`\verb|…|` 内的 `%`、`\verb` 与 `\verb*` 之后的分隔符及其空区域、`verb` 之前的转义反斜杠、不跨行的 verb 尾部、以 `verb` 结尾的注释、后续行尾随文本围栏(其后正文由上游接管)、注释内失败的围栏,以及带该反斜杠、不带该反斜杠、以 verb 尾部结尾三种重复候选的有界解析开销。 + +DOM 兼容语料新增 `math-multiline-display` 及其 `.settled.txt`、`.streaming.txt`,同时固定了「列表项之后的零缩进 `$$` 不进入列表项内」。`apps/web/tests/math-rendering.e2e.ts` 在浏览器 lane 渲染多行写法。 diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index ad36e6494c..79e0844b03 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -33,8 +33,8 @@ en: 372297005b57a08b zh: 074a635ac17e4362 /deepseek-harness-desktop/installation-ownership: - en: b3cf09d7c901d727 - zh: 2ec34045a201d8e9 + en: ae0384d699f55cea + zh: ffb879b1d4b08bae /deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation: en: a1a47b09f4a72ba4 zh: 7b1cf27f0b72cf31 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index b48f281820..64d9383f82 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -150,7 +150,7 @@ The product UI retains Web actions, including "Open In..." through the shared au Update copy preserves the complete version, including prerelease suffixes, without adding v or V. When no update is available, the dialog title reports the result and the body shows the current version. -Electron chooses typed English, Chinese, or Japanese shell copy from its application locale and falls back to English. The macOS application bundle declares English, Simplified Chinese, and Japanese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources. +Electron chooses typed English, Chinese, or Japanese shell copy from its application locale and falls back to English. The macOS application bundle declares English, Simplified Chinese, and Japanese in `CFBundleLocalizations`, allowing macOS to match the initial application language to the user’s preferred languages. Saved Client UI language preferences still take precedence for the main interface. Windows NSIS installer resources live in one JSON file per language under `installer/locales/`; packaging generates NSIS strings and discovers the installer language list from these files. English, Simplified Chinese, and Japanese resources must have matching keys and format placeholders; `pnpm run check:i18n` validates them. The native installer test builds and exercises each configured language. Platform account pages and onboarding artwork use English for languages other than Chinese. On Windows, the main document's language updates desktop menus, recovery and update prompts. The repository Client UI i18n gate checks desktop sources. Windows and macOS relay native fullscreen transitions and the state after each page load to the renderer, so overlays can adapt their caption clearance. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 2cd7db2ae2..c01ee79e40 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -150,7 +150,7 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 升级文案完整展示版本号(包括预发布后缀),不额外添加 v 或 V。无可用更新时,弹窗标题显示检查结果,正文显示当前版本。 -Electron 根据应用语言选择类型化的英文、中文或日文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语、简体中文和日语,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。 +Electron 根据应用语言选择类型化的英文、中文或日文 shell 文案,并回退到英文。macOS 应用包通过 `CFBundleLocalizations` 声明支持英语、简体中文和日语,让 macOS 根据用户的首选语言匹配初始应用语言。主界面仍优先使用已保存的 Client UI 语言偏好。 Windows NSIS 安装器在 `installer/locales/` 下按语言保存独立 JSON 资源;打包从这些文件生成 NSIS 文案和安装器语言清单。英文、简体中文和日语资源的键与格式占位符必须一致,`pnpm run check:i18n` 负责检查。原生安装器测试逐个构建并验证配置中的语言。账户平台页面和引导插图对中文以外的语言使用英文。在 Windows 上,主文档的语言会更新桌面菜单、恢复与更新提示。仓库 Client UI i18n 检查覆盖桌面端源码。 Windows 和 macOS 会在原生全屏切换及每次页面加载后向渲染器同步全屏状态,让浮层调整顶栏避让距离。 diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index 45e90a4ae4..2973a6eacc 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -53,7 +53,7 @@ export interface DesktopElectronBuilderConfig { readonly perMachine: false readonly allowElevation: false readonly allowToChangeInstallationDirectory: false - readonly installerLanguages: readonly ['en_US', 'zh_CN'] + readonly installerLanguages: readonly string[] } readonly beforeBuild: () => Promise readonly beforePack: (context: BeforePackContext) => Promise diff --git a/apps/desktop/installer/locales/en_US.json b/apps/desktop/installer/locales/en_US.json new file mode 100644 index 0000000000..f522ad9b6d --- /dev/null +++ b/apps/desktop/installer/locales/en_US.json @@ -0,0 +1,40 @@ +{ + "nsisLanguage": "ENGLISH", + "strings": { + "INSTALLER_INSTALL": "Install now", + "INSTALLER_FINISH": "Finish", + "INSTALLER_NATIVE_PROGRESS": "Preparing installation… %d%%", + "INSTALLER_PROGRESS_EXTRACT": "Extracting files… %d%%", + "INSTALLER_PROGRESS_COPY": "Installing files… %d%%", + "INSTALLER_PROGRESS_REGISTER": "Finishing installation… %d%%", + "INSTALLER_PROGRESS_CLEAN": "Cleaning temporary files… %d%%", + "INSTALLER_CHOOSE_PATH": "Choose location", + "INSTALLER_BROWSE": "Browse", + "INSTALLER_LAUNCH": "Launch now", + "INSTALLER_LAUNCH_FAILED": "DeepSeek Harness could not be launched. Retry, or clear Launch now and close the installer.", + "INSTALLER_UI_ERROR": "The installer could not initialize its window. Close it and try again.", + "INSTALLER_THEME_ERROR": "Use /THEME=auto, /THEME=light or /THEME=dark.", + "INSTALLER_FAILED": "Installation did not finish. Close this installer and retry. If the problem persists, download the installer again.", + "INSTALLER_RUNNING": "DeepSeek Harness is running. Quit it from the system tray and try again.", + "INSTALLER_DIRECTORY_FAILED": "The installation directory could not be replaced.", + "INSTALLER_DIRECTORY_HINT": "Check access to the installation directory and close programs using its files, then run the installer again. Copy the error details if the problem persists.", + "INSTALLER_DIRECTORY_RESTORE_HINT": "The previous installation could not be restored. Keep the backup directory shown in the error details and contact support before installing again.", + "INSTALLER_PATH_INVALID": "Choose a full local folder path. Drive roots, system folders, links and special characters are not supported.", + "INSTALLER_PATH_OWNERSHIP": "Choose an empty folder or the registered DeepSeek Harness installation folder.", + "INSTALLER_PATH_WRITABLE": "This location is not writable. Choose a folder available to the current user.", + "INSTALLER_DISK_SPACE": "There is not enough free disk space. Choose another location.", + "INSTALLER_PER_USER": "This installer supports the current user only. Uninstall the existing all-users installation first.", + "INSTALLER_EXTRACT_FAILED": "The application files could not be extracted.", + "INSTALLER_EXTRACT_HINT": "Security software may have blocked a file, or the disk may be full. Close this installer and try again. If it keeps failing, send us the error details below.", + "INSTALLER_EXTRACT_COPY": "Copy error details", + "INSTALLER_EXTRACT_EXPAND": "Show details", + "INSTALLER_EXTRACT_COLLAPSE": "Hide details", + "INSTALLER_EXTRACT_SAVED": "The full report was saved to %s", + "INSTALLER_EXTRACT_UNSAVED": "The full report could not be saved. Use Copy error details to keep it.", + "INSTALLER_EXTRACT_COPIED": "Error details were copied to the clipboard." + }, + "uninstaller": { + "welcome": "Welcome", + "finished": "has been uninstalled" + } +} diff --git a/apps/desktop/installer/locales/ja_JP.json b/apps/desktop/installer/locales/ja_JP.json new file mode 100644 index 0000000000..ee614789c5 --- /dev/null +++ b/apps/desktop/installer/locales/ja_JP.json @@ -0,0 +1,40 @@ +{ + "nsisLanguage": "JAPANESE", + "strings": { + "INSTALLER_INSTALL": "今すぐインストール", + "INSTALLER_FINISH": "完了", + "INSTALLER_NATIVE_PROGRESS": "インストールを準備しています… %d%%", + "INSTALLER_PROGRESS_EXTRACT": "ファイルを展開しています… %d%%", + "INSTALLER_PROGRESS_COPY": "ファイルをインストールしています… %d%%", + "INSTALLER_PROGRESS_REGISTER": "インストールを完了しています… %d%%", + "INSTALLER_PROGRESS_CLEAN": "一時ファイルを削除しています… %d%%", + "INSTALLER_CHOOSE_PATH": "インストール先を選択", + "INSTALLER_BROWSE": "参照", + "INSTALLER_LAUNCH": "今すぐ起動", + "INSTALLER_LAUNCH_FAILED": "DeepSeek Harness を起動できませんでした。再試行するか、「今すぐ起動」のチェックを外してインストーラーを閉じてください。", + "INSTALLER_UI_ERROR": "インストーラーのウィンドウを初期化できませんでした。閉じてから再試行してください。", + "INSTALLER_THEME_ERROR": "/THEME=auto、/THEME=light、/THEME=dark のいずれかを指定してください。", + "INSTALLER_FAILED": "インストールが完了しませんでした。インストーラーを閉じて再試行してください。問題が解決しない場合は、インストーラーを再度ダウンロードしてください。", + "INSTALLER_RUNNING": "DeepSeek Harness が実行中です。システムトレイから終了して再試行してください。", + "INSTALLER_DIRECTORY_FAILED": "インストール先のディレクトリを置き換えられませんでした。", + "INSTALLER_DIRECTORY_HINT": "インストール先へのアクセス権限を確認し、そのファイルを使用しているプログラムを閉じてから、インストーラーを再実行してください。問題が続く場合はエラーの詳細をコピーしてください。", + "INSTALLER_DIRECTORY_RESTORE_HINT": "以前のインストールを復元できませんでした。エラーの詳細に表示されたバックアップディレクトリを保持し、再インストール前にサポートへお問い合わせください。", + "INSTALLER_PATH_INVALID": "ローカルフォルダーの完全なパスを選択してください。ドライブのルート、システムフォルダー、リンク、特殊文字を含むパスは使用できません。", + "INSTALLER_PATH_OWNERSHIP": "空のフォルダー、または登録済みの DeepSeek Harness インストール先を選択してください。", + "INSTALLER_PATH_WRITABLE": "この場所には書き込めません。現在のユーザーが書き込めるフォルダーを選択してください。インストーラーは管理者権限を要求しません。", + "INSTALLER_DISK_SPACE": "ディスクの空き容量が不足しています。別の場所を選択してください。", + "INSTALLER_PER_USER": "このインストーラーは現在のユーザーのみを対象としています。先にすべてのユーザー向けの既存のインストールを削除してください。", + "INSTALLER_EXTRACT_FAILED": "アプリケーションのファイルを展開できませんでした。", + "INSTALLER_EXTRACT_HINT": "セキュリティソフトがファイルをブロックしたか、ディスクの空き容量が不足している可能性があります。インストーラーを閉じて再試行してください。問題が続く場合は、以下のエラーの詳細をお送りください。", + "INSTALLER_EXTRACT_COPY": "エラーの詳細をコピー", + "INSTALLER_EXTRACT_EXPAND": "詳細を表示", + "INSTALLER_EXTRACT_COLLAPSE": "詳細を非表示", + "INSTALLER_EXTRACT_SAVED": "完全なレポートを %s に保存しました", + "INSTALLER_EXTRACT_UNSAVED": "完全なレポートを保存できませんでした。「エラーの詳細をコピー」で保存してください。", + "INSTALLER_EXTRACT_COPIED": "エラーの詳細をクリップボードにコピーしました。" + }, + "uninstaller": { + "welcome": "アンインストール ウィザードへようこそ", + "finished": "アンインストールされました" + } +} diff --git a/apps/desktop/installer/locales/zh_CN.json b/apps/desktop/installer/locales/zh_CN.json new file mode 100644 index 0000000000..e3f40f13be --- /dev/null +++ b/apps/desktop/installer/locales/zh_CN.json @@ -0,0 +1,40 @@ +{ + "nsisLanguage": "SIMPCHINESE", + "strings": { + "INSTALLER_INSTALL": "立即安装", + "INSTALLER_FINISH": "完成", + "INSTALLER_NATIVE_PROGRESS": "正在准备安装… %d%%", + "INSTALLER_PROGRESS_EXTRACT": "正在解压文件… %d%%", + "INSTALLER_PROGRESS_COPY": "正在安装文件… %d%%", + "INSTALLER_PROGRESS_REGISTER": "正在完成安装… %d%%", + "INSTALLER_PROGRESS_CLEAN": "正在清理临时文件… %d%%", + "INSTALLER_CHOOSE_PATH": "选择安装位置", + "INSTALLER_BROWSE": "选择", + "INSTALLER_LAUNCH": "立即启动", + "INSTALLER_LAUNCH_FAILED": "无法启动 DeepSeek Harness。请重试,或取消勾选立即启动后关闭安装程序。", + "INSTALLER_UI_ERROR": "无法初始化安装窗口,请关闭后重试。", + "INSTALLER_THEME_ERROR": "主题参数须为 /THEME=auto、/THEME=light 或 /THEME=dark。", + "INSTALLER_FAILED": "安装未完成,请关闭安装程序后重试。如仍然失败,请重新下载安装包。", + "INSTALLER_RUNNING": "DeepSeek Harness 正在运行,请先在系统托盘中退出应用后重试。", + "INSTALLER_DIRECTORY_FAILED": "无法替换安装目录。", + "INSTALLER_DIRECTORY_HINT": "请检查安装目录的访问权限,关闭占用其中内容的程序后重新运行安装器。如仍失败,请复制错误详情。", + "INSTALLER_DIRECTORY_RESTORE_HINT": "未能恢复原安装目录。请保留错误详情中列出的备份目录,联系支持人员后再安装。", + "INSTALLER_PATH_INVALID": "请选择本地磁盘上的完整文件夹路径,不能使用磁盘根目录、系统目录、链接目录或包含特殊字符的路径。", + "INSTALLER_PATH_OWNERSHIP": "请选择空文件夹,或 DeepSeek Harness 原来的安装目录。", + "INSTALLER_PATH_WRITABLE": "无法写入此位置。请选择当前用户可写入的文件夹;安装程序不会申请管理员权限。", + "INSTALLER_DISK_SPACE": "此磁盘的可用空间不足,请选择其他位置。", + "INSTALLER_PER_USER": "此安装程序仅支持当前用户。请先卸载已有的所有用户安装版本。", + "INSTALLER_EXTRACT_FAILED": "无法解压应用程序文件。", + "INSTALLER_EXTRACT_HINT": "可能是安全软件拦截了文件,或磁盘空间不足。请关闭安装程序后重试;如仍然失败,请将下方错误信息发送给我们。", + "INSTALLER_EXTRACT_COPY": "复制错误信息", + "INSTALLER_EXTRACT_EXPAND": "显示详情", + "INSTALLER_EXTRACT_COLLAPSE": "隐藏详情", + "INSTALLER_EXTRACT_SAVED": "完整错误报告已保存到 %s", + "INSTALLER_EXTRACT_UNSAVED": "无法保存完整错误报告,请使用“复制错误信息”保留。", + "INSTALLER_EXTRACT_COPIED": "错误信息已复制到剪贴板。" + }, + "uninstaller": { + "welcome": "欢迎", + "finished": "已从你" + } +} diff --git a/apps/desktop/installer/strings.nsh b/apps/desktop/installer/strings.nsh deleted file mode 100644 index a1ac41b418..0000000000 --- a/apps/desktop/installer/strings.nsh +++ /dev/null @@ -1,63 +0,0 @@ -; NSIS owns installer localization independently of the Electron application locale. -LangString INSTALLER_INSTALL ${LANG_ENGLISH} "Install now" -LangString INSTALLER_INSTALL ${LANG_SIMPCHINESE} "立即安装" -LangString INSTALLER_FINISH ${LANG_ENGLISH} "Finish" -LangString INSTALLER_FINISH ${LANG_SIMPCHINESE} "完成" -LangString INSTALLER_NATIVE_PROGRESS ${LANG_ENGLISH} "Preparing installation… %d%%" -LangString INSTALLER_NATIVE_PROGRESS ${LANG_SIMPCHINESE} "正在准备安装… %d%%" -LangString INSTALLER_PROGRESS_EXTRACT ${LANG_ENGLISH} "Extracting files… %d%%" -LangString INSTALLER_PROGRESS_EXTRACT ${LANG_SIMPCHINESE} "正在解压文件… %d%%" -LangString INSTALLER_PROGRESS_COPY ${LANG_ENGLISH} "Installing files… %d%%" -LangString INSTALLER_PROGRESS_COPY ${LANG_SIMPCHINESE} "正在安装文件… %d%%" -LangString INSTALLER_PROGRESS_REGISTER ${LANG_ENGLISH} "Finishing installation… %d%%" -LangString INSTALLER_PROGRESS_REGISTER ${LANG_SIMPCHINESE} "正在完成安装… %d%%" -LangString INSTALLER_PROGRESS_CLEAN ${LANG_ENGLISH} "Cleaning temporary files… %d%%" -LangString INSTALLER_PROGRESS_CLEAN ${LANG_SIMPCHINESE} "正在清理临时文件… %d%%" -LangString INSTALLER_CHOOSE_PATH ${LANG_ENGLISH} "Choose location" -LangString INSTALLER_CHOOSE_PATH ${LANG_SIMPCHINESE} "选择安装位置" -LangString INSTALLER_BROWSE ${LANG_ENGLISH} "Browse" -LangString INSTALLER_BROWSE ${LANG_SIMPCHINESE} "选择" -LangString INSTALLER_LAUNCH ${LANG_ENGLISH} "Launch now" -LangString INSTALLER_LAUNCH ${LANG_SIMPCHINESE} "立即启动" -LangString INSTALLER_LAUNCH_FAILED ${LANG_ENGLISH} "DeepSeek Harness could not be launched. Retry, or clear Launch now and close the installer." -LangString INSTALLER_LAUNCH_FAILED ${LANG_SIMPCHINESE} "无法启动 DeepSeek Harness。请重试,或取消勾选立即启动后关闭安装程序。" -LangString INSTALLER_UI_ERROR ${LANG_ENGLISH} "The installer could not initialize its window. Close it and try again." -LangString INSTALLER_UI_ERROR ${LANG_SIMPCHINESE} "无法初始化安装窗口,请关闭后重试。" -LangString INSTALLER_THEME_ERROR ${LANG_ENGLISH} "Use /THEME=auto, /THEME=light or /THEME=dark." -LangString INSTALLER_THEME_ERROR ${LANG_SIMPCHINESE} "主题参数须为 /THEME=auto、/THEME=light 或 /THEME=dark。" -LangString INSTALLER_FAILED ${LANG_ENGLISH} "Installation did not finish. Close this installer and retry. If the problem persists, download the installer again." -LangString INSTALLER_FAILED ${LANG_SIMPCHINESE} "安装未完成,请关闭安装程序后重试。如仍然失败,请重新下载安装包。" -LangString INSTALLER_RUNNING ${LANG_ENGLISH} "DeepSeek Harness is running. Quit it from the system tray and try again." -LangString INSTALLER_RUNNING ${LANG_SIMPCHINESE} "DeepSeek Harness 正在运行,请先在系统托盘中退出应用后重试。" -LangString INSTALLER_DIRECTORY_FAILED ${LANG_ENGLISH} "The installation directory could not be replaced." -LangString INSTALLER_DIRECTORY_FAILED ${LANG_SIMPCHINESE} "无法替换安装目录。" -LangString INSTALLER_DIRECTORY_HINT ${LANG_ENGLISH} "Check access to the installation directory and close programs using its files, then run the installer again. Copy the error details if the problem persists." -LangString INSTALLER_DIRECTORY_HINT ${LANG_SIMPCHINESE} "请检查安装目录的访问权限,关闭占用其中内容的程序后重新运行安装器。如仍失败,请复制错误详情。" -LangString INSTALLER_DIRECTORY_RESTORE_HINT ${LANG_ENGLISH} "The previous installation could not be restored. Keep the backup directory shown in the error details and contact support before installing again." -LangString INSTALLER_DIRECTORY_RESTORE_HINT ${LANG_SIMPCHINESE} "未能恢复原安装目录。请保留错误详情中列出的备份目录,联系支持人员后再安装。" -LangString INSTALLER_PATH_INVALID ${LANG_ENGLISH} "Choose a full local folder path. Drive roots, system folders, links and special characters are not supported." -LangString INSTALLER_PATH_INVALID ${LANG_SIMPCHINESE} "请选择本地磁盘上的完整文件夹路径,不能使用磁盘根目录、系统目录、链接目录或包含特殊字符的路径。" -LangString INSTALLER_PATH_OWNERSHIP ${LANG_ENGLISH} "Choose an empty folder or the registered DeepSeek Harness installation folder." -LangString INSTALLER_PATH_OWNERSHIP ${LANG_SIMPCHINESE} "请选择空文件夹,或 DeepSeek Harness 原来的安装目录。" -LangString INSTALLER_PATH_WRITABLE ${LANG_ENGLISH} "This location is not writable. Choose a folder available to the current user." -LangString INSTALLER_PATH_WRITABLE ${LANG_SIMPCHINESE} "无法写入此位置。请选择当前用户可写入的文件夹;安装程序不会申请管理员权限。" -LangString INSTALLER_DISK_SPACE ${LANG_ENGLISH} "There is not enough free disk space. Choose another location." -LangString INSTALLER_DISK_SPACE ${LANG_SIMPCHINESE} "此磁盘的可用空间不足,请选择其他位置。" -LangString INSTALLER_PER_USER ${LANG_ENGLISH} "This installer supports the current user only. Uninstall the existing all-users installation first." -LangString INSTALLER_PER_USER ${LANG_SIMPCHINESE} "此安装程序仅支持当前用户。请先卸载已有的所有用户安装版本。" -LangString INSTALLER_EXTRACT_FAILED ${LANG_ENGLISH} "The application files could not be extracted." -LangString INSTALLER_EXTRACT_FAILED ${LANG_SIMPCHINESE} "无法解压应用程序文件。" -LangString INSTALLER_EXTRACT_HINT ${LANG_ENGLISH} "Security software may have blocked a file, or the disk may be full. Close this installer and try again. If it keeps failing, send us the error details below." -LangString INSTALLER_EXTRACT_HINT ${LANG_SIMPCHINESE} "可能是安全软件拦截了文件,或磁盘空间不足。请关闭安装程序后重试;如仍然失败,请将下方错误信息发送给我们。" -LangString INSTALLER_EXTRACT_COPY ${LANG_ENGLISH} "Copy error details" -LangString INSTALLER_EXTRACT_COPY ${LANG_SIMPCHINESE} "复制错误信息" -LangString INSTALLER_EXTRACT_EXPAND ${LANG_ENGLISH} "Show details" -LangString INSTALLER_EXTRACT_EXPAND ${LANG_SIMPCHINESE} "显示详情" -LangString INSTALLER_EXTRACT_COLLAPSE ${LANG_ENGLISH} "Hide details" -LangString INSTALLER_EXTRACT_COLLAPSE ${LANG_SIMPCHINESE} "隐藏详情" -LangString INSTALLER_EXTRACT_SAVED ${LANG_ENGLISH} "The full report was saved to %s" -LangString INSTALLER_EXTRACT_SAVED ${LANG_SIMPCHINESE} "完整错误报告已保存到 %s" -LangString INSTALLER_EXTRACT_UNSAVED ${LANG_ENGLISH} "The full report could not be saved. Use Copy error details to keep it." -LangString INSTALLER_EXTRACT_UNSAVED ${LANG_SIMPCHINESE} "无法保存完整错误报告,请使用“复制错误信息”保留。" -LangString INSTALLER_EXTRACT_COPIED ${LANG_ENGLISH} "Error details were copied to the clipboard." -LangString INSTALLER_EXTRACT_COPIED ${LANG_SIMPCHINESE} "错误信息已复制到剪贴板。" diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 7f225bebf7..4444b68ae1 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -1,7 +1,8 @@ +import { readInstallerLocales, renderInstallerStrings } from './installer-locales.mjs' import { officePackageDirectories } from '../../../scripts/libreoffice-packages.mjs' import { X509Certificate } from 'node:crypto' import { readFileSync } from 'node:fs' -import { readFile } from 'node:fs/promises' +import { readFile, writeFile } from 'node:fs/promises' import { join, relative, sep } from 'node:path' import { fileURLToPath } from 'node:url' import { execFile } from 'node:child_process' @@ -129,6 +130,7 @@ export function createElectronBuilderConfig( '-OutputDirectory', join(buildPaths.root, 'installer-ui')], { env: scrubWindowsSigningEnvironment(env), windowsHide: true, }) + await writeFile(join(buildPaths.root, 'installer-ui', 'strings.nsh'), renderInstallerStrings(readInstallerLocales()), 'utf8') if (windowsSigner !== undefined) { await windowsSigner({ path: join(buildPaths.root, 'installer-ui', 'window-frame.dll'), hash: 'sha256', isNest: false }) } @@ -262,7 +264,7 @@ export function createElectronBuilderConfig( perMachine: false, allowElevation: false, allowToChangeInstallationDirectory: false, - installerLanguages: ['en_US', 'zh_CN'], + installerLanguages: readInstallerLocales().map(locale => locale.id), differentialPackage: true, }, detectUpdateChannel: false, diff --git a/apps/desktop/scripts/installer-locales.d.mts b/apps/desktop/scripts/installer-locales.d.mts new file mode 100644 index 0000000000..1cb1b8ad93 --- /dev/null +++ b/apps/desktop/scripts/installer-locales.d.mts @@ -0,0 +1,25 @@ +/** One installer language's compiler identifier, UI strings and NSIS Modern UI test expectations. */ +export interface InstallerLocale { + /** electron-builder language and resource filename without its suffix. */ + id: string + /** NSIS LANG_ constant suffix. */ + nsisLanguage: string + /** Custom installer messages, with Windows printf placeholders preserved. */ + strings: Record + /** Substrings of the upstream Modern UI uninstaller's welcome and finished pages. */ + uninstaller: Record +} + +/** + * Read complete per-language JSON resources and reject inconsistent keys or placeholders. + * @param directory - Resource directory, defaulting to Desktop installer/locales. + * @returns Validated resources, ordered by filename. + */ +export function readInstallerLocales(directory?: string): InstallerLocale[] + +/** + * Generate NSIS LangString declarations from validated resources. + * @param locales - All configured languages or one isolated native-test language. + * @returns UTF-8 NSIS source with literal text escaped. + */ +export function renderInstallerStrings(locales: InstallerLocale[]): string diff --git a/apps/desktop/scripts/installer-locales.mjs b/apps/desktop/scripts/installer-locales.mjs new file mode 100644 index 0000000000..f9f9b55138 --- /dev/null +++ b/apps/desktop/scripts/installer-locales.mjs @@ -0,0 +1,75 @@ +/** Per-language JSON resources shared by NSIS packaging, native UI tests and the i18n check. */ +import { readFileSync, readdirSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +const defaultDirectory = fileURLToPath(new URL('../installer/locales/', import.meta.url)) + +/** + * Read and validate installer translations before generating compiler input. + * @param {string} directory - Directory containing one JSON file per installer language. + * @returns {{id: string, nsisLanguage: string, strings: Record, uninstaller: Record}[]} Complete language resources. + */ +export function readInstallerLocales(directory = defaultDirectory) { + const locales = readdirSync(directory).filter(name => name.endsWith('.json')).sort().map(name => { + if (!/^[a-z]{2}_[A-Z]{2}\.json$/u.test(name)) throw new Error(`${name}: invalid installer language filename`) + /** @type {unknown} */ + const resource = JSON.parse(readFileSync(`${directory}/${name}`, 'utf8')) + if (typeof resource !== 'object' || resource === null || !('nsisLanguage' in resource) + || typeof resource.nsisLanguage !== 'string' || !/^[A-Z]+$/u.test(resource.nsisLanguage)) { + throw new Error(`${name}: invalid NSIS language identifier`) + } + /** @param {unknown} value - Parsed JSON dictionary. @returns {Record} Nonempty text values. */ + const dictionary = (value) => { + if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(`${name}: expected text dictionary`) + /** @type {Record} */ + const result = {} + for (const [key, text] of Object.entries(value)) { + if (typeof text !== 'string' || text.trim() === '') throw new Error(`${name}: ${key} must be nonempty text`) + result[key] = text + } + return result + } + const strings = dictionary('strings' in resource ? resource.strings : undefined) + if (Object.keys(strings).some(key => !/^INSTALLER_[A-Z_]+$/u.test(key))) throw new Error(`${name}: invalid installer string key`) + return { id: name.slice(0, -5), nsisLanguage: resource.nsisLanguage, strings, + uninstaller: dictionary('uninstaller' in resource ? resource.uninstaller : undefined) } + }) + for (const id of ['en_US', 'zh_CN', 'ja_JP']) { + if (!locales.some(locale => locale.id === id)) throw new Error(`installer locales: missing ${id}.json`) + } + const english = locales.find(locale => locale.id === 'en_US') + if (!english || Object.keys(english.strings).length === 0) throw new Error('installer locales: missing English strings') + if (Object.keys(english.uninstaller).sort().join(',') !== 'finished,welcome') throw new Error('installer locales: expected uninstaller welcome and finished text') + const languages = new Set() + for (const locale of locales) { + if (languages.has(locale.nsisLanguage)) throw new Error(`${locale.id}: duplicate NSIS language ${locale.nsisLanguage}`) + languages.add(locale.nsisLanguage) + for (const field of ['strings', 'uninstaller']) { + const expected = english[field] + const actual = locale[field] + if (Object.keys(expected).sort().join(',') !== Object.keys(actual).sort().join(',')) throw new Error(`${locale.id}: ${field} keys differ from English`) + for (const [key, text] of Object.entries(expected)) { + const placeholders = (/** @type {string} */ value) => (value.match(/%.?/gu) ?? []).join(',') + if (placeholders(text) !== placeholders(actual[key])) throw new Error(`${locale.id}: ${key} changes format placeholders`) + } + } + } + return locales +} + +/** + * Generate guarded NSIS LangString declarations from validated JSON resources. + * @param {ReturnType} locales - Resources for all languages or one isolated native-test build. + * @returns {string} UTF-8 NSIS source, with literal dollar signs, quotes and line breaks escaped. + */ +export function renderInstallerStrings(locales) { + const escape = (/** @type {string} */ text) => text.replace(/\$|"|\r|\n/gu, character => ({ '$': '$$', '"': '$\\"', '\r': '$\\r', '\n': '$\\n' })[character]) + return '; Generated from installer/locales/*.json.\n' + locales.map(locale => + `!ifdef LANG_${locale.nsisLanguage}\n` + Object.entries(locale.strings).map(([key, text]) => + `LangString ${key} \${LANG_${locale.nsisLanguage}} "${escape(text)}"\n`).join('') + '!endif\n').join('') +} + +if (import.meta.main) { + const locales = readInstallerLocales() + console.log(`verify-installer-locales: ${locales.length} languages have matching keys and format placeholders.`) +} diff --git a/apps/desktop/scripts/installer.nsh b/apps/desktop/scripts/installer.nsh index 5961c071ba..43c6a4c7d7 100644 --- a/apps/desktop/scripts/installer.nsh +++ b/apps/desktop/scripts/installer.nsh @@ -9,7 +9,7 @@ ManifestDPIAware true !endif !macro customHeader - !define /ifndef INSTALLER_STRINGS_FILE "${INSTALLER_SOURCE_DIR}\strings.nsh" + !define /ifndef INSTALLER_STRINGS_FILE "${INSTALLER_BUILD_DIR}\strings.nsh" !include "${INSTALLER_STRINGS_FILE}" !ifdef BUILD_UNINSTALLER BrandingText " " diff --git a/apps/desktop/scripts/test-windows-installer.mjs b/apps/desktop/scripts/test-windows-installer.mjs index 27c6381a95..1cb7a5aae6 100644 --- a/apps/desktop/scripts/test-windows-installer.mjs +++ b/apps/desktop/scripts/test-windows-installer.mjs @@ -1,4 +1,5 @@ /** Builds an isolated native payload through the production NSIS configuration and exercises its UI. */ +import { readInstallerLocales, renderInstallerStrings } from './installer-locales.mjs' import { execFile } from 'node:child_process' import { randomUUID } from 'node:crypto' import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' @@ -107,8 +108,8 @@ SectionEnd const compiler = await getMakeNsisPath() await execute(compiler.path, ['/V2', payloadSource], { ...childOptions, env: { ...childOptions.env, ...compiler.env } }) if (sign) await sign({ path: join(payload, `${productName}.exe`), hash: 'sha256', isNest: false }) - const sourceStrings = await readFile(join(appRoot, 'installer', 'strings.nsh'), 'utf8') - for (const language of ['en_US', 'zh_CN']) { + for (const locale of readInstallerLocales()) { + const language = locale.id const config = createElectronBuilderConfig() if (sign) { config.win.forceCodeSigning = true @@ -117,9 +118,7 @@ SectionEnd const languageOutput = join(output, language) await mkdir(languageOutput) const strings = join(languageOutput, 'strings.nsh') - const languageId = language === 'en_US' ? 'ENGLISH' : 'SIMPCHINESE' - await writeFile(strings, sourceStrings.split('\n').filter((line) => - !line.startsWith('LangString ') || line.includes(`\${LANG_${languageId}}`)).join('\n')) + await writeFile(strings, renderInstallerStrings([locale]), 'utf8') const include = join(languageOutput, 'include.nsh') await writeFile(include, `!define INSTALLER_BUILD_DIR "${join(output, 'ui')}"\n!define INSTALLER_STRINGS_FILE "${strings}"\n!include "${join(appRoot, 'scripts', 'installer.nsh')}"\n`) await build({ projectDir: appRoot, prepackaged: payload, targets: Platform.WINDOWS.createTarget(['nsis'], Arch.x64), publish: 'never', @@ -132,7 +131,7 @@ SectionEnd join(appRoot, 'tests', uninstallOnly ? 'windows-uninstall-smoke.ps1' : 'windows-installer-smoke.ps1'), '-Installer', join(languageOutput, 'installer-test.exe'), '-ProductName', productName, '-RegistryKey', guid, '-OutputDirectory', languageOutput, - ...uninstallOnly ? ['-Language', languageId, '-PackageName', packageName] : []], childOptions) + '-Language', language, ...uninstallOnly ? ['-PackageName', packageName] : []], childOptions) process.stdout.write(`${language}\n${result.stdout}`) } succeeded = true diff --git a/apps/desktop/tests/installer-locales.spec.ts b/apps/desktop/tests/installer-locales.spec.ts new file mode 100644 index 0000000000..4dea51a414 --- /dev/null +++ b/apps/desktop/tests/installer-locales.spec.ts @@ -0,0 +1,56 @@ +/** Per-language installer resources feed both the packaging language list and the NSIS compiler. */ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, onTestFinished } from 'vitest' +import { readInstallerLocales, renderInstallerStrings } from '../scripts/installer-locales.mjs' + +function fixture(): string { + const directory = mkdtempSync(join(tmpdir(), 'dsh-installer-locales-')) + onTestFinished(() => { rmSync(directory, { recursive: true, force: true }) }) + for (const id of ['en_US', 'zh_CN', 'ja_JP']) { + writeFileSync(join(directory, `${id}.json`), readFileSync(new URL(`../installer/locales/${id}.json`, import.meta.url))) + } + return directory +} + +it('generates Japanese NSIS strings from the same files that select packaged languages', () => { + const locales = readInstallerLocales() + expect(locales.map(locale => locale.id)).toEqual(['en_US', 'ja_JP', 'zh_CN']) + const source = renderInstallerStrings(locales) + expect(source).toContain('LangString INSTALLER_INSTALL ${LANG_JAPANESE} "今すぐインストール"') + expect(source).toContain('LangString INSTALLER_NATIVE_PROGRESS ${LANG_JAPANESE} "インストールを準備しています… %d%%"') + expect(source).toContain('!ifdef LANG_JAPANESE\n') + expect(source.match(/^LangString /gmu)).toHaveLength(93) + const japanese = locales.filter(locale => locale.id === 'ja_JP') + expect(renderInstallerStrings(japanese)).not.toContain('${LANG_ENGLISH}') +}) + +it.each(['en_US', 'zh_CN', 'ja_JP'])('rejects a missing %s configuration', (id) => { + const directory = fixture() + rmSync(join(directory, `${id}.json`)) + expect(() => readInstallerLocales(directory)).toThrow(`missing ${id}.json`) +}) + +it.each([ + ['missing key', (resource: ReturnType[number]) => { delete resource.strings.INSTALLER_INSTALL }, 'keys differ'], + ['extra key', (resource: ReturnType[number]) => { resource.strings.INSTALLER_EXTRA = 'extra' }, 'keys differ'], + ['empty text', (resource: ReturnType[number]) => { resource.strings.INSTALLER_INSTALL = '' }, 'nonempty text'], + ['changed placeholder', (resource: ReturnType[number]) => { resource.strings.INSTALLER_NATIVE_PROGRESS = '%s%%' }, 'format placeholders'], + ['missing percent escape', (resource: ReturnType[number]) => { resource.strings.INSTALLER_NATIVE_PROGRESS = '%d%' }, 'format placeholders'], + ['extra format specifier', (resource: ReturnType[number]) => { resource.strings.INSTALLER_NATIVE_PROGRESS = '%d%% %q' }, 'format placeholders'], + ['duplicate language', (resource: ReturnType[number]) => { resource.nsisLanguage = 'ENGLISH' }, 'duplicate NSIS language'], + ['invalid compiler token', (resource: ReturnType[number]) => { resource.nsisLanguage = 'JA\n!error' }, 'invalid NSIS language'], +])('rejects %s', (_name, mutate, diagnostic) => { + const directory = fixture() + const resource = readInstallerLocales(directory).find(locale => locale.id === 'ja_JP')! + mutate(resource) + writeFileSync(join(directory, 'ja_JP.json'), JSON.stringify(resource)) + expect(() => readInstallerLocales(directory)).toThrow(diagnostic) +}) + +it('escapes literal NSIS syntax without changing translated text', () => { + const locale = readInstallerLocales()[0]! + expect(renderInstallerStrings([{ ...locale, strings: { INSTALLER_INSTALL: '"$name"\nnext\rline' } }])) + .toContain('"$\\"$$name$\\"$\\nnext$\\rline"') +}) diff --git a/apps/desktop/tests/installer-packaging.spec.ts b/apps/desktop/tests/installer-packaging.spec.ts index d971bb262d..57da6058df 100644 --- a/apps/desktop/tests/installer-packaging.spec.ts +++ b/apps/desktop/tests/installer-packaging.spec.ts @@ -5,13 +5,17 @@ import { AppInfo, Packager } from 'app-builder-lib' import { expandMacro } from 'app-builder-lib/out/util/macroExpander.js' import { describe, expect, it, vi } from 'vitest' -const { execute } = vi.hoisted(() => ({ execute: vi.fn(async () => undefined) })) +const { execute, write } = vi.hoisted(() => ({ execute: vi.fn(async () => undefined), write: vi.fn(async () => undefined) })) vi.mock('node:child_process', async (importOriginal) => { const original = await importOriginal() const { promisify } = await import('node:util') return { ...original, execFile: Object.assign(vi.fn(), { [promisify.custom]: execute }) } }) +vi.mock('node:fs/promises', async importOriginal => ({ + ...await importOriginal(), writeFile: write, +})) + describe('installer preparation preserves application dependencies', () => { it.each(['win32', 'darwin'] as const)('rejects a missing production policy before signing on %s', async (platform) => { const { createElectronBuilderConfig } = await import('../scripts/electron-builder-config.mjs') @@ -22,6 +26,7 @@ describe('installer preparation preserves application dependencies', () => { }) it.each(['win32', 'darwin'] as const)('preserves installer metadata and dependency ownership on %s', async (platform) => { execute.mockClear() + write.mockClear() const env = { DSH_DESKTOP_APP_ID: 'com.example.installer', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', @@ -49,6 +54,7 @@ describe('installer preparation preserves application dependencies', () => { expect(appInfo.copyright).toBe(`Copyright © ${new Date().getFullYear()} DeepSeek Harness`) if (platform === 'win32') { expect(expandMacro(config.nsis.uninstallDisplayName, null, appInfo, {}, false)).toBe('DeepSeek Harness') + expect(config.nsis.installerLanguages).toEqual(['en_US', 'ja_JP', 'zh_CN']) } const aboutIcon = config.extraResources.find(resource => resource.to === 'icon.png') expect(aboutIcon).toBeDefined() @@ -71,6 +77,11 @@ describe('installer preparation preserves application dependencies', () => { await packager.installAppDependencies(process.platform === 'win32' ? Platform.LINUX : Platform.WINDOWS, Arch.x64) expect(packager.areNodeModulesHandledExternally).toBe(false) expect(execute).toHaveBeenCalledTimes(platform === 'win32' ? 1 : 0) + expect(write).toHaveBeenCalledTimes(platform === 'win32' ? 1 : 0) + if (platform === 'win32') { + expect(write).toHaveBeenCalledWith(expect.stringMatching(/installer-ui[\\/]strings\.nsh$/u), + expect.stringContaining('LangString INSTALLER_INSTALL ${LANG_JAPANESE}'), 'utf8') + } } finally { vi.unstubAllEnvs() vi.restoreAllMocks() diff --git a/apps/desktop/tests/windows-installer-smoke.ps1 b/apps/desktop/tests/windows-installer-smoke.ps1 index 796f885bb4..ea917263b9 100644 --- a/apps/desktop/tests/windows-installer-smoke.ps1 +++ b/apps/desktop/tests/windows-installer-smoke.ps1 @@ -2,7 +2,7 @@ [CmdletBinding()] param([Parameter(Mandatory)][string]$Installer, [Parameter(Mandatory)][string]$ProductName, [Parameter(Mandatory)][string]$RegistryKey, - [Parameter(Mandatory)][string]$OutputDirectory) + [Parameter(Mandatory)][string]$OutputDirectory, [Parameter(Mandatory)][string]$Language) $ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'windows-installer-ui.ps1') Add-Type -AssemblyName UIAutomationClient, UIAutomationTypes @@ -14,12 +14,8 @@ $uninstaller = Join-Path $installPath ('Uninstall ' + $ProductName + '.exe') $processes = [Collections.Generic.List[Diagnostics.Process]]::new() $results = [Collections.Generic.List[string]]::new() $expected = Get-Content (Join-Path $PSScriptRoot 'expected/windows-installer.json') -Raw | ConvertFrom-Json -$localizedCopy = @{ ENGLISH = @{}; SIMPCHINESE = @{} } -Get-Content (Join-Path $PSScriptRoot '../installer/strings.nsh') -Encoding UTF8 | ForEach-Object { - if ($_ -match '^LangString (INSTALLER_\w+) \$\{LANG_(ENGLISH|SIMPCHINESE)\} "(.*)"$') { - $localizedCopy[$Matches[2]][$Matches[1]] = $Matches[3] - } -} +$resource = Get-Content (Join-Path $PSScriptRoot "../installer/locales/$Language.json") -Encoding UTF8 -Raw | ConvertFrom-Json +$localizedCopy = @{ $Language = $resource.strings } function Wait-Control([Diagnostics.Process]$Process, [string]$Text, [switch]$Dialog) { $timer = [Diagnostics.Stopwatch]::StartNew() do { diff --git a/apps/desktop/tests/windows-uninstall-smoke.ps1 b/apps/desktop/tests/windows-uninstall-smoke.ps1 index 556dace4da..dad594f7c3 100644 --- a/apps/desktop/tests/windows-uninstall-smoke.ps1 +++ b/apps/desktop/tests/windows-uninstall-smoke.ps1 @@ -4,6 +4,7 @@ param([Parameter(Mandatory)][string]$Installer, [Parameter(Mandatory)][string]$P [Parameter(Mandatory)][string]$RegistryKey, [Parameter(Mandatory)][string]$OutputDirectory, [Parameter(Mandatory)][string]$Language, [Parameter(Mandatory)][string]$PackageName) $ErrorActionPreference = 'Stop' +$copy = (Get-Content (Join-Path $PSScriptRoot "../installer/locales/$Language.json") -Encoding UTF8 -Raw | ConvertFrom-Json).uninstaller $OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory) . (Join-Path $PSScriptRoot 'windows-installer-ui.ps1') [InstallerCapture]::Initialize() @@ -69,12 +70,12 @@ try { if ($mode -eq 'home-inside') { $env:DSH_HOME = Join-Path $userDataPath 'home' } else { Remove-Item Env:DSH_HOME -ErrorAction SilentlyContinue } $process = Start-Process -FilePath $uninstaller -ArgumentList $arguments -PassThru -WindowStyle ($(if ($mode -eq 'interactive') { 'Normal' } else { 'Hidden' })) if ($mode -eq 'interactive') { - $welcomeText = if ($Language -eq 'ENGLISH') { 'Welcome' } else { -join [char[]]@(0x6B22, 0x8FCE) } + $welcomeText = $copy.welcome $welcome = Wait-Text $welcomeText $window = [InstallerCapture]::TopLevel($welcome) [void][InstallerCapture]::SaveNative($window, (Join-Path $OutputDirectory 'uninstall-welcome.png'), $false) [InstallerCapture]::Click([InstallerCapture]::GetDlgItem($window, 1)) - $finishedText = if ($Language -eq 'ENGLISH') { 'has been uninstalled' } else { -join [char[]]@(0x5DF2, 0x4ECE, 0x4F60) } + $finishedText = $copy.finished [void](Wait-Text $finishedText) [InstallerCapture]::Click([InstallerCapture]::GetDlgItem($window, 1)) } diff --git a/apps/web/tests/desktop-locale.e2e.ts b/apps/web/tests/desktop-locale.e2e.ts index 71590956e9..cc43d697f1 100644 --- a/apps/web/tests/desktop-locale.e2e.ts +++ b/apps/web/tests/desktop-locale.e2e.ts @@ -103,14 +103,17 @@ describe.skipIf(MODE === 'record')('web e2e: native and Client locale preference await jaDialog.getByRole('button', { name: '日本語', exact: true }).waitFor() await expect.poll(() => reported).toEqual(['ja']) expect(await page.evaluate(() => document.documentElement.lang)).toBe('ja') + await page.setViewportSize({ width: 390, height: 844 }) const overflows = (): Promise => jaDialog.evaluate((dialog) => { - const containers = [dialog, ...dialog.querySelectorAll('*')] + // The narrow navigation strip scrolls horizontally by design; inspect the panel and section content. + const options = dialog.lastElementChild! + const containers = [dialog, options, ...options.querySelectorAll('*')] .filter(element => element === dialog || ['auto', 'scroll'].includes(getComputedStyle(element).overflowX)) return containers.filter(element => element.clientWidth > 0 && element.scrollWidth > element.clientWidth + 1).length }) await expect.poll(overflows).toBe(0) await jaDialog.evaluate((dialog) => { - const options = dialog.querySelector('nav')!.nextElementSibling!.lastElementChild! + const options = dialog.lastElementChild! const probe = document.createElement('div') probe.dataset.localeOverflowProbe = '' probe.style.width = '2000px' diff --git a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md index 52a6f4effb..a141beff7b 100644 --- a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md +++ b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md @@ -76,10 +76,11 @@ - text: 共 2 个 · 2 运行中 - list: - listitem: - - text: "@deepseek-ai/dsh-experimental-translator Machine translation with reusable Session results" + - text: 翻译服务 提供机器翻译,并复用会话中已保存的翻译结果。 - code: translator + - code: "@deepseek-ai/dsh-experimental-translator" - text: 运行中 - - switch "启用组件 @deepseek-ai/dsh-experimental-translator" [checked] + - switch "启用组件 翻译服务" [checked] - listitem: - text: 思考过程机器翻译 将展开的思考内容机器翻译为其他语言。 - code: cot-translation diff --git a/apps/web/tests/expected/cot-translation/preferences.expected.md b/apps/web/tests/expected/cot-translation/preferences.expected.md index 9e63c22442..6404a2d8bc 100644 --- a/apps/web/tests/expected/cot-translation/preferences.expected.md +++ b/apps/web/tests/expected/cot-translation/preferences.expected.md @@ -73,10 +73,11 @@ - text: 共 2 个 · 2 运行中 - list: - listitem: - - text: "@deepseek-ai/dsh-experimental-translator Machine translation with reusable Session results" + - text: 翻译服务 提供机器翻译,并复用会话中已保存的翻译结果。 - code: translator + - code: "@deepseek-ai/dsh-experimental-translator" - text: 运行中 - - switch "启用组件 @deepseek-ai/dsh-experimental-translator" [checked] + - switch "启用组件 翻译服务" [checked] - listitem: - text: 思考过程机器翻译 将展开的思考内容机器翻译为其他语言。 - code: cot-translation diff --git a/apps/web/tests/expected/japanese-plugin-metadata/card.expected.md b/apps/web/tests/expected/japanese-plugin-metadata/card.expected.md new file mode 100644 index 0000000000..0fa58e0c23 --- /dev/null +++ b/apps/web/tests/expected/japanese-plugin-metadata/card.expected.md @@ -0,0 +1,5 @@ +- listitem: + - button "自動認可レビューを表示": 自動認可レビュー + - text: 実験的 各ツール呼び出しの前にモデルが認可を判断する、自動認可レビュー権限モードを提供します。 + - status + - switch "自動認可レビューを有効化" diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index 82c51f246e..f80e94775d 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -19,6 +19,7 @@ - text: . - math: π 4 < θ < π 2 - math: θ ∈ ( π 4 , π 2 ) . (1) +- math: α + β γ - table: - rowgroup: - row "Symbol Value": diff --git a/apps/web/tests/japanese-plugin-metadata.e2e.ts b/apps/web/tests/japanese-plugin-metadata.e2e.ts new file mode 100644 index 0000000000..fb56add4f1 --- /dev/null +++ b/apps/web/tests/japanese-plugin-metadata.e2e.ts @@ -0,0 +1,32 @@ +/** Japanese package resources reach the real plugin manager through Host metadata and Client locale resolution. */ +import { fileURLToPath } from 'node:url' +import { chromium, type Browser } from 'playwright' +import { expect, it, onTestFailed, onTestFinished } from 'vitest' +import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' +import { saveFailureShot } from './support.ts' + +it('displays Japanese plugin titles and descriptions from package metadata', async () => { + const resources: { scaffold?: WebScaffold; browser?: Browser } = {} + onTestFinished(async () => { + try { await resources.browser?.close() } finally { await resources.scaffold?.close() } + }) + const scaffold = await launchWebScaffold() + resources.scaffold = scaffold + const browser = await chromium.launch() + resources.browser = browser + const page = await browser.newPage({ locale: 'ja-JP', viewport: { width: 1440, height: 960 } }) + const tripwire = watchConsole(page) + onTestFailed(() => saveFailureShot(page, 'japanese-plugin-metadata')) + await page.goto(scaffold.authenticatedUrl) + await page.getByRole('button', { name: 'プラグイン', exact: true }).click() + const panel = page.locator('[data-plugin-panel]') + const title = panel.getByRole('button', { name: '自動認可レビューを表示', exact: true }) + await title.waitFor() + const card = title.locator('xpath=ancestor::li') + await card.getByText('各ツール呼び出しの前にモデルが認可を判断する、自動認可レビュー権限モードを提供します。', { exact: true }).waitFor() + const expected = fileURLToPath(new URL('./expected/japanese-plugin-metadata/card.expected.md', import.meta.url)) + await compareOrRefreshGolden(expected, + await captureStableAria(page, '[data-plugin-panel] li:has(button[aria-label="自動認可レビューを表示"])', scaffold.workspaceCwd), webSnapshotMode()) + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) +}) diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts index d7fa2b8873..8612498522 100644 --- a/apps/web/tests/math-rendering.e2e.ts +++ b/apps/web/tests/math-rendering.e2e.ts @@ -56,6 +56,9 @@ function mathFixture(): string { '', '$$\\theta \\in \\left(\\frac{\\pi}{4}, \\frac{\\pi}{2}\\right). \\tag{1}$$', '', + '$$\\alpha+\\beta', + '\\gamma$$', + '', '| Symbol | Value |', '| --- | --- |', '| $\\theta$ | \\(\\frac{1}{5}\\) |', @@ -119,8 +122,8 @@ describe('web e2e: settled Markdown math rendering', () => { await sessionRow.click() await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) - await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(6) - await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2) + await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(7) + await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(3) expect(await page.locator('.katex-error').count()).toBe(0) const fonts = await page.evaluate(async () => { const faces = [...document.fonts].filter(face => face.family.includes('KaTeX_')) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 5937dffb0e..1647c9c331 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -23,6 +23,7 @@ // this client-registered project must not also hold them (one program // cannot see both sides of the cordis Context merges). "exclude": [ + "tests/japanese-plugin-metadata.e2e.ts", "tests/rich-text-quotes.e2e.ts", "tests/hover-input.e2e.ts", "tests/collapse-timing.e2e.ts", diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index de5eb1d67b..2f95205489 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -21,8 +21,8 @@ en: 0894bd9f35430753 zh: 2d28090e71134edd /cookbook-adding-a-workspace-package/5-add-optional-plugin-display-metadata: - en: cb799f5d561dd917 - zh: 7739af36ef8ffb3f + en: 1238eaea376391d1 + zh: c7e606f95d8980bf /cookbook-adding-a-workspace-package/6-verify: en: 80c7fb90b1175aae zh: 2ba37962dbc57299 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index d5dc6454a8..2950932479 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -136,7 +136,7 @@ Merge these entries into `package.json`, retaining existing runtime exports and } ``` -Keep language files together in `locale/`, with `en.json` as the discovery entry. Fields are optional; present values must be non-empty strings. English `meta.title` and `meta.description` use sentence case: capitalize only the first word, proper nouns, and product names, as in `Git worktrees` ([rule](../../.agents/skills/dsh-prose-standard/SKILL.md#required-coverage-by-prose-location)). Missing files or fields permit fallback, while malformed JSON or invalid fields produce a per-plugin diagnostic. +Keep language files together in `locale/`, with `en.json` as the discovery entry. Fields are optional; present values must be non-empty strings. English `meta.title` and `meta.description` use sentence case: capitalize only the first word, proper nouns, and product names, as in `Git worktrees` ([rule](../../.agents/skills/dsh-prose-standard/SKILL.md#required-coverage-by-prose-location)). At runtime, missing files or fields permit fallback, while malformed JSON or invalid fields produce a per-plugin diagnostic. First-party packages and plugin templates must provide `ja.json` covering every field present in `en.json` or optional `zh.json`, preserving placeholders; detail paths may point to the existing English document. Subpath resources under `locale//` follow the same checks. English descriptions explain user-visible behavior and end with sentence punctuation. Run `pnpm run check:i18n` to verify JSON metadata and UI dictionary completeness; the JSON checks also run in `check:all`, CI static checks, and hygiene. Fields fall back independently before view-specific name formatting, using the existing locale language chain first: diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index 4ef55c443a..6ab95ce3b3 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -136,7 +136,7 @@ Append-only, prefix-stable, replacing, or independent behavior, including the ex } ``` -语言文件统一放在 `locale/`,并保留 `en.json` 作为发现入口。字段可选,填写时必须是非空字符串。英文 `meta.title` 和 `meta.description` 使用 sentence case:只大写首个单词、专有名词和产品名,例如 `Git worktrees`([rule](../../.agents/skills/dsh-prose-standard/SKILL.md#required-coverage-by-prose-location))。文件或字段缺失时允许回退,JSON 损坏或字段无效时显示单插件诊断。 +语言文件统一放在 `locale/`,并保留 `en.json` 作为发现入口。字段可选,填写时必须是非空字符串。英文 `meta.title` 和 `meta.description` 使用 sentence case:只大写首个单词、专有名词和产品名,例如 `Git worktrees`([rule](../../.agents/skills/dsh-prose-standard/SKILL.md#required-coverage-by-prose-location))。运行时文件或字段缺失时允许回退,JSON 损坏或字段无效时显示单插件诊断。仓库内的包和插件模板必须提供 `ja.json`,覆盖 `en.json` 或可选 `zh.json` 中的全部字段并保留占位符;详情路径可指向现有英文文档。`locale//` 下的子路径资源使用相同检查。英文描述面向产品用户说明可见行为,并以句末标点结束。运行 `pnpm run check:i18n` 检查 JSON 元数据和 UI 字典完整性;JSON 检查也接入 `check:all`、CI 静态检查和 hygiene。 各字段先独立回退,再按页面规则格式化技术名称;回退优先经过现有 locale 语言链: diff --git a/package.json b/package.json index cee852c67b..5bc1973359 100644 --- a/package.json +++ b/package.json @@ -146,6 +146,9 @@ "verify-package-dependencies": "tsx scripts/verify-package-dependencies.ts", "verify-npm-install-layout": "tsx scripts/verify-npm-install-layout.ts", "verify-client-packages": "tsx scripts/verify-client-packages.ts", + "check:i18n": "tsx scripts/run-gates.ts i18n", + "verify-installer-locales": "node apps/desktop/scripts/installer-locales.mjs", + "verify-locale-metadata": "tsx scripts/verify-locale-metadata.ts", "verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts", "verify-client-route-resolution": "tsx scripts/verify-client-route-resolution.ts", "verify-no-bare-dispatcher": "tsx scripts/verify-no-bare-dispatcher.ts", diff --git a/packages/acp/acp/locale/en.json b/packages/acp/acp/locale/en.json new file mode 100644 index 0000000000..25540ee89e --- /dev/null +++ b/packages/acp/acp/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ACP server", + "description": "Run agents through JSON-RPC over standard input and output for automation." + } +} diff --git a/packages/acp/acp/locale/ja.json b/packages/acp/acp/locale/ja.json new file mode 100644 index 0000000000..1c72f6b070 --- /dev/null +++ b/packages/acp/acp/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ACP サーバー", + "description": "JSON-RPC の標準入出力でエージェントを操作する、自動化専用の ACP サーバーです。" + } +} diff --git a/packages/acp/acp/locale/zh.json b/packages/acp/acp/locale/zh.json new file mode 100644 index 0000000000..0bbafae706 --- /dev/null +++ b/packages/acp/acp/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ACP 服务", + "description": "通过 JSON-RPC 标准输入输出驱动智能体,仅供自动化使用。" + } +} diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 4c5c1bf6d9..ca9ad8609d 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -19,9 +19,11 @@ "default": "./lib/index.js" }, "./src/*": "./src/*", + "./locale/*.json": "./locale/*.json", "./package.json": "./package.json" }, "files": [ + "locale/*.json", "lib/index.js", "lib/types/**/*.d.ts" ], diff --git a/packages/api/account-controller/locale/ja.json b/packages/api/account-controller/locale/ja.json new file mode 100644 index 0000000000..7ffcd54dda --- /dev/null +++ b/packages/api/account-controller/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "アカウント操作 API", + "description": "認証済みクライアントにアカウント操作機能を提供します。" + } +} diff --git a/packages/api/gateway/locale/ja.json b/packages/api/gateway/locale/ja.json new file mode 100644 index 0000000000..ae75c9ec8d --- /dev/null +++ b/packages/api/gateway/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "リモート呼び出しゲートウェイ", + "description": "クライアントからの API 呼び出しを受け取り、対応するサービスに転送します。" + } +} diff --git a/packages/api/job-controller/locale/ja.json b/packages/api/job-controller/locale/ja.json new file mode 100644 index 0000000000..58ebbdc52d --- /dev/null +++ b/packages/api/job-controller/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "バックグラウンドタスク監視 API", + "description": "バックグラウンドタスクの状態と出力をクライアントに継続して提供します。" + } +} diff --git a/packages/api/remote-access/locale/en.json b/packages/api/remote-access/locale/en.json new file mode 100644 index 0000000000..c1f4dd693d --- /dev/null +++ b/packages/api/remote-access/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Remote access control", + "description": "Control which methods non-owner connections can access and retain permission snapshots for each connection." + } +} diff --git a/packages/api/remote-access/locale/ja.json b/packages/api/remote-access/locale/ja.json new file mode 100644 index 0000000000..b9973a570d --- /dev/null +++ b/packages/api/remote-access/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "リモートアクセス制御", + "description": "所有者以外の接続にメソッド単位のリモートアクセス制御と接続ごとの権限スナップショットを提供します。" + } +} diff --git a/packages/api/remote-access/locale/zh.json b/packages/api/remote-access/locale/zh.json new file mode 100644 index 0000000000..6993164ed5 --- /dev/null +++ b/packages/api/remote-access/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "远程访问控制", + "description": "为非所有者连接提供方法级远程访问控制和各连接的权限快照。" + } +} diff --git a/packages/api/remote-access/package.json b/packages/api/remote-access/package.json index 92529d65f0..bcc911aee9 100644 --- a/packages/api/remote-access/package.json +++ b/packages/api/remote-access/package.json @@ -35,6 +35,7 @@ "default": "./lib/typert.remote-client.js" }, "./src/*": "./src/*", + "./locale/*.json": "./locale/*.json", "./package.json": "./package.json" }, "dsh": { @@ -54,6 +55,7 @@ "watch": "tsdown --watch" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.js", diff --git a/packages/api/remotes/locale/ja.json b/packages/api/remotes/locale/ja.json new file mode 100644 index 0000000000..e056d27da6 --- /dev/null +++ b/packages/api/remotes/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "クライアント API 一覧", + "description": "アプリがクライアントに公開するサーバー機能をまとめます。" + } +} diff --git a/packages/api/session-controller/locale/ja.json b/packages/api/session-controller/locale/ja.json new file mode 100644 index 0000000000..d544936282 --- /dev/null +++ b/packages/api/session-controller/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "セッション制御 API", + "description": "セッションの読み取り、実行制御、状態更新をクライアントに提供します。" + } +} diff --git a/packages/api/settings-controller/locale/ja.json b/packages/api/settings-controller/locale/ja.json new file mode 100644 index 0000000000..fdfd677b69 --- /dev/null +++ b/packages/api/settings-controller/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "設定読み書き API", + "description": "各種設定の読み取りと変更をクライアントに提供します。" + } +} diff --git a/packages/api/terminal-controller/locale/ja.json b/packages/api/terminal-controller/locale/ja.json new file mode 100644 index 0000000000..3d43b1153b --- /dev/null +++ b/packages/api/terminal-controller/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ターミナル制御 API", + "description": "セッションのターミナルの作成、入力、読み取り、復元をクライアントに提供します。" + } +} diff --git a/packages/api/workspace-controller/locale/ja.json b/packages/api/workspace-controller/locale/ja.json new file mode 100644 index 0000000000..58942ab8cb --- /dev/null +++ b/packages/api/workspace-controller/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ワークスペース制御 API", + "description": "ワークスペース操作と、再接続後に復元できる状態をクライアントに提供します。" + } +} diff --git a/packages/api/workspace-files/locale/ja.json b/packages/api/workspace-files/locale/ja.json new file mode 100644 index 0000000000..f0ce58e299 --- /dev/null +++ b/packages/api/workspace-files/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ワークスペースファイル API", + "description": "ワークスペース内のファイルの読み取り、ディレクトリ一覧、ファイル情報の更新を提供します。" + } +} diff --git a/packages/attachment/attachment-local/locale/ja.json b/packages/attachment/attachment-local/locale/ja.json new file mode 100644 index 0000000000..c0d8452c20 --- /dev/null +++ b/packages/attachment/attachment-local/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ローカル添付ファイル保存", + "description": "セッション内の画像などの添付ファイルを保存し、コンテンツ ID で読み取ります。" + } +} diff --git a/packages/boot/app-boot/src/official-bundles.generated.ts b/packages/boot/app-boot/src/official-bundles.generated.ts index 1fffe43c5e..cb993e2d24 100644 --- a/packages/boot/app-boot/src/official-bundles.generated.ts +++ b/packages/boot/app-boot/src/official-bundles.generated.ts @@ -7,16 +7,16 @@ export const OFFICIAL_ON_DEMAND_CATALOG: readonly OfficialBundleCatalogEntry[] = { packageName: '@deepseek-ai/dsh-subagent-claude-code', meta: { - title: { 'en': 'Claude Code subagent', 'zh': 'Claude Code \u5B50\u667A\u80FD\u4F53' }, - description: { 'en': 'Delegate one task to Claude Code using its native authentication and settings.', 'zh': '\u4F7F\u7528 Claude Code \u539F\u751F\u8BA4\u8BC1\u548C\u8BBE\u7F6E\u59D4\u6D3E\u5355\u4E2A\u4EFB\u52A1\u3002' }, + title: { 'en': 'Claude Code subagent', 'ja': 'Claude Code \u30B5\u30D6\u30A8\u30FC\u30B8\u30A7\u30F3\u30C8', 'zh': 'Claude Code \u5B50\u667A\u80FD\u4F53' }, + description: { 'en': 'Delegate one task to Claude Code using its native authentication and settings.', 'ja': 'Claude Code \u306E\u30CD\u30A4\u30C6\u30A3\u30D6\u306E\u8A8D\u8A3C\u3068\u8A2D\u5B9A\u3092\u4F7F\u7528\u3057\u3066\u3001\u5358\u72EC\u306E\u30BF\u30B9\u30AF\u3092\u59D4\u6D3E\u3057\u307E\u3059\u3002', 'zh': '\u4F7F\u7528 Claude Code \u539F\u751F\u8BA4\u8BC1\u548C\u8BBE\u7F6E\u59D4\u6D3E\u5355\u4E2A\u4EFB\u52A1\u3002' }, icon: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzYiIGhlaWdodD0iMzYiIHZpZXdCb3g9IjAgMCAzNiAzNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB4PSIxNC45ODkzIiB5PSIxNS4xODc3IiB3aWR0aD0iMTIuMjM2NSIgaGVpZ2h0PSIxMi4yMzY1IiByeD0iMiIgZmlsbD0idXJsKCNkc2hfcGx1Z2luX2FydF9SMGEpIiBmaWxsLW9wYWNpdHk9IjAuOCI+PC9yZWN0PjxyZWN0IHg9IjguODcxMDkiIHk9IjkuMDY5NyIgd2lkdGg9IjEyLjIzNjUiIGhlaWdodD0iMTIuMjM2NSIgcng9IjIiIGZpbGw9InVybCgjZHNoX3BsdWdpbl9hcnRfUjBiKSIgZmlsbC1vcGFjaXR5PSIwLjgiPjwvcmVjdD48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImRzaF9wbHVnaW5fYXJ0X1IwYSIgeDE9IjIxLjEwNzUiIHkxPSIxNS4xODc3IiB4Mj0iMjEuMTA3NSIgeTI9IjI3LjQyNDMiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBzdG9wLWNvbG9yPSIjNDVFN0E0Ij48L3N0b3A+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMDU5MDlEIj48L3N0b3A+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9ImRzaF9wbHVnaW5fYXJ0X1IwYiIgeDE9IjE0Ljk4OTQiIHkxPSI5LjA2OTciIHgyPSIxNC45ODk0IiB5Mj0iMjEuMzA2MyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiM2OUI5RkYiPjwvc3RvcD48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMzMjRERTIiPjwvc3RvcD48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48L3N2Zz4K', }, }, { packageName: '@deepseek-ai/dsh-subagent-codex', meta: { - title: { 'en': 'Codex subagent', 'zh': 'Codex \u5B50\u667A\u80FD\u4F53' }, - description: { 'en': 'Delegate one task to Codex using its native authentication and settings.', 'zh': '\u4F7F\u7528 Codex \u539F\u751F\u8BA4\u8BC1\u548C\u8BBE\u7F6E\u59D4\u6D3E\u5355\u4E2A\u4EFB\u52A1\u3002' }, + title: { 'en': 'Codex subagent', 'ja': 'Codex \u30B5\u30D6\u30A8\u30FC\u30B8\u30A7\u30F3\u30C8', 'zh': 'Codex \u5B50\u667A\u80FD\u4F53' }, + description: { 'en': 'Delegate one task to Codex using its native authentication and settings.', 'ja': 'Codex \u306E\u30CD\u30A4\u30C6\u30A3\u30D6\u306E\u8A8D\u8A3C\u3068\u8A2D\u5B9A\u3092\u4F7F\u7528\u3057\u3066\u3001\u5358\u72EC\u306E\u30BF\u30B9\u30AF\u3092\u59D4\u6D3E\u3057\u307E\u3059\u3002', 'zh': '\u4F7F\u7528 Codex \u539F\u751F\u8BA4\u8BC1\u548C\u8BBE\u7F6E\u59D4\u6D3E\u5355\u4E2A\u4EFB\u52A1\u3002' }, icon: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzYiIGhlaWdodD0iMzYiIHZpZXdCb3g9IjAgMCAzNiAzNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB4PSIxNC45ODkzIiB5PSIxNS4xODc3IiB3aWR0aD0iMTIuMjM2NSIgaGVpZ2h0PSIxMi4yMzY1IiByeD0iMiIgZmlsbD0idXJsKCNkc2hfcGx1Z2luX2FydF9SMGEpIiBmaWxsLW9wYWNpdHk9IjAuOCI+PC9yZWN0PjxyZWN0IHg9IjguODcxMDkiIHk9IjkuMDY5NyIgd2lkdGg9IjEyLjIzNjUiIGhlaWdodD0iMTIuMjM2NSIgcng9IjIiIGZpbGw9InVybCgjZHNoX3BsdWdpbl9hcnRfUjBiKSIgZmlsbC1vcGFjaXR5PSIwLjgiPjwvcmVjdD48ZGVmcz48bGluZWFyR3JhZGllbnQgaWQ9ImRzaF9wbHVnaW5fYXJ0X1IwYSIgeDE9IjIxLjEwNzUiIHkxPSIxNS4xODc3IiB4Mj0iMjEuMTA3NSIgeTI9IjI3LjQyNDMiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIj48c3RvcCBzdG9wLWNvbG9yPSIjNDVFN0E0Ij48L3N0b3A+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMDU5MDlEIj48L3N0b3A+PC9saW5lYXJHcmFkaWVudD48bGluZWFyR3JhZGllbnQgaWQ9ImRzaF9wbHVnaW5fYXJ0X1IwYiIgeDE9IjE0Ljk4OTQiIHkxPSI5LjA2OTciIHgyPSIxNC45ODk0IiB5Mj0iMjEuMzA2MyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiPjxzdG9wIHN0b3AtY29sb3I9IiM2OUI5RkYiPjwvc3RvcD48c3RvcCBvZmZzZXQ9IjEiIHN0b3AtY29sb3I9IiMzMjRERTIiPjwvc3RvcD48L2xpbmVhckdyYWRpZW50PjwvZGVmcz48L3N2Zz4K', }, }, diff --git a/packages/boot/config-editor/locale/ja.json b/packages/boot/config-editor/locale/ja.json new file mode 100644 index 0000000000..98ce00f39c --- /dev/null +++ b/packages/boot/config-editor/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "プラグイン設定編集", + "description": "プラグイン設定を保存し、変更を現在の実行設定に適用します。" + } +} diff --git a/packages/boot/hmr/locale/ja.json b/packages/boot/hmr/locale/ja.json new file mode 100644 index 0000000000..4b8d181949 --- /dev/null +++ b/packages/boot/hmr/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "プラグインホットリロード", + "description": "アプリの実行中にプラグインのコードと設定を再読み込みします。" + } +} diff --git a/packages/boot/plugin-manager/locale/ja.json b/packages/boot/plugin-manager/locale/ja.json new file mode 100644 index 0000000000..4d13a06aa4 --- /dev/null +++ b/packages/boot/plugin-manager/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "プラグイン管理サービス", + "description": "現在の実行設定にあるプラグインとパッケージを管理し、インストール、削除、有効化、無効化を行います。" + } +} diff --git a/packages/boot/plugin-manager/locale/tools/ja.json b/packages/boot/plugin-manager/locale/tools/ja.json new file mode 100644 index 0000000000..836f03de7b --- /dev/null +++ b/packages/boot/plugin-manager/locale/tools/ja.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "プラグイン管理ツール" + } +} diff --git a/packages/boot/plugin-market/locale/en.json b/packages/boot/plugin-market/locale/en.json new file mode 100644 index 0000000000..5baedc3f0f --- /dev/null +++ b/packages/boot/plugin-market/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Plugin market indexes", + "description": "Manage configured market indexes for plugin discovery and the plugin_market tool." + } +} diff --git a/packages/boot/plugin-market/locale/ja.json b/packages/boot/plugin-market/locale/ja.json new file mode 100644 index 0000000000..150534a797 --- /dev/null +++ b/packages/boot/plugin-market/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "プラグインマーケットのインデックス", + "description": "プラグイン検索ページと plugin_market ツールで使う、設定済みのマーケットのインデックスを管理します。" + } +} diff --git a/packages/boot/plugin-market/locale/zh.json b/packages/boot/plugin-market/locale/zh.json new file mode 100644 index 0000000000..5113c07601 --- /dev/null +++ b/packages/boot/plugin-market/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "插件市场索引", + "description": "为插件发现页面和 plugin_market 工具管理已配置的市场索引。" + } +} diff --git a/packages/boot/plugin-market/package.json b/packages/boot/plugin-market/package.json index 9b9fdd88cb..8dd7d7e0ec 100644 --- a/packages/boot/plugin-market/package.json +++ b/packages/boot/plugin-market/package.json @@ -35,9 +35,11 @@ "default": "./lib/typert.remote-client.js" }, "./src/*": "./src/*", + "./locale/*.json": "./locale/*.json", "./package.json": "./package.json" }, "files": [ + "locale/*.json", "lib/index.js", "lib/worker.cjs", "lib/types/**/*.js", diff --git a/packages/browser-use/browser-use/locale/en.json b/packages/browser-use/browser-use/locale/en.json new file mode 100644 index 0000000000..fb0661bfcc --- /dev/null +++ b/packages/browser-use/browser-use/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Browser provider registry", + "description": "Register browser automation providers by name, with one provider per name." + } +} diff --git a/packages/browser-use/browser-use/locale/ja.json b/packages/browser-use/browser-use/locale/ja.json new file mode 100644 index 0000000000..c73fe239c7 --- /dev/null +++ b/packages/browser-use/browser-use/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ブラウザ提供元レジストリ", + "description": "名前ごとに排他的なブラウザ操作の提供元を登録します。" + } +} diff --git a/packages/browser-use/browser-use/locale/zh.json b/packages/browser-use/browser-use/locale/zh.json new file mode 100644 index 0000000000..cf64929a96 --- /dev/null +++ b/packages/browser-use/browser-use/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "浏览器服务注册", + "description": "按名称注册独占的浏览器操作提供方。" + } +} diff --git a/packages/browser-use/browser-use/package.json b/packages/browser-use/browser-use/package.json index 9ca8203092..ec5aa9392c 100644 --- a/packages/browser-use/browser-use/package.json +++ b/packages/browser-use/browser-use/package.json @@ -19,6 +19,7 @@ "default": "./lib/index.js" }, "./src/*": "./src/*", + "./locale/*.json": "./locale/*.json", "./package.json": "./package.json", "./brand": { "types": "./lib/types/brand.d.ts", @@ -26,6 +27,7 @@ } }, "files": [ + "locale/*.json", "lib/index.js", "lib/types/**/*.js", "lib/types/**/*.d.ts" diff --git a/packages/bundle/acp-app/locale/en.json b/packages/bundle/acp-app/locale/en.json new file mode 100644 index 0000000000..8de14398c9 --- /dev/null +++ b/packages/bundle/acp-app/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ACP profile", + "description": "Provide automation-only ACP communication and process lifecycle management over dsh-base." + } +} diff --git a/packages/bundle/acp-app/locale/ja.json b/packages/bundle/acp-app/locale/ja.json new file mode 100644 index 0000000000..c7c4dddca4 --- /dev/null +++ b/packages/bundle/acp-app/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ACP プロファイル", + "description": "dsh-base 上で自動化専用の ACP 通信とプロセスのライフサイクルを提供します。" + } +} diff --git a/packages/bundle/acp-app/locale/zh.json b/packages/bundle/acp-app/locale/zh.json new file mode 100644 index 0000000000..b6e8c68267 --- /dev/null +++ b/packages/bundle/acp-app/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ACP 运行配置", + "description": "在 dsh-base 上提供自动化专用的 ACP 通信与进程生命周期。" + } +} diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index 752eeca7fa..86dc1bc2c5 100644 --- a/packages/bundle/acp-app/package.json +++ b/packages/bundle/acp-app/package.json @@ -20,9 +20,11 @@ }, "./cordis.patch.yml": "./cordis.patch.yml", "./src/*": "./src/*", + "./locale/*.json": "./locale/*.json", "./package.json": "./package.json" }, "files": [ + "locale/*.json", "lib/index.js", "cordis.patch.yml", "lib/types/**/*.d.ts" diff --git a/packages/bundle/headless/locale/en.json b/packages/bundle/headless/locale/en.json new file mode 100644 index 0000000000..8d393f6587 --- /dev/null +++ b/packages/bundle/headless/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Headless profile", + "description": "Run a single task directly with the core Agent/Session runner over dsh-base, without a Host, HTTP, or browser layer." + } +} diff --git a/packages/bundle/headless/locale/ja.json b/packages/bundle/headless/locale/ja.json new file mode 100644 index 0000000000..969cad80f8 --- /dev/null +++ b/packages/bundle/headless/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ヘッドレスプロファイル", + "description": "dsh-base 上でコアの Agent/Session ランナーを直接使用して単一タスクを実行し、Host、HTTP、ブラウザのレイヤーを含みません。" + } +} diff --git a/packages/bundle/headless/locale/zh.json b/packages/bundle/headless/locale/zh.json new file mode 100644 index 0000000000..b8ac8ba1f1 --- /dev/null +++ b/packages/bundle/headless/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "无界面运行配置", + "description": "在 dsh-base 上直接使用核心 Agent/Session 执行单次任务,不包含 Host、HTTP 或浏览器层。" + } +} diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 2247beae8e..cf07060097 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -24,9 +24,11 @@ }, "./cordis.patch.yml": "./cordis.patch.yml", "./src/*": "./src/*", + "./locale/*.json": "./locale/*.json", "./package.json": "./package.json" }, "files": [ + "locale/*.json", "lib/index.js", "lib/startup.js", "cordis.patch.yml", diff --git a/packages/bundle/sdk-app/locale/en.json b/packages/bundle/sdk-app/locale/en.json new file mode 100644 index 0000000000..8cdffe0ac7 --- /dev/null +++ b/packages/bundle/sdk-app/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "SDK profile", + "description": "Provide JSON-RPC over standard input and output and manage the process lifecycle over dsh-base." + } +} diff --git a/packages/bundle/sdk-app/locale/ja.json b/packages/bundle/sdk-app/locale/ja.json new file mode 100644 index 0000000000..5a031b72c4 --- /dev/null +++ b/packages/bundle/sdk-app/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "SDK プロファイル", + "description": "dsh-base 上で標準入出力の JSON-RPC とプロセスのライフサイクル管理を提供します。" + } +} diff --git a/packages/bundle/sdk-app/locale/zh.json b/packages/bundle/sdk-app/locale/zh.json new file mode 100644 index 0000000000..cf1e7a7abc --- /dev/null +++ b/packages/bundle/sdk-app/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "SDK 运行配置", + "description": "在 dsh-base 上提供标准输入输出 JSON-RPC 与进程生命周期管理。" + } +} diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index b1473dbe19..ee90ed6fb4 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -20,9 +20,11 @@ }, "./cordis.patch.yml": "./cordis.patch.yml", "./src/*": "./src/*", + "./locale/*.json": "./locale/*.json", "./package.json": "./package.json" }, "files": [ + "locale/*.json", "lib/index.js", "cordis.patch.yml", "lib/types/**/*.d.ts" diff --git a/packages/bundle/web-app/locale/ja.json b/packages/bundle/web-app/locale/ja.json new file mode 100644 index 0000000000..5832162037 --- /dev/null +++ b/packages/bundle/web-app/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Web アプリ実行サポート", + "description": "フロントエンドのページリソースを提供し、Web アプリに必要な実行設定を接続します。" + } +} diff --git a/packages/bundle/web-app/locale/startup/ja.json b/packages/bundle/web-app/locale/startup/ja.json new file mode 100644 index 0000000000..b920e8bcf5 --- /dev/null +++ b/packages/bundle/web-app/locale/startup/ja.json @@ -0,0 +1,5 @@ +{ + "meta": { + "title": "Web 起動設定" + } +} diff --git a/packages/client/connection/locale/ja.json b/packages/client/connection/locale/ja.json new file mode 100644 index 0000000000..c8a58e1dd1 --- /dev/null +++ b/packages/client/connection/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "クライアント接続", + "description": "認証済みクライアントとの通信を確立し、接続のライフサイクルを管理します。" + } +} diff --git a/packages/client/file-upload/locale/ja.json b/packages/client/file-upload/locale/ja.json new file mode 100644 index 0000000000..98cb69bae0 --- /dev/null +++ b/packages/client/file-upload/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ファイルアップロード", + "description": "ブラウザ内のファイルを現在のセッションにアップロードし、メッセージやツールで使用できるようにします。" + } +} diff --git a/packages/client/hmr/locale/ja.json b/packages/client/hmr/locale/ja.json new file mode 100644 index 0000000000..50aa6e71ef --- /dev/null +++ b/packages/client/hmr/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Web クライアントホット更新", + "description": "ブラウザ内のプラグインを同期し、コードの再ビルド後に関連する UI を更新します。" + } +} diff --git a/packages/client/locale/locale/ja.json b/packages/client/locale/locale/ja.json new file mode 100644 index 0000000000..682eb30601 --- /dev/null +++ b/packages/client/locale/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "表示言語", + "description": "表示言語の設定と各プラグインの翻訳文を管理します。" + } +} diff --git a/packages/client/modules/locale/ja.json b/packages/client/modules/locale/ja.json new file mode 100644 index 0000000000..d6935d5900 --- /dev/null +++ b/packages/client/modules/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "クライアントモジュール読み込み", + "description": "ブラウザのプラグインモジュールと読み込み依存関係を管理します。" + } +} diff --git a/packages/client/product-analytics/locale/ja.json b/packages/client/product-analytics/locale/ja.json new file mode 100644 index 0000000000..280fc6ced3 --- /dev/null +++ b/packages/client/product-analytics/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "デスクトップ利用統計", + "description": "デスクトップの指定された操作イベントを収集し、製品の統計サービスに送信します。" + } +} diff --git a/packages/client/resources/locale/ja.json b/packages/client/resources/locale/ja.json new file mode 100644 index 0000000000..05deefd46f --- /dev/null +++ b/packages/client/resources/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "クライアントリソースサービス", + "description": "ファイルなどのリソースアドレスを、UI が読み取れるコンテンツに解決します。" + } +} diff --git a/packages/client/shortcuts/locale/ja.json b/packages/client/shortcuts/locale/ja.json new file mode 100644 index 0000000000..bf9612d70c --- /dev/null +++ b/packages/client/shortcuts/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ショートカットサービス", + "description": "アプリのショートカットを登録し、キー操作を対応する機能に振り分けます。" + } +} diff --git a/packages/client/ui-agent-preset/locale/ja.json b/packages/client/ui-agent-preset/locale/ja.json new file mode 100644 index 0000000000..6a121a29bd --- /dev/null +++ b/packages/client/ui-agent-preset/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "エージェントプリセット UI", + "description": "現在および新規セッションのエージェントプリセットを選択し、使用するプラグインを編集します。" + } +} diff --git a/packages/client/ui-approval/locale/ja.json b/packages/client/ui-approval/locale/ja.json new file mode 100644 index 0000000000..5ef04937bc --- /dev/null +++ b/packages/client/ui-approval/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "操作承認 UI", + "description": "承認待ちの操作を表示し、ユーザーの選択を実行中のタスクに返します。" + } +} diff --git a/packages/client/ui-attachment/locale/ja.json b/packages/client/ui-attachment/locale/ja.json new file mode 100644 index 0000000000..424a9374ce --- /dev/null +++ b/packages/client/ui-attachment/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "添付ファイル表示", + "description": "メッセージ入力、会話、実行記録に添付ファイルと画像を表示します。" + } +} diff --git a/packages/client/ui-brand-official/locale/ja.json b/packages/client/ui-brand-official/locale/ja.json new file mode 100644 index 0000000000..284a138bce --- /dev/null +++ b/packages/client/ui-brand-official/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "DeepSeek ブランド表示", + "description": "アプリのサイドバーなどに公式ブランドの要素を表示します。" + } +} diff --git a/packages/client/ui-chat/locale/ja.json b/packages/client/ui-chat/locale/ja.json new file mode 100644 index 0000000000..fa28ca688f --- /dev/null +++ b/packages/client/ui-chat/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "チャット表示", + "description": "チャットメッセージ、ツールの内容、関連する詳細を描画します。" + } +} diff --git a/packages/client/ui-commands/locale/ja.json b/packages/client/ui-commands/locale/ja.json new file mode 100644 index 0000000000..8fed66b6fd --- /dev/null +++ b/packages/client/ui-commands/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "コマンド選択 UI", + "description": "入力欄にスラッシュコマンドのメニューと関連する操作 UI を提供します。" + } +} diff --git a/packages/client/ui-conversation/locale/ja.json b/packages/client/ui-conversation/locale/ja.json new file mode 100644 index 0000000000..fdd774d0f9 --- /dev/null +++ b/packages/client/ui-conversation/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "会話 UI フレームワーク", + "description": "会話内容、入力欄、メッセージキュー、ビューのナビゲーションを構成します。" + } +} diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index f908a94233..cde0c51e72 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -772,6 +772,7 @@ export const en = { /** Japanese copy, checked against the English keys. */ export const ja = { + 'quote.remove': '引用を削除', 'shortcut.newline': '改行', 'shortcut.complementary': '補助のキュー / 実行中の指示変更を使用', 'shortcut.slash': 'コマンドメニューを開く', @@ -1095,9 +1096,9 @@ export const ja = { 'tool.title.glob': 'Glob', 'tool.title.webSearch': '検索', 'tool.title.webFetch': '取得', - 'tool.autoReviewRejected': '自動レビューにより拒否されました', + 'tool.autoReviewRejected': '自動認可レビューにより拒否されました', 'tool.autoReviewNotExecuted': 'ツールは実行されませんでした。理由: {reason}', - 'tool.autoReviewReasonFallback': '自動レビューがこの操作を承認しませんでした', + 'tool.autoReviewReasonFallback': '自動認可レビューがこの操作を承認しませんでした', 'diff.collapseAria': '差分を折りたたむ', 'diff.expandAria': '差分の残り{count}行を表示', 'diff.expandRest': '… 残り{count}行', diff --git a/packages/client/ui-deepseek-model-label-1017/locale/en.json b/packages/client/ui-deepseek-model-label-1017/locale/en.json new file mode 100644 index 0000000000..b4906f27c6 --- /dev/null +++ b/packages/client/ui-deepseek-model-label-1017/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "DeepSeek 1017 model badge", + "description": "Add a temporary 1017 release badge to official DeepSeek Flash model names." + } +} diff --git a/packages/client/ui-deepseek-model-label-1017/locale/ja.json b/packages/client/ui-deepseek-model-label-1017/locale/ja.json new file mode 100644 index 0000000000..3a11249af3 --- /dev/null +++ b/packages/client/ui-deepseek-model-label-1017/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "DeepSeek 1017 モデルバッジ", + "description": "公式 DeepSeek Flash モデルの名前に、一時的な 1017 リリースバッジを表示します。" + } +} diff --git a/packages/client/ui-deepseek-model-label-1017/locale/zh.json b/packages/client/ui-deepseek-model-label-1017/locale/zh.json new file mode 100644 index 0000000000..a8464157af --- /dev/null +++ b/packages/client/ui-deepseek-model-label-1017/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "DeepSeek 1017 模型标记", + "description": "为官方 DeepSeek Flash 模型名称添加临时的 1017 版本标记。" + } +} diff --git a/packages/client/ui-deepseek-model-label-1017/package.json b/packages/client/ui-deepseek-model-label-1017/package.json index e029de87fd..4a44b82db6 100644 --- a/packages/client/ui-deepseek-model-label-1017/package.json +++ b/packages/client/ui-deepseek-model-label-1017/package.json @@ -23,8 +23,8 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", - "./package.json": "./package.json", - "./locale/*.json": "./locale/*.json" + "./locale/*.json": "./locale/*.json", + "./package.json": "./package.json" }, "dsh": { "client": { diff --git a/packages/client/ui-deliverables/locale/ja.json b/packages/client/ui-deliverables/locale/ja.json new file mode 100644 index 0000000000..15bb5cd7e6 --- /dev/null +++ b/packages/client/ui-deliverables/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ファイル変更と成果物の表示", + "description": "変更されたファイル、内容の比較、成果物ファイルへの入口を表示します。" + } +} diff --git a/packages/client/ui-directory-picker-browse/locale/ja.json b/packages/client/ui-directory-picker-browse/locale/ja.json new file mode 100644 index 0000000000..1e654b55ed --- /dev/null +++ b/packages/client/ui-directory-picker-browse/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "アプリ内ディレクトリ選択 UI", + "description": "アプリ内でワークスペースのディレクトリを参照、作成、選択します。" + } +} diff --git a/packages/client/ui-directory-picker-native/locale/ja.json b/packages/client/ui-directory-picker-native/locale/ja.json new file mode 100644 index 0000000000..e4ab3af037 --- /dev/null +++ b/packages/client/ui-directory-picker-native/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "システムディレクトリ選択 UI", + "description": "ワークスペースの選択操作をシステムのディレクトリ選択ダイアログに接続します。" + } +} diff --git a/packages/client/ui-entry-customization/locale/ja.json b/packages/client/ui-entry-customization/locale/ja.json new file mode 100644 index 0000000000..b1a0b30128 --- /dev/null +++ b/packages/client/ui-entry-customization/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "UI カスタマイズ", + "description": "左サイドバー、会話ビュー、右サイドバーのプラグイン項目をカスタマイズし、新しいプラグインをすばやく作成します。" + } +} diff --git a/packages/client/ui-goal/locale/ja.json b/packages/client/ui-goal/locale/ja.json new file mode 100644 index 0000000000..fd28df331a --- /dev/null +++ b/packages/client/ui-goal/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "セッション目標バー", + "description": "入力欄の上に現在のセッションの目標と状態を表示します。" + } +} diff --git a/packages/client/ui-input-trigger/locale/ja.json b/packages/client/ui-input-trigger/locale/ja.json new file mode 100644 index 0000000000..4a8f8af80f --- /dev/null +++ b/packages/client/ui-input-trigger/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "入力候補サービス", + "description": "入力中の / と @ を認識し、対応する候補を表示して選択できます。" + } +} diff --git a/packages/client/ui-jobs/locale/ja.json b/packages/client/ui-jobs/locale/ja.json new file mode 100644 index 0000000000..29c5699949 --- /dev/null +++ b/packages/client/ui-jobs/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "バックグラウンドタスク UI", + "description": "セッション内でバックグラウンドタスクの一覧、状態、リアルタイム出力を確認します。" + } +} diff --git a/packages/client/ui-layout/locale/ja.json b/packages/client/ui-layout/locale/ja.json new file mode 100644 index 0000000000..4b56f91718 --- /dev/null +++ b/packages/client/ui-layout/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "アプリレイアウト", + "description": "ナビゲーション、会話、サイドパネルを構成し、ドラッグによるレイアウト調整を提供します。" + } +} diff --git a/packages/client/ui-message-feedback/locale/ja.json b/packages/client/ui-message-feedback/locale/ja.json new file mode 100644 index 0000000000..22260213e1 --- /dev/null +++ b/packages/client/ui-message-feedback/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "メッセージフィードバック UI", + "description": "回答の評価ボタンとセッションのフィードバックダイアログを提供します。" + } +} diff --git a/packages/client/ui-model-selection/locale/ja.json b/packages/client/ui-model-selection/locale/ja.json new file mode 100644 index 0000000000..e0bc4f6b8a --- /dev/null +++ b/packages/client/ui-model-selection/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "モデル選択 UI", + "description": "利用可能なモデルを確認し、現在のセッションで使用するモデルを切り替えます。" + } +} diff --git a/packages/client/ui-open-in-app/locale/ja.json b/packages/client/ui-open-in-app/locale/ja.json new file mode 100644 index 0000000000..55c6f40aaa --- /dev/null +++ b/packages/client/ui-open-in-app/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "外部アプリで開く UI", + "description": "セッションと文書プレビューに「別のアプリで開く」操作を提供します。" + } +} diff --git a/packages/client/ui-permission-presets/locale/ja.json b/packages/client/ui-permission-presets/locale/ja.json new file mode 100644 index 0000000000..21031f2ac0 --- /dev/null +++ b/packages/client/ui-permission-presets/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "権限モード UI", + "description": "新規セッションのデフォルト権限を設定し、現在のセッションの権限モードを切り替えます。" + } +} diff --git a/packages/client/ui-permission-presets/src/client/locales.ts b/packages/client/ui-permission-presets/src/client/locales.ts index 0c3b2ed852..792c1d5605 100644 --- a/packages/client/ui-permission-presets/src/client/locales.ts +++ b/packages/client/ui-permission-presets/src/client/locales.ts @@ -111,11 +111,11 @@ export const accessJa = { 'confirm.acknowledge': 'リスクを理解し、続行します', 'confirm.cancel': 'キャンセル', 'confirm.enable': 'フルアクセスを有効にする', - 'auto.label': '自動レビュー', + 'auto.label': '自動認可レビュー', 'auto.badge': 'EXP', 'auto.description': 'すべてのネイティブツール呼び出しと PTC 内部呼び出しを実験的な同一モデルでレビューしたうえで、サンドボックスなしで実行します。', - 'auto.confirm.title': '自動レビュー(実験的)を有効にしますか?', - 'auto.confirm.description': '自動レビューはサンドボックスなしで実行されます。ネイティブツール呼び出しと PTC 内部呼び出しのたびに、現在のエージェントと同じモデルが許可可否をレビューし、拒否された呼び出しはあなたが承認または却下します。この機能は実験的であり、操作を誤って許可・拒否する可能性があり、追加のトークンを消費します。', + 'auto.confirm.title': '自動認可レビュー(実験的)を有効にしますか?', + 'auto.confirm.description': '自動認可レビューはサンドボックスなしで実行されます。ネイティブツール呼び出しと PTC 内部呼び出しのたびに、現在のエージェントと同じモデルが許可可否をレビューし、拒否された呼び出しはあなたが承認または却下します。この機能は実験的であり、操作を誤って許可・拒否する可能性があり、追加のトークンを消費します。', 'auto.confirm.acknowledge': 'これらのリスクを理解し、続行します', - 'auto.confirm.enable': '自動レビューを有効にする', + 'auto.confirm.enable': '自動認可レビューを有効にする', } satisfies Record diff --git a/packages/client/ui-plan/locale/ja.json b/packages/client/ui-plan/locale/ja.json new file mode 100644 index 0000000000..94dfa7c3a2 --- /dev/null +++ b/packages/client/ui-plan/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "計画レビュー UI", + "description": "計画モードの操作、計画カード、Markdown プレビューを表示します。" + } +} diff --git a/packages/client/ui-plugin-manager/locale/ja.json b/packages/client/ui-plugin-manager/locale/ja.json new file mode 100644 index 0000000000..dea565954e --- /dev/null +++ b/packages/client/ui-plugin-manager/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "プラグイン管理 UI", + "description": "プラグインパッケージの確認、インストール、有効化、無効化、再試行を行います。" + } +} diff --git a/packages/client/ui-plugin-market/locale/en.json b/packages/client/ui-plugin-market/locale/en.json new file mode 100644 index 0000000000..2c72ab4cec --- /dev/null +++ b/packages/client/ui-plugin-market/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Plugin discovery", + "description": "Browse configured plugin market indexes and install their packages." + } +} diff --git a/packages/client/ui-plugin-market/locale/ja.json b/packages/client/ui-plugin-market/locale/ja.json new file mode 100644 index 0000000000..7411851543 --- /dev/null +++ b/packages/client/ui-plugin-market/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "プラグイン検索 UI", + "description": "設定済みのプラグインマーケットのインデックスを参照し、プラグインパッケージをインストールします。" + } +} diff --git a/packages/client/ui-plugin-market/locale/zh.json b/packages/client/ui-plugin-market/locale/zh.json new file mode 100644 index 0000000000..4e9e89725f --- /dev/null +++ b/packages/client/ui-plugin-market/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "插件发现界面", + "description": "浏览已配置的插件市场索引,并安装其中的插件包。" + } +} diff --git a/packages/client/ui-plugin-market/package.json b/packages/client/ui-plugin-market/package.json index 383f71c19f..30883a8c70 100644 --- a/packages/client/ui-plugin-market/package.json +++ b/packages/client/ui-plugin-market/package.json @@ -23,6 +23,7 @@ "default": "./lib/client.js" }, "./src/*": "./src/*", + "./locale/*.json": "./locale/*.json", "./package.json": "./package.json" }, "dsh": { @@ -63,6 +64,7 @@ "react-dom": "^18.2.0" }, "files": [ + "locale/*.json", "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 080d24074b..9a13d1d0a6 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -54,8 +54,8 @@ en: ca75c51c89c2c9b0 zh: de54f3467ef3b9be /deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work: - en: 22fc14aa3a224552 - zh: aa6309e84ad8c2a3 + en: 039cf54697c97608 + zh: ac95443caa5be0ed /deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 48e3967d34..ecd3fd4e29 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -213,6 +213,8 @@ These limits define how the atoms behave at the edges; they are current package - **`Pill` and `Input` have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. - **No `Active` `StateDot` variant** — the supported states are done, warning, ongoing, error, and idle. - **User-facing copy is required at the render site** — the atoms are zero-Cordis and cannot reach `ctx.locale`; each feature must supply complete localized labels through the primitive's typed props ([decision](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). +- **Display math keeps fallback shapes** — a `$$x$$ trailing` on the opening line resolves through upstream inline math and leaves the surrounding prose in place, while the same trailing text on a later line declines the block and upstream takes the prose up to the next lone `$$` or EOF; a block left open ends at the next lone `$$` or at EOF; a later fence behind a `%` comment that holds text closes nothing, so a block nothing else closes reaches the same fallback; a multi-line block whose opening line carries a solitary `$` stays in its paragraph unless a blank line separates them; and a container continuation line that leaves the item indentation hands the block to upstream, which drops the opening line. `$$` fence meta is no longer honored ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md)). +- **A fence after a `\verb` delimiter is content** — the tokenizer reads the six characters before a fence, so the `$` right after `\verb` or `\verb*` is that command's delimiter rather than a closing fence, and a region whose text ends in `\verb` (`\verb**\verb*$$`) reads the same way. A fence inside an unterminated region (`\verb|$$`) still closes the block there, because the region's content is not parsed. - **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR colors, carriage return, backspace, erase-in-line, tab stops, and character width are honored; absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 92b794e0ce..3de121ab2c 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -213,6 +213,8 @@ kind: "package-library" - **`Pill` 与 `Input` 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 - **`StateDot` 没有 `Active` 变体**:支持的状态为 done、warning、ongoing、error 和 idle。 - **面向用户的文案必须由渲染点提供**:这些原子组件是 zero-Cordis 的,拿不到 `ctx.locale`;各功能必须通过原子组件的带类型 prop 提供完整本地化 label(见[决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。 +- **显示数学保留若干回退形状**:开围栏行上的 `$$x$$ trailing` 走上游行内数学,前后正文保持不变;同样形态出现在后续行时块被放弃,随后由上游把正文吞到下一个独占行 `$$` 或文件结尾;未闭合的块在下一个独占行 `$$` 或文件结尾结束;后续行的围栏若位于**含文本**的 `%` 注释之后则不闭合,若无其他围栏闭合,块走同一条回退;开围栏行含孤立 `$` 的多行块会留在段落内,除非与前文空一行;容器内续行若离开条目缩进,块交由上游 flow 处理并丢弃开围栏行。`$$` 围栏 meta 不再生效(见[决策](../../../.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md))。 +- **紧跟 `\verb` 分隔符的围栏属于内容**:解析器只读取围栏之前的六个字符,因此紧跟 `\verb` 或 `\verb*` 的 `$` 是该命令的分隔符而不是闭合围栏,内容以 `\verb` 结尾的区域(`\verb**\verb*$$`)同理。未闭合区域内部的围栏(`\verb|$$`)仍会在该处闭合块,因为区域内容不被解析。 - **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色、回车、退格、行内擦除、制表位与字符宽度会被遵循;绝对光标定位、清屏与备用屏幕序列会被剥离。 diff --git a/packages/client/ui-primitives/src/markdown/mathCompatibility.ts b/packages/client/ui-primitives/src/markdown/mathCompatibility.ts index 3edd9d1e63..15932c5e76 100644 --- a/packages/client/ui-primitives/src/markdown/mathCompatibility.ts +++ b/packages/client/ui-primitives/src/markdown/mathCompatibility.ts @@ -1,13 +1,26 @@ -/** Extend upstream dollar-only math syntax with TeX delimiters while reusing its token vocabulary. */ +/** + * Extend upstream dollar-only math syntax with TeX delimiters while reusing its + * token vocabulary. The dollar construct is dispatched before upstream's + * `mathFlow`, so the shapes it accepts keep the opening line's text as math + * instead of fence meta, which the renderer does not read. A `$$` candidate it + * declines — a `$$` sequence the line does not end after, or a block left open + * — is left to that upstream flow. + */ import { factorySpace } from 'micromark-factory-space' import type {} from 'micromark-extension-math' -import { markdownLineEnding } from 'micromark-util-character' +import { markdownLineEnding, markdownSpace } from 'micromark-util-character' import { codes, constants, types } from 'micromark-util-symbol' import type { Construct, Extension, Previous, State, Tokenizer } from 'micromark-util-types' // oxlint-disable typescript/no-this-alias -- micromark binds tokenizer context only on the outer callback. +/** Prefixes whose following character is a `\verb` delimiter, not a fence. */ +const VERB_DELIMITER_PREFIXES = ['\\verb', '\\verb*'] as const + +/** Longest prefix above, in code units. */ +const VERB_DELIMITER_TAIL = 6 + const previousBackslash: Previous = function (code) { if (code !== codes.backslash) return true const tail = this.events.at(-1) @@ -120,10 +133,16 @@ const tokenizeBackslashMathText: Tokenizer = function (effects, ok, nok) { } } -function createMathFlow(marker: number, openMarker: number, closeMarker: number, multiline: boolean): Construct { +function createMathFlow(marker: number, openMarker: number, closeMarker: number): Construct { const tokenize: Tokenizer = function (effects, ok, nok) { const self = this let oddBackslashRun = false + let inComment = false + let commentHasContent = false + let crossedLineEnding = false + let sawClosingFenceSequence = false + let consumedTail = '' + let failedClose = false const tail = self.events.at(-1) const initialSize = tail?.[1].type === types.linePrefix ? tail[2].sliceSerialize(tail[1], true).length @@ -155,7 +174,13 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number, function content(code: number | null): State | undefined { if (code === codes.eof) return nok(code) - if (code === marker && (marker !== codes.dollarSign || !oddBackslashRun)) { + if (code === marker && marker === codes.dollarSign && !inComment && verbDelimiterAhead()) { + // The `$` here delimits a `\verb…` region rather than closing the block, + // so it is content, and a `$` right after it closes an empty region and + // is content too. A comment holds no commands. + return verbatimDelimiter(code) + } + if (code === marker && closingFenceAllowed()) { return effects.attempt( { partial: true, tokenize: tokenizeClosingFence }, closed, @@ -163,17 +188,83 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number, )(code) } if (markdownLineEnding(code)) { - return multiline - ? effects.attempt(nonLazyContinuation, afterContinuation, nok)(code) - : nok(code) + // A close that failed earlier on this candidate ends it at the line + // ending, which is what the grammar this flow replaced did: a failed + // `$$x$$ trailing` never reaches the next line. + if (marker === codes.dollarSign && failedClose) return nok(code) + // A trailing backslash escapes the line ending, not the next line's + // first character, so the escape run ends here, and a TeX comment ends + // with the line. The comment exception covers only a closing fence on a + // later line, which is where the multi-line block can hide one; the + // same-line form keeps closing as it did before this flow spanned lines. + oddBackslashRun = false + inComment = false + commentHasContent = false + consumedTail = '' + if (marker === codes.dollarSign) crossedLineEnding = true + // A paragraph-interruption check on the dollar flow must not consume the + // line ending: that keeps the paragraph open across the line boundary, + // and the document layer then counts the next line lazy for every + // construct tried afterwards. Declining leaves the interruption decision + // to the upstream flow, whose own check short-circuits without consuming + // and claims only the fences it accepts. The backslash construct has no + // upstream counterpart and keeps consuming here. + if (self.interrupt && marker === codes.dollarSign) return nok(code) + return effects.attempt(nonLazyContinuation, afterContinuation, nok)(code) } return valueStart(code) } + /** + * Whether the marker under the scanner may close the block. The dollar flow + * keeps an escaped `$` and a `%` comment that holds text from closing. + */ + function closingFenceAllowed(): boolean { + if (marker !== codes.dollarSign) return true + return !oddBackslashRun && !(inComment && commentHasContent && crossedLineEnding) + } + + /** Whether the marker under the scanner is a `\verb` region's delimiter. */ + function verbDelimiterAhead(): boolean { + return VERB_DELIMITER_PREFIXES.some(prefix => consumedTail.endsWith(prefix)) + } + + function verbatimDelimiter(code: number | null): State | undefined { + effects.enter('mathFlowValue') + oddBackslashRun = false + rememberConsumed(code) + effects.consume(code) + return verbatimDelimiterClose + } + + function verbatimDelimiterClose(code: number | null): State | undefined { + if (code !== marker) return value(code) + rememberConsumed(code) + effects.consume(code) + return value + } + + /** Record one consumed code unit so a fence can be told from a delimiter. */ + function rememberConsumed(code: number | null): void { + /* v8 ignore next 2 -- every call site passes a code unit, never EOF. */ + if (code === null) return + consumedTail = `${consumedTail}${String.fromCharCode(code)}`.slice(-VERB_DELIMITER_TAIL) + } + function afterClosingFenceAttempt(code: number | null): State | undefined { - return marker === codes.backslash - ? effects.check({ partial: true, tokenize: tokenizeOpeningFence }, nok, markerValueStart)(code) - : markerValueStart(code) + if (marker === codes.backslash) { + return effects.check({ partial: true, tokenize: tokenizeOpeningFence }, nok, markerValueStart)(code) + } + // A full `$$` that the line does not end after closes nothing there. On + // the opening line the scan continues, so `$$x$$ trailing` declines at + // that line's ending while a later `$$` on the same line still closes; on + // any later line it ends the candidate outright, so a following block is + // not swallowed. A solitary `$` is content, not a failed fence. + if (sawClosingFenceSequence) { + if (crossedLineEnding) return nok(code) + failedClose = true + } + return markerValueStart(code) } function afterContinuation(code: number | null): State | undefined { @@ -188,7 +279,17 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number, function valueStart(code: number | null): State | undefined { effects.enter('mathFlowValue') + // An unescaped `%` opens a TeX comment wherever it is the first character + // of a value run, such as a line that starts inside the block. The + // backslash construct reads neither this state nor the escape run. oddBackslashRun = code === codes.backslash + if (code === codes.percentSign && marker === codes.dollarSign && !inComment) { + inComment = true + commentHasContent = false + } else if (inComment && !markdownSpace(code)) { + commentHasContent = true + } + rememberConsumed(code) effects.consume(code) return value } @@ -196,12 +297,15 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number, function markerValueStart(code: number | null): State | undefined { effects.enter('mathFlowValue') oddBackslashRun = false + if (inComment) commentHasContent = true + rememberConsumed(code) effects.consume(code) return valueAfterMarker } function valueAfterMarker(code: number | null): State | undefined { if (code === marker) { + rememberConsumed(code) effects.consume(code) return value } @@ -213,7 +317,25 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number, effects.exit('mathFlowValue') return content(code) } - oddBackslashRun = code === codes.backslash ? !oddBackslashRun : false + const escaped = oddBackslashRun + const doubleBackslash = code === codes.backslash && escaped + oddBackslashRun = code === codes.backslash ? !escaped : false + // An unescaped `%` starts a TeX comment that runs to the line ending, so + // a `$$` inside it closes nothing. A `%` the fence follows immediately + // holds no comment text, which is how writers use it mid-formula + // (`95%$$`, and `95 %$$` alike); only a comment holding a non-whitespace + // character suppresses the close, and a `%` inside an open comment is + // comment text like any other character. + if (!escaped && code === codes.percentSign && marker === codes.dollarSign && !inComment) { + inComment = true + commentHasContent = false + } else if (inComment && !markdownSpace(code)) { + commentHasContent = true + } + // `\\verb` is a line break followed by letters, not the command, so the + // pair's second backslash never reaches the tail the command match reads. + if (doubleBackslash) consumedTail = '' + else rememberConsumed(code) effects.consume(code) return value } @@ -228,6 +350,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number, closeOk: State, closeNok: State, ): State { + sawClosingFenceSequence = false return factorySpace(closeEffects, sequenceStart, types.linePrefix, constants.tabSize) function sequenceStart(code: number | null): State | undefined { @@ -242,6 +365,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number, if (code !== closeMarker) return closeNok(code) closeEffects.consume(code) closeEffects.exit('mathFlowFenceSequence') + sawClosingFenceSequence = true return factorySpace(closeEffects, after, types.whitespace) } @@ -278,7 +402,7 @@ function createMathFlow(marker: number, openMarker: number, closeMarker: number, return { concrete: true, - name: marker === codes.dollarSign ? 'sameLineDollarMathFlow' : 'backslashMathFlow', + name: marker === codes.dollarSign ? 'dollarMathFlow' : 'backslashMathFlow', tokenize, } } @@ -319,29 +443,29 @@ const backslashMathFlow = createMathFlow( codes.backslash, codes.leftSquareBracket, codes.rightSquareBracket, - true, ) -const sameLineDollarMathFlow = createMathFlow( +const dollarMathFlow = createMathFlow( codes.dollarSign, codes.dollarSign, codes.dollarSign, - false, ) const backslashMath: Extension = { flow: { [codes.backslash]: backslashMathFlow, - [codes.dollarSign]: sameLineDollarMathFlow, + [codes.dollarSign]: dollarMathFlow, }, text: { [codes.backslash]: backslashMathText }, } /** - * TeX backslash delimiters and same-line display-dollar blocks as a micromark - * syntax extension reusing `micromark-extension-math`'s token vocabulary; the - * caller must also register `math()` on the same parse so the emitted tokens - * compile to standard math nodes. + * TeX backslash delimiters and display-dollar blocks as a micromark syntax + * extension reusing `micromark-extension-math`'s token vocabulary; the caller + * must also register `math()` on the same parse so the emitted tokens compile + * to standard math nodes. Both `$$$$` on one line and a `$$` block + * closed by a trailing `$$` parse as one math node, and a closing `$$` may sit + * at the end of a content line. * @returns The micromark syntax extension. */ export function mathCompatibility(): Extension { diff --git a/packages/client/ui-primitives/src/markdown/parse.ts b/packages/client/ui-primitives/src/markdown/parse.ts index 8f56d3b7b4..6933ce389f 100644 --- a/packages/client/ui-primitives/src/markdown/parse.ts +++ b/packages/client/ui-primitives/src/markdown/parse.ts @@ -42,7 +42,10 @@ export function parseGfm(text: string): Root { */ export function parseGfmWithMath(text: string): Root { return recoverLocalImages(fromMarkdown(text, { - extensions: [gfm(gfmOptions), cjkFriendlyStrong(), mathCompatibility(), math()], + // `mathCompatibility()` MUST stay last: micromark dispatches the extension + // registered last first, so its dollar flow takes precedence over `math()` + // and keeps the opening line's text as math instead of upstream's fence meta. + extensions: [gfm(gfmOptions), cjkFriendlyStrong(), math(), mathCompatibility()], mdastExtensions: [gfmFromMarkdown(), mathFromMarkdown()], }), text) } diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-multiline-display.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-multiline-display.settled.txt new file mode 100644 index 0000000000..cb3815748b --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-multiline-display.settled.txt @@ -0,0 +1,89 @@ +
+

+ #text "Opening line carries math:" + + + + + + + + #text "a" + + #text "+" + + #text "b" + + #text "c" + + #text "+" + + #text "d" + + #text "a+b\nc+d" +