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

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