|
|
@@ -1,160 +1,216 @@
|
|
|
/**
|
|
|
* Pure assembly of the minimal-update briefing for one out-of-sync
|
|
|
- * translation pair: the authored side's diff since the last confirmed
|
|
|
- * state, the counterpart sections that diff lands in, the terminology rows
|
|
|
- * the diff touches, and a digest of the binding update rules. The CLI
|
|
|
- * wrapper is `scripts/gen-translation-brief.ts`; the workflow that consumes
|
|
|
- * the briefing is `.agents/skills/dsh-translate-docs/SKILL.md`.
|
|
|
+ * translation pair: the authored side's changes since the last confirmed
|
|
|
+ * state at the narrowest safely mapped granularity (code-fence-only splice,
|
|
|
+ * changed Markdown units, heading sections, whole document), the terminology
|
|
|
+ * rows those changes touch, first-occurrence movement notes, and a digest of
|
|
|
+ * the binding update rules. The unit mapping, mechanical code splice, and
|
|
|
+ * first-occurrence tracking adopt the planner mechanics validated in the
|
|
|
+ * incremental-pipeline work (PR #684). The CLI wrapper is
|
|
|
+ * `scripts/gen-translation-brief.ts`; the workflow that consumes the
|
|
|
+ * briefing is `.agents/skills/dsh-translate-docs/SKILL.md`.
|
|
|
*/
|
|
|
|
|
|
import type { Nodes } from 'mdast'
|
|
|
import { parseTranslationMarkdown } from './translation-pairing.ts'
|
|
|
|
|
|
-/** One hunk of a unified diff, in old-side line coordinates. */
|
|
|
-export interface DiffHunk {
|
|
|
- /** First old-side line the hunk touches (0 for an insertion at the top). */
|
|
|
- start: number
|
|
|
- /** Old-side line count (0 for a pure insertion). */
|
|
|
- count: number
|
|
|
+/** One block-level span of a Markdown document, in document order. */
|
|
|
+export interface MarkdownSpan {
|
|
|
+ /** Position in the span list; briefing ids derive from it. */
|
|
|
+ index: number
|
|
|
+ /**
|
|
|
+ * Structural kind compared for alignment, language-neutral: container path
|
|
|
+ * plus node type for units (`root.3:tableRow`), depth for sections (`section:2`).
|
|
|
+ */
|
|
|
+ kind: string
|
|
|
+ /** Reader-facing label: heading text for sections, node type for units. */
|
|
|
+ label: string
|
|
|
+ /** 1-based first source line. */
|
|
|
+ startLine: number
|
|
|
+ /** 1-based last source line. */
|
|
|
+ endLine: number
|
|
|
+ /** The span's text, trailing newline normalized to exactly one. */
|
|
|
+ text: string
|
|
|
}
|
|
|
|
|
|
-/**
|
|
|
- * Parse the `@@ -start,count +… @@` hunk headers of a unified diff.
|
|
|
- *
|
|
|
- * @param diff - Unified diff text.
|
|
|
- * @returns Hunks in old-side coordinates, in order of appearance.
|
|
|
- */
|
|
|
-export function parseUnifiedDiffHunks(diff: string): DiffHunk[] {
|
|
|
- const hunks: DiffHunk[] = []
|
|
|
- for (const line of diff.split('\n')) {
|
|
|
- const match = /^@@ -(\d+)(?:,(\d+))? \+\d+(?:,\d+)? @@/.exec(line)
|
|
|
- if (match?.[1] === undefined) continue
|
|
|
- hunks.push({ start: Number(match[1]), count: match[2] === undefined ? 1 : Number(match[2]) })
|
|
|
- }
|
|
|
- return hunks
|
|
|
+function linesOf(markdown: string): string[] {
|
|
|
+ const lines = markdown.replaceAll('\r\n', '\n').split('\n')
|
|
|
+ if (lines.at(-1) === '') lines.pop()
|
|
|
+ return lines
|
|
|
+}
|
|
|
+
|
|
|
+function sliceLines(lines: string[], startLine: number, endLine: number): string {
|
|
|
+ return `${lines.slice(startLine - 1, endLine).join('\n')}\n`
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Extract the added and removed content lines of a unified diff.
|
|
|
+ * List a document's translation units: the outermost block nodes a minimal
|
|
|
+ * update can replace independently. Headings, paragraphs, code fences, table
|
|
|
+ * rows, list items, block quotes, HTML blocks, thematic breaks, and link
|
|
|
+ * definitions are units; the container path is part of the kind so kind
|
|
|
+ * sequences only align when container membership also aligns.
|
|
|
*
|
|
|
- * @param diff - Unified diff text.
|
|
|
- * @returns The changed lines joined by newlines, diff markers stripped.
|
|
|
+ * @param markdown - Document text.
|
|
|
+ * @returns Units in document order.
|
|
|
*/
|
|
|
-export function changedLinesOfDiff(diff: string): string {
|
|
|
- const out: string[] = []
|
|
|
- for (const line of diff.split('\n')) {
|
|
|
- if (line.startsWith('+++') || line.startsWith('---')) continue
|
|
|
- if (line.startsWith('+') || line.startsWith('-')) out.push(line.slice(1))
|
|
|
+export function markdownUnits(markdown: string): MarkdownSpan[] {
|
|
|
+ const positions: Array<{ kind: string; label: string; startLine: number; endLine: number }> = []
|
|
|
+ const visit = (node: Nodes, path: string): void => {
|
|
|
+ let kind: string | undefined
|
|
|
+ switch (node.type) {
|
|
|
+ case 'heading':
|
|
|
+ kind = `${path}:heading:${node.depth}`
|
|
|
+ break
|
|
|
+ case 'paragraph':
|
|
|
+ case 'code':
|
|
|
+ case 'tableRow':
|
|
|
+ case 'listItem':
|
|
|
+ case 'blockquote':
|
|
|
+ case 'html':
|
|
|
+ case 'thematicBreak':
|
|
|
+ case 'definition':
|
|
|
+ kind = `${path}:${node.type}`
|
|
|
+ break
|
|
|
+ default:
|
|
|
+ break
|
|
|
+ }
|
|
|
+ if (kind !== undefined && node.position !== undefined) {
|
|
|
+ positions.push({ kind, label: node.type, startLine: node.position.start.line, endLine: node.position.end.line })
|
|
|
+ return
|
|
|
+ }
|
|
|
+ if ('children' in node) for (const [index, child] of node.children.entries()) visit(child, `${path}.${index}`)
|
|
|
}
|
|
|
- return out.join('\n')
|
|
|
-}
|
|
|
-
|
|
|
-/** One heading of a Markdown document, in document order. */
|
|
|
-export interface HeadingSection {
|
|
|
- /** 1-based source line the heading starts on. */
|
|
|
- line: number
|
|
|
- /** Heading depth (`##` is 2). */
|
|
|
- depth: number
|
|
|
- /** Concatenated plain text of the heading. */
|
|
|
- label: string
|
|
|
+ visit(parseTranslationMarkdown(markdown), 'root')
|
|
|
+ positions.sort((left, right) => left.startLine - right.startLine)
|
|
|
+ const lines = linesOf(markdown)
|
|
|
+ return positions.map((position, index) => ({
|
|
|
+ index,
|
|
|
+ ...position,
|
|
|
+ text: sliceLines(lines, position.startLine, position.endLine),
|
|
|
+ }))
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * List a document's headings with their start lines via the pairing-gate parser.
|
|
|
+ * List a document's heading-delimited sections, including a leading
|
|
|
+ * `preamble` span when content precedes the first heading.
|
|
|
*
|
|
|
* @param markdown - Document text.
|
|
|
- * @returns Headings in document order.
|
|
|
+ * @returns Sections in document order.
|
|
|
*/
|
|
|
-export function headingSections(markdown: string): HeadingSection[] {
|
|
|
- const out: HeadingSection[] = []
|
|
|
+export function sectionSpans(markdown: string): MarkdownSpan[] {
|
|
|
+ const headings: Array<{ depth: number; line: number; label: string }> = []
|
|
|
const visit = (node: Nodes): void => {
|
|
|
- if (node.type === 'heading') {
|
|
|
+ if (node.type === 'heading' && node.position !== undefined) {
|
|
|
let label = ''
|
|
|
const collect = (child: Nodes): void => {
|
|
|
if ('value' in child && typeof child.value === 'string') label += child.value
|
|
|
if ('children' in child) for (const grandchild of child.children) collect(grandchild)
|
|
|
}
|
|
|
for (const child of node.children) collect(child)
|
|
|
- out.push({ line: node.position?.start.line ?? 1, depth: node.depth, label })
|
|
|
+ headings.push({ depth: node.depth, line: node.position.start.line, label })
|
|
|
}
|
|
|
if ('children' in node) for (const child of node.children) visit(child)
|
|
|
}
|
|
|
visit(parseTranslationMarkdown(markdown))
|
|
|
- return out
|
|
|
+ headings.sort((left, right) => left.line - right.line)
|
|
|
+ const lines = linesOf(markdown)
|
|
|
+ const spans: MarkdownSpan[] = []
|
|
|
+ const firstHeadingLine = headings[0]?.line ?? lines.length + 1
|
|
|
+ if (firstHeadingLine > 1) {
|
|
|
+ spans.push({ index: 0, kind: 'preamble', label: '(preamble before the first heading)', startLine: 1, endLine: firstHeadingLine - 1, text: sliceLines(lines, 1, firstHeadingLine - 1) })
|
|
|
+ }
|
|
|
+ for (const [order, heading] of headings.entries()) {
|
|
|
+ const endLine = (headings[order + 1]?.line ?? lines.length + 1) - 1
|
|
|
+ spans.push({
|
|
|
+ index: spans.length,
|
|
|
+ // Depth only: heading TEXT is translated across a pair, so it cannot
|
|
|
+ // participate in cross-language alignment.
|
|
|
+ kind: `section:${heading.depth}`,
|
|
|
+ label: heading.label === '' ? '(untitled section)' : heading.label,
|
|
|
+ startLine: heading.line,
|
|
|
+ endLine,
|
|
|
+ text: sliceLines(lines, heading.line, endLine),
|
|
|
+ })
|
|
|
+ }
|
|
|
+ return spans
|
|
|
}
|
|
|
|
|
|
-/** Section index containing a 1-based line: 0 is the preamble before the first heading, i is the i-th heading's section. */
|
|
|
-function sectionOf(line: number, headings: HeadingSection[]): number {
|
|
|
- let section = 0
|
|
|
- for (let index = 0; index < headings.length; index++) {
|
|
|
- const heading = headings[index]
|
|
|
- if (heading !== undefined && heading.line <= line) section = index + 1
|
|
|
- }
|
|
|
- return section
|
|
|
+/**
|
|
|
+ * Whether two span lists map one to one: same non-zero length and the same
|
|
|
+ * kind at every position.
|
|
|
+ *
|
|
|
+ * @param left - One document's spans.
|
|
|
+ * @param right - The other document's spans.
|
|
|
+ * @returns True when index-wise mapping is sound.
|
|
|
+ */
|
|
|
+export function spansAligned(left: MarkdownSpan[], right: MarkdownSpan[]): boolean {
|
|
|
+ return left.length > 0
|
|
|
+ && left.length === right.length
|
|
|
+ && left.every((span, index) => span.kind === right[index]?.kind)
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Map diff hunks to the section indices they touch in the diffed document.
|
|
|
+ * Indices whose text differs between two aligned span lists.
|
|
|
*
|
|
|
- * @param hunks - Hunks in the diffed document's old-side coordinates.
|
|
|
- * @param headings - The diffed document's headings at that same old state.
|
|
|
- * @returns Ascending section indices (0 = preamble).
|
|
|
+ * @param before - Spans of the earlier state.
|
|
|
+ * @param after - Spans of the later state, aligned with `before`.
|
|
|
+ * @returns Ascending changed indices.
|
|
|
*/
|
|
|
-export function mapHunksToSections(hunks: DiffHunk[], headings: HeadingSection[]): number[] {
|
|
|
- const sections = new Set<number>()
|
|
|
- for (const hunk of hunks) {
|
|
|
- const first = sectionOf(Math.max(hunk.start, 1), headings)
|
|
|
- const last = sectionOf(Math.max(hunk.start + Math.max(hunk.count - 1, 0), 1), headings)
|
|
|
- for (let section = first; section <= last; section++) sections.add(section)
|
|
|
+export function changedSpanIndices(before: MarkdownSpan[], after: MarkdownSpan[]): number[] {
|
|
|
+ return before.filter((span, index) => span.text !== after[index]?.text).map(span => span.index)
|
|
|
+}
|
|
|
+
|
|
|
+function codeSpansOf(markdown: string): MarkdownSpan[] {
|
|
|
+ return markdownUnits(markdown).filter(span => span.kind.endsWith(':code'))
|
|
|
+ .map((span, index) => ({ ...span, index }))
|
|
|
+}
|
|
|
+
|
|
|
+function replaceSpanTexts(markdown: string, spans: MarkdownSpan[], replacements: Map<number, string>): string {
|
|
|
+ const lines = linesOf(markdown)
|
|
|
+ for (const [index, replacement] of [...replacements.entries()].sort((left, right) => right[0] - left[0])) {
|
|
|
+ const span = spans[index]
|
|
|
+ if (span === undefined) throw new Error(`translation brief: unknown replacement span ${index}`)
|
|
|
+ lines.splice(span.startLine - 1, span.endLine - span.startLine + 1, ...linesOf(replacement))
|
|
|
}
|
|
|
- return [...sections].sort((a, b) => a - b)
|
|
|
+ return `${lines.join('\n')}\n`
|
|
|
}
|
|
|
|
|
|
-/** One counterpart section to update, with its current location. */
|
|
|
-export interface CounterpartSection {
|
|
|
- /** Heading label, or the preamble marker for section 0. */
|
|
|
- label: string
|
|
|
- /** 1-based line the section starts on in the counterpart file. */
|
|
|
- startLine: number
|
|
|
- /** Current section text, trailing blank lines trimmed. */
|
|
|
- text: string
|
|
|
+function maskCodeSpans(markdown: string, spans: MarkdownSpan[]): string {
|
|
|
+ return replaceSpanTexts(markdown, spans, new Map(spans.map(span => [span.index, `DSH_TRANSLATION_CODE_${span.index}\n`])))
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Extract the counterpart's text for the given section indices.
|
|
|
+ * Compute the counterpart update for a change confined to fenced code
|
|
|
+ * blocks. Fences are byte-identical across a pair, so when the source's
|
|
|
+ * prose is untouched and the counterpart's fences match the last-confirmed
|
|
|
+ * source, splicing the edited fences into the counterpart is the complete
|
|
|
+ * update — no translation judgment is involved.
|
|
|
*
|
|
|
- * Callers must only pass indices produced against a structurally aligned
|
|
|
- * pair (same heading count and order), which the pairing gate guarantees
|
|
|
- * for a recorded-consistent state.
|
|
|
- *
|
|
|
- * @param counterpart - Current counterpart document text.
|
|
|
- * @param sections - Ascending section indices (0 = preamble).
|
|
|
- * @returns One entry per requested section.
|
|
|
+ * @param confirmedSource - The changed side's last-confirmed text.
|
|
|
+ * @param currentSource - The changed side's current text.
|
|
|
+ * @param counterpart - The other side's current text.
|
|
|
+ * @returns The updated counterpart, or undefined when the change is not code-only.
|
|
|
*/
|
|
|
-export function extractCounterpartSections(counterpart: string, sections: number[]): CounterpartSection[] {
|
|
|
- const headings = headingSections(counterpart)
|
|
|
- const lines = counterpart.split('\n')
|
|
|
- return sections.map((section) => {
|
|
|
- const heading = section === 0 ? undefined : headings[section - 1]
|
|
|
- const startLine = heading?.line ?? 1
|
|
|
- const nextHeading = headings[section]
|
|
|
- const endLine = nextHeading === undefined ? lines.length : nextHeading.line - 1
|
|
|
- const body = lines.slice(startLine - 1, endLine)
|
|
|
- while (body.length > 0 && body.at(-1) === '') body.pop()
|
|
|
- return {
|
|
|
- label: heading === undefined ? '(preamble before the first heading)' : `${'#'.repeat(heading.depth)} ${heading.label}`,
|
|
|
- startLine,
|
|
|
- text: body.join('\n'),
|
|
|
- }
|
|
|
- })
|
|
|
+export function computeMechanicalUpdate(confirmedSource: string, currentSource: string, counterpart: string): string | undefined {
|
|
|
+ const confirmed = codeSpansOf(confirmedSource)
|
|
|
+ const current = codeSpansOf(currentSource)
|
|
|
+ const target = codeSpansOf(counterpart)
|
|
|
+ if (confirmed.length === 0 || confirmed.length !== current.length || confirmed.length !== target.length) return undefined
|
|
|
+ if (maskCodeSpans(confirmedSource, confirmed) !== maskCodeSpans(currentSource, current)) return undefined
|
|
|
+ if (confirmed.some((span, index) => span.text !== target[index]?.text)) return undefined
|
|
|
+ const changed = current.filter((span, index) => span.text !== confirmed[index]?.text)
|
|
|
+ if (changed.length === 0) return undefined
|
|
|
+ return replaceSpanTexts(counterpart, target, new Map(changed.map(span => [span.index, span.text])))
|
|
|
}
|
|
|
|
|
|
-/** Terminology rows relevant to one diff, grouped under their table header. */
|
|
|
-export interface TerminologyMatches {
|
|
|
- /** The matched rows' shared header row, or undefined when no row matched. */
|
|
|
- header?: string | undefined
|
|
|
- /** Matched data rows, verbatim, in table order. */
|
|
|
- rows: string[]
|
|
|
+/** One parsed terminology-table data row. */
|
|
|
+export interface TerminologyRow {
|
|
|
+ english: string
|
|
|
+ chinese: string
|
|
|
+ /** The 首次出现 cell (first-occurrence rendering), possibly empty. */
|
|
|
+ first: string
|
|
|
+ /** The verbatim table row. */
|
|
|
+ line: string
|
|
|
}
|
|
|
|
|
|
/** Strip Markdown emphasis and code markers from a terminology cell. */
|
|
|
@@ -163,37 +219,122 @@ function plainTerm(cell: string): string {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * Select the terminology rows whose English or Chinese term occurs in the diff.
|
|
|
- *
|
|
|
- * English terms match case-insensitively on non-alphanumeric boundaries;
|
|
|
- * Chinese terms match by substring.
|
|
|
+ * Parse the data rows of the terminology table.
|
|
|
*
|
|
|
* @param terminology - Full `docs/i18n/terminology.md` contents.
|
|
|
- * @param changedText - Changed diff lines (see {@link changedLinesOfDiff}).
|
|
|
- * @returns Matched rows under their header.
|
|
|
+ * @returns Rows in table order.
|
|
|
*/
|
|
|
-export function matchTerminologyRows(terminology: string, changedText: string): TerminologyMatches {
|
|
|
- const matches: TerminologyMatches = { rows: [] }
|
|
|
- let header: string | undefined
|
|
|
+export function parseTerminologyRows(terminology: string): TerminologyRow[] {
|
|
|
+ const rows: TerminologyRow[] = []
|
|
|
for (const line of terminology.split('\n')) {
|
|
|
if (!line.startsWith('|')) continue
|
|
|
if (/^\|[\s:|-]+\|$/.test(line)) continue
|
|
|
const cells = line.split('|').map(cell => cell.trim())
|
|
|
- if (line.includes('English') && line.includes('中文')) {
|
|
|
- header = line
|
|
|
- continue
|
|
|
- }
|
|
|
const english = plainTerm(cells[1] ?? '')
|
|
|
- const chinese = plainTerm(cells[2] ?? '')
|
|
|
- const escaped = english.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
|
- const englishHit = english.length > 1 && new RegExp(`(?<![A-Za-z0-9_])${escaped}(?![A-Za-z0-9_])`, 'i').test(changedText)
|
|
|
- const chineseHit = /[一-鿿]/.test(chinese) && changedText.includes(chinese)
|
|
|
- if (englishHit || chineseHit) {
|
|
|
- matches.header ??= header
|
|
|
- matches.rows.push(line)
|
|
|
+ if (english === '' || english === 'English') continue
|
|
|
+ rows.push({ english, chinese: plainTerm(cells[2] ?? ''), first: plainTerm(cells[3] ?? ''), line })
|
|
|
+ }
|
|
|
+ return rows
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Character offsets of a term's occurrences. English word-like terms match
|
|
|
+ * on word boundaries and accept plural inflections (`agents`, `registries`);
|
|
|
+ * other terms match as case-insensitive substrings.
|
|
|
+ *
|
|
|
+ * @param text - Text to search.
|
|
|
+ * @param term - The term to find.
|
|
|
+ * @param englishInflections - Whether to accept English plural forms.
|
|
|
+ * @returns Ascending match offsets.
|
|
|
+ */
|
|
|
+export function termOffsets(text: string, term: string, englishInflections = false): number[] {
|
|
|
+ if (term === '') return []
|
|
|
+ const escape = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
|
+ const wordLike = /^[A-Za-z0-9][A-Za-z0-9 ._-]*[A-Za-z0-9]$/.test(term)
|
|
|
+ const inflected = englishInflections && wordLike
|
|
|
+ ? /[^aeiou]y$/i.test(term)
|
|
|
+ ? `${escape(term.slice(0, -1))}(?:y|ies)`
|
|
|
+ : `${escape(term)}(?:s|es)?`
|
|
|
+ : escape(term)
|
|
|
+ const expression = new RegExp(wordLike ? `(?<![A-Za-z0-9_])${inflected}(?![A-Za-z0-9_])` : inflected, 'gi')
|
|
|
+ return [...text.matchAll(expression)].map(match => match.index)
|
|
|
+}
|
|
|
+
|
|
|
+/** The two update directions a pair supports. */
|
|
|
+export type BriefDirection = 'en-to-zh' | 'zh-to-en'
|
|
|
+
|
|
|
+/** Whether a row's source-language term occurs in the given text. */
|
|
|
+function rowOccurs(row: TerminologyRow, direction: BriefDirection, text: string): boolean {
|
|
|
+ const terms = direction === 'en-to-zh' ? [row.english] : [row.first, row.chinese].filter(term => /[一-鿿]/.test(term))
|
|
|
+ return terms.some(term => termOffsets(text, term, direction === 'en-to-zh').length > 0)
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Select the terminology rows whose source-language term occurs in the
|
|
|
+ * changed text (old and new states combined).
|
|
|
+ *
|
|
|
+ * @param terminology - Full `docs/i18n/terminology.md` contents.
|
|
|
+ * @param direction - Update direction; decides which columns to match.
|
|
|
+ * @param changedText - Concatenated old and new text of the changed spans.
|
|
|
+ * @returns Matched rows in table order.
|
|
|
+ */
|
|
|
+export function relevantTerminologyRows(terminology: string, direction: BriefDirection, changedText: string): TerminologyRow[] {
|
|
|
+ return parseTerminologyRows(terminology).filter(row => rowOccurs(row, direction, changedText))
|
|
|
+}
|
|
|
+
|
|
|
+function lineAtOffset(text: string, offset: number): number {
|
|
|
+ return text.slice(0, offset).split('\n').length
|
|
|
+}
|
|
|
+
|
|
|
+function spanIndexAtOffset(text: string, spans: MarkdownSpan[], offset: number | undefined): number | undefined {
|
|
|
+ if (offset === undefined) return undefined
|
|
|
+ const line = lineAtOffset(text, offset)
|
|
|
+ return spans.find(span => line >= span.startLine && line <= span.endLine)?.index
|
|
|
+}
|
|
|
+
|
|
|
+/** First-occurrence guidance computed for a Chinese-target update. */
|
|
|
+export interface FirstOccurrenceContext {
|
|
|
+ /** Human-readable notes for the briefing. */
|
|
|
+ notes: string[]
|
|
|
+ /** Unchanged span indices that must join the briefing because a first occurrence moved into or out of them. */
|
|
|
+ extraSpanIndices: number[]
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Track document-wide first occurrences of the relevant English terms. The
|
|
|
+ * 首次出现 rendering attaches to a term's first occurrence, so when an edit
|
|
|
+ * moves that occurrence across spans, both the old and new spans need
|
|
|
+ * counterpart edits even when only one of them changed.
|
|
|
+ *
|
|
|
+ * @param confirmedSource - Last-confirmed English text.
|
|
|
+ * @param currentSource - Current English text.
|
|
|
+ * @param confirmedSpans - Spans of the last-confirmed English text.
|
|
|
+ * @param currentSpans - Spans of the current English text, aligned with `confirmedSpans`.
|
|
|
+ * @param rows - The relevant terminology rows.
|
|
|
+ * @param changed - Span indices already in the briefing.
|
|
|
+ * @returns Notes and extra span indices to include.
|
|
|
+ */
|
|
|
+export function firstOccurrenceContext(
|
|
|
+ confirmedSource: string,
|
|
|
+ currentSource: string,
|
|
|
+ confirmedSpans: MarkdownSpan[],
|
|
|
+ currentSpans: MarkdownSpan[],
|
|
|
+ rows: TerminologyRow[],
|
|
|
+ changed: Set<number>,
|
|
|
+): FirstOccurrenceContext {
|
|
|
+ const notes: string[] = []
|
|
|
+ const extra = new Set<number>()
|
|
|
+ for (const row of rows) {
|
|
|
+ if (row.first === '') continue
|
|
|
+ const oldIndex = spanIndexAtOffset(confirmedSource, confirmedSpans, termOffsets(confirmedSource, row.english, true)[0])
|
|
|
+ const newIndex = spanIndexAtOffset(currentSource, currentSpans, termOffsets(currentSource, row.english, true)[0])
|
|
|
+ if (oldIndex === newIndex) continue
|
|
|
+ for (const index of [oldIndex, newIndex]) {
|
|
|
+ if (index !== undefined && !changed.has(index)) extra.add(index)
|
|
|
}
|
|
|
+ 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).`)
|
|
|
}
|
|
|
- return matches
|
|
|
+ return { notes, extraSpanIndices: [...extra].sort((left, right) => left - right) }
|
|
|
}
|
|
|
|
|
|
/** Smallest fence of `mark` characters that safely wraps `body`. */
|
|
|
@@ -206,8 +347,27 @@ function fenceFor(body: string, mark: '`' | '~'): string {
|
|
|
return mark.repeat(longest + 1)
|
|
|
}
|
|
|
|
|
|
-/** The two update directions a pair supports. */
|
|
|
-export type BriefDirection = 'en-to-zh' | 'zh-to-en'
|
|
|
+/** One changed (or first-occurrence) span with its three-way context. */
|
|
|
+export interface BriefBundle {
|
|
|
+ /** Span index shared by the aligned documents. */
|
|
|
+ index: number
|
|
|
+ /** Human label: heading text or node type. */
|
|
|
+ label: string
|
|
|
+ /** Why the bundle is present when its source text did not change. */
|
|
|
+ reason?: 'first-occurrence' | undefined
|
|
|
+ confirmedSourceText: string
|
|
|
+ currentSourceText: string
|
|
|
+ counterpartText: string
|
|
|
+ /** 1-based line the counterpart span starts on. */
|
|
|
+ counterpartStartLine: number
|
|
|
+}
|
|
|
+
|
|
|
+/** The granularities a briefing can map the change at, narrowest first. */
|
|
|
+export type BriefScope =
|
|
|
+ | { kind: 'mechanical' }
|
|
|
+ | { kind: 'units'; bundles: BriefBundle[]; firstOccurrenceNotes: string[] }
|
|
|
+ | { kind: 'sections'; bundles: BriefBundle[]; firstOccurrenceNotes: string[] }
|
|
|
+ | { kind: 'document'; reason: string }
|
|
|
|
|
|
/** Inputs for rendering one pair's briefing. */
|
|
|
export interface TranslationBriefInput {
|
|
|
@@ -218,26 +378,24 @@ export interface TranslationBriefInput {
|
|
|
direction: BriefDirection
|
|
|
/** Unified diff of the changed side, last-confirmed to current. */
|
|
|
diff: string
|
|
|
- /** Counterpart sections the diff maps to, or undefined when alignment is untrusted. */
|
|
|
- counterpartSections?: CounterpartSection[] | undefined
|
|
|
- /** Whether both sides drifted since the last confirmed state. */
|
|
|
- bothDrifted: boolean
|
|
|
- terminology: TerminologyMatches
|
|
|
+ scope: BriefScope
|
|
|
+ terminology: TerminologyRow[]
|
|
|
}
|
|
|
|
|
|
const ZH_TARGET_DIGEST = [
|
|
|
- '- Edit ONLY what the diff requires; preserve the reviewed phrasing of everything unchanged.',
|
|
|
+ '- Edit ONLY what the change requires; preserve the reviewed phrasing of everything unchanged.',
|
|
|
'- Nothing added, nothing dropped: the Chinese must state exactly what the new English states.',
|
|
|
'- Write natural institutional technical Chinese, not word-by-word gloss; terse stays terse.',
|
|
|
'- Code fences byte-identical to the English side, comments included; inline code spans verbatim.',
|
|
|
'- Relative links keep the `.md` target; only the switcher line links `.zh.md`.',
|
|
|
'- Structure mirrors the counterpart: heading depths and order, list kinds and item counts, table rows and columns.',
|
|
|
+ '- 首次出现 annotations attach to the document-wide first occurrence only; later occurrences use the bare form, and an empty 首次出现 cell means never gloss.',
|
|
|
'- Typography: one half-width space between Chinese and Latin or digits; full-width punctuation in Chinese prose; 顿号 for enumerations; second person is 你.',
|
|
|
'- One physical line per paragraph; exactly one trailing newline.',
|
|
|
]
|
|
|
|
|
|
const EN_TARGET_DIGEST = [
|
|
|
- '- Edit ONLY what the diff requires; preserve the reviewed phrasing of everything unchanged.',
|
|
|
+ '- Edit ONLY what the change requires; preserve the reviewed phrasing of everything unchanged.',
|
|
|
'- Nothing added, nothing dropped: the English must state exactly what the new Chinese states.',
|
|
|
'- Write concise professional developer prose, not word-by-word gloss; terse stays terse.',
|
|
|
'- Code fences byte-identical to the Chinese side, comments included; inline code spans verbatim.',
|
|
|
@@ -246,10 +404,46 @@ const EN_TARGET_DIGEST = [
|
|
|
'- One physical line per paragraph; exactly one trailing newline.',
|
|
|
]
|
|
|
|
|
|
+function renderBundles(out: string[], input: TranslationBriefInput, bundles: BriefBundle[], firstOccurrenceNotes: string[]): void {
|
|
|
+ const sourceLanguage = input.direction === 'en-to-zh' ? 'English' : 'Chinese'
|
|
|
+ const counterpartLanguage = input.direction === 'en-to-zh' ? 'Chinese' : 'English'
|
|
|
+ for (const bundle of bundles) {
|
|
|
+ out.push('')
|
|
|
+ out.push(`### #${bundle.index} ${bundle.label}${bundle.reason === 'first-occurrence' ? ' — unchanged; included for a first-occurrence move' : ''} — counterpart at ${input.counterpartPath}:${bundle.counterpartStartLine}`)
|
|
|
+ const fence = fenceFor([bundle.confirmedSourceText, bundle.currentSourceText, bundle.counterpartText].join('\n'), '~')
|
|
|
+ if (bundle.confirmedSourceText !== bundle.currentSourceText) {
|
|
|
+ out.push('')
|
|
|
+ out.push(`Last-confirmed ${sourceLanguage}:`)
|
|
|
+ out.push('')
|
|
|
+ out.push(`${fence}markdown`)
|
|
|
+ out.push(bundle.confirmedSourceText.trimEnd())
|
|
|
+ out.push(fence)
|
|
|
+ }
|
|
|
+ out.push('')
|
|
|
+ out.push(`Current ${sourceLanguage}:`)
|
|
|
+ out.push('')
|
|
|
+ out.push(`${fence}markdown`)
|
|
|
+ out.push(bundle.currentSourceText.trimEnd())
|
|
|
+ out.push(fence)
|
|
|
+ out.push('')
|
|
|
+ out.push(`Current ${counterpartLanguage} (bring this along):`)
|
|
|
+ out.push('')
|
|
|
+ out.push(`${fence}markdown`)
|
|
|
+ out.push(bundle.counterpartText.trimEnd())
|
|
|
+ out.push(fence)
|
|
|
+ }
|
|
|
+ if (firstOccurrenceNotes.length > 0) {
|
|
|
+ out.push('')
|
|
|
+ out.push('## First-occurrence notes')
|
|
|
+ out.push('')
|
|
|
+ for (const note of firstOccurrenceNotes) out.push(`- ${note}`)
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* Render the complete briefing for one out-of-sync pair.
|
|
|
*
|
|
|
- * @param input - Diff, mapped sections, terminology, and pair identity.
|
|
|
+ * @param input - Diff, mapped scope, terminology, and pair identity.
|
|
|
* @returns Markdown briefing text.
|
|
|
*/
|
|
|
export function renderTranslationBrief(input: TranslationBriefInput): string {
|
|
|
@@ -258,9 +452,13 @@ export function renderTranslationBrief(input: TranslationBriefInput): string {
|
|
|
const out: string[] = []
|
|
|
out.push(`# Translation update briefing: ${input.sourcePath}`)
|
|
|
out.push('')
|
|
|
- out.push(input.bothDrifted
|
|
|
- ? `WARNING: BOTH sides changed since the pair was last confirmed consistent. Reconcile the two sides by hand — decide which side owns each divergence per docs/i18n/translation-rules.md — before recording. The diff below covers the ${sourceLanguage} side only.`
|
|
|
- : `The ${sourceLanguage} side changed; bring \`${input.counterpartPath}\` along with the smallest edit that covers the diff. The ${counterpartLanguage} side is untouched since the pair was last confirmed consistent.`)
|
|
|
+ out.push(`The ${sourceLanguage} side changed; bring \`${input.counterpartPath}\` along with the smallest edit that covers the change.`)
|
|
|
+ if (input.scope.kind === 'mechanical') {
|
|
|
+ out.push('')
|
|
|
+ out.push('## Mechanical update — no translation judgment involved')
|
|
|
+ out.push('')
|
|
|
+ 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.`)
|
|
|
+ }
|
|
|
out.push('')
|
|
|
out.push(`## ${sourceLanguage} diff (last-confirmed → current)`)
|
|
|
out.push('')
|
|
|
@@ -268,29 +466,35 @@ export function renderTranslationBrief(input: TranslationBriefInput): string {
|
|
|
out.push(`${diffFence}diff`)
|
|
|
out.push(input.diff.trimEnd())
|
|
|
out.push(diffFence)
|
|
|
- if (input.counterpartSections !== undefined) {
|
|
|
- out.push('')
|
|
|
- out.push(`## ${counterpartLanguage} text to update (aligned sections, current line numbers)`)
|
|
|
- for (const section of input.counterpartSections) {
|
|
|
+ switch (input.scope.kind) {
|
|
|
+ case 'mechanical':
|
|
|
+ break
|
|
|
+ case 'units':
|
|
|
out.push('')
|
|
|
- out.push(`### ${section.label} — ${input.counterpartPath}:${section.startLine}`)
|
|
|
+ out.push(`## Changed units (last-confirmed ${sourceLanguage} → current ${sourceLanguage}, with the current ${counterpartLanguage})`)
|
|
|
+ renderBundles(out, input, input.scope.bundles, input.scope.firstOccurrenceNotes)
|
|
|
+ break
|
|
|
+ case 'sections':
|
|
|
out.push('')
|
|
|
- const fence = fenceFor(section.text, '~')
|
|
|
- out.push(`${fence}markdown`)
|
|
|
- out.push(section.text)
|
|
|
- out.push(fence)
|
|
|
- }
|
|
|
- } else {
|
|
|
- out.push('')
|
|
|
- out.push(`Counterpart sections are not shown: the pair's heading structures do not align at the compared states, so open \`${input.counterpartPath}\` directly and locate the regions yourself.`)
|
|
|
+ out.push('## Changed sections (fine-grained units do not align across the pair; whole heading sections shown)')
|
|
|
+ renderBundles(out, input, input.scope.bundles, input.scope.firstOccurrenceNotes)
|
|
|
+ break
|
|
|
+ case 'document':
|
|
|
+ out.push('')
|
|
|
+ out.push('## Whole-document update required')
|
|
|
+ out.push('')
|
|
|
+ out.push(`${input.scope.reason} Open \`${input.counterpartPath}\` directly, locate the affected regions yourself, and reconcile under docs/i18n/translation-rules.md.`)
|
|
|
+ break
|
|
|
+ default:
|
|
|
+ input.scope satisfies never
|
|
|
}
|
|
|
- if (input.terminology.rows.length > 0 && input.terminology.header !== undefined) {
|
|
|
+ if (input.terminology.length > 0) {
|
|
|
out.push('')
|
|
|
- out.push('## Binding terminology rows matching this diff (docs/i18n/terminology.md)')
|
|
|
+ out.push('## Binding terminology rows matching this change (docs/i18n/terminology.md)')
|
|
|
out.push('')
|
|
|
- out.push(input.terminology.header)
|
|
|
- out.push(`|${' --- |'.repeat(Math.max(input.terminology.header.split('|').length - 2, 1))}`)
|
|
|
- for (const row of input.terminology.rows) out.push(row)
|
|
|
+ out.push('| English | 中文 | 首次出现 | 不要译作 | 备注 |')
|
|
|
+ out.push('|---|---|---|---|---|')
|
|
|
+ for (const row of input.terminology) out.push(row.line)
|
|
|
out.push('')
|
|
|
out.push('For any term you introduce that is not listed above, consult the full table before inventing a rendering.')
|
|
|
}
|
|
|
@@ -301,7 +505,7 @@ export function renderTranslationBrief(input: TranslationBriefInput): string {
|
|
|
out.push('')
|
|
|
out.push('## Finish')
|
|
|
out.push('')
|
|
|
- out.push('1. Apply the smallest counterpart edit that covers the diff, then verify the changed hunks clause by clause against the source.')
|
|
|
+ out.push('1. Apply the smallest counterpart edit that covers the change, then verify the changed spans clause by clause against the source.')
|
|
|
out.push(`2. \`pnpm run verify-translation-pairing --write ${input.sourcePath.replace(/\.zh\.md$/, '.md')}\``)
|
|
|
out.push(`3. \`pnpm run verify-translation-pairing ${input.sourcePath.replace(/\.zh\.md$/, '.md')}\``)
|
|
|
out.push('')
|