|
| 1 | +import { fromMarkdown } from "mdast-util-from-markdown"; |
| 2 | +import { toString } from "mdast-util-to-string"; |
| 3 | +import { directive } from "micromark-extension-directive"; |
| 4 | +import { |
| 5 | + directiveFromMarkdown, |
| 6 | + type LeafDirective, |
| 7 | +} from "mdast-util-directive"; |
| 8 | +import type { ListItem, RootContent } from "mdast"; |
| 9 | + |
| 10 | +const docSectionDirectiveName = "doc-section"; |
| 11 | + |
| 12 | +const parseMarkdown = (markdown: string) => |
| 13 | + fromMarkdown(markdown, { |
| 14 | + extensions: [directive()], |
| 15 | + mdastExtensions: [directiveFromMarkdown()], |
| 16 | + }); |
| 17 | + |
| 18 | +const getStartOffset = (node: RootContent) => node.position?.start.offset ?? 0; |
| 19 | + |
| 20 | +const getNodeText = (node: RootContent | ListItem) => toString(node); |
| 21 | + |
| 22 | +const getSectionEndIndex = ( |
| 23 | + children: readonly RootContent[], |
| 24 | + startIndex: number, |
| 25 | + maxHeadingDepth: number | undefined |
| 26 | +) => { |
| 27 | + for (let index = startIndex; index < children.length; index += 1) { |
| 28 | + const node = children[index]; |
| 29 | + if (node?.type !== "heading") { |
| 30 | + continue; |
| 31 | + } |
| 32 | + if (maxHeadingDepth === undefined || (node.depth ?? 0) <= maxHeadingDepth) { |
| 33 | + return index; |
| 34 | + } |
| 35 | + } |
| 36 | + return children.length; |
| 37 | +}; |
| 38 | + |
| 39 | +const getMarkedSectionNodes = ( |
| 40 | + children: readonly RootContent[], |
| 41 | + directiveIndex: number |
| 42 | +) => { |
| 43 | + const firstNode = children[directiveIndex + 1]; |
| 44 | + if (firstNode === undefined) { |
| 45 | + return []; |
| 46 | + } |
| 47 | + const startsWithHeading = firstNode.type === "heading"; |
| 48 | + const contentStartIndex = startsWithHeading |
| 49 | + ? directiveIndex + 2 |
| 50 | + : directiveIndex + 1; |
| 51 | + const endIndex = getSectionEndIndex( |
| 52 | + children, |
| 53 | + contentStartIndex, |
| 54 | + startsWithHeading ? (firstNode.depth ?? 0) : undefined |
| 55 | + ); |
| 56 | + return children.slice(contentStartIndex, endIndex); |
| 57 | +}; |
| 58 | + |
| 59 | +const isDocSectionDirective = (node: RootContent): node is LeafDirective => |
| 60 | + node.type === "leafDirective" && node.name === docSectionDirectiveName; |
| 61 | + |
| 62 | +const getDirectiveRemovalEnd = (markdown: string, node: LeafDirective) => { |
| 63 | + let end = node.position?.end.offset ?? 0; |
| 64 | + while (markdown[end] === "\n") { |
| 65 | + end += 1; |
| 66 | + } |
| 67 | + return end; |
| 68 | +}; |
| 69 | + |
| 70 | +export const stripDocMeta = (markdown: string) => { |
| 71 | + const tree = parseMarkdown(markdown); |
| 72 | + const ranges = tree.children |
| 73 | + .filter(isDocSectionDirective) |
| 74 | + .map((node) => ({ |
| 75 | + start: getStartOffset(node), |
| 76 | + end: getDirectiveRemovalEnd(markdown, node), |
| 77 | + })) |
| 78 | + .sort((left, right) => right.start - left.start); |
| 79 | + let strippedMarkdown = markdown; |
| 80 | + for (const range of ranges) { |
| 81 | + strippedMarkdown = |
| 82 | + strippedMarkdown.slice(0, range.start) + |
| 83 | + strippedMarkdown.slice(range.end); |
| 84 | + } |
| 85 | + return strippedMarkdown; |
| 86 | +}; |
| 87 | + |
| 88 | +const getListItemText = (node: ListItem) => |
| 89 | + getNodeText(node).replace(/\s+/g, " ").trim(); |
| 90 | + |
| 91 | +const getListItems = (nodes: readonly RootContent[]) => |
| 92 | + nodes.flatMap((node) => |
| 93 | + node.type === "list" ? node.children.map(getListItemText) : [] |
| 94 | + ); |
| 95 | + |
| 96 | +export const getTitle = (markdown: string) => { |
| 97 | + const heading = parseMarkdown(markdown).children.find( |
| 98 | + (node) => node.type === "heading" && node.depth === 1 |
| 99 | + ); |
| 100 | + return heading === undefined ? "" : getNodeText(heading).trim(); |
| 101 | +}; |
| 102 | + |
| 103 | +export const buildDocSections = (docs: Record<string, string>) => |
| 104 | + Object.fromEntries( |
| 105 | + Object.entries(docs).map(([docName, markdown]) => { |
| 106 | + const tree = parseMarkdown(markdown); |
| 107 | + const docSections: Record<string, string[]> = {}; |
| 108 | + tree.children.forEach((node, index) => { |
| 109 | + if (isDocSectionDirective(node) === false) { |
| 110 | + return; |
| 111 | + } |
| 112 | + const fieldName = node.attributes?.field; |
| 113 | + if (typeof fieldName !== "string" || fieldName.length === 0) { |
| 114 | + throw new Error(`Missing doc-section field ${docName}`); |
| 115 | + } |
| 116 | + const items = getListItems(getMarkedSectionNodes(tree.children, index)); |
| 117 | + if (items.length === 0) { |
| 118 | + throw new Error( |
| 119 | + `Missing generated doc section ${docName}:${fieldName}` |
| 120 | + ); |
| 121 | + } |
| 122 | + docSections[fieldName] = [...(docSections[fieldName] ?? []), ...items]; |
| 123 | + }); |
| 124 | + if ( |
| 125 | + docName.startsWith("manual-") && |
| 126 | + Object.keys(docSections).length === 0 |
| 127 | + ) { |
| 128 | + throw new Error(`Missing generated doc sections ${docName}`); |
| 129 | + } |
| 130 | + return [docName, docSections]; |
| 131 | + }) |
| 132 | + ); |
| 133 | + |
| 134 | +export const buildDocTitles = (docs: Record<string, string>) => |
| 135 | + Object.fromEntries( |
| 136 | + Object.entries(docs).map(([docName, markdown]) => { |
| 137 | + const title = getTitle(markdown); |
| 138 | + if (title === "" && docName.startsWith("manual-")) { |
| 139 | + throw new Error(`Missing generated doc title ${docName}`); |
| 140 | + } |
| 141 | + return [docName, title === "" ? docName : title]; |
| 142 | + }) |
| 143 | + ); |
0 commit comments