UNPKG

markdown-code

Version:

Keep code examples in Markdown synchronized with actual source files

212 lines (210 loc) 5.93 kB
// 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 };