remark-mdat
Version:
A remark plugin implementing the Markdown Autophagic Template (MDAT) system.
658 lines (657 loc) • 23.7 kB
JavaScript
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 };