translation-brief.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. /**
  2. * Pure assembly of the minimal-update briefing for one out-of-sync
  3. * translation pair: the authored side's changes since the last confirmed
  4. * state at the narrowest safely mapped granularity (code-fence-only splice,
  5. * changed Markdown units, heading sections, whole document), the terminology
  6. * rows those changes touch, first-occurrence movement notes, and a digest of
  7. * the binding update rules. The unit mapping, mechanical code splice, and
  8. * first-occurrence tracking follow the incremental-pipeline planner mechanics.
  9. * The CLI wrapper is `scripts/gen-translation-brief.ts`; the workflow that
  10. * consumes the briefing is `.agents/skills/dsh-translate-docs/SKILL.md`.
  11. */
  12. import type { Nodes } from 'mdast'
  13. import { parseTranslationMarkdown } from './translation-pairing.ts'
  14. /** One block-level span of a Markdown document, in document order. */
  15. export interface MarkdownSpan {
  16. /** Position in the span list; briefing ids derive from it. */
  17. index: number
  18. /**
  19. * Structural kind compared for alignment, language-neutral: container path
  20. * plus node type for units (`root.3:tableRow`), depth for sections (`section:2`).
  21. */
  22. kind: string
  23. /** Reader-facing label: heading text for sections, node type for units. */
  24. label: string
  25. /** 1-based first source line. */
  26. startLine: number
  27. /** 1-based last source line. */
  28. endLine: number
  29. /** The span's text, trailing newline normalized to exactly one. */
  30. text: string
  31. }
  32. function linesOf(markdown: string): string[] {
  33. const lines = markdown.replaceAll('\r\n', '\n').split('\n')
  34. if (lines.at(-1) === '') lines.pop()
  35. return lines
  36. }
  37. function sliceLines(lines: string[], startLine: number, endLine: number): string {
  38. return `${lines.slice(startLine - 1, endLine).join('\n')}\n`
  39. }
  40. /**
  41. * List a document's translation units: the outermost block nodes a minimal
  42. * update can replace independently. Headings, paragraphs, code fences, table
  43. * rows, list items, block quotes, HTML blocks, thematic breaks, and link
  44. * definitions are units; the container path is part of the kind so kind
  45. * sequences only align when container membership also aligns.
  46. *
  47. * @param markdown - Document text.
  48. * @returns Units in document order.
  49. */
  50. export function markdownUnits(markdown: string): MarkdownSpan[] {
  51. const positions: Array<{ kind: string; label: string; startLine: number; endLine: number }> = []
  52. const visit = (node: Nodes, path: string): void => {
  53. let kind: string | undefined
  54. switch (node.type) {
  55. case 'heading':
  56. kind = `${path}:heading:${node.depth}`
  57. break
  58. case 'paragraph':
  59. case 'code':
  60. case 'tableRow':
  61. case 'listItem':
  62. case 'blockquote':
  63. case 'html':
  64. case 'thematicBreak':
  65. case 'definition':
  66. kind = `${path}:${node.type}`
  67. break
  68. default:
  69. break
  70. }
  71. if (kind !== undefined && node.position !== undefined) {
  72. positions.push({ kind, label: node.type, startLine: node.position.start.line, endLine: node.position.end.line })
  73. return
  74. }
  75. if ('children' in node) for (const [index, child] of node.children.entries()) visit(child, `${path}.${index}`)
  76. }
  77. visit(parseTranslationMarkdown(markdown), 'root')
  78. positions.sort((left, right) => left.startLine - right.startLine)
  79. const lines = linesOf(markdown)
  80. return positions.map((position, index) => ({
  81. index,
  82. ...position,
  83. text: sliceLines(lines, position.startLine, position.endLine),
  84. }))
  85. }
  86. /**
  87. * List a document's heading-delimited sections, including a leading
  88. * `preamble` span when content precedes the first heading.
  89. *
  90. * @param markdown - Document text.
  91. * @returns Sections in document order.
  92. */
  93. export function sectionSpans(markdown: string): MarkdownSpan[] {
  94. const headings: Array<{ depth: number; line: number; label: string }> = []
  95. const visit = (node: Nodes): void => {
  96. if (node.type === 'heading' && node.position !== undefined) {
  97. let label = ''
  98. const collect = (child: Nodes): void => {
  99. if ('value' in child && typeof child.value === 'string') label += child.value
  100. if ('children' in child) for (const grandchild of child.children) collect(grandchild)
  101. }
  102. for (const child of node.children) collect(child)
  103. headings.push({ depth: node.depth, line: node.position.start.line, label })
  104. }
  105. if ('children' in node) for (const child of node.children) visit(child)
  106. }
  107. visit(parseTranslationMarkdown(markdown))
  108. headings.sort((left, right) => left.line - right.line)
  109. const lines = linesOf(markdown)
  110. const spans: MarkdownSpan[] = []
  111. const firstHeadingLine = headings[0]?.line ?? lines.length + 1
  112. if (firstHeadingLine > 1) {
  113. spans.push({ index: 0, kind: 'preamble', label: '(preamble before the first heading)', startLine: 1, endLine: firstHeadingLine - 1, text: sliceLines(lines, 1, firstHeadingLine - 1) })
  114. }
  115. for (const [order, heading] of headings.entries()) {
  116. const endLine = (headings[order + 1]?.line ?? lines.length + 1) - 1
  117. spans.push({
  118. index: spans.length,
  119. // Depth only: heading TEXT is translated across a pair, so it cannot
  120. // participate in cross-language alignment.
  121. kind: `section:${heading.depth}`,
  122. label: heading.label === '' ? '(untitled section)' : heading.label,
  123. startLine: heading.line,
  124. endLine,
  125. text: sliceLines(lines, heading.line, endLine),
  126. })
  127. }
  128. return spans
  129. }
  130. /**
  131. * Whether two span lists map one to one: same non-zero length and the same
  132. * kind at every position.
  133. *
  134. * @param left - One document's spans.
  135. * @param right - The other document's spans.
  136. * @returns True when index-wise mapping is sound.
  137. */
  138. export function spansAligned(left: MarkdownSpan[], right: MarkdownSpan[]): boolean {
  139. return left.length > 0
  140. && left.length === right.length
  141. && left.every((span, index) => span.kind === right[index]?.kind)
  142. }
  143. /**
  144. * Indices whose text differs between two aligned span lists.
  145. *
  146. * @param before - Spans of the earlier state.
  147. * @param after - Spans of the later state, aligned with `before`.
  148. * @returns Ascending changed indices.
  149. */
  150. export function changedSpanIndices(before: MarkdownSpan[], after: MarkdownSpan[]): number[] {
  151. return before.filter((span, index) => span.text !== after[index]?.text).map(span => span.index)
  152. }
  153. function codeSpansOf(markdown: string): MarkdownSpan[] {
  154. return markdownUnits(markdown).filter(span => span.kind.endsWith(':code'))
  155. .map((span, index) => ({ ...span, index }))
  156. }
  157. function replaceSpanTexts(markdown: string, spans: MarkdownSpan[], replacements: Map<number, string>): string {
  158. const lines = linesOf(markdown)
  159. for (const [index, replacement] of [...replacements.entries()].sort((left, right) => right[0] - left[0])) {
  160. const span = spans[index]
  161. if (span === undefined) throw new Error(`translation brief: unknown replacement span ${index}`)
  162. lines.splice(span.startLine - 1, span.endLine - span.startLine + 1, ...linesOf(replacement))
  163. }
  164. return `${lines.join('\n')}\n`
  165. }
  166. function maskCodeSpans(markdown: string, spans: MarkdownSpan[]): string {
  167. return replaceSpanTexts(markdown, spans, new Map(spans.map(span => [span.index, `DSH_TRANSLATION_CODE_${span.index}\n`])))
  168. }
  169. /**
  170. * Compute the counterpart update for a change confined to fenced code
  171. * blocks. Fences are byte-identical across a pair, so when the source's
  172. * prose is untouched and the counterpart's fences match the last-confirmed
  173. * source, splicing the edited fences into the counterpart is the complete
  174. * update — no translation judgment is involved.
  175. *
  176. * @param confirmedSource - The changed side's last-confirmed text.
  177. * @param currentSource - The changed side's current text.
  178. * @param counterpart - The other side's current text.
  179. * @returns The updated counterpart, or undefined when the change is not code-only.
  180. */
  181. export function computeMechanicalUpdate(confirmedSource: string, currentSource: string, counterpart: string): string | undefined {
  182. const confirmed = codeSpansOf(confirmedSource)
  183. const current = codeSpansOf(currentSource)
  184. const target = codeSpansOf(counterpart)
  185. if (confirmed.length === 0 || confirmed.length !== current.length || confirmed.length !== target.length) return undefined
  186. if (maskCodeSpans(confirmedSource, confirmed) !== maskCodeSpans(currentSource, current)) return undefined
  187. if (confirmed.some((span, index) => span.text !== target[index]?.text)) return undefined
  188. const changed = current.filter((span, index) => span.text !== confirmed[index]?.text)
  189. if (changed.length === 0) return undefined
  190. return replaceSpanTexts(counterpart, target, new Map(changed.map(span => [span.index, span.text])))
  191. }
  192. /** One parsed terminology-table data row. */
  193. export interface TerminologyRow {
  194. english: string
  195. chinese: string
  196. /** The 首次出现 cell (first-occurrence rendering), possibly empty. */
  197. first: string
  198. /** The verbatim table row. */
  199. line: string
  200. }
  201. /** Strip Markdown emphasis and code markers from a terminology cell. */
  202. function plainTerm(cell: string): string {
  203. return cell.replaceAll('`', '').replaceAll('**', '').trim()
  204. }
  205. /**
  206. * Parse the data rows of the terminology table.
  207. *
  208. * @param terminology - Full `docs/i18n/terminology.md` contents.
  209. * @returns Rows in table order.
  210. */
  211. export function parseTerminologyRows(terminology: string): TerminologyRow[] {
  212. const rows: TerminologyRow[] = []
  213. for (const line of terminology.split('\n')) {
  214. if (!line.startsWith('|')) continue
  215. if (/^\|[\s:|-]+\|$/.test(line)) continue
  216. const cells = line.split('|').map(cell => cell.trim())
  217. const english = plainTerm(cells[1] ?? '')
  218. if (english === '' || english === 'English') continue
  219. rows.push({ english, chinese: plainTerm(cells[2] ?? ''), first: plainTerm(cells[3] ?? ''), line })
  220. }
  221. return rows
  222. }
  223. /**
  224. * Character offsets of a term's occurrences. English word-like terms match
  225. * on word boundaries and accept plural inflections (`agents`, `registries`);
  226. * other terms match as case-insensitive substrings.
  227. *
  228. * @param text - Text to search.
  229. * @param term - The term to find.
  230. * @param englishInflections - Whether to accept English plural forms.
  231. * @returns Ascending match offsets.
  232. */
  233. export function termOffsets(text: string, term: string, englishInflections = false): number[] {
  234. if (term === '') return []
  235. const escape = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  236. const wordLike = /^[A-Za-z0-9][A-Za-z0-9 ._-]*[A-Za-z0-9]$/.test(term)
  237. const inflected = englishInflections && wordLike
  238. ? /[^aeiou]y$/i.test(term)
  239. ? `${escape(term.slice(0, -1))}(?:y|ies)`
  240. : `${escape(term)}(?:s|es)?`
  241. : escape(term)
  242. const expression = new RegExp(wordLike ? `(?<![A-Za-z0-9_])${inflected}(?![A-Za-z0-9_])` : inflected, 'gi')
  243. return [...text.matchAll(expression)].map(match => match.index)
  244. }
  245. /** The two update directions a pair supports. */
  246. export type BriefDirection = 'en-to-zh' | 'zh-to-en'
  247. /** Whether a row's source-language term occurs in the given text. */
  248. function rowOccurs(row: TerminologyRow, direction: BriefDirection, text: string): boolean {
  249. const terms = direction === 'en-to-zh' ? [row.english] : [row.first, row.chinese].filter(term => /[一-鿿]/.test(term))
  250. return terms.some(term => termOffsets(text, term, direction === 'en-to-zh').length > 0)
  251. }
  252. /**
  253. * Select the terminology rows whose source-language term occurs in the
  254. * changed text (old and new states combined).
  255. *
  256. * @param terminology - Full `docs/i18n/terminology.md` contents.
  257. * @param direction - Update direction; decides which columns to match.
  258. * @param changedText - Concatenated old and new text of the changed spans.
  259. * @returns Matched rows in table order.
  260. */
  261. export function relevantTerminologyRows(terminology: string, direction: BriefDirection, changedText: string): TerminologyRow[] {
  262. return parseTerminologyRows(terminology).filter(row => rowOccurs(row, direction, changedText))
  263. }
  264. function lineAtOffset(text: string, offset: number): number {
  265. return text.slice(0, offset).split('\n').length
  266. }
  267. function spanIndexAtOffset(text: string, spans: MarkdownSpan[], offset: number | undefined): number | undefined {
  268. if (offset === undefined) return undefined
  269. const line = lineAtOffset(text, offset)
  270. return spans.find(span => line >= span.startLine && line <= span.endLine)?.index
  271. }
  272. /** First-occurrence guidance computed for a Chinese-target update. */
  273. export interface FirstOccurrenceContext {
  274. /** Human-readable notes for the briefing. */
  275. notes: string[]
  276. /** Unchanged span indices that must join the briefing because a first occurrence moved into or out of them. */
  277. extraSpanIndices: number[]
  278. }
  279. /**
  280. * Track document-wide first occurrences of the relevant English terms. The
  281. * 首次出现 rendering attaches to a term's first occurrence, so when an edit
  282. * moves that occurrence across spans, both the old and new spans need
  283. * counterpart edits even when only one of them changed.
  284. *
  285. * @param confirmedSource - Last-confirmed English text.
  286. * @param currentSource - Current English text.
  287. * @param confirmedSpans - Spans of the last-confirmed English text.
  288. * @param currentSpans - Spans of the current English text, aligned with `confirmedSpans`.
  289. * @param rows - The relevant terminology rows.
  290. * @param changed - Span indices already in the briefing.
  291. * @returns Notes and extra span indices to include.
  292. */
  293. export function firstOccurrenceContext(
  294. confirmedSource: string,
  295. currentSource: string,
  296. confirmedSpans: MarkdownSpan[],
  297. currentSpans: MarkdownSpan[],
  298. rows: TerminologyRow[],
  299. changed: Set<number>,
  300. ): FirstOccurrenceContext {
  301. const notes: string[] = []
  302. const extra = new Set<number>()
  303. for (const row of rows) {
  304. if (row.first === '') continue
  305. const oldIndex = spanIndexAtOffset(confirmedSource, confirmedSpans, termOffsets(confirmedSource, row.english, true)[0])
  306. const newIndex = spanIndexAtOffset(currentSource, currentSpans, termOffsets(currentSource, row.english, true)[0])
  307. if (oldIndex === newIndex) continue
  308. for (const index of [oldIndex, newIndex]) {
  309. if (index !== undefined && !changed.has(index)) extra.add(index)
  310. }
  311. notes.push(`${row.english}: the document-wide first occurrence moved from ${oldIndex === undefined ? 'absent' : `#${oldIndex}`} to ${newIndex === undefined ? 'absent' : `#${newIndex}`}; the ${row.first} form moves with it (later occurrences drop the annotation).`)
  312. }
  313. return { notes, extraSpanIndices: [...extra].sort((left, right) => left - right) }
  314. }
  315. /** Smallest fence of `mark` characters that safely wraps `body`. */
  316. function fenceFor(body: string, mark: '`' | '~'): string {
  317. let longest = 2
  318. for (const line of body.split('\n')) {
  319. const run = new RegExp(`^\\s*(${mark === '`' ? '`' : '~'}{3,})`).exec(line)
  320. if (run?.[1] !== undefined && run[1].length > longest) longest = run[1].length
  321. }
  322. return mark.repeat(longest + 1)
  323. }
  324. /** One changed (or first-occurrence) span with its three-way context. */
  325. export interface BriefBundle {
  326. /** Span index shared by the aligned documents. */
  327. index: number
  328. /** Human label: heading text or node type. */
  329. label: string
  330. /** Why the bundle is present when its source text did not change. */
  331. reason?: 'first-occurrence' | undefined
  332. confirmedSourceText: string
  333. currentSourceText: string
  334. counterpartText: string
  335. /** 1-based line the counterpart span starts on. */
  336. counterpartStartLine: number
  337. }
  338. /** The granularities a briefing can map the change at, narrowest first. */
  339. export type BriefScope =
  340. | { kind: 'mechanical' }
  341. | { kind: 'units'; bundles: BriefBundle[]; firstOccurrenceNotes: string[] }
  342. | { kind: 'sections'; bundles: BriefBundle[]; firstOccurrenceNotes: string[] }
  343. | { kind: 'document'; reason: string }
  344. /** Inputs for rendering one pair's briefing. */
  345. export interface TranslationBriefInput {
  346. /** Repo-relative path of the side that changed. */
  347. sourcePath: string
  348. /** Repo-relative path of the counterpart to update. */
  349. counterpartPath: string
  350. direction: BriefDirection
  351. /** Unified diff of the changed side, last-confirmed to current. */
  352. diff: string
  353. scope: BriefScope
  354. terminology: TerminologyRow[]
  355. }
  356. const ZH_TARGET_DIGEST = [
  357. '- Edit ONLY what the change requires; preserve the reviewed phrasing of everything unchanged.',
  358. '- Nothing added, nothing dropped: the Chinese must state exactly what the new English states.',
  359. '- Write natural institutional technical Chinese, not word-by-word gloss; terse stays terse.',
  360. '- Code fences byte-identical to the English side, comments included; inline code spans verbatim.',
  361. '- Repository-relative document links keep the same semantic target and exact query/fragment; targets in the active bilingual corpus use `.zh.md` for Chinese, a missing in-scope counterpart is an error, and targets outside the corpus keep the authored path. The switcher remains the cross-locale exception.',
  362. '- Structure mirrors the counterpart: heading depths and order, list kinds and item counts, table rows and columns.',
  363. '- 首次出现 annotations attach to the document-wide first occurrence only; later occurrences use the bare form, and an empty 首次出现 cell means never gloss.',
  364. '- Typography: one half-width space between Chinese and Latin or digits; full-width punctuation in Chinese prose; 顿号 for enumerations; second person is 你.',
  365. '- One physical line per paragraph; exactly one trailing newline.',
  366. ]
  367. const EN_TARGET_DIGEST = [
  368. '- Edit ONLY what the change requires; preserve the reviewed phrasing of everything unchanged.',
  369. '- Nothing added, nothing dropped: the English must state exactly what the new Chinese states.',
  370. '- Write concise professional developer prose, not word-by-word gloss; terse stays terse.',
  371. '- Code fences byte-identical to the Chinese side, comments included; inline code spans verbatim.',
  372. '- Repository-relative document links keep the same semantic target and exact query/fragment; targets in the active bilingual corpus use `.md` for English, a missing in-scope counterpart is an error, and targets outside the corpus keep the authored path. The switcher remains the cross-locale exception.',
  373. '- Structure mirrors the counterpart: heading depths and order, list kinds and item counts, table rows and columns.',
  374. '- One physical line per paragraph; exactly one trailing newline.',
  375. ]
  376. function renderBundles(out: string[], input: TranslationBriefInput, bundles: BriefBundle[], firstOccurrenceNotes: string[]): void {
  377. const sourceLanguage = input.direction === 'en-to-zh' ? 'English' : 'Chinese'
  378. const counterpartLanguage = input.direction === 'en-to-zh' ? 'Chinese' : 'English'
  379. for (const bundle of bundles) {
  380. out.push('')
  381. out.push(`### #${bundle.index} ${bundle.label}${bundle.reason === 'first-occurrence' ? ' — unchanged; included for a first-occurrence move' : ''} — counterpart at ${input.counterpartPath}:${bundle.counterpartStartLine}`)
  382. const fence = fenceFor([bundle.confirmedSourceText, bundle.currentSourceText, bundle.counterpartText].join('\n'), '~')
  383. if (bundle.confirmedSourceText !== bundle.currentSourceText) {
  384. out.push('')
  385. out.push(`Last-confirmed ${sourceLanguage}:`)
  386. out.push('')
  387. out.push(`${fence}markdown`)
  388. out.push(bundle.confirmedSourceText.trimEnd())
  389. out.push(fence)
  390. }
  391. out.push('')
  392. out.push(`Current ${sourceLanguage}:`)
  393. out.push('')
  394. out.push(`${fence}markdown`)
  395. out.push(bundle.currentSourceText.trimEnd())
  396. out.push(fence)
  397. out.push('')
  398. out.push(`Current ${counterpartLanguage} (bring this along):`)
  399. out.push('')
  400. out.push(`${fence}markdown`)
  401. out.push(bundle.counterpartText.trimEnd())
  402. out.push(fence)
  403. }
  404. if (firstOccurrenceNotes.length > 0) {
  405. out.push('')
  406. out.push('## First-occurrence notes')
  407. out.push('')
  408. for (const note of firstOccurrenceNotes) out.push(`- ${note}`)
  409. }
  410. }
  411. /**
  412. * Render the complete briefing for one out-of-sync pair.
  413. *
  414. * @param input - Diff, mapped scope, terminology, and pair identity.
  415. * @returns Markdown briefing text.
  416. */
  417. export function renderTranslationBrief(input: TranslationBriefInput): string {
  418. const sourceLanguage = input.direction === 'en-to-zh' ? 'English' : 'Chinese'
  419. const counterpartLanguage = input.direction === 'en-to-zh' ? 'Chinese' : 'English'
  420. const out: string[] = []
  421. out.push(`# Translation update briefing: ${input.sourcePath}`)
  422. out.push('')
  423. out.push(`The ${sourceLanguage} side changed; bring \`${input.counterpartPath}\` along with the smallest edit that covers the change.`)
  424. if (input.scope.kind === 'mechanical') {
  425. out.push('')
  426. out.push('## Mechanical update — no translation judgment involved')
  427. out.push('')
  428. out.push(`Every change since the last confirmed state is inside fenced code blocks, which are byte-identical across the pair. Run \`pnpm run gen-translation-brief --apply ${input.sourcePath}\` to splice the updated fences into the counterpart (the result is structure-validated before writing), then record per the Finish steps.`)
  429. }
  430. out.push('')
  431. out.push(`## ${sourceLanguage} diff (last-confirmed → current)`)
  432. out.push('')
  433. const diffFence = fenceFor(input.diff, '`')
  434. out.push(`${diffFence}diff`)
  435. out.push(input.diff.trimEnd())
  436. out.push(diffFence)
  437. switch (input.scope.kind) {
  438. case 'mechanical':
  439. break
  440. case 'units':
  441. out.push('')
  442. out.push(`## Changed units (last-confirmed ${sourceLanguage} → current ${sourceLanguage}, with the current ${counterpartLanguage})`)
  443. renderBundles(out, input, input.scope.bundles, input.scope.firstOccurrenceNotes)
  444. break
  445. case 'sections':
  446. out.push('')
  447. out.push('## Changed sections (fine-grained units do not align across the pair; whole heading sections shown)')
  448. renderBundles(out, input, input.scope.bundles, input.scope.firstOccurrenceNotes)
  449. break
  450. case 'document':
  451. out.push('')
  452. out.push('## Whole-document update required')
  453. out.push('')
  454. out.push(`${input.scope.reason} Open \`${input.counterpartPath}\` directly, locate the affected regions yourself, and reconcile under docs/i18n/translation-rules.md.`)
  455. break
  456. default:
  457. input.scope satisfies never
  458. }
  459. if (input.terminology.length > 0) {
  460. out.push('')
  461. out.push('## Binding terminology rows matching this change (docs/i18n/terminology.md)')
  462. out.push('')
  463. out.push('| English | 中文 | 首次出现 | 不要译作 | 备注 |')
  464. out.push('|---|---|---|---|---|')
  465. for (const row of input.terminology) out.push(row.line)
  466. out.push('')
  467. out.push('For any term you introduce that is not listed above, consult the full table before inventing a rendering.')
  468. }
  469. out.push('')
  470. out.push('## Rules digest (full rules: docs/i18n/translation-rules.md)')
  471. out.push('')
  472. out.push(...(input.direction === 'en-to-zh' ? ZH_TARGET_DIGEST : EN_TARGET_DIGEST))
  473. out.push('')
  474. out.push('## Finish')
  475. out.push('')
  476. out.push('1. Apply the smallest counterpart edit that covers the change, then verify the changed spans clause by clause against the source.')
  477. out.push(`2. \`pnpm run verify-translation-pairing --write ${input.sourcePath.replace(/\.zh\.md$/, '.md')}\``)
  478. out.push(`3. \`pnpm run verify-translation-pairing ${input.sourcePath.replace(/\.zh\.md$/, '.md')}\``)
  479. out.push('')
  480. return out.join('\n')
  481. }