import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { parse as parseYaml } from "yaml";
import { fromMarkdown } from "mdast-util-from-markdown";
const ENV_TYPES = new Set([
"theorem",
"lemma",
"proposition",
"corollary",
"definition",
"example",
"remark",
"proof",
]);
const ENV_LABELS = {
theorem: "Theorem",
lemma: "Lemma",
proposition: "Proposition",
corollary: "Corollary",
definition: "Definition",
example: "Example",
remark: "Remark",
proof: "Proof",
};
function tokenizeInfo(info) {
return (
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ??
[]
);
}
function stripQuotes(value) {
if (!value) return "";
const first = value[0];
const last = value[value.length - 1];
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
return value.slice(1, -1);
}
return value;
}
function parseFenceInfo(node) {
const lang = node.lang ?? "";
const meta = node.meta ?? "";
const raw = `${lang} ${meta}`.trim();
const attrs = {
classes: [],
id: "",
values: {},
raw,
};
if (!raw) return attrs;
let body = raw;
if (body.startsWith("{")) {
body = body.slice(1);
if (body.endsWith("}")) body = body.slice(0, -1);
} else if (lang) {
attrs.classes.push(lang);
body = meta;
}
for (let token of tokenizeInfo(body.trim())) {
if (token.endsWith("}") && !token.includes("=")) token = token.slice(0, -1);
if (!token) continue;
if (token.startsWith(".")) {
attrs.classes.push(token.slice(1));
continue;
}
if (token.startsWith("#")) {
attrs.id = token.slice(1);
continue;
}
const eq = token.indexOf("=");
if (eq > 0) {
const key = token.slice(0, eq);
const value = stripQuotes(token.slice(eq + 1));
if (key === "id") attrs.id = value;
attrs.values[key] = value;
}
}
return attrs;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
function attribute(value) {
return escapeHtml(value).replaceAll("\n", " ");
}
function encodedJson(value) {
return encodeURIComponent(JSON.stringify(value));
}
function csv(value) {
return String(value || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function keyValueList(value) {
const entries = {};
for (const item of csv(value)) {
const separator = item.indexOf(":");
if (separator <= 0) continue;
const key = item.slice(0, separator).trim();
const entryValue = item.slice(separator + 1).trim();
if (key && entryValue) entries[key] = entryValue;
}
return entries;
}
function booleanAttr(value, defaultValue = true) {
if (value === undefined || value === "") return defaultValue;
return !["0", "false", "no", "off"].includes(
String(value).trim().toLowerCase(),
);
}
function labelize(value) {
return String(value || "")
.replace(/[-_]+/g, " ")
.replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function plainText(value) {
return String(value ?? "")
.replace(/
/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/\s+/g, " ")
.trim();
}
function inlineMarkup(value) {
return escapeHtml(value ?? "")
.replace(/<br\s*\/?>/gi, "
")
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1');
}
function semanticError(message) {
return {
type: "html",
value: ``,
};
}
function firstClass(attrs, excluded) {
return attrs.classes.find((className) => !excluded.has(className));
}
function sourcePathForFile(file) {
return file?.path || file?.history?.[0] || "";
}
function resolveDataPath(src, file) {
if (!src) return "";
if (path.isAbsolute(src)) return src;
const sourcePath = sourcePathForFile(file);
const baseDir = sourcePath ? path.dirname(sourcePath) : process.cwd();
return path.resolve(baseDir, src);
}
function readDataRows(src, file) {
const resolvedPath = resolveDataPath(src, file);
if (!resolvedPath || !existsSync(resolvedPath)) {
throw new Error(`Data file not found: ${src}`);
}
const raw = readFileSync(resolvedPath, "utf8");
const extension = path.extname(resolvedPath).toLowerCase();
const parsed = extension === ".json" ? JSON.parse(raw) : parseYaml(raw);
const rows = Array.isArray(parsed) ? parsed : parsed?.items;
if (!Array.isArray(rows)) {
throw new Error(
`Data file must contain an array or an object with an items array: ${src}`,
);
}
return rows.filter((row) => row && typeof row === "object");
}
const VALUE_LABELS = {
MOC: "Map / index",
book: "Book",
course: "Course",
essay: "Essay",
other: "Other",
software: "Software",
youtube: "YouTube",
};
function displayValue(value) {
return VALUE_LABELS[value] || value;
}
function renderCell(row, column, options) {
const raw = row[column] ?? "";
const display = displayValue(raw);
const text = plainText(display);
const sortValue = attribute(text.toLowerCase());
const cellAttrs = `data-column="${attribute(column)}" data-sort-value="${sortValue}"`;
if (column === options.linkColumn && row[options.urlField]) {
const numberValue = row[options.numberField];
const numberLabel =
numberValue === undefined || numberValue === null || numberValue === ""
? ""
: String(numberValue).padStart(2, "0");
const prefix = options.numberField && numberLabel ? `${numberLabel}. ` : "";
return `
src="...".');
}
let rows;
try {
rows = readDataRows(src, file);
} catch (error) {
return semanticError(escapeHtml(error.message));
}
const firstRow = rows[0] || {};
const columns = csv(attrs.values.columns).length
? csv(attrs.values.columns)
: Object.keys(firstRow).filter((key) => key !== "link");
const headers = keyValueList(attrs.values.headers || attrs.values.labels);
const linkColumn =
attrs.values.link || (columns.includes("title") ? "title" : "");
const urlField = attrs.values.url || attrs.values.href || "link";
const numberField = attrs.values.number || "";
const caption = attrs.values.caption || attrs.values.title || "";
const searchable = booleanAttr(attrs.values.search, true);
const sortable = booleanAttr(attrs.values.sort, true);
const placeholder =
attrs.values["search-placeholder"] || `Search ${caption || "table"}`;
const id =
attrs.id ||
`data-table-${src.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "")}`;
const tableId = `${id}-table`;
const searchId = `${id}-search`;
const headerHtml = columns
.map((column) => {
const label = headers[column] || labelize(column);
const content = sortable
? ``
: escapeHtml(label);
return `No matching rows.
`, "${escapeHtml(fallback)}
` : "", "