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

@ -186,8 +186,15 @@ Astro wraps these glyphs in small semantic spans so they get stable font
fallbacks and accessible labels:
```html
<span class="symbol symbol-chess" data-symbol="white-knight" aria-label="white knight"></span>
<span class="symbol symbol-card" data-symbol="heart" aria-label="heart"></span>
<span
class="symbol symbol-chess"
data-symbol="white-knight"
aria-label="white knight"
>♘</span
>
<span class="symbol symbol-card" data-symbol="heart" aria-label="heart"
>♥</span
>
<span class="symbol symbol-emoji">🎯</span>
```
@ -263,6 +270,28 @@ Drag files along a tape and compare the access cost.
Use the Pandoc-style form for posts that should export to PDF.
### Data Tables
Use `.data-table` when a post should keep rows in a colocated YAML or JSON file
but render as a searchable, sortable table in Astro:
````markdown
```{.data-table #tbl:resources src="resources.yml" columns="type,title,summary" headers="type:Type,title:Resource,summary:Notes" link="title" number="slno" caption="Resources" search-placeholder="Search resources"}
The HTML version renders this as a searchable, sortable table. In Obsidian,
edit `resources.yml` to update the rows.
```
````
The data file should contain either a top-level array or an object with an
`items` array. The `src` path is resolved relative to the Markdown file. Use
`columns` to control the visible columns, `headers` to rename table headers,
`link` to choose the column that should link through the row's `link` field,
and `number` to prefix that linked column with a row number such as `slno`.
In Obsidian this remains a readable code fence next to the data file. In Astro
it becomes static HTML enhanced with local search and sort. In PDF it becomes a
short callout fallback unless a `pdf="..."` or fence body fallback is provided.
### LaTeX-Style Environments
Use `.env` plus the environment class and a label:

View file

@ -319,6 +319,100 @@ Every bipartite graph satisfying Hall's condition has a matching...
Use the canonical Pandoc-style form for any material that should export to PDF.
## Data Tables
Use data tables when a post should keep rows in a colocated YAML or JSON file
but render as a searchable, sortable table in Astro.
Canonical form:
````markdown
```{.data-table #tbl:resources src="resources.yml" columns="type,title,summary" headers="type:Type,title:Resource,summary:Notes" link="title" number="slno" caption="Resources" search-placeholder="Search resources"}
The HTML version renders this as a searchable, sortable table. In Obsidian,
edit `resources.yml` to update the rows.
```
````
Attributes:
| Attribute | Required | Meaning |
| ------------------------------ | -------- | ---------------------------------------------------------- |
| `.data-table` | Yes | Marks this as a data-backed table. |
| `#tbl:<id>` | Optional | HTML id and future cross-reference label. |
| `src="resources.yml"` | Yes | YAML or JSON file path, resolved relative to the Markdown. |
| `columns="type,title,summary"` | Optional | Visible columns and their order. |
| `headers="key:Label,..."` | Optional | Human-readable labels for selected columns. |
| `link="title"` | Optional | Column whose value links through the row's URL field. |
| `url="link"` | Optional | URL field used by the linked column; defaults to `link`. |
| `number="slno"` | Optional | Prefix the linked column with a row number field. |
| `caption="Resources"` | Optional | Visible toolbar title and accessible table caption. |
| `search="false"` | Optional | Disable local table search; enabled by default. |
| `sort="false"` | Optional | Disable sortable headers; enabled by default. |
| `search-placeholder="Search"` | Optional | Placeholder text for the search input. |
| `pdf="Fallback text"` | Optional | PDF-specific fallback text. |
| `fallback="Fallback text"` | Optional | General fallback text when `pdf` is absent. |
The data file should contain either a top-level array:
```yaml
- slno: 01
title: "Cognitive Productivity"
summary: "Book by Luc P. Beaudoin."
link: https://leanpub.com/cognitiveproductivity/
type: book
```
or an object with an `items` array:
```yaml
items:
- title: "Cognitive Productivity"
summary: "Book by Luc P. Beaudoin."
link: https://leanpub.com/cognitiveproductivity/
type: book
```
HTML output:
```html
<section class="semantic-data-table" id="tbl:resources" data-data-table-root>
<div class="data-table-toolbar">
<h2>Resources</h2>
<div class="data-table-search">...</div>
</div>
<div class="data-table-scroll">
<table>
...
</table>
</div>
</section>
<script>
...
</script>
```
The generated table is static HTML. The inline script only adds local search and
sort behavior; it does not fetch data at runtime.
PDF output:
```tex
\begin{blogasidebox}{Resources}
The HTML version renders a searchable, sortable data table from `resources.yml`.
\end{blogasidebox}
```
If the code fence has body text, or `pdf="..."`, that text is used as the PDF
fallback. Full YAML-to-LaTeX table rendering is deliberately not implemented
yet because long prose fields usually typeset better as curated PDF content.
Obsidian behavior:
- The fence stays readable in the note.
- The YAML file remains a normal sidecar file you can edit directly.
- Obsidian does not render the enhanced table unless a future vault plugin adds
a preview renderer for `.data-table`.
## Interactive Blocks
Interactive blocks reserve a place for a React component in HTML and provide a

57
package-lock.json generated
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",
@ -2831,6 +2832,60 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.4.3",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.0.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.4.3",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.0.2",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.11",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.9.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.9.0",
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.0",
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.11.tgz",

View file

@ -63,7 +63,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

@ -329,6 +329,27 @@ local function interactive_to_latex(el)
)
end
local function data_table_to_latex(el)
local caption = el.attributes.caption or el.attributes.title or "Data table"
local src = el.attributes.src or el.attributes.source or ""
local fallback = el.attributes.pdf or el.attributes.fallback or el.text
if fallback == "" then
if src ~= "" then
fallback = "The HTML version renders a searchable, sortable data table from `" .. src .. "`."
else
fallback = "The HTML version renders a searchable, sortable data table."
end
end
return pandoc.RawBlock(
"latex",
"\\begin{blogasidebox}{" .. latex_escape(caption) .. "}\n"
.. markdown_to_latex(fallback)
.. "\n\\end{blogasidebox}"
)
end
local function remove_first_inline(block)
if not block or block.t ~= "Para" or not block.content or #block.content == 0 then
return
@ -513,6 +534,10 @@ local function env_to_latex(el)
end
function CodeBlock(el)
if FORMAT:match("latex") and has_class(el, "data-table") then
return data_table_to_latex(el)
end
if FORMAT:match("latex") and has_class(el, "interactive") then
return interactive_to_latex(el)
end

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;

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;

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;

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;

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;

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

@ -13,138 +13,6 @@ Here is a list of courses, books, essays, YouTube playlists, and other miscellan
Thanks to [Abhinav](https://twitter.com/abhinav) for [the prompt](https://twitter.com/abhinav/status/1664359012467441664?s=20).
The original page used a dynamic table. This version keeps the same resource list as ordinary Markdown, so it renders cleanly in Obsidian, RSS, and the Astro blog.
## Resources
### 01. [Cognitive Productivity: Using Knowledge to Become Profoundly Effective](https://leanpub.com/cognitiveproductivity/)
_Type: Book._
Book by Luc P. Beaudoin.
Official book summary: You distinguish yourself from others not so much by what you've read, viewed or listened to, but by the lasting impact that information has had upon you. How can you use knowledge to become a more effective person? Cognitive Productivity answers this question with cognitive science and practical strategies.
### 02. [Cognitive Productivity with macOS: 7 Principles for Getting Smarter with Knowledge](https://leanpub.com/cognitive-productivity-macos)
_Type: Book._
Book by Luc P. Beaudoin.
Official book summary: In today's competitive knowledge economy, you always need to keep learning —and fast. But most information and apps get in the way. This book describes 7 principles for selecting, processing and mastering knowledge using potent Mac apps.
### 03. [The Introduction to the Zettelkasten Method](https://zettelkasten.de/posts/overview/)
_Type: Map / index._
Zettelkasten is a rather specific note-taking system (disclaimer: it is not something that I use, but I do find the principles vaguely intriguing). It relies heavily on tagging, linking, going with the flow, not overthinking, and somehow simultaneously keeps things systematic from a retrieval perspective. This overview page has a bunch of pointers that that should serve as a roadmap for the Zettelkasten for anyone inclined to find out more.
### 04. [How can we develop transformative tools for thought?](https://numinous.productions/ttft/)
_Type: Essay._
Essay by Andy Matuschak and Michael Nielsen
Arguably not about taking notes per se, but about philosophies associated with systems and — as the title suggests — tools for thought.
### 05. [Evergreen Notes](https://notes.andymatuschak.org/z4SDCZQeRo4xFEQ8H4qrSqd68ucpgE6LU155C)
_Type: Essay._
Thoughts from Andy Matuschak
The OG 'digital garden', if I am not mistaken!
### 06. [Obsidian for Everyone](https://courses.nicolevanderhoeven.com/o4e)
_Type: Course._
Nicole van der Hoeven's course.
Nicole is a pro Obsidian user and a master expositor. This particular course is nice because it focuses on all the things you can achieve without the use of any plugins: just bare-bones Obsidian. You'd be surprised at how far you can go!
### 07. [Youtube channel: Nicole van der Hoeven](https://www.youtube.com/@nicolevdh/playlists)
_Type: YouTube._
Excellent content for pro and starter Obsidian users alike. Very accessible and engaging, and I believe most people will find something relevant and relatable here.
### 08. [Grok TiddlyWiki](https://groktiddlywiki.com/read/)
_Type: Book._
Grok TiddlyWiki is written and maintained by Soren Bjornstad.
Outstanding introduction to TiddlyWiki. Even non-TW users are likely to get some fun ideas from skimming this. And if you are a TW user, then this is one of the best journeys from starter to pro! Also check out Soren's own digital garden.
### 09. [Youtube channel: Linking Your Thinking (LYT)](https://www.youtube.com/@linkingyourthinking)
_Type: YouTube._
This is Nick Milo's Youtube channel. It is mostly principles around linking, but uses Obsidian as the tool of choice. Nick also organizes a LYT conference regularly that is hugely popular and not particularly restricted to Obsidian. He also has a cohort-based course around LYT principles, a great Obsidian starter playlist, and a course called Obsidian Flight School as well.
### 10. [Youtube channel: August Bradley](https://www.youtube.com/@augustbradley)
_Type: YouTube._
This channel focuses on 'life design' as a broad concept, using Notion as the underlying tool of choice for organization. There is an extensive discussion of the 'PPV' system - Pillars, Pipelines, and Vaults. Lots of system thinking in here, and I think although all of it is done on Notion, several principles are tool-agnostic.
### 11. [Building a Second Brain](https://www.buildingasecondbrain.com/course)
_Type: Book._
This is a cohort-based course around the notion of building a second brain by Tiago Forte. The material is centered around the 'PARA' method - Projects, Areas, Resources, Archives: a fairly intuitive system that many have found useful. Tiago has one published and one upcoming [at the time of this writing] book on these methods. His blog and Youtube channel dive fairly deep into these concepts, and I think the cohorts are useful if you are building something out and are looking for ways to stay accountable on sticking a system, or want a community/platform to brainstorm ideas.
### 12. [Youtube channel: Marie Poulin](https://www.youtube.com/c/mariepoulin)
_Type: YouTube._
Marie is an authority when it comes to adapting Notion in creative ways for both personal and business use. She has a very comprehensive course called Notion Mastery that covers a lot of ground in terms of both techniques and principles.
### 13. [Youtube channel: Zsolt's Visual Personal Knowledge Management](https://www.youtube.com/@VisualPKM)
_Type: YouTube._
This is the Zsolt's channel, who is behind tools like Excalibrain and Excalidraw for Obsidian and beyond. The channel emphasizes visual aspects of note-taking, while some of it is focused on Excalidraw/Excalibrain, a lot of content is also about general principles, examples, and so on.
### 14. [Youtube channel: Thomas Frank](https://www.youtube.com/@Thomasfrank)
_Type: YouTube._
This channel is another one that pushes Notion to its limits. While overall this might be more productivity/systems/management territory than note-taking, I have found some great discussions about organizational principles here.
### 15. [Johnny Decimal](https://johnnydecimal.com/)
_Type: Other._
This is purely about organization, but for me how notes are stored are an important piece of the puzzle. If you like folders, you might enjoy this system.
### 16. [A list of PKM tools](https://www.reddit.com/r/PKMS/comments/nfef59/list_of_personal_knowledge_management_systems/)
_Type: Map / index._
A reddit post collecting a bunch of PKM software options, ranging from mainstream to exotic and everything in between!
### 17. [To Obsidian and Beyond](https://thesweetsetup.com/obsidian/)
_Type: Course._
A course by Mike Schmitz. From the official description: A master-course for Obsidian users (new and old alike). Finally organize your notes and ideas to make creative output easy.
### 18. [Youtube channel: Red Gregory](https://www.youtube.com/c/RedGregory)
_Type: YouTube._
A Notion-based channel: I have found some of the workflows here to be advanced and intriguing in equal measure. Many principles are tool-agnostic, although the focus here is mostly on Notion.
### 19. [Youtube channel: Danny Hatcher](https://www.youtube.com/@DannyHatcher)
_Type: YouTube._
A channel focused on PKM and organizational systems broadly. I have mostly viewed the Obsidian-related material here: several demonstrations and tutorials have been very helpful.
### 20. [Verbal to Visual](https://verbaltovisual.com/)
_Type: Course._
Lots of resources on sketchnoting and visual approaches to note-taking.
```{.data-table #tbl:note-taking-resources src="resources.yml" columns="type,title,summary" headers="type:Type,title:Resource,summary:Why it may be useful" link="title" number="slno" caption="Note-taking resources" search-placeholder="Search resources"}
The HTML version renders this as a searchable, sortable table. In Obsidian, edit `resources.yml` to update the rows.
```

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;

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;