translation-links.spec.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. /** Regression coverage for locale-aware bilingual Markdown links. */
  2. import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import {
  7. normalizeTranslationMarkdownLinks,
  8. rewriteTranslationLinkLocales,
  9. translationLinkLocaleViolations,
  10. type TranslationLinkContext,
  11. } from './translation-links.ts'
  12. import { removeFixtureSafely } from './test-fixture-cleanup.ts'
  13. const roots: string[] = []
  14. afterEach(() => {
  15. for (const root of roots.splice(0)) removeFixtureSafely(root)
  16. })
  17. function fixture(): string {
  18. const root = mkdtempSync(join(tmpdir(), 'dsh-translation-links-'))
  19. roots.push(root)
  20. mkdirSync(join(root, 'docs/section'), { recursive: true })
  21. mkdirSync(join(root, 'packages'), { recursive: true })
  22. writeFileSync(join(root, 'docs/guide.md'), '# Guide\n')
  23. writeFileSync(join(root, 'docs/guide.zh.md'), '# 指南\n')
  24. writeFileSync(join(root, 'docs/reference.md'), '# Overview\n')
  25. writeFileSync(join(root, 'docs/reference.zh.md'), '# 概览\n')
  26. writeFileSync(join(root, 'docs/unpaired.md'), '# Only\n')
  27. writeFileSync(join(root, 'docs/section/index.md'), '# Section\n')
  28. writeFileSync(join(root, 'docs/section/index.zh.md'), '# 章节\n')
  29. writeFileSync(join(root, 'packages/outside.md'), '# Outside\n')
  30. writeFileSync(join(root, 'packages/outside.zh.md'), '# 范围外\n')
  31. return root
  32. }
  33. function linkContext(
  34. root: string,
  35. sourcePath: string,
  36. repositoryFileExists?: (repoPath: string) => boolean,
  37. ): TranslationLinkContext {
  38. return {
  39. repoRoot: root,
  40. sourcePath,
  41. isTranslationPairSource: path => path.startsWith('docs/'),
  42. ...(repositoryFileExists === undefined ? {} : { repositoryFileExists }),
  43. }
  44. }
  45. function expectUnchangedLinkInput(root: string, input: string): void {
  46. const context = linkContext(root, 'docs/guide.md')
  47. expect(translationLinkLocaleViolations(input, context)).toEqual([])
  48. expect(rewriteTranslationLinkLocales(input, context)).toEqual({ content: input, rewritten: 0 })
  49. expect(normalizeTranslationMarkdownLinks(input, context)).toBe(input)
  50. }
  51. describe('translation link locale validation', () => {
  52. it('rejects a Chinese link to the English sibling with an exact diagnostic', () => {
  53. const root = fixture()
  54. expect(translationLinkLocaleViolations(
  55. '# 指南\n\n正文。\n\n[概览](reference.md?view=full#overview)\n',
  56. linkContext(root, 'docs/guide.zh.md'),
  57. )).toEqual([{
  58. sourcePath: 'docs/guide.zh.md',
  59. line: 5,
  60. url: 'reference.md?view=full#overview',
  61. expectedUrl: 'reference.zh.md?view=full#overview',
  62. }])
  63. })
  64. it('rewrites an encoded exact filename without changing its query or fragment suffix', () => {
  65. const root = fixture()
  66. const input = '[概览](reference%2Emd?view=full&mode=all#overview)\n'
  67. expect(translationLinkLocaleViolations(
  68. input,
  69. linkContext(root, 'docs/guide.zh.md'),
  70. )[0]).toMatchObject({
  71. url: 'reference%2Emd?view=full&mode=all#overview',
  72. expectedUrl: 'reference.zh.md?view=full&mode=all#overview',
  73. })
  74. expect(rewriteTranslationLinkLocales(input, linkContext(root, 'docs/guide.zh.md'))).toEqual({
  75. content: '[概览](reference.zh.md?view=full&mode=all#overview)\n',
  76. rewritten: 1,
  77. })
  78. })
  79. it('encodes each exact path segment with only RFC 3986 unreserved characters', () => {
  80. const root = fixture()
  81. const input = '[保留](a%29%23%3Fb%2Emd?view=full#section)\n'
  82. const repositoryFiles = new Set(['docs/a)#?b.md', 'docs/a)#?b.zh.md'])
  83. expect(rewriteTranslationLinkLocales(
  84. input,
  85. linkContext(root, 'docs/guide.zh.md', path => repositoryFiles.has(path)),
  86. )).toEqual({
  87. content: '[保留](a%29%23%3Fb.zh.md?view=full#section)\n',
  88. rewritten: 1,
  89. })
  90. })
  91. it('accepts the target-locale sibling and an out-of-scope target with its own sibling', () => {
  92. const root = fixture()
  93. expect(translationLinkLocaleViolations(
  94. '[paired](reference.zh.md) [outside](../packages/outside.md)\n',
  95. linkContext(root, 'docs/guide.zh.md'),
  96. )).toEqual([])
  97. })
  98. it('does not fall back when an active target is missing its locale sibling', () => {
  99. const root = fixture()
  100. expect(translationLinkLocaleViolations(
  101. '[missing](unpaired.md)\n',
  102. linkContext(root, 'docs/guide.zh.md'),
  103. )[0]).toMatchObject({ expectedUrl: 'unpaired.zh.md' })
  104. })
  105. it('requires English sources to use the English sibling', () => {
  106. const root = fixture()
  107. expect(translationLinkLocaleViolations(
  108. '[Reference](reference.zh.md)\n',
  109. linkContext(root, 'docs/guide.md'),
  110. )[0]).toMatchObject({
  111. url: 'reference.zh.md',
  112. expectedUrl: 'reference.md',
  113. })
  114. })
  115. it('does not infer an index page from a directory target', () => {
  116. const root = fixture()
  117. const input = '[Section](section/)\n'
  118. expect(translationLinkLocaleViolations(input, linkContext(root, 'docs/guide.zh.md'))).toEqual([])
  119. expect(rewriteTranslationLinkLocales(input, linkContext(root, 'docs/guide.zh.md')))
  120. .toEqual({ content: input, rewritten: 0 })
  121. })
  122. it('exempts the language switcher target explicitly', () => {
  123. const root = fixture()
  124. expect(translationLinkLocaleViolations(
  125. '# 指南\n\n[English](guide.md) | 中文\n',
  126. linkContext(root, 'docs/guide.zh.md'),
  127. ['guide.md'],
  128. )).toEqual([])
  129. })
  130. it('does not exempt an ordinary body link to the counterpart', () => {
  131. const root = fixture()
  132. const markdown = '# 指南\n\n[English](guide.md) | 中文\n\n[正文](guide.md)\n'
  133. expect(translationLinkLocaleViolations(
  134. markdown,
  135. linkContext(root, 'docs/guide.zh.md'),
  136. ['guide.md'],
  137. )).toEqual([{
  138. sourcePath: 'docs/guide.zh.md',
  139. line: 5,
  140. url: 'guide.md',
  141. expectedUrl: 'guide.zh.md',
  142. }])
  143. expect(rewriteTranslationLinkLocales(
  144. markdown,
  145. linkContext(root, 'docs/guide.zh.md'),
  146. ['guide.md'],
  147. ).content).toBe('# 指南\n\n[English](guide.md) | 中文\n\n[正文](guide.zh.md)\n')
  148. })
  149. it('uses the selected content plane for target existence without deriving scope from siblings', () => {
  150. const root = fixture()
  151. const staged = new Set(['docs/reference.md', 'docs/reference.zh.md'])
  152. expect(translationLinkLocaleViolations(
  153. '[概览](reference.md)\n',
  154. linkContext(root, 'docs/guide.zh.md', path => staged.has(path)),
  155. )).toHaveLength(1)
  156. staged.delete('docs/reference.zh.md')
  157. expect(translationLinkLocaleViolations(
  158. '[概览](reference.md)\n',
  159. linkContext(root, 'docs/guide.zh.md', path => staged.has(path)),
  160. )).toHaveLength(1)
  161. staged.delete('docs/reference.md')
  162. expect(translationLinkLocaleViolations(
  163. '[概览](reference.md)\n',
  164. linkContext(root, 'docs/guide.zh.md', path => staged.has(path)),
  165. )).toEqual([])
  166. })
  167. })
  168. describe('translation link rewriting and normalization', () => {
  169. it('rewrites only the destination while preserving the suffix and title', () => {
  170. const root = fixture()
  171. const input = '[概览](reference.md?view=full&mode=all#overview "reference.md title")\n'
  172. expect(rewriteTranslationLinkLocales(
  173. input,
  174. linkContext(root, 'docs/guide.zh.md'),
  175. )).toEqual({
  176. content: '[概览](reference.zh.md?view=full&mode=all#overview "reference.md title")\n',
  177. rewritten: 1,
  178. })
  179. })
  180. it('rewrites link definitions without changing their labels', () => {
  181. const root = fixture()
  182. expect(rewriteTranslationLinkLocales(
  183. '[概览][ref]\n\n[ref]: <reference.md#overview> "title"\n',
  184. linkContext(root, 'docs/guide.zh.md'),
  185. ).content).toBe('[概览][ref]\n\n[ref]: <reference.zh.md#overview> "title"\n')
  186. })
  187. it('uses only the first duplicate reference definition', () => {
  188. const root = fixture()
  189. expect(translationLinkLocaleViolations(
  190. '[概览][ref]\n\n[ref]: reference.zh.md\n[ref]: reference.md\n',
  191. linkContext(root, 'docs/guide.zh.md'),
  192. )).toEqual([])
  193. })
  194. it('does not treat an image-only definition as a document link', () => {
  195. const root = fixture()
  196. const input = '![preview][asset]\n\n[asset]: reference.zh.md#overview\n'
  197. expectUnchangedLinkInput(root, input)
  198. })
  199. it.each([
  200. '<https://example.com/reference.md>\n',
  201. 'https://example.com/reference.md\n',
  202. ])('leaves GFM autolink source unchanged: %s', (input) => {
  203. const root = fixture()
  204. expectUnchangedLinkInput(root, input)
  205. })
  206. it('normalizes only paired locale paths and retains other bytes', () => {
  207. const root = fixture()
  208. const english = '[Reference](reference.md#overview) [Outside](../packages/outside.md)\n'
  209. const chinese = '[Reference](reference.zh.md#overview) [Outside](../packages/outside.md)\n'
  210. expect(normalizeTranslationMarkdownLinks(
  211. english,
  212. linkContext(root, 'docs/guide.md'),
  213. )).toBe(normalizeTranslationMarkdownLinks(
  214. chinese,
  215. linkContext(root, 'docs/guide.zh.md'),
  216. ))
  217. })
  218. it('retains authored query bytes during normalization', () => {
  219. const root = fixture()
  220. const escaped = '[Reference](reference.md?x=1&amp;y=2#overview)\n'
  221. const literal = '[Reference](reference.zh.md?x=1&y=2#overview)\n'
  222. expect(normalizeTranslationMarkdownLinks(
  223. escaped,
  224. linkContext(root, 'docs/guide.md'),
  225. )).not.toBe(normalizeTranslationMarkdownLinks(
  226. literal,
  227. linkContext(root, 'docs/guide.zh.md'),
  228. ))
  229. })
  230. })