markdown-render-units.client.spec.tsx 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. // @vitest-environment jsdom
  2. // Branch coverage for the mdast renderer that real parses cannot reach: the
  3. // grammar only emits references whose definitions exist, always stamps
  4. // positions and align arrays, and never emits bare list items — but the
  5. // renderer is a pure function over mdast, so hand-built trees exercise its
  6. // defensive arms directly.
  7. import { StrictMode } from 'react'
  8. import { cleanup, render } from '@testing-library/react'
  9. import { afterEach, describe, expect, it } from 'vitest'
  10. import type * as Md from 'mdast'
  11. import { MarkdownText } from './markdown-test-components.tsx'
  12. import { markdownLabels } from './labels.client.ts'
  13. import {
  14. collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection,
  15. } from '../src/markdown/render.tsx'
  16. import type { MarkdownRenderContext } from '../src/markdown/render.tsx'
  17. afterEach(cleanup)
  18. function makeContext(): MarkdownRenderContext {
  19. return {
  20. streaming: false,
  21. labels: markdownLabels,
  22. fileMentions: undefined,
  23. pathImages: undefined,
  24. targets: createReferenceTargets(),
  25. footnoteOrder: [],
  26. footnoteCounts: new Map(),
  27. }
  28. }
  29. function renderNodes(nodes: Md.RootContent[], context = makeContext()): HTMLElement {
  30. const { container } = render(
  31. <div>{renderBlocks(nodes.map((node, key) => ({ node, key })), context)}</div>,
  32. )
  33. return container
  34. }
  35. const text = (value: string): Md.Text => ({ type: 'text', value })
  36. describe('renderBlocks over hand-built trees', () => {
  37. it('reverts unresolved references to their bracketed source', () => {
  38. const container = renderNodes([
  39. {
  40. type: 'paragraph',
  41. children: [
  42. { type: 'linkReference', identifier: 'a', referenceType: 'shortcut', children: [text('one')] },
  43. { type: 'linkReference', identifier: 'b', referenceType: 'collapsed', children: [text('two')] },
  44. { type: 'linkReference', identifier: 'c', label: 'C', referenceType: 'full', children: [text('three')] },
  45. { type: 'imageReference', identifier: 'd', referenceType: 'full', alt: 'pic' },
  46. { type: 'imageReference', identifier: 'e', referenceType: 'shortcut', alt: null },
  47. ],
  48. },
  49. ])
  50. expect(container.textContent).toBe('[one][two][][three][C]![pic][d]![]')
  51. expect(container.querySelector('a')).toBeNull()
  52. })
  53. it('keeps the first definition when identifiers repeat', () => {
  54. const targets = createReferenceTargets()
  55. collectReferenceTargets([
  56. { type: 'definition', identifier: 'dup', url: 'https://example.com/first' },
  57. { type: 'definition', identifier: 'dup', url: 'https://example.com/second' },
  58. { type: 'footnoteDefinition', identifier: 'fn', children: [] },
  59. { type: 'footnoteDefinition', identifier: 'fn', children: [{ type: 'paragraph', children: [text('late')] }] },
  60. ], targets)
  61. expect(targets.definitions.get('DUP')?.url).toBe('https://example.com/first')
  62. expect(targets.footnotes.get('FN')?.children).toEqual([])
  63. })
  64. it('renders a bare list item, computing looseness from the item itself', () => {
  65. const item: Md.ListItem = {
  66. type: 'listItem',
  67. spread: null,
  68. children: [
  69. { type: 'paragraph', children: [text('alpha')] },
  70. { type: 'paragraph', children: [text('beta')] },
  71. ],
  72. }
  73. const container = renderNodes([item])
  74. // Two block children make the parentless item loose: paragraphs stay wrapped.
  75. expect([...container.querySelectorAll('li > p')].map(p => p.textContent)).toEqual(['alpha', 'beta'])
  76. })
  77. it('renders spread-null lists and align-less tables', () => {
  78. const container = renderNodes([
  79. {
  80. type: 'list',
  81. ordered: false,
  82. spread: null,
  83. children: [{ type: 'listItem', spread: null, children: [{ type: 'paragraph', children: [text('solo')] }] }],
  84. },
  85. {
  86. type: 'table',
  87. children: [
  88. { type: 'tableRow', children: [{ type: 'tableCell', children: [text('h')] }] },
  89. { type: 'tableRow', children: [{ type: 'tableCell', children: [text('short')] }] },
  90. ],
  91. },
  92. ])
  93. expect(container.querySelector('li')?.textContent).toBe('solo')
  94. expect(container.querySelector('th')?.getAttribute('style')).toBeNull()
  95. expect(container.querySelector('td')?.textContent).toBe('short')
  96. })
  97. it('renders a rowless align-less table as an empty fill wrapper', () => {
  98. // Zero columns is below the wide threshold, so the fill arm applies.
  99. const container = renderNodes([{ type: 'table', children: [] }])
  100. const wrapper = container.querySelector('table')?.parentElement
  101. expect(wrapper?.className).not.toContain('md-table-wide')
  102. expect(container.querySelector('table')?.childElementCount).toBe(0)
  103. })
  104. it('pads rows against the alignment width with empty cells', () => {
  105. const container = renderNodes([
  106. {
  107. type: 'table',
  108. align: ['left', 'right'],
  109. children: [
  110. { type: 'tableRow', children: [{ type: 'tableCell', children: [text('only')] }] },
  111. ],
  112. },
  113. ])
  114. const cells = [...container.querySelectorAll('th')]
  115. expect(cells).toHaveLength(2)
  116. expect(cells[1]?.textContent).toBe('')
  117. })
  118. it('renders a checked item without any content as a bare checkbox', () => {
  119. const container = renderNodes([
  120. {
  121. type: 'list',
  122. ordered: false,
  123. children: [
  124. { type: 'listItem', checked: true, children: [] },
  125. { type: 'listItem', checked: false, children: [{ type: 'paragraph', children: [] }] },
  126. ],
  127. },
  128. ])
  129. const items = [...container.querySelectorAll('li.task-list-item')]
  130. expect(items).toHaveLength(2)
  131. for (const item of items) {
  132. expect(item.querySelector('input[type="checkbox"]')).not.toBeNull()
  133. expect(item.textContent?.trim()).toBe('')
  134. }
  135. })
  136. it('renders images with a null alt as an empty alt attribute', () => {
  137. const targets = createReferenceTargets()
  138. targets.definitions.set('R', { type: 'definition', identifier: 'r', url: 'https://example.com/r.png' })
  139. const container = renderNodes([
  140. { type: 'paragraph', children: [{ type: 'image', url: 'https://example.com/x.png', alt: null }] },
  141. { type: 'paragraph', children: [{ type: 'imageReference', identifier: 'r', referenceType: 'full', alt: null }] },
  142. ], { ...makeContext(), targets })
  143. const images = [...container.querySelectorAll('img')]
  144. expect(images.map(image => image.getAttribute('alt'))).toEqual(['', ''])
  145. })
  146. it('drops a definition nested in a list item without leaving a separator behind', () => {
  147. const container = renderNodes([
  148. {
  149. type: 'list',
  150. ordered: true,
  151. start: 3,
  152. children: [{
  153. type: 'listItem',
  154. children: [
  155. { type: 'paragraph', children: [text('body')] },
  156. { type: 'definition', identifier: 'x', url: 'https://example.com' },
  157. ],
  158. }],
  159. },
  160. ])
  161. expect(container.querySelector('ol')?.getAttribute('start')).toBe('3')
  162. // The two mdast children make the item loose (wrap newlines around the
  163. // paragraph); the dropped definition contributes nothing else.
  164. expect(container.querySelector('li')?.textContent).toBe('\nbody\n')
  165. })
  166. it('renders nothing for node types without a mapping', () => {
  167. const container = renderNodes([
  168. { type: 'yaml', value: 'front: matter' },
  169. { type: 'tableRow', children: [] },
  170. { type: 'paragraph', children: [text('after')] },
  171. ])
  172. expect(container.textContent).toBe('after')
  173. })
  174. })
  175. describe('renderFootnoteSection edge shapes', () => {
  176. it('skips referenced footnotes without definitions and returns null when none remain', () => {
  177. const context = makeContext()
  178. context.footnoteOrder.push('GHOST')
  179. context.footnoteCounts.set('GHOST', 1)
  180. expect(renderFootnoteSection(context)).toBeNull()
  181. })
  182. it('renders no back-reference markers for an uncounted footnote', () => {
  183. const context = makeContext()
  184. context.targets.footnotes.set('Q', {
  185. type: 'footnoteDefinition',
  186. identifier: 'q',
  187. children: [{ type: 'paragraph', children: [text('quiet')] }],
  188. })
  189. context.footnoteOrder.push('Q')
  190. const { container } = render(<div>{renderFootnoteSection(context)}</div>)
  191. expect(container.querySelector('li')?.textContent).toBe('\nquiet \n')
  192. })
  193. it('appends back-references after a non-paragraph body', () => {
  194. const context = makeContext()
  195. context.targets.footnotes.set('N', {
  196. type: 'footnoteDefinition',
  197. identifier: 'n',
  198. children: [{ type: 'code', value: 'code body', lang: null }],
  199. })
  200. context.footnoteOrder.push('N')
  201. context.footnoteCounts.set('N', 1)
  202. const { container } = render(<div>{renderFootnoteSection(context)}</div>)
  203. const item = container.querySelector('li')
  204. expect(item?.querySelector('.md-code-block')).not.toBeNull()
  205. expect(item?.textContent).toContain('↩')
  206. })
  207. })
  208. describe('MarkdownText under StrictMode', () => {
  209. it('streams identically when React double-invokes render work', () => {
  210. const doc = 'one\n\ntwo\n\nthree\n\nfour\n\nfive'
  211. const strict = render(<StrictMode><MarkdownText text={doc.slice(0, 8)} streaming /></StrictMode>)
  212. strict.rerender(<StrictMode><MarkdownText text={doc} streaming /></StrictMode>)
  213. const plain = render(<MarkdownText text={doc} streaming />)
  214. expect(strict.container.innerHTML).toBe(plain.container.innerHTML)
  215. strict.unmount()
  216. plain.unmount()
  217. })
  218. })