blogs/sites/reviews/src/remark/semantic-blocks.mjs
Neeldhara Misra b2684d5cee
Some checks are pending
Build / build (push) Waiting to run
Normalize data table sort keys
2026-06-29 02:31:41 +05:30

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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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(/&lt;br\s*\/?&gt;/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);
}