markdown.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  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. /** Parse GitHub-flavored Markdown with the repository's standard extensions. */
  21. export function parseMarkdown(source: string): Nodes {
  22. return fromMarkdown(source, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
  23. }
  24. /**
  25. * Visit a Markdown tree depth-first; returning false prunes a node's children.
  26. * @param node - current tree node.
  27. * @param visitor - callback invoked before each node's children.
  28. */
  29. export function visitMarkdown(node: Nodes, visitor: (node: Nodes) => boolean | void): void {
  30. if (visitor(node) === false) return
  31. if ('children' in node) {
  32. for (const child of node.children) visitMarkdown(child, visitor)
  33. }
  34. }
  35. /** Text a reader sees from one Markdown node; raw HTML itself contributes none. */
  36. function renderedText(node: Nodes): string {
  37. if (node.type === 'text' || node.type === 'inlineCode') return node.value
  38. if (node.type === 'image' || node.type === 'imageReference') return node.alt ?? ''
  39. if (node.type === 'break') return ' '
  40. if ('children' in node) return node.children.map(child => renderedText(child)).join('')
  41. return ''
  42. }
  43. /** Return every parsed Markdown heading with its rendered text and source line. */
  44. export function markdownHeadingLines(source: string): MarkdownHeadingLine[] {
  45. const rawLines = source.split('\n')
  46. const headings: MarkdownHeadingLine[] = []
  47. visitMarkdown(parseMarkdown(source), (node) => {
  48. if (node.type !== 'heading' || node.position === undefined) return
  49. headings.push({
  50. depth: node.depth,
  51. index: node.position.start.line,
  52. raw: rawLines[node.position.start.line - 1] ?? '',
  53. text: renderedText(node),
  54. })
  55. })
  56. return headings
  57. }
  58. type ColumnRange = readonly [start: number, end: number]
  59. type OffsetRange = readonly [start: number, end: number]
  60. /** Source-column ranges occupied by parsed HTML comments, keyed by source line. */
  61. function htmlCommentRanges(source: string, rawLines: readonly string[]): Map<number, ColumnRange[]> {
  62. const comments: OffsetRange[] = []
  63. visitMarkdown(parseMarkdown(source), (node) => {
  64. if (node.type !== 'html' || node.position?.start.offset === undefined) return
  65. let cursor = 0
  66. while (true) {
  67. const start = node.value.indexOf('<!--', cursor)
  68. if (start < 0) break
  69. const close = node.value.indexOf('-->', start + '<!--'.length)
  70. const end = close < 0 ? node.value.length : close + '-->'.length
  71. comments.push([node.position.start.offset + start, node.position.start.offset + end])
  72. cursor = end
  73. }
  74. })
  75. const ranges = new Map<number, ColumnRange[]>()
  76. let lineOffset = 0
  77. rawLines.forEach((raw, index) => {
  78. const lineEnd = lineOffset + raw.length
  79. for (const [start, end] of comments) {
  80. const from = Math.max(start, lineOffset)
  81. const to = Math.min(end, lineEnd)
  82. const coversEmptyLine = raw.length === 0 && start <= lineOffset && end > lineOffset
  83. if (from < to || coversEmptyLine) {
  84. const lineRanges = ranges.get(index + 1) ?? []
  85. lineRanges.push([from - lineOffset, to - lineOffset])
  86. ranges.set(index + 1, lineRanges)
  87. }
  88. }
  89. lineOffset = lineEnd + 1
  90. })
  91. return ranges
  92. }
  93. /** Whether a source line retains non-whitespace text after HTML comments disappear. */
  94. function hasRenderedTextOutsideComments(raw: string, ranges: readonly ColumnRange[] | undefined): boolean {
  95. if (ranges === undefined) return true
  96. let cursor = 0
  97. let visible = ''
  98. for (const [start, end] of [...ranges].sort((left, right) => left[0] - right[0])) {
  99. visible += raw.slice(cursor, start)
  100. cursor = Math.max(cursor, end)
  101. }
  102. visible += raw.slice(cursor)
  103. return visible.trim().length > 0
  104. }
  105. /**
  106. * Return source lines outside backtick or tilde fences and HTML comments.
  107. * @param source - Markdown source whose prose should be retained verbatim.
  108. * @returns unfenced lines with their original 1-based locations.
  109. */
  110. export function markdownProseLines(source: string): MarkdownProseLine[] {
  111. let fence: { marker: '`' | '~'; length: number } | undefined
  112. const kept: MarkdownProseLine[] = []
  113. const rawLines = source.split('\n')
  114. const comments = htmlCommentRanges(source, rawLines)
  115. rawLines.forEach((raw, i) => {
  116. const token = /^ {0,3}(`{3,}|~{3,})/.exec(raw)?.[1]
  117. if (token !== undefined) {
  118. const marker = token[0] as '`' | '~'
  119. if (fence === undefined) {
  120. fence = { marker, length: token.length }
  121. } else if (marker === fence.marker && token.length >= fence.length) {
  122. fence = undefined
  123. }
  124. return
  125. }
  126. if (fence === undefined && hasRenderedTextOutsideComments(raw, comments.get(i + 1))) {
  127. kept.push({ index: i + 1, raw })
  128. }
  129. })
  130. return kept
  131. }