markdown-code
Version:
Keep code examples in Markdown synchronized with actual source files
212 lines (210 loc) • 5.93 kB
JavaScript
// src/parser.ts
import { readFile } from "fs/promises";
import { resolve } from "path";
import { unified } from "unified";
import remarkParse from "remark-parse";
import { visit } from "unist-util-visit";
function parseSnippetDirective(info) {
const snippetMatch = info.match(/snippet=([^\s]+)/);
if (!snippetMatch?.[1]) {
return void 0;
}
const snippetPath = snippetMatch[1];
const lastHashIndex = snippetPath.lastIndexOf("#");
if (lastHashIndex === -1) {
return { filePath: snippetPath };
}
const filePath = snippetPath.substring(0, lastHashIndex);
const lineSpec = snippetPath.substring(lastHashIndex + 1);
if (lineSpec.startsWith("L")) {
const lineRange = lineSpec.substring(1);
const rangeParts = lineRange.split("-");
if (rangeParts.length === 1) {
const line = parseInt(rangeParts[0], 10);
if (isNaN(line) || line < 0 || rangeParts[0] === "") {
return { filePath: snippetPath };
}
return {
filePath,
startLine: line,
endLine: line
};
}
if (rangeParts.length === 2) {
if (rangeParts[0] === "") {
return { filePath: snippetPath };
}
const startLine = parseInt(rangeParts[0], 10);
if (isNaN(startLine) || startLine < 0) {
return { filePath: snippetPath };
}
if (rangeParts[1] === "") {
return {
filePath,
startLine
};
}
const endLine = parseInt(rangeParts[1].replace(/^L/, ""), 10);
if (isNaN(endLine) || endLine < 0) {
return {
filePath,
startLine
};
}
return {
filePath,
startLine,
endLine
};
}
if (rangeParts.length > 2) {
return { filePath: snippetPath };
}
} else {
const lineNumber = parseInt(lineSpec, 10);
if (!isNaN(lineNumber)) {
return {
filePath,
startLine: lineNumber,
endLine: lineNumber
};
}
}
return { filePath: snippetPath };
}
async function parseMarkdownFile(filePath) {
const content = await readFile(filePath, "utf-8");
const tree = unified().use(remarkParse).parse(content);
const codeBlocks = [];
visit(tree, "code", (node) => {
if (!node.lang || !node.meta) {
return;
}
const snippet = parseSnippetDirective(node.meta);
if (!snippet) {
return;
}
codeBlocks.push({
language: node.lang,
content: node.value,
snippet,
position: {
start: node.position?.start.offset ?? 0,
end: node.position?.end.offset ?? 0
},
lineNumber: node.position?.start.line ?? 1,
columnNumber: node.position?.start.column ?? 1
});
});
return {
filePath,
content,
codeBlocks
};
}
async function parseMarkdownForExtraction(filePath) {
const content = await readFile(filePath, "utf-8");
const tree = unified().use(remarkParse).parse(content);
const codeBlocks = [];
visit(tree, "code", (node) => {
if (!node.lang) {
return;
}
const hasSnippetDirective = node.meta && parseSnippetDirective(node.meta);
if (hasSnippetDirective) {
return;
}
codeBlocks.push({
language: node.lang,
content: node.value,
position: {
start: node.position?.start.offset ?? 0,
end: node.position?.end.offset ?? 0
}
});
});
return {
filePath,
content,
codeBlocks
};
}
async function loadSnippetContent(snippetPath, snippetRoot) {
const fullPath = resolve(snippetRoot, snippetPath);
const resolvedRoot = resolve(snippetRoot);
if (!fullPath.startsWith(resolvedRoot)) {
throw new Error(`Path traversal attempt detected: ${snippetPath}`);
}
return await readFile(fullPath, "utf-8");
}
function trimBlankLines(content) {
const lines = content.split("\n");
let start = 0;
while (start < lines.length && lines[start].trim() === "") {
start++;
}
let end = lines.length - 1;
while (end >= 0 && lines[end].trim() === "") {
end--;
}
if (start > end) {
return "";
}
return lines.slice(start, end + 1).join("\n");
}
function extractLines(content, startLine, endLine) {
if (!startLine && !endLine) {
return trimBlankLines(content);
}
const lines = content.split("\n");
let extractedLines;
if (startLine && endLine) {
extractedLines = lines.slice(startLine - 1, endLine);
} else if (startLine) {
extractedLines = lines.slice(startLine - 1);
} else {
extractedLines = lines;
}
return trimBlankLines(extractedLines.join("\n"));
}
function replaceCodeBlock(markdownContent, codeBlock, newContent) {
const lines = markdownContent.split("\n");
let inCodeBlock = false;
let codeBlockStart = -1;
let codeBlockEnd = -1;
for (let i = 0; i < lines.length; i += 1) {
const line = lines[i];
if (!line) {
continue;
}
if (line.startsWith("```") && codeBlock.snippet && line.includes("snippet=")) {
const snippetMatch = line.match(/snippet=([^\s]+)/);
if (snippetMatch?.[0]) {
const lineSnippet = parseSnippetDirective(snippetMatch[0]);
if (lineSnippet && lineSnippet.filePath === codeBlock.snippet.filePath && lineSnippet.startLine === codeBlock.snippet.startLine && lineSnippet.endLine === codeBlock.snippet.endLine) {
inCodeBlock = true;
codeBlockStart = i;
continue;
}
}
}
if (inCodeBlock && line.trim() === "```") {
codeBlockEnd = i;
break;
}
}
if (codeBlockStart === -1 || codeBlockEnd === -1) {
return markdownContent;
}
const beforeBlock = lines.slice(0, codeBlockStart + 1);
const afterBlock = lines.slice(codeBlockEnd);
const newContentLines = newContent.split("\n");
return [...beforeBlock, ...newContentLines, ...afterBlock].join("\n");
}
export {
parseMarkdownFile,
parseMarkdownForExtraction,
loadSnippetContent,
extractLines,
replaceCodeBlock
};