Add data table markdown block
Some checks are pending
Build / build (push) Waiting to run

This commit is contained in:
Neeldhara Misra 2026-06-29 02:09:20 +05:30
parent 5dfcdfd516
commit c06f71a5ad
34 changed files with 3641 additions and 279 deletions

View file

@ -50,7 +50,8 @@
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.12",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.9.2"
"typescript": "^5.9.2",
"yaml": "^2.4.2"
},
"devDependencies": {
"prettier": "^3.5.3",

View file

@ -62,7 +62,8 @@
"tailwind-merge": "^3.0.2",
"tailwindcss": "^4.0.12",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.9.2"
"typescript": "^5.9.2",
"yaml": "^2.4.2"
},
"devDependencies": {
"prettier": "^3.5.3",

View file

@ -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(/&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 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);
}

View file

@ -67,8 +67,8 @@
.article-content {
color: hsl(var(--foreground));
font-family:
var(--font-heliotrope), "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji",
"Noto Sans Symbols 2", "DejaVu Sans", sans-serif;
var(--font-heliotrope), "Apple Color Emoji", "Segoe UI Emoji",
"Noto Color Emoji", "Noto Sans Symbols 2", "DejaVu Sans", sans-serif;
font-size: 1.0625rem;
line-height: 1.78;
}
@ -138,7 +138,8 @@
}
.article-content :where(.symbol-emoji) {
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", emoji, sans-serif;
font-family:
"Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", emoji, sans-serif;
}
.article-content :where(.symbol-chess, .symbol-card) {
@ -146,7 +147,11 @@
font-size: 1.04em;
}
.article-content :where(.symbol-card[data-symbol="heart"], .symbol-card[data-symbol="diamond"]) {
.article-content
:where(
.symbol-card[data-symbol="heart"],
.symbol-card[data-symbol="diamond"]
) {
color: color-mix(in srgb, crimson 80%, hsl(var(--foreground)));
}
@ -281,7 +286,8 @@
.article-content :where(.semantic-error) {
margin-block: 1.5rem;
border: 1px solid color-mix(in srgb, hsl(var(--destructive)) 50%, hsl(var(--border)));
border: 1px solid
color-mix(in srgb, hsl(var(--destructive)) 50%, hsl(var(--border)));
border-radius: 0.55rem;
background: color-mix(in srgb, hsl(var(--destructive)) 8%, transparent);
padding: 1rem;
@ -333,7 +339,8 @@
padding: 1rem;
}
.article-content :where(.interactive-mount[data-interactive-state="missing-component"]) {
.article-content
:where(.interactive-mount[data-interactive-state="missing-component"]) {
display: grid;
min-height: 8rem;
place-items: center;
@ -375,6 +382,168 @@
padding: 0.45rem 0.6rem;
}
.article-content :where(.semantic-data-table) {
margin-top: 2rem;
border: 1px solid hsl(var(--border));
border-radius: 0.75rem;
background: color-mix(in srgb, hsl(var(--background)) 92%, hsl(var(--muted)));
box-shadow: 0 16px 40px
color-mix(in srgb, hsl(var(--foreground)) 7%, transparent);
}
.article-content :where(.data-table-toolbar) {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.85rem;
border-bottom: 1px solid hsl(var(--border));
padding: 1rem;
}
.article-content :where(.data-table-toolbar h2) {
margin: 0;
font-size: 1rem;
}
.article-content :where(.data-table-search) {
display: flex;
align-items: center;
gap: 0.55rem;
margin-left: auto;
}
.article-content :where(.data-table-search label) {
color: hsl(var(--muted-foreground));
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.article-content :where(.data-table-search input) {
width: min(18rem, 58vw);
border: 1px solid hsl(var(--border));
border-radius: 999px;
background: hsl(var(--background));
padding: 0.45rem 0.8rem;
color: hsl(var(--foreground));
font: inherit;
font-size: 0.95rem;
}
.article-content :where(.data-table-scroll) {
overflow-x: auto;
}
.article-content :where(.semantic-data-table table) {
display: table;
width: 100%;
min-width: 44rem;
border-collapse: collapse;
overflow: visible;
}
.article-content :where(.semantic-data-table caption) {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.article-content :where(.semantic-data-table th) {
background: color-mix(in srgb, hsl(var(--muted)) 48%, transparent);
color: hsl(var(--muted-foreground));
font-family: var(--font-mono);
font-size: 0.72rem;
font-weight: 650;
letter-spacing: 0.08em;
text-align: left;
text-transform: uppercase;
}
.article-content :where(.semantic-data-table th button) {
display: inline-flex;
align-items: center;
gap: 0.3rem;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
font: inherit;
letter-spacing: inherit;
padding: 0;
text-align: left;
text-transform: inherit;
}
.article-content :where(.semantic-data-table th button)::after {
content: "";
display: inline-block;
width: 0.42rem;
height: 0.42rem;
border-right: 1px solid currentColor;
border-bottom: 1px solid currentColor;
color: color-mix(in srgb, hsl(var(--muted-foreground)) 58%, transparent);
transform: rotate(45deg);
}
.article-content
:where(.semantic-data-table th button[aria-sort="ascending"])::after {
transform: translateY(0.12rem) rotate(225deg);
}
.article-content
:where(.semantic-data-table th button[aria-sort="descending"])::after {
transform: translateY(-0.12rem) rotate(45deg);
}
.article-content :where(.semantic-data-table td) {
vertical-align: top;
}
.article-content :where(.semantic-data-table td:first-child) {
width: 7.5rem;
}
.article-content :where(.semantic-data-table td:nth-child(2)) {
width: 18rem;
font-weight: 650;
}
.article-content :where(.data-table-badge) {
display: inline-flex;
border: 1px solid hsl(var(--border));
border-radius: 999px;
padding: 0.15rem 0.5rem;
background: hsl(var(--background));
color: hsl(var(--muted-foreground));
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
.article-content :where(.data-table-rich-cell) {
color: hsl(var(--muted-foreground));
font-size: 0.98rem;
line-height: 1.55;
}
.article-content :where(.data-table-rich-cell br + br) {
display: none;
}
.article-content :where(.data-table-empty) {
margin: 0;
padding: 1rem;
color: hsl(var(--muted-foreground));
font-size: 0.95rem;
}
.article-content :where(.twitter-tweet) {
max-width: 100% !important;
margin: 1.5rem auto !important;