This commit is contained in:
parent
5dfcdfd516
commit
c06f71a5ad
34 changed files with 3641 additions and 279 deletions
|
|
@ -1,3 +1,6 @@
|
|||
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([
|
||||
|
|
@ -24,7 +27,8 @@ const ENV_LABELS = {
|
|||
|
||||
function tokenizeInfo(info) {
|
||||
return (
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? []
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ??
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -102,22 +106,308 @@ 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 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 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 `<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" });
|
||||
|
||||
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 current = button.getAttribute("aria-sort");
|
||||
const direction = current === "ascending" ? "descending" : "ascending";
|
||||
|
||||
root.querySelectorAll("[data-data-table-sort]").forEach((other) => {
|
||||
other.removeAttribute("aria-sort");
|
||||
});
|
||||
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);
|
||||
return direction === "ascending" ? result : -result;
|
||||
})
|
||||
.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 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}`);
|
||||
(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;
|
||||
const fallback =
|
||||
attrs.values.pdf || attrs.values.fallback || description || caption;
|
||||
|
||||
if (!componentId) {
|
||||
return {
|
||||
|
|
@ -141,7 +431,9 @@ function interactiveFromCode(node) {
|
|||
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>` : "",
|
||||
fallback
|
||||
? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>`
|
||||
: "",
|
||||
"</div>",
|
||||
caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "",
|
||||
"</figure>",
|
||||
|
|
@ -154,7 +446,10 @@ function envFromCode(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);
|
||||
const isEnv =
|
||||
attrs.classes.includes("env") ||
|
||||
attrs.classes.includes("latex-env") ||
|
||||
Boolean(type);
|
||||
|
||||
if (!isEnv) return null;
|
||||
|
||||
|
|
@ -168,7 +463,8 @@ function envFromCode(node) {
|
|||
|
||||
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 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 = [
|
||||
|
|
@ -210,23 +506,26 @@ function envFromCode(node) {
|
|||
};
|
||||
}
|
||||
|
||||
function transformTree(parent) {
|
||||
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 = interactiveFromCode(child) || envFromCode(child);
|
||||
const replacement =
|
||||
dataTableFromCode(child, file) ||
|
||||
interactiveFromCode(child) ||
|
||||
envFromCode(child);
|
||||
if (replacement) {
|
||||
parent.children[index] = replacement;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
transformTree(child);
|
||||
transformTree(child, file);
|
||||
}
|
||||
}
|
||||
|
||||
export default function remarkSemanticBlocks() {
|
||||
return (tree) => transformTree(tree);
|
||||
return (tree, file) => transformTree(tree, file);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue