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 `${escapeHtml(prefix)}${inlineMarkup(display)}`; } if (column === "type") { return `${escapeHtml(display)}`; } if ( column === "summary" || String(display).length > 140 || String(display).includes("
${inlineMarkup(display)}
`; } return `${inlineMarkup(display)}`; } function dataTableScript(id) { return ``; } function dataTableFromCode(node, file) { const attrs = parseFenceInfo(node); if (!attrs.classes.includes("data-table")) return null; const src = attrs.values.src || attrs.values.source || ""; if (!src) { return semanticError('Data table block missing 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 `${content}`; }) .join(""); const bodyHtml = rows .map((row) => { const searchText = columns .map((column) => plainText(displayValue(row[column] ?? ""))) .join(" ") .toLowerCase(); const cells = columns .map((column) => renderCell(row, column, { linkColumn, urlField, numberField }), ) .join(""); return `${cells}`; }) .join(""); const searchHtml = searchable ? [ '", ].join("") : ""; return { type: "html", value: [ `
`, caption || searchable ? `
${caption ? `

${escapeHtml(caption)}

` : ""}${searchHtml}
` : "", '
', ``, caption ? `` : "", `${headerHtml}`, `${bodyHtml}`, "
${escapeHtml(caption)}
", "
", ``, "
", dataTableScript(id), ].join(""), }; } function interactiveFromCode(node) { const attrs = parseFenceInfo(node); if (!attrs.classes.includes("interactive")) return null; const declaredId = attrs.id || attrs.values.component || attrs.values.name || ""; const componentId = attrs.values.component || (declaredId.startsWith("int:") ? declaredId.slice("int:".length) : declaredId); const label = attrs.values.label || (declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`); const caption = attrs.values.caption || attrs.values.title || ""; const description = node.value.trim(); const fallback = attrs.values.pdf || attrs.values.fallback || description || caption; if (!componentId) { return { type: "html", value: '', }; } const props = { id: componentId, label, caption, description, fallback, attrs: attrs.values, }; return { type: "html", value: [ `
`, `
`, fallback ? `

${escapeHtml(fallback)}

` : "", "
", caption ? `
${escapeHtml(caption)}
` : "", "
", ].join(""), }; } function envFromCode(node) { const attrs = parseFenceInfo(node); const shorthandType = firstClass(attrs, new Set(["env", "latex-env"])); const explicitType = attrs.values.type || shorthandType; const type = ENV_TYPES.has(explicitType) ? explicitType : ""; const isEnv = attrs.classes.includes("env") || attrs.classes.includes("latex-env") || Boolean(type); if (!isEnv) return null; if (!type) { return { type: "html", value: '', }; } const title = attrs.values.title || attrs.values.name || ""; const label = attrs.id || attrs.values.label || ""; const renderMode = attrs.values.html || (type === "proof" ? "plain" : "callout"); const heading = title ? `${ENV_LABELS[type]} (${title})` : ENV_LABELS[type]; const parsed = fromMarkdown(node.value || ""); const className = [ "semantic-env", `semantic-env-${type}`, `semantic-env-${renderMode}`, renderMode === "callout" ? "callout" : "", ].filter(Boolean); return { type: "blockquote", data: { hName: "section", hProperties: { id: label || undefined, className, "data-env-type": type, "data-env-title": title || undefined, "data-env-render": renderMode, }, }, children: [ { type: "paragraph", data: { hProperties: { className: ["semantic-env-heading"], }, }, children: [ { type: "strong", children: [{ type: "text", value: heading }], }, ], }, ...parsed.children, ], }; } function transformTree(parent, file) { if (!parent?.children) return; for (let index = 0; index < parent.children.length; index += 1) { const child = parent.children[index]; if (child.type === "code") { const replacement = dataTableFromCode(child, file) || interactiveFromCode(child) || envFromCode(child); if (replacement) { parent.children[index] = replacement; continue; } } transformTree(child, file); } } export default function remarkSemanticBlocks() { return (tree, file) => transformTree(tree, file); }