550 lines
16 KiB
JavaScript
550 lines
16 KiB
JavaScript
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(/<br\s*\/?>/gi, " ")
|
|
.replace(/<[^>]+>/g, " ")
|
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function sortText(value) {
|
|
return plainText(value)
|
|
.normalize("NFKD")
|
|
.replace(/[\u0300-\u036f]/g, "")
|
|
.replace(/[^a-z0-9]+/gi, " ")
|
|
.trim()
|
|
.toLowerCase();
|
|
}
|
|
|
|
function inlineMarkup(value) {
|
|
return escapeHtml(value ?? "")
|
|
.replace(/<br\s*\/?>/gi, "<br>")
|
|
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2">$1</a>');
|
|
}
|
|
|
|
function semanticError(message) {
|
|
return {
|
|
type: "html",
|
|
value: `<aside class="semantic-error">${message}</aside>`,
|
|
};
|
|
}
|
|
|
|
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 sortValue = attribute(sortText(display));
|
|
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 `<td ${cellAttrs}><a href="${attribute(row[options.urlField])}">${escapeHtml(prefix)}${inlineMarkup(display)}</a></td>`;
|
|
}
|
|
|
|
if (column === "type") {
|
|
return `<td ${cellAttrs}><span class="data-table-badge">${escapeHtml(display)}</span></td>`;
|
|
}
|
|
|
|
if (
|
|
column === "summary" ||
|
|
String(display).length > 140 ||
|
|
String(display).includes("<br")
|
|
) {
|
|
return `<td ${cellAttrs}><div class="data-table-rich-cell">${inlineMarkup(display)}</div></td>`;
|
|
}
|
|
|
|
return `<td ${cellAttrs}>${inlineMarkup(display)}</td>`;
|
|
}
|
|
|
|
function dataTableScript(id) {
|
|
return `<script>
|
|
(() => {
|
|
const root = document.getElementById(${JSON.stringify(id)});
|
|
if (!root || root.dataset.dataTableReady) return;
|
|
root.dataset.dataTableReady = "true";
|
|
|
|
const input = root.querySelector("[data-data-table-search]");
|
|
const tbody = root.querySelector("tbody");
|
|
const empty = root.querySelector("[data-data-table-empty]");
|
|
const rows = Array.from(tbody?.querySelectorAll("tr") || []);
|
|
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
|
|
|
rows.forEach((row, index) => {
|
|
row.dataset.dataTableIndex = String(index);
|
|
});
|
|
|
|
function applyFilter() {
|
|
const query = (input?.value || "").trim().toLowerCase();
|
|
let visible = 0;
|
|
|
|
for (const row of rows) {
|
|
const matches = !query || row.dataset.search.includes(query);
|
|
row.hidden = !matches;
|
|
if (matches) visible += 1;
|
|
}
|
|
|
|
if (empty) empty.hidden = visible !== 0;
|
|
}
|
|
|
|
input?.addEventListener("input", applyFilter);
|
|
|
|
root.querySelectorAll("[data-data-table-sort]").forEach((button) => {
|
|
button.addEventListener("click", () => {
|
|
const column = button.dataset.dataTableSort;
|
|
const previousColumn = root.dataset.dataTableSortColumn || "";
|
|
const previousDirection = root.dataset.dataTableSortDirection || "";
|
|
const direction =
|
|
previousColumn === column && previousDirection === "ascending"
|
|
? "descending"
|
|
: "ascending";
|
|
|
|
root.querySelectorAll("[data-data-table-sort]").forEach((other) => {
|
|
other.removeAttribute("aria-sort");
|
|
});
|
|
root.dataset.dataTableSortColumn = column;
|
|
root.dataset.dataTableSortDirection = direction;
|
|
button.setAttribute("aria-sort", direction);
|
|
|
|
rows
|
|
.slice()
|
|
.sort((left, right) => {
|
|
const leftValue = left.querySelector(\`[data-column="\${column}"]\`)?.dataset.sortValue || "";
|
|
const rightValue = right.querySelector(\`[data-column="\${column}"]\`)?.dataset.sortValue || "";
|
|
const result = collator.compare(leftValue, rightValue);
|
|
if (result !== 0) return direction === "ascending" ? result : -result;
|
|
return Number(left.dataset.dataTableIndex || 0) - Number(right.dataset.dataTableIndex || 0);
|
|
})
|
|
.forEach((row) => tbody.appendChild(row));
|
|
|
|
applyFilter();
|
|
});
|
|
});
|
|
})();
|
|
</script>`;
|
|
}
|
|
|
|
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 <code>src="..."</code>.');
|
|
}
|
|
|
|
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
|
|
? `<button type="button" data-data-table-sort="${attribute(column)}">${escapeHtml(label)}</button>`
|
|
: escapeHtml(label);
|
|
return `<th scope="col">${content}</th>`;
|
|
})
|
|
.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 `<tr data-search="${attribute(searchText)}">${cells}</tr>`;
|
|
})
|
|
.join("");
|
|
|
|
const searchHtml = searchable
|
|
? [
|
|
'<div class="data-table-search">',
|
|
`<label for="${attribute(searchId)}">Search</label>`,
|
|
`<input id="${attribute(searchId)}" type="search" autocomplete="off" placeholder="${attribute(placeholder)}" data-data-table-search>`,
|
|
"</div>",
|
|
].join("")
|
|
: "";
|
|
|
|
return {
|
|
type: "html",
|
|
value: [
|
|
`<section class="semantic-data-table" id="${attribute(id)}" data-data-table-root>`,
|
|
caption || searchable
|
|
? `<div class="data-table-toolbar">${caption ? `<h2>${escapeHtml(caption)}</h2>` : ""}${searchHtml}</div>`
|
|
: "",
|
|
'<div class="data-table-scroll">',
|
|
`<table id="${attribute(tableId)}">`,
|
|
caption ? `<caption>${escapeHtml(caption)}</caption>` : "",
|
|
`<thead><tr>${headerHtml}</tr></thead>`,
|
|
`<tbody>${bodyHtml}</tbody>`,
|
|
"</table>",
|
|
"</div>",
|
|
`<p class="data-table-empty" data-data-table-empty hidden>No matching rows.</p>`,
|
|
"</section>",
|
|
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:
|
|
'<aside class="semantic-error">Interactive block missing an id. Add <code>#int:name</code> or <code>id="name"</code>.</aside>',
|
|
};
|
|
}
|
|
|
|
const props = {
|
|
id: componentId,
|
|
label,
|
|
caption,
|
|
description,
|
|
fallback,
|
|
attrs: attrs.values,
|
|
};
|
|
|
|
return {
|
|
type: "html",
|
|
value: [
|
|
`<figure class="semantic-interactive" id="${attribute(label)}" data-interactive-label="${attribute(label)}">`,
|
|
`<div class="interactive-mount" data-interactive-id="${attribute(componentId)}" data-interactive-props="${attribute(encodedJson(props))}">`,
|
|
fallback
|
|
? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>`
|
|
: "",
|
|
"</div>",
|
|
caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "",
|
|
"</figure>",
|
|
].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:
|
|
'<aside class="semantic-error">Environment block missing a type. Add <code>.theorem</code>, <code>.definition</code>, or <code>type="theorem"</code>.</aside>',
|
|
};
|
|
}
|
|
|
|
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);
|
|
}
|