UNPKG

remark-mdat

Version:

A remark plugin implementing the Markdown Autophagic Template (MDAT) system.

658 lines (657 loc) 23.7 kB
import { CONTINUE, SKIP, visit } from "unist-util-visit"; import path from "node:path"; import picocolors from "picocolors"; import { createLogger, injectionHelper } from "lognow"; import json5 from "json5"; import { VFileMessage } from "vfile-message"; import { matter } from "gray-matter-es"; import { remark } from "remark"; import remarkGfm from "remark-gfm"; import { z } from "zod"; import { fromHtml } from "hast-util-from-html"; //#endregion //#region src/lib/mdat/log.ts /** * The default logger instance for the library. */ let log = createLogger({ logToConsole: { showTime: false }, name: "remark-mdat" }); /** * Set the logger instance for the module. Export this for library consumers to * inject their own logger. * * @param logger - Accepts either a LogLayer instance or a Console- or * Stream-like log target */ function setLogger(logger) { log = injectionHelper(logger); } //#endregion //#region src/lib/mdat/mdat-log.ts function saveLog(file, level, source, message, lineOrNode, maybeColumn) { let line; let column; if (lineOrNode === void 0 || typeof lineOrNode === "number") { line = lineOrNode ?? 0; column = maybeColumn ?? 0; } else { line = lineOrNode?.position?.start.line ?? 0; column = lineOrNode?.position?.start.column ?? 0; } const options = { place: { start: { column, line }, end: { column, line } }, source }; const vFileMessage = file.message(message, options); vFileMessage.fatal = level === "error" ? true : level === "warn" ? false : void 0; } function vFileMessageToMdatMessage(vFileMessage) { return { column: vFileMessage.column, level: vFileMessage.fatal ? "error" : vFileMessage.fatal === false ? "warn" : "info", line: vFileMessage.line, message: vFileMessage.reason, source: vFileMessage.source }; } /** Converts an array of processed VFiles into {@link MdatFileReport} objects. */ function getMdatReports(files) { return files.map((file) => getMdatReport(file)); } function getMdatReport(file) { const mdatFileReport = { destinationPath: file.history.length > 0 ? file.history.at(-1) : void 0, errors: [], infos: [], sourcePath: file.history.at(0) ?? file.path, warnings: [] }; if (mdatFileReport.sourcePath !== void 0) mdatFileReport.sourcePath = path.normalize(mdatFileReport.sourcePath); for (const message of file.messages) { const mdatMessage = vFileMessageToMdatMessage(message); if (mdatMessage.level === "error") mdatFileReport.errors.push(mdatMessage); else if (mdatMessage.level === "warn") mdatFileReport.warnings.push(mdatMessage); else mdatFileReport.infos.push(mdatMessage); } return mdatFileReport; } /** Logs a human-readable processing report for each VFile to the library logger. */ function reporterMdat(files) { for (const file of files) { const { destinationPath, errors, infos, sourcePath, warnings } = getMdatReport(file); log.debug(picocolors.bold("MDAT Report:")); log.debug(`\tFrom: ${picocolors.blue(picocolors.bold(sourcePath))}`); if (destinationPath !== void 0) log.debug(`\tTo: ${picocolors.blue(picocolors.bold(destinationPath))}`); for (const message of errors) log.error(mdatMessageToLogString(sourcePath, message)); for (const message of warnings) log.warn(mdatMessageToLogString(sourcePath, message)); for (const message of infos) log.debug(mdatMessageToLogString(sourcePath, message)); if (errors.length === 0 && warnings.length === 0) log.debug(`No issues found in ${sourcePath}`); else log.error(`${errors.length} errors, ${warnings.length} warnings found in ${sourcePath}`); } } function mdatMessageToLogString(sourcePath, mdatMessage) { const { column, level, line, message, source } = mdatMessage; const resolvedSource = source ? picocolors.gray(`[${source}] `) : ""; const lineColumn = line && column ? `:${line}:${column}` : ""; return `${resolvedSource}${highlightComments(message, level)} ${picocolors.whiteBright(sourcePath + lineColumn)}`; } function highlightComments(text, level) { return text.replaceAll(/<!--.+-->/g, (match) => level === "info" ? picocolors.green(match) : level === "warn" ? picocolors.yellow(match) : picocolors.red(match)); } //#endregion //#region src/lib/mdat/parse.ts /** * Parse an Mdast HTML comment node into structured data. * * @returns A CommentMarkerNode or undefined if the node is not a recognized * comment. */ function parseCommentNode(node, parent) { try { const result = parseComment(node.value); if (result === void 0) return; return { ...result, node, parent }; } catch (error) { if (error instanceof VFileMessage) { error.line = node.position?.start.line; throw error; } if (error instanceof Error) throw new VFileMessage(error.message, node); throw new VFileMessage("Unknown error", node); } } const HTML_COMMENT_OPEN_REGEX = /^\s*<!-{2,}\s*/; const HTML_COMMENT_CLOSE_REGEX = /\s*-{2,}>\s*$/; const WHITESPACE_REGEX = /\s/; /** * Parse any comment string into structured data. Comments using code-style * notation (`//`, `#`, `/*`) are ignored and return `undefined`. * * @returns A CommentMarker or undefined if the node is not a recognized * comment. */ function parseComment(text) { if (!isComment(text)) return; const closingPrefix = "/"; const commentHtml = text.trim(); const commentBody = commentHtml.replace(HTML_COMMENT_OPEN_REGEX, "").replace(HTML_COMMENT_CLOSE_REGEX, ""); const parenIndex = commentBody.indexOf("("); const rawKeyword = parenIndex === -1 ? commentBody.split(WHITESPACE_REGEX)[0] : commentBody.slice(0, parenIndex).trim(); if (rawKeyword.startsWith("//") || rawKeyword.startsWith("#") || rawKeyword.startsWith("/*")) return; const type = rawKeyword.startsWith(closingPrefix) ? "close" : "open"; let keyword = rawKeyword; if (type === "close") keyword = keyword.slice(1); let options = {}; if (parenIndex !== -1) { const lastParen = commentBody.lastIndexOf(")"); if (lastParen > parenIndex) { const argText = commentBody.slice(parenIndex + 1, lastParen).trim(); if (argText.length > 0) try { options = json5.parse(argText); } catch (error) { if (error instanceof Error) throw new VFileMessage(`Failed to parse comment options "${argText}" for keyword "${keyword}": ${error.message}`); } } } return { html: commentHtml, keyword, options, type }; } function isComment(text) { const trimmed = text.trim(); return trimmed.startsWith("<!--") && trimmed.endsWith("-->"); } //#endregion //#region src/lib/mdast-utils/mdast-util-mdat-collapse.ts /** * Collapses any expanded mdat comments, effectively resetting the document to * its pre-expansion state, preserving the original comments. No-op if no mdat * comments are found. */ function mdatCollapse(tree, file) { let lastOpenMarker; visit(tree, "html", (node, index, parent) => { if (parent === void 0 || index === void 0) return CONTINUE; const marker = parseCommentNode(node, parent); if (marker === void 0) return CONTINUE; if (marker.type === "open") { lastOpenMarker = marker; return CONTINUE; } if (lastOpenMarker === void 0) { saveLog(file, "error", "collapse", "Found closing marker without opening marker", node); return CONTINUE; } if (lastOpenMarker.parent !== marker.parent) { saveLog(file, "error", "collapse", "Opening marker doesn't share a parent", node); return CONTINUE; } if (lastOpenMarker.keyword !== marker.keyword) { saveLog(file, "error", "collapse", "Opening marker doesn't share a keyword", node); return CONTINUE; } const openMarkerIndex = parent.children.indexOf(lastOpenMarker.node); const nodesToRemove = parent.children.indexOf(marker.node) - openMarkerIndex + 1; parent.children.splice(openMarkerIndex + 1, nodesToRemove - 1); lastOpenMarker = void 0; return [CONTINUE, index - nodesToRemove + 1]; }); } //#endregion //#region src/lib/mdat/rules.ts /** Brand symbol to detect pre-normalized rules and skip re-validation. */ const NORMALIZED = Symbol("normalized"); /** Check whether rules have already been normalized. */ function isNormalized(rules) { return NORMALIZED in rules; } /** * Converts flexible {@link Rules} into strict {@link NormalizedRules} for * internal processing. */ function normalizeRules(rules) { validateRules(rules); const normalizedRules = {}; for (const [keyword, rule] of Object.entries(rules)) if (typeof rule === "string") normalizedRules[keyword] = { content: async () => rule, order: 0 }; else if (typeof rule === "function") normalizedRules[keyword] = { content: async (options, context) => rule(options, context), order: 0 }; else if (Array.isArray(rule)) normalizedRules[keyword] = { content: Object.values(normalizeRules(Object.fromEntries(rule.entries()))), order: 0 }; else if (typeof rule.content === "string") { const ruleContent = rule.content; normalizedRules[keyword] = { content: async () => ruleContent, order: rule.order ?? 0 }; } else if (Array.isArray(rule.content)) normalizedRules[keyword] = { content: Object.values(normalizeRules(Object.fromEntries(rule.content.entries()))), order: rule.order ?? 0 }; else { const ruleContent = rule.content; normalizedRules[keyword] = { content: async (options, context) => ruleContent(options, context), order: rule.order ?? 0 }; } Object.defineProperty(normalizedRules, NORMALIZED, { value: true }); return normalizedRules; } /** Validates rules against {@link rulesSchema}, throwing on invalid input. */ function validateRules(rules) { try { rulesSchema.parse(rules); } catch (error) { if (error instanceof Error) throw new TypeError(`Error validating rules: ${error.message}`); } } const functionSchema = z.custom((value) => typeof value === "function"); const ruleSchema = z.lazy(() => z.union([ functionSchema, z.array(ruleSchema), z.string(), z.object({ content: z.union([ functionSchema, z.array(ruleSchema), z.string() ]), order: z.number().optional() }) ])); const COMMENT_PREFIX_REGEX = /^[/*#]/; const keywordSchema = z.string().check(z.refine((key) => !COMMENT_PREFIX_REGEX.test(key), { message: "Rule keywords must not start with \"/\", \"*\", or \"#\" — these prefixes are reserved for comment syntax" })); /** Zod schema for validating {@link Rules} records. */ const rulesSchema = z.record(keywordSchema, ruleSchema).describe("MDAT Rules"); /** * Expand rule content. For compound rules (content arrays), individual sub-rule * failures are reported via `onWarning` and skipped. The entire expansion only * fails if every sub-rule fails. */ async function getRuleContent(rule, options, context, onWarning) { if (Array.isArray(rule.content)) { const subruleContent = []; const errors = []; for (const [index, subrule] of rule.content.entries()) { const subruleOptions = Array.isArray(options) ? options.at(index) : void 0; try { subruleContent.push(await getRuleContent(subrule, subruleOptions ?? {}, context, onWarning)); } catch (error) { const message = error instanceof Error ? error.cause instanceof Error ? error.cause.message : error.message : String(error); onWarning?.(`Sub-rule ${String(index)} failed: ${message}`); errors.push(error instanceof Error ? error : new Error(String(error))); } } if (subruleContent.length === 0) throw new AggregateError(errors, "All sub-rules failed in compound rule"); return subruleContent.join("\n\n"); } try { return await rule.content(options, context); } catch (error) { throw new Error("Failed to expand content", { cause: error }); } } /** * Returns the rule value from a single-rule record. Useful when aliasing rules * or invoking them programmatically. * * Throws if there are no entries or more than one entry. */ function getSoleRule(rules) { return getSoleRecord(rules); } /** * Returns the rule key from a single-rule record. Useful for comment * placeholder validation. * * Throws if there are no entries or more than one entry. */ function getSoleRuleKey(rules) { const keys = Object.keys(rules); if (keys.length !== 1) throw new Error(`Expected exactly one rule, found ${keys.length}`); return keys[0]; } /** * Get the sole entry in a record. * * Useful for working with Rules records that are only supposed to contain a * single rule. * * @param record The record to get the sole entry from * * @returns The value of the sole entry in the record * @throws {Error} If there are no entries or more than one entry */ function getSoleRecord(record) { const recordValues = Object.values(record); if (recordValues.length === 0) throw new Error("Found no entries in a \"sole record\" record. This should never happen"); if (recordValues.length > 1) throw new Error("Found multiple entries in \"sole record\" record. This should never happen"); return recordValues[0]; } //#endregion //#region src/lib/mdast-utils/mdast-util-mdat-expand.ts /** * Mdast utility to expand mdat comments in the tree. */ async function mdatExpand(tree, file, rules) { const normalizedRules = isNormalized(rules) ? rules : normalizeRules(rules); const frontmatter = (() => { if (typeof file.value !== "string") return; const { data } = matter(file.value); return Object.keys(data).length > 0 ? data : void 0; })(); const context = { filePath: file.history.length > 0 ? file.path : void 0, frontmatter, tree }; const commentMarkers = []; visit(tree, "html", (node, index, parent) => { if (parent === void 0 || index === void 0) return CONTINUE; const commentMarker = parseCommentNode(node, parent); if (commentMarker?.type !== "open") return CONTINUE; if (normalizedRules[commentMarker.keyword] === void 0) { saveLog(file, "warn", "expand", `Missing rule for: ${commentMarker.html}`, node); return CONTINUE; } commentMarkers.push(commentMarker); }); commentMarkers.sort((a, b) => normalizedRules[a.keyword].order - normalizedRules[b.keyword].order); const parser = remark().use(remarkGfm); for (const comment of commentMarkers) { const { html, keyword, node, options, parent } = comment; const rule = normalizedRules[keyword]; let newMarkdownString = ""; try { newMarkdownString = await getRuleContent(rule, options, context, (warning) => { saveLog(file, "warn", "expand", `${html}: ${warning}`, node); }); if (newMarkdownString.trim() === "") saveLog(file, "error", "expand", `Got empty content when expanding ${html}`, node); } catch (error) { if (error instanceof Error) { const causeMessage = error.cause instanceof Error ? `: ${error.cause.message}` : ""; saveLog(file, "error", "expand", `Caught error expanding ${html}, Error message: "${error.message}${causeMessage}"`, node); } continue; } const newNodes = parser.parse(newMarkdownString).children; const closingNode = { type: "html", value: `<!-- /${keyword} -->` }; const openingCommentIndex = parent.children.indexOf(node); parent.children.splice(openingCommentIndex + 1, 0, ...newNodes, closingNode); saveLog(file, "info", "expand", `Expanded: ${html}`, node); } } //#endregion //#region src/lib/mdast-utils/mdast-util-mdat-split.ts /** * Mdast utility plugin to split any multi-comment nodes and their content into * individual MDAST HTML nodes. They're wrapped in a paragraph so as not to * introduce new breaks. */ function mdatSplit(tree, file) { visit(tree, "html", (node, index, parent) => { if (parent === void 0 || index === void 0) return CONTINUE; const v = node.value; if (v.startsWith("<!--") && v.endsWith("-->") && !v.includes("<!--", 4)) return CONTINUE; const htmlNodes = splitHtmlIntoMdastNodes(node); if (htmlNodes.length > 1) { saveLog(file, "warn", "split", "Multiple comments in a single HTML node.", node); parent.children.splice(index, 1, { children: htmlNodes, type: "paragraph" }); } }); } /** * Splits a single mdast HTML node containing multiple comments into individual * HTML and text nodes. Exported for testing. */ function splitHtmlIntoMdastNodes(mdastNode) { const htmlTree = fromHtml(mdastNode.value, { fragment: true }); const mdastNodes = []; visit(htmlTree, (hastNode) => { if (hastNode.type === "root") return CONTINUE; if (hastNode.type === "text") { mdastNodes.push({ position: addStartPoint(hastNode.position, mdastNode.position?.start), type: "text", value: getOriginalMarkup(mdastNode, hastNode) }); return CONTINUE; } mdastNodes.push({ position: addStartPoint(hastNode.position, mdastNode.position?.start), type: "html", value: getOriginalMarkup(mdastNode, hastNode) }); return SKIP; }); return mdastNodes; } function addStartPoint(position, start) { if (position === void 0 || start === void 0) return; const startLine = position.start.line - 1 + start.line; const endLine = position.end.line - 1 + start.line; return { start: { column: position.start.line === 1 ? position.start.column - 1 + start.column : position.start.column, line: startLine, offset: position.start.offset !== void 0 && start.offset !== void 0 ? position.start.offset + start.offset : void 0 }, end: { column: position.end.line === 1 ? position.end.column - 1 + start.column : position.end.column, line: endLine, offset: position.end.offset !== void 0 && start.offset !== void 0 ? position.end.offset + start.offset : void 0 } }; } function getOriginalMarkup(mdastNode, hastNode) { if (hastNode.position === void 0) throw new Error("Hast ElementContent node has no position!"); return mdastNode.value.slice(hastNode.position.start.offset, hastNode.position.end.offset); } //#endregion //#region src/lib/mdast-utils/mdast-util-mdat.ts /** * Mdast utility that splits, collapses, and then re-expands all mdat comments * in the tree. */ async function mdat(tree, file, rules) { mdatSplit(tree, file); mdatCollapse(tree, file); await mdatExpand(tree, file, rules); } //#endregion //#region src/lib/mdast-utils/mdast-util-mdat-clean.ts /** * @deprecated Use {@link mdatCollapse} instead. This alias will be removed in a * future major version. */ const mdatClean = mdatCollapse; //#endregion //#region src/lib/mdast-utils/mdast-util-mdat-diff.ts /** * Compare original and expanded documents per-tag. Walks both ASTs to extract * content between open/close comment markers, then compares per-tag. * * Callers should run {@link mdatSplit} on both trees before calling this * function to ensure multi-comment nodes are split into individual nodes. * * Adds diagnostic messages to `expandedFile` via the VFile message pipeline. * * @returns Per-tag comparison results. */ function mdatDiff(originalTree, originalFile, expandedTree, expandedFile) { const originalText = originalFile.toString(); const expandedText = expandedFile.toString(); const originalSections = extractSections(originalTree, originalText, originalFile); const expandedSections = extractSections(expandedTree, expandedText, expandedFile); const results = []; const originalByKeyword = groupByKeyword(originalSections); const expandedByKeyword = groupByKeyword(expandedSections); for (const [keyword, expandedList] of expandedByKeyword) { const originalList = originalByKeyword.get(keyword) ?? []; for (const [i, exp] of expandedList.entries()) { const orig = originalList.at(i); if (orig === void 0) { results.push({ keyword, line: exp.line, status: "added" }); saveLog(expandedFile, "info", "diff", `Added: <!-- ${keyword} -->`, exp.line); } else if (orig.content === void 0) { results.push({ keyword, line: exp.line, status: "unexpanded" }); saveLog(expandedFile, "warn", "diff", `Unexpanded: <!-- ${keyword} -->`, exp.line); } else if (orig.content === exp.content) { results.push({ keyword, line: exp.line, status: "ok" }); saveLog(expandedFile, "info", "diff", `Up to date: <!-- ${keyword} -->`, exp.line); } else { results.push({ keyword, line: exp.line, status: "stale" }); saveLog(expandedFile, "warn", "diff", `Stale: <!-- ${keyword} -->`, exp.line); } } } for (const [keyword, originalList] of originalByKeyword) { const expandedList = expandedByKeyword.get(keyword) ?? []; for (const orig of originalList.slice(expandedList.length)) { results.push({ keyword, line: orig.line, status: "missing" }); saveLog(expandedFile, "warn", "diff", `Missing: <!-- ${keyword} -->`, orig.line); } } return results; } function extractSections(tree, text, file) { const sections = []; let lastOpenKeyword; let lastOpenLine = 0; let lastOpenEndOffset; visit(tree, "html", (node, index, parent) => { if (parent === void 0 || index === void 0) return CONTINUE; const marker = parseCommentNode(node, parent); if (marker === void 0) return CONTINUE; if (marker.type === "open") { if (lastOpenKeyword !== void 0) sections.push({ content: void 0, keyword: lastOpenKeyword, line: lastOpenLine }); lastOpenKeyword = marker.keyword; lastOpenLine = marker.node.position?.start.line ?? 0; lastOpenEndOffset = marker.node.position?.end.offset; return CONTINUE; } if (lastOpenKeyword === void 0) { saveLog(file, "warn", "diff", `Close marker without open: <!-- /${marker.keyword} -->`, node); return CONTINUE; } if (lastOpenKeyword !== marker.keyword) { saveLog(file, "warn", "diff", `Keyword mismatch: expected <!-- /${lastOpenKeyword} -->, found <!-- /${marker.keyword} -->`, node); return CONTINUE; } const closeStartOffset = marker.node.position?.start.offset; const sliced = closeStartOffset === void 0 || lastOpenEndOffset === void 0 ? void 0 : text.slice(lastOpenEndOffset, closeStartOffset).replaceAll("\r\n", "\n").trim(); const content = sliced === "" ? void 0 : sliced; sections.push({ content, keyword: lastOpenKeyword, line: lastOpenLine }); lastOpenKeyword = void 0; lastOpenEndOffset = void 0; return CONTINUE; }); if (lastOpenKeyword !== void 0) sections.push({ content: void 0, keyword: lastOpenKeyword, line: lastOpenLine }); return sections; } function groupByKeyword(sections) { const map = /* @__PURE__ */ new Map(); for (const section of sections) { const list = map.get(section.keyword); if (list) list.push(section); else map.set(section.keyword, [section]); } return map; } //#endregion //#region src/lib/mdast-utils/mdast-util-mdat-strip.ts /** * Strips all mdat comment nodes (both opening and closing) from the tree, * preserving any content between them. Code-style comments (`//`, `#`, `/*`) * are left untouched. */ function mdatStrip(tree, _file) { visit(tree, "html", (node, index, parent) => { if (parent === void 0 || index === void 0) return CONTINUE; if (parseCommentNode(node, parent) === void 0) return CONTINUE; parent.children.splice(index, 1); return [CONTINUE, index]; }); } //#endregion //#region src/lib/remark-mdat.ts const defaultRules = { mdat: `Powered by the Markdown Autophagic Template system: [mdat](https://github.com/kitschpatrol/mdat).` }; /** Zod schema for validating {@link Options}. */ const optionsSchema = z.object({ rules: rulesSchema.optional() }).describe("MDAT Plugin Options"); /** * A remark plugin that expands HTML comments in Markdown files. */ const remarkMdat = function(options) { const normalizedRules = normalizeRules({ ...defaultRules, ...options?.rules }); return async function(tree, file) { await mdat(tree, file, normalizedRules); }; }; //#endregion export { remarkMdat as default, getMdatReports, getSoleRule, getSoleRuleKey, mdat, mdatClean, mdatCollapse, mdatDiff, mdatExpand, mdatSplit, mdatStrip, optionsSchema, reporterMdat, rulesSchema, setLogger };