markdown-code-example-inserter
Version:
Syncs code examples with markdown documentation.
157 lines (140 loc) • 5.34 kB
text/typescript
import type {Comment} from 'hast';
import type {Code, Html} from 'mdast';
import type {Node, Point, Position} from 'unist';
import {InvalidNodeError} from '../errors/invalid-node.error.js';
import {linkCommentTriggerPhrase, startsWithTriggerPhraseRegExp} from '../trigger-phrase.js';
import {isCodeBlock, isCommentNode, parseMarkdownContents} from './parse-markdown.js';
import {walk} from './walk.js';
export interface FullyDefinedPoint extends Point {
offset: number;
}
export interface FullyDefinedPosition extends Position {
start: FullyDefinedPoint;
end: FullyDefinedPoint;
}
export interface FullyPositionedNode extends Node {
position: FullyDefinedPosition;
}
export type CodeExampleLink = {
node: Readonly<Comment & FullyPositionedNode>;
linkPath: string;
indent: string;
linkedCodeBlock: Readonly<Code & FullyPositionedNode> | undefined;
};
function isExampleLinkComment(input: Node): input is Comment {
return isCommentNode(input) && input.value.trim().startsWith(linkCommentTriggerPhrase);
}
export function extractIndent(
line: string,
node: Readonly<{value: unknown} & FullyPositionedNode>,
): string {
if (typeof node.value === 'string' && line.trim().startsWith(node.value)) {
return line.slice(
0,
// column is 1 indexed so we must remove 1 from it
node.position.start.column - 1,
);
}
return '';
}
export function extractLinks(
markdownFileContents: string | Readonly<Buffer>,
): Readonly<CodeExampleLink>[] {
const parsedRoot = parseMarkdownContents(markdownFileContents);
const markdownLines = markdownFileContents.toString().split('\n');
const commentData: {
comment: Comment;
indent: string;
codeBlock?: Readonly<Code & FullyPositionedNode> | undefined;
}[] = [];
let lastNode: Node | undefined;
let lastHtmlNode: {htmlNode: Html & FullyPositionedNode; indent: string} | undefined;
walk(parsedRoot, 'markdown', (node, language) => {
const lastComment = commentData[commentData.length - 1];
if (language === 'markdown' && isHtmlNode(node)) {
assertFullyPositionedNode(node);
const htmlLine =
markdownLines[
// line is 1 indexed
node.position.start.line - 1
];
if (!htmlLine) {
throw new InvalidNodeError(
node,
`this Html node's position.start.line is not actually a valid line number from the file it's in`,
);
}
lastHtmlNode = {
htmlNode: node,
indent: extractIndent(htmlLine, node),
};
} else if (language === 'html' && isExampleLinkComment(node)) {
if (!lastHtmlNode) {
throw new InvalidNodeError(
node,
'encountered html node without first encountering html root node',
);
}
assertFullyPositionedNode(node);
const newNode = offsetNodePosition(node, lastHtmlNode.htmlNode);
node = newNode;
commentData.push({comment: newNode, indent: lastHtmlNode.indent});
} else if (
language === 'markdown' &&
lastComment &&
lastNode === lastComment.comment &&
isCodeBlock(node)
) {
assertFullyPositionedNode(node);
lastComment.codeBlock = node;
}
lastNode = node;
});
return commentData.map((comment): CodeExampleLink => {
assertFullyPositionedNode(comment.comment);
return {
node: comment.comment,
indent: comment.indent,
linkPath: comment.comment.value
.trim()
.replace(startsWithTriggerPhraseRegExp, '')
.trim(),
linkedCodeBlock: comment.codeBlock,
};
});
}
function offsetNodePosition<T extends Node>(
needsOffset: T & FullyPositionedNode,
offsetBase: FullyPositionedNode,
): T & FullyPositionedNode {
return {
...needsOffset,
position: {
...needsOffset.position,
start: {
...needsOffset.position.start,
line: offsetBase.position.start.line + needsOffset.position.start.line - 1,
offset: offsetBase.position.start.offset + needsOffset.position.start.offset,
},
end: {
...needsOffset.position.end,
line: offsetBase.position.start.line + needsOffset.position.end.line - 1,
offset: offsetBase.position.start.offset + needsOffset.position.end.offset,
},
},
};
}
function isHtmlNode(input: Node): input is Html {
return input.type === 'html';
}
export function assertFullyPositionedNode(node: Node): asserts node is FullyPositionedNode {
if (!node.position) {
throw new InvalidNodeError(node, 'missing position property');
}
if (node.position.end.offset == undefined) {
throw new InvalidNodeError(node, 'missing end position offset');
}
if (node.position.start.offset == undefined) {
throw new InvalidNodeError(node, 'missing start position offset');
}
}