translation-pairing.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. /**
  2. * Pure parsing and structural helpers for the bilingual-document pairing
  3. * gate. Kept separate from the CLI so cutoff and signature behavior can be
  4. * regression-tested without reading or mutating the repository tree.
  5. */
  6. import { fromMarkdown } from 'mdast-util-from-markdown'
  7. import { gfmFromMarkdown } from 'mdast-util-gfm'
  8. import { gfm } from 'micromark-extension-gfm'
  9. import type { Nodes } from 'mdast'
  10. /** Validated shape of `scripts/translation-pairing.manifest.json`. */
  11. export interface TranslationPairingManifest {
  12. required: string[]
  13. excluded: string[]
  14. /** Date-named documents on or after this day must merge bilingual. */
  15. requiredSince: string
  16. }
  17. const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
  18. const DATED_DOCUMENT = /(?:^|\/)(\d{4}-\d{2}-\d{2})-[^/]*\.md$/
  19. /** Whether a string names one real calendar day in canonical ISO form. */
  20. export function isIsoDate(value: string): boolean {
  21. if (!ISO_DATE.test(value)) return false
  22. const date = new Date(`${value}T00:00:00.000Z`)
  23. return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
  24. }
  25. /** Read one manifest string-array field or fail before enforcement starts. */
  26. function stringArrayField(record: Record<string, unknown>, field: 'required' | 'excluded'): string[] {
  27. const value = record[field]
  28. if (!Array.isArray(value)) {
  29. throw new Error(`translation-pairing.manifest.json: ${field} must be an array of strings`)
  30. }
  31. const entries: unknown[] = value
  32. if (!entries.every((entry): entry is string => typeof entry === 'string')) {
  33. throw new Error(`translation-pairing.manifest.json: ${field} must be an array of strings`)
  34. }
  35. return entries
  36. }
  37. /** Parse and validate the checked-in bilingual manifest. */
  38. export function parseTranslationPairingManifest(content: string): TranslationPairingManifest {
  39. const value: unknown = JSON.parse(content)
  40. if (typeof value !== 'object' || value === null || Array.isArray(value)) {
  41. throw new Error('translation-pairing.manifest.json: expected an object')
  42. }
  43. const record = value as Record<string, unknown>
  44. const requiredSince = record.requiredSince
  45. if (typeof requiredSince !== 'string' || !isIsoDate(requiredSince)) {
  46. throw new Error(`translation-pairing.manifest.json: requiredSince must be a valid YYYY-MM-DD date; got ${JSON.stringify(requiredSince)}`)
  47. }
  48. return {
  49. required: stringArrayField(record, 'required'),
  50. excluded: stringArrayField(record, 'excluded'),
  51. requiredSince,
  52. }
  53. }
  54. /** Return the leading date of a `yyyy-mm-dd-*.md` basename, if present. */
  55. export function datedDocumentDate(file: string): string | undefined {
  56. return DATED_DOCUMENT.exec(file)?.[1]
  57. }
  58. /** Whether a date-named document falls on or after the pairing cutoff. */
  59. export function requiresPairByDate(file: string, requiredSince: string): boolean {
  60. const date = datedDocumentDate(file)
  61. return date !== undefined && date >= requiredSince
  62. }
  63. /** The structural surface compared between the two sides of a pair. */
  64. export interface TranslationStructureSignature {
  65. /** Heading depths in document order (h2 -> 2). */
  66. headings: number[]
  67. /** Fenced code blocks verbatim: info string plus content, in order. */
  68. code: string[]
  69. /** Row and column count of each table, in order. */
  70. tables: string[]
  71. /** Kind, ordered-list start, and direct item count of each list, in order. */
  72. lists: string[]
  73. /** Every link target in order; the language switcher is excluded. */
  74. links: string[]
  75. }
  76. /** Parse Markdown with the same GFM extensions used by the pairing gate. */
  77. export function parseTranslationMarkdown(content: string): Nodes {
  78. return fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
  79. }
  80. /** Whether the tree contains a link to exactly `target`. */
  81. export function linksTo(tree: Nodes, target: string): boolean {
  82. let found = false
  83. const visit = (node: Nodes): void => {
  84. if (node.type === 'link' && node.url === target) found = true
  85. if ('children' in node) for (const child of node.children) visit(child)
  86. }
  87. visit(tree)
  88. return found
  89. }
  90. /** Collect the ordered structural signature, skipping one switcher target. */
  91. export function translationStructureSignature(tree: Nodes, switcherTarget: string): TranslationStructureSignature {
  92. const sig: TranslationStructureSignature = { headings: [], code: [], tables: [], lists: [], links: [] }
  93. const visit = (node: Nodes): void => {
  94. switch (node.type) {
  95. case 'heading':
  96. sig.headings.push(node.depth)
  97. break
  98. case 'code':
  99. sig.code.push(`\`\`\`${node.lang ?? ''}${node.meta ? ` ${node.meta}` : ''}\n${node.value}`)
  100. break
  101. case 'table':
  102. sig.tables.push(`${node.children.length}x${node.children[0]?.children.length ?? 0}`)
  103. break
  104. case 'list':
  105. sig.lists.push(node.ordered
  106. ? `ordered:start=${node.start ?? 1}:items=${node.children.length}`
  107. : `bullet:items=${node.children.length}`)
  108. break
  109. case 'link':
  110. if (node.url !== switcherTarget) sig.links.push(node.url)
  111. break
  112. default:
  113. // Every other node kind is prose or a container, not part of the signature.
  114. break
  115. }
  116. if ('children' in node) for (const child of node.children) visit(child)
  117. }
  118. visit(tree)
  119. return sig
  120. }
  121. /** Render a signature element for an error message, truncated for readability. */
  122. function show(value: string | number | undefined): string {
  123. if (value === undefined) return 'nothing'
  124. const text = JSON.stringify(value)
  125. return text.length > 72 ? `${text.slice(0, 72)}…` : text
  126. }
  127. /** Return the first divergence for each structural field; empty means equal. */
  128. export function translationStructureDiff(
  129. source: TranslationStructureSignature,
  130. zh: TranslationStructureSignature,
  131. ): string[] {
  132. const out: string[] = []
  133. const fields: [string, (string | number)[], (string | number)[]][] = [
  134. ['heading (depth)', source.headings, zh.headings],
  135. ['code block', source.code, zh.code],
  136. ['table (row x column count)', source.tables, zh.tables],
  137. ['list (kind, start, item count)', source.lists, zh.lists],
  138. ['link target', source.links, zh.links],
  139. ]
  140. for (const [field, sourceValues, zhValues] of fields) {
  141. const length = Math.max(sourceValues.length, zhValues.length)
  142. for (let index = 0; index < length; index++) {
  143. if (sourceValues[index] !== zhValues[index]) {
  144. out.push(`${field} #${index + 1} diverges between the pair: ${show(sourceValues[index])} vs ${show(zhValues[index])}`)
  145. break
  146. }
  147. }
  148. }
  149. return out
  150. }