markdown.ts 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. /** Shared Markdown parsing and depth-first traversal for documentation gates. */
  2. import { fromMarkdown } from 'mdast-util-from-markdown'
  3. import { gfmFromMarkdown } from 'mdast-util-gfm'
  4. import { gfm } from 'micromark-extension-gfm'
  5. import type { Nodes } from 'mdast'
  6. /** One authored Markdown line outside fenced code and rendered-away HTML comments. */
  7. export interface MarkdownProseLine {
  8. /** 1-based source line number. */
  9. index: number
  10. /** Source text without normalization. */
  11. raw: string
  12. }
  13. /** One parsed Markdown heading, retaining its authored first line and rendered text. */
  14. export interface MarkdownHeadingLine extends MarkdownProseLine {
  15. /** Parsed ATX or Setext heading depth. */
  16. depth: 1 | 2 | 3 | 4 | 5 | 6
  17. /** Rendered heading text, excluding raw HTML such as comments. */
  18. text: string
  19. }
  20. /** One code block from a parsed Markdown source. */
  21. export interface MarkdownFence {
  22. /** 1-based source line of the opening fence. */
  23. line: number
  24. /** Info-string language (its first word), null on a bare or indented block. */
  25. lang: string | null
  26. /** Full info string (e.g. `ts ignore-check`), '' on a bare or indented block. */
  27. info: string
  28. /** Block body without the fence delimiters. */
  29. code: string
  30. /**
  31. * Whether a closing fence delimiter terminates the block — mdast silently
  32. * closes an unterminated fence at end of file. False on indented
  33. * (non-fenced) blocks, whose end line is code.
  34. */
  35. closed: boolean
  36. }
  37. /** Parse GitHub-flavored Markdown with the repository's standard extensions. */
  38. export function parseMarkdown(source: string): Nodes {
  39. return fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
  40. }
  41. /**
  42. * Visit a Markdown tree depth-first; returning false prunes a node's children.
  43. * @param node - current tree node.
  44. * @param visitor - callback invoked before each node's children.
  45. */
  46. export function visitMarkdown(node: Nodes, visitor: (node: Nodes) => boolean | void): void {
  47. if (visitor(node) === false) return
  48. if ('children' in node) {
  49. for (const child of node.children) visitMarkdown(child, visitor)
  50. }
  51. }
  52. /**
  53. * Extract every parsed code block with its info string, in document order.
  54. * @param source - Markdown source to scan.
  55. * @returns each block's opening line, language, info string, and body.
  56. */
  57. export function markdownFences(source: string): MarkdownFence[] {
  58. const lines = source.split('\n')
  59. const fences: MarkdownFence[] = []
  60. visitMarkdown(parseMarkdown(source), (node) => {
  61. if (node.type !== 'code' || node.position === undefined) return
  62. const lang = node.lang ?? null
  63. const meta = node.meta ?? ''
  64. const info = lang === null ? '' : meta === '' ? lang : `${lang} ${meta}`
  65. const endLine = lines[node.position.end.line - 1] ?? ''
  66. const closed = /^ {0,3}(`{3,}|~{3,})\s*$/.test(endLine)
  67. fences.push({ line: node.position.start.line, lang, info, code: node.value, closed })
  68. })
  69. return fences
  70. }
  71. /** Text a reader sees from one Markdown node; raw HTML itself contributes none. */
  72. function renderedText(node: Nodes): string {
  73. if (node.type === 'text' || node.type === 'inlineCode') return node.value
  74. if (node.type === 'image' || node.type === 'imageReference') return node.alt ?? ''
  75. if (node.type === 'break') return ' '
  76. if ('children' in node) return node.children.map(child => renderedText(child)).join('')
  77. return ''
  78. }
  79. /** Return every parsed Markdown heading with its rendered text and source line. */
  80. export function markdownHeadingLines(source: string): MarkdownHeadingLine[] {
  81. const rawLines = source.split('\n')
  82. const headings: MarkdownHeadingLine[] = []
  83. visitMarkdown(parseMarkdown(source), (node) => {
  84. if (node.type !== 'heading' || node.position === undefined) return
  85. headings.push({
  86. depth: node.depth,
  87. index: node.position.start.line,
  88. raw: rawLines[node.position.start.line - 1] ?? '',
  89. text: renderedText(node),
  90. })
  91. })
  92. return headings
  93. }
  94. type ColumnRange = readonly [start: number, end: number]
  95. type OffsetRange = readonly [start: number, end: number]
  96. /** Source-column ranges occupied by parsed HTML comments, keyed by source line. */
  97. function htmlCommentRanges(source: string, rawLines: readonly string[]): Map<number, ColumnRange[]> {
  98. const comments: OffsetRange[] = []
  99. visitMarkdown(parseMarkdown(source), (node) => {
  100. if (node.type !== 'html' || node.position?.start.offset === undefined) return
  101. let cursor = 0
  102. while (true) {
  103. const start = node.value.indexOf('<!--', cursor)
  104. if (start < 0) break
  105. const close = node.value.indexOf('-->', start + '<!--'.length)
  106. const end = close < 0 ? node.value.length : close + '-->'.length
  107. comments.push([node.position.start.offset + start, node.position.start.offset + end])
  108. cursor = end
  109. }
  110. })
  111. const ranges = new Map<number, ColumnRange[]>()
  112. let lineOffset = 0
  113. rawLines.forEach((raw, index) => {
  114. const lineEnd = lineOffset + raw.length
  115. for (const [start, end] of comments) {
  116. const from = Math.max(start, lineOffset)
  117. const to = Math.min(end, lineEnd)
  118. const coversEmptyLine = raw.length === 0 && start <= lineOffset && end > lineOffset
  119. if (from < to || coversEmptyLine) {
  120. const lineRanges = ranges.get(index + 1) ?? []
  121. lineRanges.push([from - lineOffset, to - lineOffset])
  122. ranges.set(index + 1, lineRanges)
  123. }
  124. }
  125. lineOffset = lineEnd + 1
  126. })
  127. return ranges
  128. }
  129. /** Whether a source line retains non-whitespace text after HTML comments disappear. */
  130. function hasRenderedTextOutsideComments(raw: string, ranges: readonly ColumnRange[] | undefined): boolean {
  131. if (ranges === undefined) return true
  132. let cursor = 0
  133. let visible = ''
  134. for (const [start, end] of [...ranges].sort((left, right) => left[0] - right[0])) {
  135. visible += raw.slice(cursor, start)
  136. cursor = Math.max(cursor, end)
  137. }
  138. visible += raw.slice(cursor)
  139. return visible.trim().length > 0
  140. }
  141. /**
  142. * Return source lines outside code blocks and HTML comments.
  143. * @param source - Markdown source whose prose should be retained verbatim.
  144. * @returns unfenced lines with their original 1-based locations.
  145. */
  146. export function markdownProseLines(source: string): MarkdownProseLine[] {
  147. const rawLines = source.split('\n')
  148. const comments = htmlCommentRanges(source, rawLines)
  149. const fenced = new Set<number>()
  150. visitMarkdown(parseMarkdown(source), (node) => {
  151. if (node.type !== 'code' || node.position === undefined) return
  152. for (let line = node.position.start.line; line <= node.position.end.line; line += 1) fenced.add(line)
  153. })
  154. const kept: MarkdownProseLine[] = []
  155. rawLines.forEach((raw, i) => {
  156. if (fenced.has(i + 1)) return
  157. if (hasRenderedTextOutsideComments(raw, comments.get(i + 1))) {
  158. kept.push({ index: i + 1, raw })
  159. }
  160. })
  161. return kept
  162. }