docs(i18n): address ds-review-bot on the v4 restoration

解析器:三段闭合标签改为行首锚定(正文提及 </translation> 不再截
断)、重复段全文扫描并校验段序(补两条回归测试)。切换行:v4 模板
保持字面占位不变,资产文档写明全新配对由流水线在解析后按目标文件
名机械插入、配对门禁兜底。加粗后空格限定于字母/数字/汉字、标点前
一律不加;RFC 2119 关键词改为保留源侧强调标记(斜体归斜体、加粗
归加粗)。i18n README 双侧同步 v4 契约描述(不再承诺 CDATA 协议与
规则注入)并重录配对。
This commit is contained in:
ZiyaZhang
2026-07-20 19:46:46 -07:00
parent 60fcb494a7
commit 64d70670e8
6 changed files with 32 additions and 20 deletions

View File

@@ -42,9 +42,19 @@ describe('translation response sections', () => {
expect(parseTranslationResponse(fenced).final).toBe('A')
})
it('keeps an inline close tag inside prose from terminating the section', () => {
const doc = { translation: 'the wire format uses </translation> as its close tag', review: '- 无修正', final: 'F' }
expect(parseTranslationResponse(renderTranslationResponse(doc))).toEqual(doc)
})
it('rejects a duplicate section appearing before final', () => {
const early = '<translation>\nA\n</translation>\n<translation>\nB\n</translation>\n<review>\nR\n</review>\n<final>\nF\n</final>'
expect(() => parseTranslationResponse(early)).toThrow(/duplicate <translation>/)
})
it('rejects missing, unterminated, or duplicated sections', () => {
expect(() => parseTranslationResponse('<translation>\nA\n</translation>')).toThrow(/missing <review>/)
expect(() => parseTranslationResponse('<translation>\nA')).toThrow(/unterminated <translation>/)
expect(() => parseTranslationResponse('<translation>\nA\n</translation>')).toThrow(/missing or unterminated <review>/)
expect(() => parseTranslationResponse('<translation>\nA')).toThrow(/missing or unterminated <translation>/)
const dup = '<translation>\nA\n</translation>\n<review>\nR\n</review>\n<final>\nF\n</final>\n<final>\nG\n</final>'
expect(() => parseTranslationResponse(dup)).toThrow(/duplicate <final>/)
})

View File

@@ -81,6 +81,10 @@ export function renderTranslationResponse(response: TranslationResponse): string
* and in order; bodies are raw Markdown taken verbatim between the tags.
* A fenced ```xml wrapper around the whole response is tolerated, matching
* the shape some models echo back from the prompt's own example.
*
* Section close tags are matched at line starts (the wire shape the prompt
* example establishes), so a tag mentioned inline in translated prose does
* not terminate its section early.
*/
export function parseTranslationResponse(text: string): TranslationResponse {
let body = text.trim()
@@ -88,20 +92,16 @@ export function parseTranslationResponse(text: string): TranslationResponse {
if (fenced?.[1] !== undefined) body = fenced[1].trim()
const values: Partial<Record<(typeof RESPONSE_SECTIONS)[number], string>> = {}
let cursor = 0
for (const section of RESPONSE_SECTIONS) {
const open = `<${section}>`
const close = `</${section}>`
const start = body.indexOf(open, cursor)
if (start === -1) throw new Error(`translation response: missing <${section}> section`)
const end = body.indexOf(close, start + open.length)
if (end === -1) throw new Error(`translation response: unterminated <${section}> section`)
values[section] = body.slice(start + open.length, end).replace(/^\n/, '').replace(/\n$/, '')
cursor = end + close.length
const pattern = new RegExp(`^<${section}>\\n?([\\s\\S]*?)\\n?^</${section}>$`, 'gm')
const first = pattern.exec(body)
if (first?.[1] === undefined) throw new Error(`translation response: missing or unterminated <${section}> section`)
if (pattern.exec(body) !== null) throw new Error(`translation response: duplicate <${section}> section`)
values[section] = first[1]
}
for (const section of RESPONSE_SECTIONS) {
const again = body.indexOf(`<${section}>`, cursor)
if (again !== -1) throw new Error(`translation response: duplicate <${section}> section`)
const order = RESPONSE_SECTIONS.map(section => body.search(new RegExp(`^<${section}>`, 'm')))
if (!(order[0]! < order[1]! && order[1]! < order[2]!)) {
throw new Error('translation response: sections must appear in translation, review, final order')
}
return values as TranslationResponse
}