This commit is contained in:
parent
5dfcdfd516
commit
c06f71a5ad
34 changed files with 3641 additions and 279 deletions
3
sites/art/package-lock.json
generated
3
sites/art/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
|
||||
const ENV_TYPES = new Set([
|
||||
|
|
@ -24,7 +27,8 @@ const ENV_LABELS = {
|
|||
|
||||
function tokenizeInfo(info) {
|
||||
return (
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? []
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ??
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -102,22 +106,308 @@ function encodedJson(value) {
|
|||
return encodeURIComponent(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function csv(value) {
|
||||
return String(value || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function keyValueList(value) {
|
||||
const entries = {};
|
||||
for (const item of csv(value)) {
|
||||
const separator = item.indexOf(":");
|
||||
if (separator <= 0) continue;
|
||||
const key = item.slice(0, separator).trim();
|
||||
const entryValue = item.slice(separator + 1).trim();
|
||||
if (key && entryValue) entries[key] = entryValue;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function booleanAttr(value, defaultValue = true) {
|
||||
if (value === undefined || value === "") return defaultValue;
|
||||
return !["0", "false", "no", "off"].includes(
|
||||
String(value).trim().toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
function labelize(value) {
|
||||
return String(value || "")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function plainText(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function inlineMarkup(value) {
|
||||
return escapeHtml(value ?? "")
|
||||
.replace(/<br\s*\/?>/gi, "<br>")
|
||||
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2">$1</a>');
|
||||
}
|
||||
|
||||
function semanticError(message) {
|
||||
return {
|
||||
type: "html",
|
||||
value: `<aside class="semantic-error">${message}</aside>`,
|
||||
};
|
||||
}
|
||||
|
||||
function firstClass(attrs, excluded) {
|
||||
return attrs.classes.find((className) => !excluded.has(className));
|
||||
}
|
||||
|
||||
function sourcePathForFile(file) {
|
||||
return file?.path || file?.history?.[0] || "";
|
||||
}
|
||||
|
||||
function resolveDataPath(src, file) {
|
||||
if (!src) return "";
|
||||
if (path.isAbsolute(src)) return src;
|
||||
|
||||
const sourcePath = sourcePathForFile(file);
|
||||
const baseDir = sourcePath ? path.dirname(sourcePath) : process.cwd();
|
||||
return path.resolve(baseDir, src);
|
||||
}
|
||||
|
||||
function readDataRows(src, file) {
|
||||
const resolvedPath = resolveDataPath(src, file);
|
||||
if (!resolvedPath || !existsSync(resolvedPath)) {
|
||||
throw new Error(`Data file not found: ${src}`);
|
||||
}
|
||||
|
||||
const raw = readFileSync(resolvedPath, "utf8");
|
||||
const extension = path.extname(resolvedPath).toLowerCase();
|
||||
const parsed = extension === ".json" ? JSON.parse(raw) : parseYaml(raw);
|
||||
const rows = Array.isArray(parsed) ? parsed : parsed?.items;
|
||||
|
||||
if (!Array.isArray(rows)) {
|
||||
throw new Error(
|
||||
`Data file must contain an array or an object with an items array: ${src}`,
|
||||
);
|
||||
}
|
||||
|
||||
return rows.filter((row) => row && typeof row === "object");
|
||||
}
|
||||
|
||||
const VALUE_LABELS = {
|
||||
MOC: "Map / index",
|
||||
book: "Book",
|
||||
course: "Course",
|
||||
essay: "Essay",
|
||||
other: "Other",
|
||||
software: "Software",
|
||||
youtube: "YouTube",
|
||||
};
|
||||
|
||||
function displayValue(value) {
|
||||
return VALUE_LABELS[value] || value;
|
||||
}
|
||||
|
||||
function renderCell(row, column, options) {
|
||||
const raw = row[column] ?? "";
|
||||
const display = displayValue(raw);
|
||||
const text = plainText(display);
|
||||
const sortValue = attribute(text.toLowerCase());
|
||||
const cellAttrs = `data-column="${attribute(column)}" data-sort-value="${sortValue}"`;
|
||||
|
||||
if (column === options.linkColumn && row[options.urlField]) {
|
||||
const numberValue = row[options.numberField];
|
||||
const numberLabel =
|
||||
numberValue === undefined || numberValue === null || numberValue === ""
|
||||
? ""
|
||||
: String(numberValue).padStart(2, "0");
|
||||
const prefix = options.numberField && numberLabel ? `${numberLabel}. ` : "";
|
||||
return `<td ${cellAttrs}><a href="${attribute(row[options.urlField])}">${escapeHtml(prefix)}${inlineMarkup(display)}</a></td>`;
|
||||
}
|
||||
|
||||
if (column === "type") {
|
||||
return `<td ${cellAttrs}><span class="data-table-badge">${escapeHtml(display)}</span></td>`;
|
||||
}
|
||||
|
||||
if (
|
||||
column === "summary" ||
|
||||
String(display).length > 140 ||
|
||||
String(display).includes("<br")
|
||||
) {
|
||||
return `<td ${cellAttrs}><div class="data-table-rich-cell">${inlineMarkup(display)}</div></td>`;
|
||||
}
|
||||
|
||||
return `<td ${cellAttrs}>${inlineMarkup(display)}</td>`;
|
||||
}
|
||||
|
||||
function dataTableScript(id) {
|
||||
return `<script>
|
||||
(() => {
|
||||
const root = document.getElementById(${JSON.stringify(id)});
|
||||
if (!root || root.dataset.dataTableReady) return;
|
||||
root.dataset.dataTableReady = "true";
|
||||
|
||||
const input = root.querySelector("[data-data-table-search]");
|
||||
const tbody = root.querySelector("tbody");
|
||||
const empty = root.querySelector("[data-data-table-empty]");
|
||||
const rows = Array.from(tbody?.querySelectorAll("tr") || []);
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
function applyFilter() {
|
||||
const query = (input?.value || "").trim().toLowerCase();
|
||||
let visible = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const matches = !query || row.dataset.search.includes(query);
|
||||
row.hidden = !matches;
|
||||
if (matches) visible += 1;
|
||||
}
|
||||
|
||||
if (empty) empty.hidden = visible !== 0;
|
||||
}
|
||||
|
||||
input?.addEventListener("input", applyFilter);
|
||||
|
||||
root.querySelectorAll("[data-data-table-sort]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const column = button.dataset.dataTableSort;
|
||||
const current = button.getAttribute("aria-sort");
|
||||
const direction = current === "ascending" ? "descending" : "ascending";
|
||||
|
||||
root.querySelectorAll("[data-data-table-sort]").forEach((other) => {
|
||||
other.removeAttribute("aria-sort");
|
||||
});
|
||||
button.setAttribute("aria-sort", direction);
|
||||
|
||||
rows
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const leftValue = left.querySelector(\`[data-column="\${column}"]\`)?.dataset.sortValue || "";
|
||||
const rightValue = right.querySelector(\`[data-column="\${column}"]\`)?.dataset.sortValue || "";
|
||||
const result = collator.compare(leftValue, rightValue);
|
||||
return direction === "ascending" ? result : -result;
|
||||
})
|
||||
.forEach((row) => tbody.appendChild(row));
|
||||
|
||||
applyFilter();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>`;
|
||||
}
|
||||
|
||||
function dataTableFromCode(node, file) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
if (!attrs.classes.includes("data-table")) return null;
|
||||
|
||||
const src = attrs.values.src || attrs.values.source || "";
|
||||
if (!src) {
|
||||
return semanticError('Data table block missing <code>src="..."</code>.');
|
||||
}
|
||||
|
||||
let rows;
|
||||
try {
|
||||
rows = readDataRows(src, file);
|
||||
} catch (error) {
|
||||
return semanticError(escapeHtml(error.message));
|
||||
}
|
||||
|
||||
const firstRow = rows[0] || {};
|
||||
const columns = csv(attrs.values.columns).length
|
||||
? csv(attrs.values.columns)
|
||||
: Object.keys(firstRow).filter((key) => key !== "link");
|
||||
const headers = keyValueList(attrs.values.headers || attrs.values.labels);
|
||||
const linkColumn =
|
||||
attrs.values.link || (columns.includes("title") ? "title" : "");
|
||||
const urlField = attrs.values.url || attrs.values.href || "link";
|
||||
const numberField = attrs.values.number || "";
|
||||
const caption = attrs.values.caption || attrs.values.title || "";
|
||||
const searchable = booleanAttr(attrs.values.search, true);
|
||||
const sortable = booleanAttr(attrs.values.sort, true);
|
||||
const placeholder =
|
||||
attrs.values["search-placeholder"] || `Search ${caption || "table"}`;
|
||||
const id =
|
||||
attrs.id ||
|
||||
`data-table-${src.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "")}`;
|
||||
const tableId = `${id}-table`;
|
||||
const searchId = `${id}-search`;
|
||||
|
||||
const headerHtml = columns
|
||||
.map((column) => {
|
||||
const label = headers[column] || labelize(column);
|
||||
const content = sortable
|
||||
? `<button type="button" data-data-table-sort="${attribute(column)}">${escapeHtml(label)}</button>`
|
||||
: escapeHtml(label);
|
||||
return `<th scope="col">${content}</th>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const bodyHtml = rows
|
||||
.map((row) => {
|
||||
const searchText = columns
|
||||
.map((column) => plainText(displayValue(row[column] ?? "")))
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
const cells = columns
|
||||
.map((column) =>
|
||||
renderCell(row, column, { linkColumn, urlField, numberField }),
|
||||
)
|
||||
.join("");
|
||||
return `<tr data-search="${attribute(searchText)}">${cells}</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const searchHtml = searchable
|
||||
? [
|
||||
'<div class="data-table-search">',
|
||||
`<label for="${attribute(searchId)}">Search</label>`,
|
||||
`<input id="${attribute(searchId)}" type="search" autocomplete="off" placeholder="${attribute(placeholder)}" data-data-table-search>`,
|
||||
"</div>",
|
||||
].join("")
|
||||
: "";
|
||||
|
||||
return {
|
||||
type: "html",
|
||||
value: [
|
||||
`<section class="semantic-data-table" id="${attribute(id)}" data-data-table-root>`,
|
||||
caption || searchable
|
||||
? `<div class="data-table-toolbar">${caption ? `<h2>${escapeHtml(caption)}</h2>` : ""}${searchHtml}</div>`
|
||||
: "",
|
||||
'<div class="data-table-scroll">',
|
||||
`<table id="${attribute(tableId)}">`,
|
||||
caption ? `<caption>${escapeHtml(caption)}</caption>` : "",
|
||||
`<thead><tr>${headerHtml}</tr></thead>`,
|
||||
`<tbody>${bodyHtml}</tbody>`,
|
||||
"</table>",
|
||||
"</div>",
|
||||
`<p class="data-table-empty" data-data-table-empty hidden>No matching rows.</p>`,
|
||||
"</section>",
|
||||
dataTableScript(id),
|
||||
].join(""),
|
||||
};
|
||||
}
|
||||
|
||||
function interactiveFromCode(node) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
if (!attrs.classes.includes("interactive")) return null;
|
||||
|
||||
const declaredId = attrs.id || attrs.values.component || attrs.values.name || "";
|
||||
const declaredId =
|
||||
attrs.id || attrs.values.component || attrs.values.name || "";
|
||||
const componentId =
|
||||
attrs.values.component ||
|
||||
(declaredId.startsWith("int:") ? declaredId.slice("int:".length) : declaredId);
|
||||
const label = attrs.values.label || (declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`);
|
||||
(declaredId.startsWith("int:")
|
||||
? declaredId.slice("int:".length)
|
||||
: declaredId);
|
||||
const label =
|
||||
attrs.values.label ||
|
||||
(declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`);
|
||||
const caption = attrs.values.caption || attrs.values.title || "";
|
||||
const description = node.value.trim();
|
||||
const fallback = attrs.values.pdf || attrs.values.fallback || description || caption;
|
||||
const fallback =
|
||||
attrs.values.pdf || attrs.values.fallback || description || caption;
|
||||
|
||||
if (!componentId) {
|
||||
return {
|
||||
|
|
@ -141,7 +431,9 @@ function interactiveFromCode(node) {
|
|||
value: [
|
||||
`<figure class="semantic-interactive" id="${attribute(label)}" data-interactive-label="${attribute(label)}">`,
|
||||
`<div class="interactive-mount" data-interactive-id="${attribute(componentId)}" data-interactive-props="${attribute(encodedJson(props))}">`,
|
||||
fallback ? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>` : "",
|
||||
fallback
|
||||
? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>`
|
||||
: "",
|
||||
"</div>",
|
||||
caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "",
|
||||
"</figure>",
|
||||
|
|
@ -154,7 +446,10 @@ function envFromCode(node) {
|
|||
const shorthandType = firstClass(attrs, new Set(["env", "latex-env"]));
|
||||
const explicitType = attrs.values.type || shorthandType;
|
||||
const type = ENV_TYPES.has(explicitType) ? explicitType : "";
|
||||
const isEnv = attrs.classes.includes("env") || attrs.classes.includes("latex-env") || Boolean(type);
|
||||
const isEnv =
|
||||
attrs.classes.includes("env") ||
|
||||
attrs.classes.includes("latex-env") ||
|
||||
Boolean(type);
|
||||
|
||||
if (!isEnv) return null;
|
||||
|
||||
|
|
@ -168,7 +463,8 @@ function envFromCode(node) {
|
|||
|
||||
const title = attrs.values.title || attrs.values.name || "";
|
||||
const label = attrs.id || attrs.values.label || "";
|
||||
const renderMode = attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const renderMode =
|
||||
attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const heading = title ? `${ENV_LABELS[type]} (${title})` : ENV_LABELS[type];
|
||||
const parsed = fromMarkdown(node.value || "");
|
||||
const className = [
|
||||
|
|
@ -210,23 +506,26 @@ function envFromCode(node) {
|
|||
};
|
||||
}
|
||||
|
||||
function transformTree(parent) {
|
||||
function transformTree(parent, file) {
|
||||
if (!parent?.children) return;
|
||||
|
||||
for (let index = 0; index < parent.children.length; index += 1) {
|
||||
const child = parent.children[index];
|
||||
if (child.type === "code") {
|
||||
const replacement = interactiveFromCode(child) || envFromCode(child);
|
||||
const replacement =
|
||||
dataTableFromCode(child, file) ||
|
||||
interactiveFromCode(child) ||
|
||||
envFromCode(child);
|
||||
if (replacement) {
|
||||
parent.children[index] = replacement;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
transformTree(child);
|
||||
transformTree(child, file);
|
||||
}
|
||||
}
|
||||
|
||||
export default function remarkSemanticBlocks() {
|
||||
return (tree) => transformTree(tree);
|
||||
return (tree, file) => transformTree(tree, file);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
3
sites/poetry/package-lock.json
generated
3
sites/poetry/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
|
||||
const ENV_TYPES = new Set([
|
||||
|
|
@ -24,7 +27,8 @@ const ENV_LABELS = {
|
|||
|
||||
function tokenizeInfo(info) {
|
||||
return (
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? []
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ??
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -102,22 +106,308 @@ function encodedJson(value) {
|
|||
return encodeURIComponent(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function csv(value) {
|
||||
return String(value || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function keyValueList(value) {
|
||||
const entries = {};
|
||||
for (const item of csv(value)) {
|
||||
const separator = item.indexOf(":");
|
||||
if (separator <= 0) continue;
|
||||
const key = item.slice(0, separator).trim();
|
||||
const entryValue = item.slice(separator + 1).trim();
|
||||
if (key && entryValue) entries[key] = entryValue;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function booleanAttr(value, defaultValue = true) {
|
||||
if (value === undefined || value === "") return defaultValue;
|
||||
return !["0", "false", "no", "off"].includes(
|
||||
String(value).trim().toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
function labelize(value) {
|
||||
return String(value || "")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function plainText(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function inlineMarkup(value) {
|
||||
return escapeHtml(value ?? "")
|
||||
.replace(/<br\s*\/?>/gi, "<br>")
|
||||
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2">$1</a>');
|
||||
}
|
||||
|
||||
function semanticError(message) {
|
||||
return {
|
||||
type: "html",
|
||||
value: `<aside class="semantic-error">${message}</aside>`,
|
||||
};
|
||||
}
|
||||
|
||||
function firstClass(attrs, excluded) {
|
||||
return attrs.classes.find((className) => !excluded.has(className));
|
||||
}
|
||||
|
||||
function sourcePathForFile(file) {
|
||||
return file?.path || file?.history?.[0] || "";
|
||||
}
|
||||
|
||||
function resolveDataPath(src, file) {
|
||||
if (!src) return "";
|
||||
if (path.isAbsolute(src)) return src;
|
||||
|
||||
const sourcePath = sourcePathForFile(file);
|
||||
const baseDir = sourcePath ? path.dirname(sourcePath) : process.cwd();
|
||||
return path.resolve(baseDir, src);
|
||||
}
|
||||
|
||||
function readDataRows(src, file) {
|
||||
const resolvedPath = resolveDataPath(src, file);
|
||||
if (!resolvedPath || !existsSync(resolvedPath)) {
|
||||
throw new Error(`Data file not found: ${src}`);
|
||||
}
|
||||
|
||||
const raw = readFileSync(resolvedPath, "utf8");
|
||||
const extension = path.extname(resolvedPath).toLowerCase();
|
||||
const parsed = extension === ".json" ? JSON.parse(raw) : parseYaml(raw);
|
||||
const rows = Array.isArray(parsed) ? parsed : parsed?.items;
|
||||
|
||||
if (!Array.isArray(rows)) {
|
||||
throw new Error(
|
||||
`Data file must contain an array or an object with an items array: ${src}`,
|
||||
);
|
||||
}
|
||||
|
||||
return rows.filter((row) => row && typeof row === "object");
|
||||
}
|
||||
|
||||
const VALUE_LABELS = {
|
||||
MOC: "Map / index",
|
||||
book: "Book",
|
||||
course: "Course",
|
||||
essay: "Essay",
|
||||
other: "Other",
|
||||
software: "Software",
|
||||
youtube: "YouTube",
|
||||
};
|
||||
|
||||
function displayValue(value) {
|
||||
return VALUE_LABELS[value] || value;
|
||||
}
|
||||
|
||||
function renderCell(row, column, options) {
|
||||
const raw = row[column] ?? "";
|
||||
const display = displayValue(raw);
|
||||
const text = plainText(display);
|
||||
const sortValue = attribute(text.toLowerCase());
|
||||
const cellAttrs = `data-column="${attribute(column)}" data-sort-value="${sortValue}"`;
|
||||
|
||||
if (column === options.linkColumn && row[options.urlField]) {
|
||||
const numberValue = row[options.numberField];
|
||||
const numberLabel =
|
||||
numberValue === undefined || numberValue === null || numberValue === ""
|
||||
? ""
|
||||
: String(numberValue).padStart(2, "0");
|
||||
const prefix = options.numberField && numberLabel ? `${numberLabel}. ` : "";
|
||||
return `<td ${cellAttrs}><a href="${attribute(row[options.urlField])}">${escapeHtml(prefix)}${inlineMarkup(display)}</a></td>`;
|
||||
}
|
||||
|
||||
if (column === "type") {
|
||||
return `<td ${cellAttrs}><span class="data-table-badge">${escapeHtml(display)}</span></td>`;
|
||||
}
|
||||
|
||||
if (
|
||||
column === "summary" ||
|
||||
String(display).length > 140 ||
|
||||
String(display).includes("<br")
|
||||
) {
|
||||
return `<td ${cellAttrs}><div class="data-table-rich-cell">${inlineMarkup(display)}</div></td>`;
|
||||
}
|
||||
|
||||
return `<td ${cellAttrs}>${inlineMarkup(display)}</td>`;
|
||||
}
|
||||
|
||||
function dataTableScript(id) {
|
||||
return `<script>
|
||||
(() => {
|
||||
const root = document.getElementById(${JSON.stringify(id)});
|
||||
if (!root || root.dataset.dataTableReady) return;
|
||||
root.dataset.dataTableReady = "true";
|
||||
|
||||
const input = root.querySelector("[data-data-table-search]");
|
||||
const tbody = root.querySelector("tbody");
|
||||
const empty = root.querySelector("[data-data-table-empty]");
|
||||
const rows = Array.from(tbody?.querySelectorAll("tr") || []);
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
function applyFilter() {
|
||||
const query = (input?.value || "").trim().toLowerCase();
|
||||
let visible = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const matches = !query || row.dataset.search.includes(query);
|
||||
row.hidden = !matches;
|
||||
if (matches) visible += 1;
|
||||
}
|
||||
|
||||
if (empty) empty.hidden = visible !== 0;
|
||||
}
|
||||
|
||||
input?.addEventListener("input", applyFilter);
|
||||
|
||||
root.querySelectorAll("[data-data-table-sort]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const column = button.dataset.dataTableSort;
|
||||
const current = button.getAttribute("aria-sort");
|
||||
const direction = current === "ascending" ? "descending" : "ascending";
|
||||
|
||||
root.querySelectorAll("[data-data-table-sort]").forEach((other) => {
|
||||
other.removeAttribute("aria-sort");
|
||||
});
|
||||
button.setAttribute("aria-sort", direction);
|
||||
|
||||
rows
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const leftValue = left.querySelector(\`[data-column="\${column}"]\`)?.dataset.sortValue || "";
|
||||
const rightValue = right.querySelector(\`[data-column="\${column}"]\`)?.dataset.sortValue || "";
|
||||
const result = collator.compare(leftValue, rightValue);
|
||||
return direction === "ascending" ? result : -result;
|
||||
})
|
||||
.forEach((row) => tbody.appendChild(row));
|
||||
|
||||
applyFilter();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>`;
|
||||
}
|
||||
|
||||
function dataTableFromCode(node, file) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
if (!attrs.classes.includes("data-table")) return null;
|
||||
|
||||
const src = attrs.values.src || attrs.values.source || "";
|
||||
if (!src) {
|
||||
return semanticError('Data table block missing <code>src="..."</code>.');
|
||||
}
|
||||
|
||||
let rows;
|
||||
try {
|
||||
rows = readDataRows(src, file);
|
||||
} catch (error) {
|
||||
return semanticError(escapeHtml(error.message));
|
||||
}
|
||||
|
||||
const firstRow = rows[0] || {};
|
||||
const columns = csv(attrs.values.columns).length
|
||||
? csv(attrs.values.columns)
|
||||
: Object.keys(firstRow).filter((key) => key !== "link");
|
||||
const headers = keyValueList(attrs.values.headers || attrs.values.labels);
|
||||
const linkColumn =
|
||||
attrs.values.link || (columns.includes("title") ? "title" : "");
|
||||
const urlField = attrs.values.url || attrs.values.href || "link";
|
||||
const numberField = attrs.values.number || "";
|
||||
const caption = attrs.values.caption || attrs.values.title || "";
|
||||
const searchable = booleanAttr(attrs.values.search, true);
|
||||
const sortable = booleanAttr(attrs.values.sort, true);
|
||||
const placeholder =
|
||||
attrs.values["search-placeholder"] || `Search ${caption || "table"}`;
|
||||
const id =
|
||||
attrs.id ||
|
||||
`data-table-${src.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "")}`;
|
||||
const tableId = `${id}-table`;
|
||||
const searchId = `${id}-search`;
|
||||
|
||||
const headerHtml = columns
|
||||
.map((column) => {
|
||||
const label = headers[column] || labelize(column);
|
||||
const content = sortable
|
||||
? `<button type="button" data-data-table-sort="${attribute(column)}">${escapeHtml(label)}</button>`
|
||||
: escapeHtml(label);
|
||||
return `<th scope="col">${content}</th>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const bodyHtml = rows
|
||||
.map((row) => {
|
||||
const searchText = columns
|
||||
.map((column) => plainText(displayValue(row[column] ?? "")))
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
const cells = columns
|
||||
.map((column) =>
|
||||
renderCell(row, column, { linkColumn, urlField, numberField }),
|
||||
)
|
||||
.join("");
|
||||
return `<tr data-search="${attribute(searchText)}">${cells}</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const searchHtml = searchable
|
||||
? [
|
||||
'<div class="data-table-search">',
|
||||
`<label for="${attribute(searchId)}">Search</label>`,
|
||||
`<input id="${attribute(searchId)}" type="search" autocomplete="off" placeholder="${attribute(placeholder)}" data-data-table-search>`,
|
||||
"</div>",
|
||||
].join("")
|
||||
: "";
|
||||
|
||||
return {
|
||||
type: "html",
|
||||
value: [
|
||||
`<section class="semantic-data-table" id="${attribute(id)}" data-data-table-root>`,
|
||||
caption || searchable
|
||||
? `<div class="data-table-toolbar">${caption ? `<h2>${escapeHtml(caption)}</h2>` : ""}${searchHtml}</div>`
|
||||
: "",
|
||||
'<div class="data-table-scroll">',
|
||||
`<table id="${attribute(tableId)}">`,
|
||||
caption ? `<caption>${escapeHtml(caption)}</caption>` : "",
|
||||
`<thead><tr>${headerHtml}</tr></thead>`,
|
||||
`<tbody>${bodyHtml}</tbody>`,
|
||||
"</table>",
|
||||
"</div>",
|
||||
`<p class="data-table-empty" data-data-table-empty hidden>No matching rows.</p>`,
|
||||
"</section>",
|
||||
dataTableScript(id),
|
||||
].join(""),
|
||||
};
|
||||
}
|
||||
|
||||
function interactiveFromCode(node) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
if (!attrs.classes.includes("interactive")) return null;
|
||||
|
||||
const declaredId = attrs.id || attrs.values.component || attrs.values.name || "";
|
||||
const declaredId =
|
||||
attrs.id || attrs.values.component || attrs.values.name || "";
|
||||
const componentId =
|
||||
attrs.values.component ||
|
||||
(declaredId.startsWith("int:") ? declaredId.slice("int:".length) : declaredId);
|
||||
const label = attrs.values.label || (declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`);
|
||||
(declaredId.startsWith("int:")
|
||||
? declaredId.slice("int:".length)
|
||||
: declaredId);
|
||||
const label =
|
||||
attrs.values.label ||
|
||||
(declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`);
|
||||
const caption = attrs.values.caption || attrs.values.title || "";
|
||||
const description = node.value.trim();
|
||||
const fallback = attrs.values.pdf || attrs.values.fallback || description || caption;
|
||||
const fallback =
|
||||
attrs.values.pdf || attrs.values.fallback || description || caption;
|
||||
|
||||
if (!componentId) {
|
||||
return {
|
||||
|
|
@ -141,7 +431,9 @@ function interactiveFromCode(node) {
|
|||
value: [
|
||||
`<figure class="semantic-interactive" id="${attribute(label)}" data-interactive-label="${attribute(label)}">`,
|
||||
`<div class="interactive-mount" data-interactive-id="${attribute(componentId)}" data-interactive-props="${attribute(encodedJson(props))}">`,
|
||||
fallback ? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>` : "",
|
||||
fallback
|
||||
? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>`
|
||||
: "",
|
||||
"</div>",
|
||||
caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "",
|
||||
"</figure>",
|
||||
|
|
@ -154,7 +446,10 @@ function envFromCode(node) {
|
|||
const shorthandType = firstClass(attrs, new Set(["env", "latex-env"]));
|
||||
const explicitType = attrs.values.type || shorthandType;
|
||||
const type = ENV_TYPES.has(explicitType) ? explicitType : "";
|
||||
const isEnv = attrs.classes.includes("env") || attrs.classes.includes("latex-env") || Boolean(type);
|
||||
const isEnv =
|
||||
attrs.classes.includes("env") ||
|
||||
attrs.classes.includes("latex-env") ||
|
||||
Boolean(type);
|
||||
|
||||
if (!isEnv) return null;
|
||||
|
||||
|
|
@ -168,7 +463,8 @@ function envFromCode(node) {
|
|||
|
||||
const title = attrs.values.title || attrs.values.name || "";
|
||||
const label = attrs.id || attrs.values.label || "";
|
||||
const renderMode = attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const renderMode =
|
||||
attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const heading = title ? `${ENV_LABELS[type]} (${title})` : ENV_LABELS[type];
|
||||
const parsed = fromMarkdown(node.value || "");
|
||||
const className = [
|
||||
|
|
@ -210,23 +506,26 @@ function envFromCode(node) {
|
|||
};
|
||||
}
|
||||
|
||||
function transformTree(parent) {
|
||||
function transformTree(parent, file) {
|
||||
if (!parent?.children) return;
|
||||
|
||||
for (let index = 0; index < parent.children.length; index += 1) {
|
||||
const child = parent.children[index];
|
||||
if (child.type === "code") {
|
||||
const replacement = interactiveFromCode(child) || envFromCode(child);
|
||||
const replacement =
|
||||
dataTableFromCode(child, file) ||
|
||||
interactiveFromCode(child) ||
|
||||
envFromCode(child);
|
||||
if (replacement) {
|
||||
parent.children[index] = replacement;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
transformTree(child);
|
||||
transformTree(child, file);
|
||||
}
|
||||
}
|
||||
|
||||
export default function remarkSemanticBlocks() {
|
||||
return (tree) => transformTree(tree);
|
||||
return (tree, file) => transformTree(tree, file);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
3
sites/puzzles/package-lock.json
generated
3
sites/puzzles/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
|
||||
const ENV_TYPES = new Set([
|
||||
|
|
@ -24,7 +27,8 @@ const ENV_LABELS = {
|
|||
|
||||
function tokenizeInfo(info) {
|
||||
return (
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? []
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ??
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -102,22 +106,308 @@ function encodedJson(value) {
|
|||
return encodeURIComponent(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function csv(value) {
|
||||
return String(value || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function keyValueList(value) {
|
||||
const entries = {};
|
||||
for (const item of csv(value)) {
|
||||
const separator = item.indexOf(":");
|
||||
if (separator <= 0) continue;
|
||||
const key = item.slice(0, separator).trim();
|
||||
const entryValue = item.slice(separator + 1).trim();
|
||||
if (key && entryValue) entries[key] = entryValue;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function booleanAttr(value, defaultValue = true) {
|
||||
if (value === undefined || value === "") return defaultValue;
|
||||
return !["0", "false", "no", "off"].includes(
|
||||
String(value).trim().toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
function labelize(value) {
|
||||
return String(value || "")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function plainText(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function inlineMarkup(value) {
|
||||
return escapeHtml(value ?? "")
|
||||
.replace(/<br\s*\/?>/gi, "<br>")
|
||||
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2">$1</a>');
|
||||
}
|
||||
|
||||
function semanticError(message) {
|
||||
return {
|
||||
type: "html",
|
||||
value: `<aside class="semantic-error">${message}</aside>`,
|
||||
};
|
||||
}
|
||||
|
||||
function firstClass(attrs, excluded) {
|
||||
return attrs.classes.find((className) => !excluded.has(className));
|
||||
}
|
||||
|
||||
function sourcePathForFile(file) {
|
||||
return file?.path || file?.history?.[0] || "";
|
||||
}
|
||||
|
||||
function resolveDataPath(src, file) {
|
||||
if (!src) return "";
|
||||
if (path.isAbsolute(src)) return src;
|
||||
|
||||
const sourcePath = sourcePathForFile(file);
|
||||
const baseDir = sourcePath ? path.dirname(sourcePath) : process.cwd();
|
||||
return path.resolve(baseDir, src);
|
||||
}
|
||||
|
||||
function readDataRows(src, file) {
|
||||
const resolvedPath = resolveDataPath(src, file);
|
||||
if (!resolvedPath || !existsSync(resolvedPath)) {
|
||||
throw new Error(`Data file not found: ${src}`);
|
||||
}
|
||||
|
||||
const raw = readFileSync(resolvedPath, "utf8");
|
||||
const extension = path.extname(resolvedPath).toLowerCase();
|
||||
const parsed = extension === ".json" ? JSON.parse(raw) : parseYaml(raw);
|
||||
const rows = Array.isArray(parsed) ? parsed : parsed?.items;
|
||||
|
||||
if (!Array.isArray(rows)) {
|
||||
throw new Error(
|
||||
`Data file must contain an array or an object with an items array: ${src}`,
|
||||
);
|
||||
}
|
||||
|
||||
return rows.filter((row) => row && typeof row === "object");
|
||||
}
|
||||
|
||||
const VALUE_LABELS = {
|
||||
MOC: "Map / index",
|
||||
book: "Book",
|
||||
course: "Course",
|
||||
essay: "Essay",
|
||||
other: "Other",
|
||||
software: "Software",
|
||||
youtube: "YouTube",
|
||||
};
|
||||
|
||||
function displayValue(value) {
|
||||
return VALUE_LABELS[value] || value;
|
||||
}
|
||||
|
||||
function renderCell(row, column, options) {
|
||||
const raw = row[column] ?? "";
|
||||
const display = displayValue(raw);
|
||||
const text = plainText(display);
|
||||
const sortValue = attribute(text.toLowerCase());
|
||||
const cellAttrs = `data-column="${attribute(column)}" data-sort-value="${sortValue}"`;
|
||||
|
||||
if (column === options.linkColumn && row[options.urlField]) {
|
||||
const numberValue = row[options.numberField];
|
||||
const numberLabel =
|
||||
numberValue === undefined || numberValue === null || numberValue === ""
|
||||
? ""
|
||||
: String(numberValue).padStart(2, "0");
|
||||
const prefix = options.numberField && numberLabel ? `${numberLabel}. ` : "";
|
||||
return `<td ${cellAttrs}><a href="${attribute(row[options.urlField])}">${escapeHtml(prefix)}${inlineMarkup(display)}</a></td>`;
|
||||
}
|
||||
|
||||
if (column === "type") {
|
||||
return `<td ${cellAttrs}><span class="data-table-badge">${escapeHtml(display)}</span></td>`;
|
||||
}
|
||||
|
||||
if (
|
||||
column === "summary" ||
|
||||
String(display).length > 140 ||
|
||||
String(display).includes("<br")
|
||||
) {
|
||||
return `<td ${cellAttrs}><div class="data-table-rich-cell">${inlineMarkup(display)}</div></td>`;
|
||||
}
|
||||
|
||||
return `<td ${cellAttrs}>${inlineMarkup(display)}</td>`;
|
||||
}
|
||||
|
||||
function dataTableScript(id) {
|
||||
return `<script>
|
||||
(() => {
|
||||
const root = document.getElementById(${JSON.stringify(id)});
|
||||
if (!root || root.dataset.dataTableReady) return;
|
||||
root.dataset.dataTableReady = "true";
|
||||
|
||||
const input = root.querySelector("[data-data-table-search]");
|
||||
const tbody = root.querySelector("tbody");
|
||||
const empty = root.querySelector("[data-data-table-empty]");
|
||||
const rows = Array.from(tbody?.querySelectorAll("tr") || []);
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
function applyFilter() {
|
||||
const query = (input?.value || "").trim().toLowerCase();
|
||||
let visible = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const matches = !query || row.dataset.search.includes(query);
|
||||
row.hidden = !matches;
|
||||
if (matches) visible += 1;
|
||||
}
|
||||
|
||||
if (empty) empty.hidden = visible !== 0;
|
||||
}
|
||||
|
||||
input?.addEventListener("input", applyFilter);
|
||||
|
||||
root.querySelectorAll("[data-data-table-sort]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const column = button.dataset.dataTableSort;
|
||||
const current = button.getAttribute("aria-sort");
|
||||
const direction = current === "ascending" ? "descending" : "ascending";
|
||||
|
||||
root.querySelectorAll("[data-data-table-sort]").forEach((other) => {
|
||||
other.removeAttribute("aria-sort");
|
||||
});
|
||||
button.setAttribute("aria-sort", direction);
|
||||
|
||||
rows
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const leftValue = left.querySelector(\`[data-column="\${column}"]\`)?.dataset.sortValue || "";
|
||||
const rightValue = right.querySelector(\`[data-column="\${column}"]\`)?.dataset.sortValue || "";
|
||||
const result = collator.compare(leftValue, rightValue);
|
||||
return direction === "ascending" ? result : -result;
|
||||
})
|
||||
.forEach((row) => tbody.appendChild(row));
|
||||
|
||||
applyFilter();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>`;
|
||||
}
|
||||
|
||||
function dataTableFromCode(node, file) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
if (!attrs.classes.includes("data-table")) return null;
|
||||
|
||||
const src = attrs.values.src || attrs.values.source || "";
|
||||
if (!src) {
|
||||
return semanticError('Data table block missing <code>src="..."</code>.');
|
||||
}
|
||||
|
||||
let rows;
|
||||
try {
|
||||
rows = readDataRows(src, file);
|
||||
} catch (error) {
|
||||
return semanticError(escapeHtml(error.message));
|
||||
}
|
||||
|
||||
const firstRow = rows[0] || {};
|
||||
const columns = csv(attrs.values.columns).length
|
||||
? csv(attrs.values.columns)
|
||||
: Object.keys(firstRow).filter((key) => key !== "link");
|
||||
const headers = keyValueList(attrs.values.headers || attrs.values.labels);
|
||||
const linkColumn =
|
||||
attrs.values.link || (columns.includes("title") ? "title" : "");
|
||||
const urlField = attrs.values.url || attrs.values.href || "link";
|
||||
const numberField = attrs.values.number || "";
|
||||
const caption = attrs.values.caption || attrs.values.title || "";
|
||||
const searchable = booleanAttr(attrs.values.search, true);
|
||||
const sortable = booleanAttr(attrs.values.sort, true);
|
||||
const placeholder =
|
||||
attrs.values["search-placeholder"] || `Search ${caption || "table"}`;
|
||||
const id =
|
||||
attrs.id ||
|
||||
`data-table-${src.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "")}`;
|
||||
const tableId = `${id}-table`;
|
||||
const searchId = `${id}-search`;
|
||||
|
||||
const headerHtml = columns
|
||||
.map((column) => {
|
||||
const label = headers[column] || labelize(column);
|
||||
const content = sortable
|
||||
? `<button type="button" data-data-table-sort="${attribute(column)}">${escapeHtml(label)}</button>`
|
||||
: escapeHtml(label);
|
||||
return `<th scope="col">${content}</th>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const bodyHtml = rows
|
||||
.map((row) => {
|
||||
const searchText = columns
|
||||
.map((column) => plainText(displayValue(row[column] ?? "")))
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
const cells = columns
|
||||
.map((column) =>
|
||||
renderCell(row, column, { linkColumn, urlField, numberField }),
|
||||
)
|
||||
.join("");
|
||||
return `<tr data-search="${attribute(searchText)}">${cells}</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const searchHtml = searchable
|
||||
? [
|
||||
'<div class="data-table-search">',
|
||||
`<label for="${attribute(searchId)}">Search</label>`,
|
||||
`<input id="${attribute(searchId)}" type="search" autocomplete="off" placeholder="${attribute(placeholder)}" data-data-table-search>`,
|
||||
"</div>",
|
||||
].join("")
|
||||
: "";
|
||||
|
||||
return {
|
||||
type: "html",
|
||||
value: [
|
||||
`<section class="semantic-data-table" id="${attribute(id)}" data-data-table-root>`,
|
||||
caption || searchable
|
||||
? `<div class="data-table-toolbar">${caption ? `<h2>${escapeHtml(caption)}</h2>` : ""}${searchHtml}</div>`
|
||||
: "",
|
||||
'<div class="data-table-scroll">',
|
||||
`<table id="${attribute(tableId)}">`,
|
||||
caption ? `<caption>${escapeHtml(caption)}</caption>` : "",
|
||||
`<thead><tr>${headerHtml}</tr></thead>`,
|
||||
`<tbody>${bodyHtml}</tbody>`,
|
||||
"</table>",
|
||||
"</div>",
|
||||
`<p class="data-table-empty" data-data-table-empty hidden>No matching rows.</p>`,
|
||||
"</section>",
|
||||
dataTableScript(id),
|
||||
].join(""),
|
||||
};
|
||||
}
|
||||
|
||||
function interactiveFromCode(node) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
if (!attrs.classes.includes("interactive")) return null;
|
||||
|
||||
const declaredId = attrs.id || attrs.values.component || attrs.values.name || "";
|
||||
const declaredId =
|
||||
attrs.id || attrs.values.component || attrs.values.name || "";
|
||||
const componentId =
|
||||
attrs.values.component ||
|
||||
(declaredId.startsWith("int:") ? declaredId.slice("int:".length) : declaredId);
|
||||
const label = attrs.values.label || (declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`);
|
||||
(declaredId.startsWith("int:")
|
||||
? declaredId.slice("int:".length)
|
||||
: declaredId);
|
||||
const label =
|
||||
attrs.values.label ||
|
||||
(declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`);
|
||||
const caption = attrs.values.caption || attrs.values.title || "";
|
||||
const description = node.value.trim();
|
||||
const fallback = attrs.values.pdf || attrs.values.fallback || description || caption;
|
||||
const fallback =
|
||||
attrs.values.pdf || attrs.values.fallback || description || caption;
|
||||
|
||||
if (!componentId) {
|
||||
return {
|
||||
|
|
@ -141,7 +431,9 @@ function interactiveFromCode(node) {
|
|||
value: [
|
||||
`<figure class="semantic-interactive" id="${attribute(label)}" data-interactive-label="${attribute(label)}">`,
|
||||
`<div class="interactive-mount" data-interactive-id="${attribute(componentId)}" data-interactive-props="${attribute(encodedJson(props))}">`,
|
||||
fallback ? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>` : "",
|
||||
fallback
|
||||
? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>`
|
||||
: "",
|
||||
"</div>",
|
||||
caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "",
|
||||
"</figure>",
|
||||
|
|
@ -154,7 +446,10 @@ function envFromCode(node) {
|
|||
const shorthandType = firstClass(attrs, new Set(["env", "latex-env"]));
|
||||
const explicitType = attrs.values.type || shorthandType;
|
||||
const type = ENV_TYPES.has(explicitType) ? explicitType : "";
|
||||
const isEnv = attrs.classes.includes("env") || attrs.classes.includes("latex-env") || Boolean(type);
|
||||
const isEnv =
|
||||
attrs.classes.includes("env") ||
|
||||
attrs.classes.includes("latex-env") ||
|
||||
Boolean(type);
|
||||
|
||||
if (!isEnv) return null;
|
||||
|
||||
|
|
@ -168,7 +463,8 @@ function envFromCode(node) {
|
|||
|
||||
const title = attrs.values.title || attrs.values.name || "";
|
||||
const label = attrs.id || attrs.values.label || "";
|
||||
const renderMode = attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const renderMode =
|
||||
attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const heading = title ? `${ENV_LABELS[type]} (${title})` : ENV_LABELS[type];
|
||||
const parsed = fromMarkdown(node.value || "");
|
||||
const className = [
|
||||
|
|
@ -210,23 +506,26 @@ function envFromCode(node) {
|
|||
};
|
||||
}
|
||||
|
||||
function transformTree(parent) {
|
||||
function transformTree(parent, file) {
|
||||
if (!parent?.children) return;
|
||||
|
||||
for (let index = 0; index < parent.children.length; index += 1) {
|
||||
const child = parent.children[index];
|
||||
if (child.type === "code") {
|
||||
const replacement = interactiveFromCode(child) || envFromCode(child);
|
||||
const replacement =
|
||||
dataTableFromCode(child, file) ||
|
||||
interactiveFromCode(child) ||
|
||||
envFromCode(child);
|
||||
if (replacement) {
|
||||
parent.children[index] = replacement;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
transformTree(child);
|
||||
transformTree(child, file);
|
||||
}
|
||||
}
|
||||
|
||||
export default function remarkSemanticBlocks() {
|
||||
return (tree) => transformTree(tree);
|
||||
return (tree, file) => transformTree(tree, file);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
3
sites/reflections/package-lock.json
generated
3
sites/reflections/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
|
||||
const ENV_TYPES = new Set([
|
||||
|
|
@ -24,7 +27,8 @@ const ENV_LABELS = {
|
|||
|
||||
function tokenizeInfo(info) {
|
||||
return (
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? []
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ??
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -102,22 +106,308 @@ function encodedJson(value) {
|
|||
return encodeURIComponent(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function csv(value) {
|
||||
return String(value || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function keyValueList(value) {
|
||||
const entries = {};
|
||||
for (const item of csv(value)) {
|
||||
const separator = item.indexOf(":");
|
||||
if (separator <= 0) continue;
|
||||
const key = item.slice(0, separator).trim();
|
||||
const entryValue = item.slice(separator + 1).trim();
|
||||
if (key && entryValue) entries[key] = entryValue;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function booleanAttr(value, defaultValue = true) {
|
||||
if (value === undefined || value === "") return defaultValue;
|
||||
return !["0", "false", "no", "off"].includes(
|
||||
String(value).trim().toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
function labelize(value) {
|
||||
return String(value || "")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function plainText(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function inlineMarkup(value) {
|
||||
return escapeHtml(value ?? "")
|
||||
.replace(/<br\s*\/?>/gi, "<br>")
|
||||
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2">$1</a>');
|
||||
}
|
||||
|
||||
function semanticError(message) {
|
||||
return {
|
||||
type: "html",
|
||||
value: `<aside class="semantic-error">${message}</aside>`,
|
||||
};
|
||||
}
|
||||
|
||||
function firstClass(attrs, excluded) {
|
||||
return attrs.classes.find((className) => !excluded.has(className));
|
||||
}
|
||||
|
||||
function sourcePathForFile(file) {
|
||||
return file?.path || file?.history?.[0] || "";
|
||||
}
|
||||
|
||||
function resolveDataPath(src, file) {
|
||||
if (!src) return "";
|
||||
if (path.isAbsolute(src)) return src;
|
||||
|
||||
const sourcePath = sourcePathForFile(file);
|
||||
const baseDir = sourcePath ? path.dirname(sourcePath) : process.cwd();
|
||||
return path.resolve(baseDir, src);
|
||||
}
|
||||
|
||||
function readDataRows(src, file) {
|
||||
const resolvedPath = resolveDataPath(src, file);
|
||||
if (!resolvedPath || !existsSync(resolvedPath)) {
|
||||
throw new Error(`Data file not found: ${src}`);
|
||||
}
|
||||
|
||||
const raw = readFileSync(resolvedPath, "utf8");
|
||||
const extension = path.extname(resolvedPath).toLowerCase();
|
||||
const parsed = extension === ".json" ? JSON.parse(raw) : parseYaml(raw);
|
||||
const rows = Array.isArray(parsed) ? parsed : parsed?.items;
|
||||
|
||||
if (!Array.isArray(rows)) {
|
||||
throw new Error(
|
||||
`Data file must contain an array or an object with an items array: ${src}`,
|
||||
);
|
||||
}
|
||||
|
||||
return rows.filter((row) => row && typeof row === "object");
|
||||
}
|
||||
|
||||
const VALUE_LABELS = {
|
||||
MOC: "Map / index",
|
||||
book: "Book",
|
||||
course: "Course",
|
||||
essay: "Essay",
|
||||
other: "Other",
|
||||
software: "Software",
|
||||
youtube: "YouTube",
|
||||
};
|
||||
|
||||
function displayValue(value) {
|
||||
return VALUE_LABELS[value] || value;
|
||||
}
|
||||
|
||||
function renderCell(row, column, options) {
|
||||
const raw = row[column] ?? "";
|
||||
const display = displayValue(raw);
|
||||
const text = plainText(display);
|
||||
const sortValue = attribute(text.toLowerCase());
|
||||
const cellAttrs = `data-column="${attribute(column)}" data-sort-value="${sortValue}"`;
|
||||
|
||||
if (column === options.linkColumn && row[options.urlField]) {
|
||||
const numberValue = row[options.numberField];
|
||||
const numberLabel =
|
||||
numberValue === undefined || numberValue === null || numberValue === ""
|
||||
? ""
|
||||
: String(numberValue).padStart(2, "0");
|
||||
const prefix = options.numberField && numberLabel ? `${numberLabel}. ` : "";
|
||||
return `<td ${cellAttrs}><a href="${attribute(row[options.urlField])}">${escapeHtml(prefix)}${inlineMarkup(display)}</a></td>`;
|
||||
}
|
||||
|
||||
if (column === "type") {
|
||||
return `<td ${cellAttrs}><span class="data-table-badge">${escapeHtml(display)}</span></td>`;
|
||||
}
|
||||
|
||||
if (
|
||||
column === "summary" ||
|
||||
String(display).length > 140 ||
|
||||
String(display).includes("<br")
|
||||
) {
|
||||
return `<td ${cellAttrs}><div class="data-table-rich-cell">${inlineMarkup(display)}</div></td>`;
|
||||
}
|
||||
|
||||
return `<td ${cellAttrs}>${inlineMarkup(display)}</td>`;
|
||||
}
|
||||
|
||||
function dataTableScript(id) {
|
||||
return `<script>
|
||||
(() => {
|
||||
const root = document.getElementById(${JSON.stringify(id)});
|
||||
if (!root || root.dataset.dataTableReady) return;
|
||||
root.dataset.dataTableReady = "true";
|
||||
|
||||
const input = root.querySelector("[data-data-table-search]");
|
||||
const tbody = root.querySelector("tbody");
|
||||
const empty = root.querySelector("[data-data-table-empty]");
|
||||
const rows = Array.from(tbody?.querySelectorAll("tr") || []);
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
function applyFilter() {
|
||||
const query = (input?.value || "").trim().toLowerCase();
|
||||
let visible = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const matches = !query || row.dataset.search.includes(query);
|
||||
row.hidden = !matches;
|
||||
if (matches) visible += 1;
|
||||
}
|
||||
|
||||
if (empty) empty.hidden = visible !== 0;
|
||||
}
|
||||
|
||||
input?.addEventListener("input", applyFilter);
|
||||
|
||||
root.querySelectorAll("[data-data-table-sort]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const column = button.dataset.dataTableSort;
|
||||
const current = button.getAttribute("aria-sort");
|
||||
const direction = current === "ascending" ? "descending" : "ascending";
|
||||
|
||||
root.querySelectorAll("[data-data-table-sort]").forEach((other) => {
|
||||
other.removeAttribute("aria-sort");
|
||||
});
|
||||
button.setAttribute("aria-sort", direction);
|
||||
|
||||
rows
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const leftValue = left.querySelector(\`[data-column="\${column}"]\`)?.dataset.sortValue || "";
|
||||
const rightValue = right.querySelector(\`[data-column="\${column}"]\`)?.dataset.sortValue || "";
|
||||
const result = collator.compare(leftValue, rightValue);
|
||||
return direction === "ascending" ? result : -result;
|
||||
})
|
||||
.forEach((row) => tbody.appendChild(row));
|
||||
|
||||
applyFilter();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>`;
|
||||
}
|
||||
|
||||
function dataTableFromCode(node, file) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
if (!attrs.classes.includes("data-table")) return null;
|
||||
|
||||
const src = attrs.values.src || attrs.values.source || "";
|
||||
if (!src) {
|
||||
return semanticError('Data table block missing <code>src="..."</code>.');
|
||||
}
|
||||
|
||||
let rows;
|
||||
try {
|
||||
rows = readDataRows(src, file);
|
||||
} catch (error) {
|
||||
return semanticError(escapeHtml(error.message));
|
||||
}
|
||||
|
||||
const firstRow = rows[0] || {};
|
||||
const columns = csv(attrs.values.columns).length
|
||||
? csv(attrs.values.columns)
|
||||
: Object.keys(firstRow).filter((key) => key !== "link");
|
||||
const headers = keyValueList(attrs.values.headers || attrs.values.labels);
|
||||
const linkColumn =
|
||||
attrs.values.link || (columns.includes("title") ? "title" : "");
|
||||
const urlField = attrs.values.url || attrs.values.href || "link";
|
||||
const numberField = attrs.values.number || "";
|
||||
const caption = attrs.values.caption || attrs.values.title || "";
|
||||
const searchable = booleanAttr(attrs.values.search, true);
|
||||
const sortable = booleanAttr(attrs.values.sort, true);
|
||||
const placeholder =
|
||||
attrs.values["search-placeholder"] || `Search ${caption || "table"}`;
|
||||
const id =
|
||||
attrs.id ||
|
||||
`data-table-${src.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "")}`;
|
||||
const tableId = `${id}-table`;
|
||||
const searchId = `${id}-search`;
|
||||
|
||||
const headerHtml = columns
|
||||
.map((column) => {
|
||||
const label = headers[column] || labelize(column);
|
||||
const content = sortable
|
||||
? `<button type="button" data-data-table-sort="${attribute(column)}">${escapeHtml(label)}</button>`
|
||||
: escapeHtml(label);
|
||||
return `<th scope="col">${content}</th>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const bodyHtml = rows
|
||||
.map((row) => {
|
||||
const searchText = columns
|
||||
.map((column) => plainText(displayValue(row[column] ?? "")))
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
const cells = columns
|
||||
.map((column) =>
|
||||
renderCell(row, column, { linkColumn, urlField, numberField }),
|
||||
)
|
||||
.join("");
|
||||
return `<tr data-search="${attribute(searchText)}">${cells}</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const searchHtml = searchable
|
||||
? [
|
||||
'<div class="data-table-search">',
|
||||
`<label for="${attribute(searchId)}">Search</label>`,
|
||||
`<input id="${attribute(searchId)}" type="search" autocomplete="off" placeholder="${attribute(placeholder)}" data-data-table-search>`,
|
||||
"</div>",
|
||||
].join("")
|
||||
: "";
|
||||
|
||||
return {
|
||||
type: "html",
|
||||
value: [
|
||||
`<section class="semantic-data-table" id="${attribute(id)}" data-data-table-root>`,
|
||||
caption || searchable
|
||||
? `<div class="data-table-toolbar">${caption ? `<h2>${escapeHtml(caption)}</h2>` : ""}${searchHtml}</div>`
|
||||
: "",
|
||||
'<div class="data-table-scroll">',
|
||||
`<table id="${attribute(tableId)}">`,
|
||||
caption ? `<caption>${escapeHtml(caption)}</caption>` : "",
|
||||
`<thead><tr>${headerHtml}</tr></thead>`,
|
||||
`<tbody>${bodyHtml}</tbody>`,
|
||||
"</table>",
|
||||
"</div>",
|
||||
`<p class="data-table-empty" data-data-table-empty hidden>No matching rows.</p>`,
|
||||
"</section>",
|
||||
dataTableScript(id),
|
||||
].join(""),
|
||||
};
|
||||
}
|
||||
|
||||
function interactiveFromCode(node) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
if (!attrs.classes.includes("interactive")) return null;
|
||||
|
||||
const declaredId = attrs.id || attrs.values.component || attrs.values.name || "";
|
||||
const declaredId =
|
||||
attrs.id || attrs.values.component || attrs.values.name || "";
|
||||
const componentId =
|
||||
attrs.values.component ||
|
||||
(declaredId.startsWith("int:") ? declaredId.slice("int:".length) : declaredId);
|
||||
const label = attrs.values.label || (declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`);
|
||||
(declaredId.startsWith("int:")
|
||||
? declaredId.slice("int:".length)
|
||||
: declaredId);
|
||||
const label =
|
||||
attrs.values.label ||
|
||||
(declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`);
|
||||
const caption = attrs.values.caption || attrs.values.title || "";
|
||||
const description = node.value.trim();
|
||||
const fallback = attrs.values.pdf || attrs.values.fallback || description || caption;
|
||||
const fallback =
|
||||
attrs.values.pdf || attrs.values.fallback || description || caption;
|
||||
|
||||
if (!componentId) {
|
||||
return {
|
||||
|
|
@ -141,7 +431,9 @@ function interactiveFromCode(node) {
|
|||
value: [
|
||||
`<figure class="semantic-interactive" id="${attribute(label)}" data-interactive-label="${attribute(label)}">`,
|
||||
`<div class="interactive-mount" data-interactive-id="${attribute(componentId)}" data-interactive-props="${attribute(encodedJson(props))}">`,
|
||||
fallback ? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>` : "",
|
||||
fallback
|
||||
? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>`
|
||||
: "",
|
||||
"</div>",
|
||||
caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "",
|
||||
"</figure>",
|
||||
|
|
@ -154,7 +446,10 @@ function envFromCode(node) {
|
|||
const shorthandType = firstClass(attrs, new Set(["env", "latex-env"]));
|
||||
const explicitType = attrs.values.type || shorthandType;
|
||||
const type = ENV_TYPES.has(explicitType) ? explicitType : "";
|
||||
const isEnv = attrs.classes.includes("env") || attrs.classes.includes("latex-env") || Boolean(type);
|
||||
const isEnv =
|
||||
attrs.classes.includes("env") ||
|
||||
attrs.classes.includes("latex-env") ||
|
||||
Boolean(type);
|
||||
|
||||
if (!isEnv) return null;
|
||||
|
||||
|
|
@ -168,7 +463,8 @@ function envFromCode(node) {
|
|||
|
||||
const title = attrs.values.title || attrs.values.name || "";
|
||||
const label = attrs.id || attrs.values.label || "";
|
||||
const renderMode = attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const renderMode =
|
||||
attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const heading = title ? `${ENV_LABELS[type]} (${title})` : ENV_LABELS[type];
|
||||
const parsed = fromMarkdown(node.value || "");
|
||||
const className = [
|
||||
|
|
@ -210,23 +506,26 @@ function envFromCode(node) {
|
|||
};
|
||||
}
|
||||
|
||||
function transformTree(parent) {
|
||||
function transformTree(parent, file) {
|
||||
if (!parent?.children) return;
|
||||
|
||||
for (let index = 0; index < parent.children.length; index += 1) {
|
||||
const child = parent.children[index];
|
||||
if (child.type === "code") {
|
||||
const replacement = interactiveFromCode(child) || envFromCode(child);
|
||||
const replacement =
|
||||
dataTableFromCode(child, file) ||
|
||||
interactiveFromCode(child) ||
|
||||
envFromCode(child);
|
||||
if (replacement) {
|
||||
parent.children[index] = replacement;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
transformTree(child);
|
||||
transformTree(child, file);
|
||||
}
|
||||
}
|
||||
|
||||
export default function remarkSemanticBlocks() {
|
||||
return (tree) => transformTree(tree);
|
||||
return (tree, file) => transformTree(tree, file);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
3
sites/research/package-lock.json
generated
3
sites/research/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
|
||||
const ENV_TYPES = new Set([
|
||||
|
|
@ -24,7 +27,8 @@ const ENV_LABELS = {
|
|||
|
||||
function tokenizeInfo(info) {
|
||||
return (
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? []
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ??
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -102,22 +106,308 @@ function encodedJson(value) {
|
|||
return encodeURIComponent(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function csv(value) {
|
||||
return String(value || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function keyValueList(value) {
|
||||
const entries = {};
|
||||
for (const item of csv(value)) {
|
||||
const separator = item.indexOf(":");
|
||||
if (separator <= 0) continue;
|
||||
const key = item.slice(0, separator).trim();
|
||||
const entryValue = item.slice(separator + 1).trim();
|
||||
if (key && entryValue) entries[key] = entryValue;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function booleanAttr(value, defaultValue = true) {
|
||||
if (value === undefined || value === "") return defaultValue;
|
||||
return !["0", "false", "no", "off"].includes(
|
||||
String(value).trim().toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
function labelize(value) {
|
||||
return String(value || "")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function plainText(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function inlineMarkup(value) {
|
||||
return escapeHtml(value ?? "")
|
||||
.replace(/<br\s*\/?>/gi, "<br>")
|
||||
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2">$1</a>');
|
||||
}
|
||||
|
||||
function semanticError(message) {
|
||||
return {
|
||||
type: "html",
|
||||
value: `<aside class="semantic-error">${message}</aside>`,
|
||||
};
|
||||
}
|
||||
|
||||
function firstClass(attrs, excluded) {
|
||||
return attrs.classes.find((className) => !excluded.has(className));
|
||||
}
|
||||
|
||||
function sourcePathForFile(file) {
|
||||
return file?.path || file?.history?.[0] || "";
|
||||
}
|
||||
|
||||
function resolveDataPath(src, file) {
|
||||
if (!src) return "";
|
||||
if (path.isAbsolute(src)) return src;
|
||||
|
||||
const sourcePath = sourcePathForFile(file);
|
||||
const baseDir = sourcePath ? path.dirname(sourcePath) : process.cwd();
|
||||
return path.resolve(baseDir, src);
|
||||
}
|
||||
|
||||
function readDataRows(src, file) {
|
||||
const resolvedPath = resolveDataPath(src, file);
|
||||
if (!resolvedPath || !existsSync(resolvedPath)) {
|
||||
throw new Error(`Data file not found: ${src}`);
|
||||
}
|
||||
|
||||
const raw = readFileSync(resolvedPath, "utf8");
|
||||
const extension = path.extname(resolvedPath).toLowerCase();
|
||||
const parsed = extension === ".json" ? JSON.parse(raw) : parseYaml(raw);
|
||||
const rows = Array.isArray(parsed) ? parsed : parsed?.items;
|
||||
|
||||
if (!Array.isArray(rows)) {
|
||||
throw new Error(
|
||||
`Data file must contain an array or an object with an items array: ${src}`,
|
||||
);
|
||||
}
|
||||
|
||||
return rows.filter((row) => row && typeof row === "object");
|
||||
}
|
||||
|
||||
const VALUE_LABELS = {
|
||||
MOC: "Map / index",
|
||||
book: "Book",
|
||||
course: "Course",
|
||||
essay: "Essay",
|
||||
other: "Other",
|
||||
software: "Software",
|
||||
youtube: "YouTube",
|
||||
};
|
||||
|
||||
function displayValue(value) {
|
||||
return VALUE_LABELS[value] || value;
|
||||
}
|
||||
|
||||
function renderCell(row, column, options) {
|
||||
const raw = row[column] ?? "";
|
||||
const display = displayValue(raw);
|
||||
const text = plainText(display);
|
||||
const sortValue = attribute(text.toLowerCase());
|
||||
const cellAttrs = `data-column="${attribute(column)}" data-sort-value="${sortValue}"`;
|
||||
|
||||
if (column === options.linkColumn && row[options.urlField]) {
|
||||
const numberValue = row[options.numberField];
|
||||
const numberLabel =
|
||||
numberValue === undefined || numberValue === null || numberValue === ""
|
||||
? ""
|
||||
: String(numberValue).padStart(2, "0");
|
||||
const prefix = options.numberField && numberLabel ? `${numberLabel}. ` : "";
|
||||
return `<td ${cellAttrs}><a href="${attribute(row[options.urlField])}">${escapeHtml(prefix)}${inlineMarkup(display)}</a></td>`;
|
||||
}
|
||||
|
||||
if (column === "type") {
|
||||
return `<td ${cellAttrs}><span class="data-table-badge">${escapeHtml(display)}</span></td>`;
|
||||
}
|
||||
|
||||
if (
|
||||
column === "summary" ||
|
||||
String(display).length > 140 ||
|
||||
String(display).includes("<br")
|
||||
) {
|
||||
return `<td ${cellAttrs}><div class="data-table-rich-cell">${inlineMarkup(display)}</div></td>`;
|
||||
}
|
||||
|
||||
return `<td ${cellAttrs}>${inlineMarkup(display)}</td>`;
|
||||
}
|
||||
|
||||
function dataTableScript(id) {
|
||||
return `<script>
|
||||
(() => {
|
||||
const root = document.getElementById(${JSON.stringify(id)});
|
||||
if (!root || root.dataset.dataTableReady) return;
|
||||
root.dataset.dataTableReady = "true";
|
||||
|
||||
const input = root.querySelector("[data-data-table-search]");
|
||||
const tbody = root.querySelector("tbody");
|
||||
const empty = root.querySelector("[data-data-table-empty]");
|
||||
const rows = Array.from(tbody?.querySelectorAll("tr") || []);
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
function applyFilter() {
|
||||
const query = (input?.value || "").trim().toLowerCase();
|
||||
let visible = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const matches = !query || row.dataset.search.includes(query);
|
||||
row.hidden = !matches;
|
||||
if (matches) visible += 1;
|
||||
}
|
||||
|
||||
if (empty) empty.hidden = visible !== 0;
|
||||
}
|
||||
|
||||
input?.addEventListener("input", applyFilter);
|
||||
|
||||
root.querySelectorAll("[data-data-table-sort]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const column = button.dataset.dataTableSort;
|
||||
const current = button.getAttribute("aria-sort");
|
||||
const direction = current === "ascending" ? "descending" : "ascending";
|
||||
|
||||
root.querySelectorAll("[data-data-table-sort]").forEach((other) => {
|
||||
other.removeAttribute("aria-sort");
|
||||
});
|
||||
button.setAttribute("aria-sort", direction);
|
||||
|
||||
rows
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const leftValue = left.querySelector(\`[data-column="\${column}"]\`)?.dataset.sortValue || "";
|
||||
const rightValue = right.querySelector(\`[data-column="\${column}"]\`)?.dataset.sortValue || "";
|
||||
const result = collator.compare(leftValue, rightValue);
|
||||
return direction === "ascending" ? result : -result;
|
||||
})
|
||||
.forEach((row) => tbody.appendChild(row));
|
||||
|
||||
applyFilter();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>`;
|
||||
}
|
||||
|
||||
function dataTableFromCode(node, file) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
if (!attrs.classes.includes("data-table")) return null;
|
||||
|
||||
const src = attrs.values.src || attrs.values.source || "";
|
||||
if (!src) {
|
||||
return semanticError('Data table block missing <code>src="..."</code>.');
|
||||
}
|
||||
|
||||
let rows;
|
||||
try {
|
||||
rows = readDataRows(src, file);
|
||||
} catch (error) {
|
||||
return semanticError(escapeHtml(error.message));
|
||||
}
|
||||
|
||||
const firstRow = rows[0] || {};
|
||||
const columns = csv(attrs.values.columns).length
|
||||
? csv(attrs.values.columns)
|
||||
: Object.keys(firstRow).filter((key) => key !== "link");
|
||||
const headers = keyValueList(attrs.values.headers || attrs.values.labels);
|
||||
const linkColumn =
|
||||
attrs.values.link || (columns.includes("title") ? "title" : "");
|
||||
const urlField = attrs.values.url || attrs.values.href || "link";
|
||||
const numberField = attrs.values.number || "";
|
||||
const caption = attrs.values.caption || attrs.values.title || "";
|
||||
const searchable = booleanAttr(attrs.values.search, true);
|
||||
const sortable = booleanAttr(attrs.values.sort, true);
|
||||
const placeholder =
|
||||
attrs.values["search-placeholder"] || `Search ${caption || "table"}`;
|
||||
const id =
|
||||
attrs.id ||
|
||||
`data-table-${src.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "")}`;
|
||||
const tableId = `${id}-table`;
|
||||
const searchId = `${id}-search`;
|
||||
|
||||
const headerHtml = columns
|
||||
.map((column) => {
|
||||
const label = headers[column] || labelize(column);
|
||||
const content = sortable
|
||||
? `<button type="button" data-data-table-sort="${attribute(column)}">${escapeHtml(label)}</button>`
|
||||
: escapeHtml(label);
|
||||
return `<th scope="col">${content}</th>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const bodyHtml = rows
|
||||
.map((row) => {
|
||||
const searchText = columns
|
||||
.map((column) => plainText(displayValue(row[column] ?? "")))
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
const cells = columns
|
||||
.map((column) =>
|
||||
renderCell(row, column, { linkColumn, urlField, numberField }),
|
||||
)
|
||||
.join("");
|
||||
return `<tr data-search="${attribute(searchText)}">${cells}</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const searchHtml = searchable
|
||||
? [
|
||||
'<div class="data-table-search">',
|
||||
`<label for="${attribute(searchId)}">Search</label>`,
|
||||
`<input id="${attribute(searchId)}" type="search" autocomplete="off" placeholder="${attribute(placeholder)}" data-data-table-search>`,
|
||||
"</div>",
|
||||
].join("")
|
||||
: "";
|
||||
|
||||
return {
|
||||
type: "html",
|
||||
value: [
|
||||
`<section class="semantic-data-table" id="${attribute(id)}" data-data-table-root>`,
|
||||
caption || searchable
|
||||
? `<div class="data-table-toolbar">${caption ? `<h2>${escapeHtml(caption)}</h2>` : ""}${searchHtml}</div>`
|
||||
: "",
|
||||
'<div class="data-table-scroll">',
|
||||
`<table id="${attribute(tableId)}">`,
|
||||
caption ? `<caption>${escapeHtml(caption)}</caption>` : "",
|
||||
`<thead><tr>${headerHtml}</tr></thead>`,
|
||||
`<tbody>${bodyHtml}</tbody>`,
|
||||
"</table>",
|
||||
"</div>",
|
||||
`<p class="data-table-empty" data-data-table-empty hidden>No matching rows.</p>`,
|
||||
"</section>",
|
||||
dataTableScript(id),
|
||||
].join(""),
|
||||
};
|
||||
}
|
||||
|
||||
function interactiveFromCode(node) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
if (!attrs.classes.includes("interactive")) return null;
|
||||
|
||||
const declaredId = attrs.id || attrs.values.component || attrs.values.name || "";
|
||||
const declaredId =
|
||||
attrs.id || attrs.values.component || attrs.values.name || "";
|
||||
const componentId =
|
||||
attrs.values.component ||
|
||||
(declaredId.startsWith("int:") ? declaredId.slice("int:".length) : declaredId);
|
||||
const label = attrs.values.label || (declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`);
|
||||
(declaredId.startsWith("int:")
|
||||
? declaredId.slice("int:".length)
|
||||
: declaredId);
|
||||
const label =
|
||||
attrs.values.label ||
|
||||
(declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`);
|
||||
const caption = attrs.values.caption || attrs.values.title || "";
|
||||
const description = node.value.trim();
|
||||
const fallback = attrs.values.pdf || attrs.values.fallback || description || caption;
|
||||
const fallback =
|
||||
attrs.values.pdf || attrs.values.fallback || description || caption;
|
||||
|
||||
if (!componentId) {
|
||||
return {
|
||||
|
|
@ -141,7 +431,9 @@ function interactiveFromCode(node) {
|
|||
value: [
|
||||
`<figure class="semantic-interactive" id="${attribute(label)}" data-interactive-label="${attribute(label)}">`,
|
||||
`<div class="interactive-mount" data-interactive-id="${attribute(componentId)}" data-interactive-props="${attribute(encodedJson(props))}">`,
|
||||
fallback ? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>` : "",
|
||||
fallback
|
||||
? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>`
|
||||
: "",
|
||||
"</div>",
|
||||
caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "",
|
||||
"</figure>",
|
||||
|
|
@ -154,7 +446,10 @@ function envFromCode(node) {
|
|||
const shorthandType = firstClass(attrs, new Set(["env", "latex-env"]));
|
||||
const explicitType = attrs.values.type || shorthandType;
|
||||
const type = ENV_TYPES.has(explicitType) ? explicitType : "";
|
||||
const isEnv = attrs.classes.includes("env") || attrs.classes.includes("latex-env") || Boolean(type);
|
||||
const isEnv =
|
||||
attrs.classes.includes("env") ||
|
||||
attrs.classes.includes("latex-env") ||
|
||||
Boolean(type);
|
||||
|
||||
if (!isEnv) return null;
|
||||
|
||||
|
|
@ -168,7 +463,8 @@ function envFromCode(node) {
|
|||
|
||||
const title = attrs.values.title || attrs.values.name || "";
|
||||
const label = attrs.id || attrs.values.label || "";
|
||||
const renderMode = attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const renderMode =
|
||||
attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const heading = title ? `${ENV_LABELS[type]} (${title})` : ENV_LABELS[type];
|
||||
const parsed = fromMarkdown(node.value || "");
|
||||
const className = [
|
||||
|
|
@ -210,23 +506,26 @@ function envFromCode(node) {
|
|||
};
|
||||
}
|
||||
|
||||
function transformTree(parent) {
|
||||
function transformTree(parent, file) {
|
||||
if (!parent?.children) return;
|
||||
|
||||
for (let index = 0; index < parent.children.length; index += 1) {
|
||||
const child = parent.children[index];
|
||||
if (child.type === "code") {
|
||||
const replacement = interactiveFromCode(child) || envFromCode(child);
|
||||
const replacement =
|
||||
dataTableFromCode(child, file) ||
|
||||
interactiveFromCode(child) ||
|
||||
envFromCode(child);
|
||||
if (replacement) {
|
||||
parent.children[index] = replacement;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
transformTree(child);
|
||||
transformTree(child, file);
|
||||
}
|
||||
}
|
||||
|
||||
export default function remarkSemanticBlocks() {
|
||||
return (tree) => transformTree(tree);
|
||||
return (tree, file) => transformTree(tree, file);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
3
sites/reviews/package-lock.json
generated
3
sites/reviews/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
|
||||
const ENV_TYPES = new Set([
|
||||
|
|
@ -24,7 +27,8 @@ const ENV_LABELS = {
|
|||
|
||||
function tokenizeInfo(info) {
|
||||
return (
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? []
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ??
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -102,22 +106,308 @@ function encodedJson(value) {
|
|||
return encodeURIComponent(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function csv(value) {
|
||||
return String(value || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function keyValueList(value) {
|
||||
const entries = {};
|
||||
for (const item of csv(value)) {
|
||||
const separator = item.indexOf(":");
|
||||
if (separator <= 0) continue;
|
||||
const key = item.slice(0, separator).trim();
|
||||
const entryValue = item.slice(separator + 1).trim();
|
||||
if (key && entryValue) entries[key] = entryValue;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function booleanAttr(value, defaultValue = true) {
|
||||
if (value === undefined || value === "") return defaultValue;
|
||||
return !["0", "false", "no", "off"].includes(
|
||||
String(value).trim().toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
function labelize(value) {
|
||||
return String(value || "")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function plainText(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function inlineMarkup(value) {
|
||||
return escapeHtml(value ?? "")
|
||||
.replace(/<br\s*\/?>/gi, "<br>")
|
||||
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2">$1</a>');
|
||||
}
|
||||
|
||||
function semanticError(message) {
|
||||
return {
|
||||
type: "html",
|
||||
value: `<aside class="semantic-error">${message}</aside>`,
|
||||
};
|
||||
}
|
||||
|
||||
function firstClass(attrs, excluded) {
|
||||
return attrs.classes.find((className) => !excluded.has(className));
|
||||
}
|
||||
|
||||
function sourcePathForFile(file) {
|
||||
return file?.path || file?.history?.[0] || "";
|
||||
}
|
||||
|
||||
function resolveDataPath(src, file) {
|
||||
if (!src) return "";
|
||||
if (path.isAbsolute(src)) return src;
|
||||
|
||||
const sourcePath = sourcePathForFile(file);
|
||||
const baseDir = sourcePath ? path.dirname(sourcePath) : process.cwd();
|
||||
return path.resolve(baseDir, src);
|
||||
}
|
||||
|
||||
function readDataRows(src, file) {
|
||||
const resolvedPath = resolveDataPath(src, file);
|
||||
if (!resolvedPath || !existsSync(resolvedPath)) {
|
||||
throw new Error(`Data file not found: ${src}`);
|
||||
}
|
||||
|
||||
const raw = readFileSync(resolvedPath, "utf8");
|
||||
const extension = path.extname(resolvedPath).toLowerCase();
|
||||
const parsed = extension === ".json" ? JSON.parse(raw) : parseYaml(raw);
|
||||
const rows = Array.isArray(parsed) ? parsed : parsed?.items;
|
||||
|
||||
if (!Array.isArray(rows)) {
|
||||
throw new Error(
|
||||
`Data file must contain an array or an object with an items array: ${src}`,
|
||||
);
|
||||
}
|
||||
|
||||
return rows.filter((row) => row && typeof row === "object");
|
||||
}
|
||||
|
||||
const VALUE_LABELS = {
|
||||
MOC: "Map / index",
|
||||
book: "Book",
|
||||
course: "Course",
|
||||
essay: "Essay",
|
||||
other: "Other",
|
||||
software: "Software",
|
||||
youtube: "YouTube",
|
||||
};
|
||||
|
||||
function displayValue(value) {
|
||||
return VALUE_LABELS[value] || value;
|
||||
}
|
||||
|
||||
function renderCell(row, column, options) {
|
||||
const raw = row[column] ?? "";
|
||||
const display = displayValue(raw);
|
||||
const text = plainText(display);
|
||||
const sortValue = attribute(text.toLowerCase());
|
||||
const cellAttrs = `data-column="${attribute(column)}" data-sort-value="${sortValue}"`;
|
||||
|
||||
if (column === options.linkColumn && row[options.urlField]) {
|
||||
const numberValue = row[options.numberField];
|
||||
const numberLabel =
|
||||
numberValue === undefined || numberValue === null || numberValue === ""
|
||||
? ""
|
||||
: String(numberValue).padStart(2, "0");
|
||||
const prefix = options.numberField && numberLabel ? `${numberLabel}. ` : "";
|
||||
return `<td ${cellAttrs}><a href="${attribute(row[options.urlField])}">${escapeHtml(prefix)}${inlineMarkup(display)}</a></td>`;
|
||||
}
|
||||
|
||||
if (column === "type") {
|
||||
return `<td ${cellAttrs}><span class="data-table-badge">${escapeHtml(display)}</span></td>`;
|
||||
}
|
||||
|
||||
if (
|
||||
column === "summary" ||
|
||||
String(display).length > 140 ||
|
||||
String(display).includes("<br")
|
||||
) {
|
||||
return `<td ${cellAttrs}><div class="data-table-rich-cell">${inlineMarkup(display)}</div></td>`;
|
||||
}
|
||||
|
||||
return `<td ${cellAttrs}>${inlineMarkup(display)}</td>`;
|
||||
}
|
||||
|
||||
function dataTableScript(id) {
|
||||
return `<script>
|
||||
(() => {
|
||||
const root = document.getElementById(${JSON.stringify(id)});
|
||||
if (!root || root.dataset.dataTableReady) return;
|
||||
root.dataset.dataTableReady = "true";
|
||||
|
||||
const input = root.querySelector("[data-data-table-search]");
|
||||
const tbody = root.querySelector("tbody");
|
||||
const empty = root.querySelector("[data-data-table-empty]");
|
||||
const rows = Array.from(tbody?.querySelectorAll("tr") || []);
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
function applyFilter() {
|
||||
const query = (input?.value || "").trim().toLowerCase();
|
||||
let visible = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const matches = !query || row.dataset.search.includes(query);
|
||||
row.hidden = !matches;
|
||||
if (matches) visible += 1;
|
||||
}
|
||||
|
||||
if (empty) empty.hidden = visible !== 0;
|
||||
}
|
||||
|
||||
input?.addEventListener("input", applyFilter);
|
||||
|
||||
root.querySelectorAll("[data-data-table-sort]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const column = button.dataset.dataTableSort;
|
||||
const current = button.getAttribute("aria-sort");
|
||||
const direction = current === "ascending" ? "descending" : "ascending";
|
||||
|
||||
root.querySelectorAll("[data-data-table-sort]").forEach((other) => {
|
||||
other.removeAttribute("aria-sort");
|
||||
});
|
||||
button.setAttribute("aria-sort", direction);
|
||||
|
||||
rows
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const leftValue = left.querySelector(\`[data-column="\${column}"]\`)?.dataset.sortValue || "";
|
||||
const rightValue = right.querySelector(\`[data-column="\${column}"]\`)?.dataset.sortValue || "";
|
||||
const result = collator.compare(leftValue, rightValue);
|
||||
return direction === "ascending" ? result : -result;
|
||||
})
|
||||
.forEach((row) => tbody.appendChild(row));
|
||||
|
||||
applyFilter();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>`;
|
||||
}
|
||||
|
||||
function dataTableFromCode(node, file) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
if (!attrs.classes.includes("data-table")) return null;
|
||||
|
||||
const src = attrs.values.src || attrs.values.source || "";
|
||||
if (!src) {
|
||||
return semanticError('Data table block missing <code>src="..."</code>.');
|
||||
}
|
||||
|
||||
let rows;
|
||||
try {
|
||||
rows = readDataRows(src, file);
|
||||
} catch (error) {
|
||||
return semanticError(escapeHtml(error.message));
|
||||
}
|
||||
|
||||
const firstRow = rows[0] || {};
|
||||
const columns = csv(attrs.values.columns).length
|
||||
? csv(attrs.values.columns)
|
||||
: Object.keys(firstRow).filter((key) => key !== "link");
|
||||
const headers = keyValueList(attrs.values.headers || attrs.values.labels);
|
||||
const linkColumn =
|
||||
attrs.values.link || (columns.includes("title") ? "title" : "");
|
||||
const urlField = attrs.values.url || attrs.values.href || "link";
|
||||
const numberField = attrs.values.number || "";
|
||||
const caption = attrs.values.caption || attrs.values.title || "";
|
||||
const searchable = booleanAttr(attrs.values.search, true);
|
||||
const sortable = booleanAttr(attrs.values.sort, true);
|
||||
const placeholder =
|
||||
attrs.values["search-placeholder"] || `Search ${caption || "table"}`;
|
||||
const id =
|
||||
attrs.id ||
|
||||
`data-table-${src.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "")}`;
|
||||
const tableId = `${id}-table`;
|
||||
const searchId = `${id}-search`;
|
||||
|
||||
const headerHtml = columns
|
||||
.map((column) => {
|
||||
const label = headers[column] || labelize(column);
|
||||
const content = sortable
|
||||
? `<button type="button" data-data-table-sort="${attribute(column)}">${escapeHtml(label)}</button>`
|
||||
: escapeHtml(label);
|
||||
return `<th scope="col">${content}</th>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const bodyHtml = rows
|
||||
.map((row) => {
|
||||
const searchText = columns
|
||||
.map((column) => plainText(displayValue(row[column] ?? "")))
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
const cells = columns
|
||||
.map((column) =>
|
||||
renderCell(row, column, { linkColumn, urlField, numberField }),
|
||||
)
|
||||
.join("");
|
||||
return `<tr data-search="${attribute(searchText)}">${cells}</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const searchHtml = searchable
|
||||
? [
|
||||
'<div class="data-table-search">',
|
||||
`<label for="${attribute(searchId)}">Search</label>`,
|
||||
`<input id="${attribute(searchId)}" type="search" autocomplete="off" placeholder="${attribute(placeholder)}" data-data-table-search>`,
|
||||
"</div>",
|
||||
].join("")
|
||||
: "";
|
||||
|
||||
return {
|
||||
type: "html",
|
||||
value: [
|
||||
`<section class="semantic-data-table" id="${attribute(id)}" data-data-table-root>`,
|
||||
caption || searchable
|
||||
? `<div class="data-table-toolbar">${caption ? `<h2>${escapeHtml(caption)}</h2>` : ""}${searchHtml}</div>`
|
||||
: "",
|
||||
'<div class="data-table-scroll">',
|
||||
`<table id="${attribute(tableId)}">`,
|
||||
caption ? `<caption>${escapeHtml(caption)}</caption>` : "",
|
||||
`<thead><tr>${headerHtml}</tr></thead>`,
|
||||
`<tbody>${bodyHtml}</tbody>`,
|
||||
"</table>",
|
||||
"</div>",
|
||||
`<p class="data-table-empty" data-data-table-empty hidden>No matching rows.</p>`,
|
||||
"</section>",
|
||||
dataTableScript(id),
|
||||
].join(""),
|
||||
};
|
||||
}
|
||||
|
||||
function interactiveFromCode(node) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
if (!attrs.classes.includes("interactive")) return null;
|
||||
|
||||
const declaredId = attrs.id || attrs.values.component || attrs.values.name || "";
|
||||
const declaredId =
|
||||
attrs.id || attrs.values.component || attrs.values.name || "";
|
||||
const componentId =
|
||||
attrs.values.component ||
|
||||
(declaredId.startsWith("int:") ? declaredId.slice("int:".length) : declaredId);
|
||||
const label = attrs.values.label || (declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`);
|
||||
(declaredId.startsWith("int:")
|
||||
? declaredId.slice("int:".length)
|
||||
: declaredId);
|
||||
const label =
|
||||
attrs.values.label ||
|
||||
(declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`);
|
||||
const caption = attrs.values.caption || attrs.values.title || "";
|
||||
const description = node.value.trim();
|
||||
const fallback = attrs.values.pdf || attrs.values.fallback || description || caption;
|
||||
const fallback =
|
||||
attrs.values.pdf || attrs.values.fallback || description || caption;
|
||||
|
||||
if (!componentId) {
|
||||
return {
|
||||
|
|
@ -141,7 +431,9 @@ function interactiveFromCode(node) {
|
|||
value: [
|
||||
`<figure class="semantic-interactive" id="${attribute(label)}" data-interactive-label="${attribute(label)}">`,
|
||||
`<div class="interactive-mount" data-interactive-id="${attribute(componentId)}" data-interactive-props="${attribute(encodedJson(props))}">`,
|
||||
fallback ? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>` : "",
|
||||
fallback
|
||||
? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>`
|
||||
: "",
|
||||
"</div>",
|
||||
caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "",
|
||||
"</figure>",
|
||||
|
|
@ -154,7 +446,10 @@ function envFromCode(node) {
|
|||
const shorthandType = firstClass(attrs, new Set(["env", "latex-env"]));
|
||||
const explicitType = attrs.values.type || shorthandType;
|
||||
const type = ENV_TYPES.has(explicitType) ? explicitType : "";
|
||||
const isEnv = attrs.classes.includes("env") || attrs.classes.includes("latex-env") || Boolean(type);
|
||||
const isEnv =
|
||||
attrs.classes.includes("env") ||
|
||||
attrs.classes.includes("latex-env") ||
|
||||
Boolean(type);
|
||||
|
||||
if (!isEnv) return null;
|
||||
|
||||
|
|
@ -168,7 +463,8 @@ function envFromCode(node) {
|
|||
|
||||
const title = attrs.values.title || attrs.values.name || "";
|
||||
const label = attrs.id || attrs.values.label || "";
|
||||
const renderMode = attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const renderMode =
|
||||
attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const heading = title ? `${ENV_LABELS[type]} (${title})` : ENV_LABELS[type];
|
||||
const parsed = fromMarkdown(node.value || "");
|
||||
const className = [
|
||||
|
|
@ -210,23 +506,26 @@ function envFromCode(node) {
|
|||
};
|
||||
}
|
||||
|
||||
function transformTree(parent) {
|
||||
function transformTree(parent, file) {
|
||||
if (!parent?.children) return;
|
||||
|
||||
for (let index = 0; index < parent.children.length; index += 1) {
|
||||
const child = parent.children[index];
|
||||
if (child.type === "code") {
|
||||
const replacement = interactiveFromCode(child) || envFromCode(child);
|
||||
const replacement =
|
||||
dataTableFromCode(child, file) ||
|
||||
interactiveFromCode(child) ||
|
||||
envFromCode(child);
|
||||
if (replacement) {
|
||||
parent.children[index] = replacement;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
transformTree(child);
|
||||
transformTree(child, file);
|
||||
}
|
||||
}
|
||||
|
||||
export default function remarkSemanticBlocks() {
|
||||
return (tree) => transformTree(tree);
|
||||
return (tree, file) => transformTree(tree, file);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
3
sites/vibes/package-lock.json
generated
3
sites/vibes/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { existsSync, readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
|
||||
const ENV_TYPES = new Set([
|
||||
|
|
@ -24,7 +27,8 @@ const ENV_LABELS = {
|
|||
|
||||
function tokenizeInfo(info) {
|
||||
return (
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? []
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ??
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -102,22 +106,308 @@ function encodedJson(value) {
|
|||
return encodeURIComponent(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function csv(value) {
|
||||
return String(value || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function keyValueList(value) {
|
||||
const entries = {};
|
||||
for (const item of csv(value)) {
|
||||
const separator = item.indexOf(":");
|
||||
if (separator <= 0) continue;
|
||||
const key = item.slice(0, separator).trim();
|
||||
const entryValue = item.slice(separator + 1).trim();
|
||||
if (key && entryValue) entries[key] = entryValue;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function booleanAttr(value, defaultValue = true) {
|
||||
if (value === undefined || value === "") return defaultValue;
|
||||
return !["0", "false", "no", "off"].includes(
|
||||
String(value).trim().toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
function labelize(value) {
|
||||
return String(value || "")
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
function plainText(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<[^>]+>/g, " ")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function inlineMarkup(value) {
|
||||
return escapeHtml(value ?? "")
|
||||
.replace(/<br\s*\/?>/gi, "<br>")
|
||||
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2">$1</a>');
|
||||
}
|
||||
|
||||
function semanticError(message) {
|
||||
return {
|
||||
type: "html",
|
||||
value: `<aside class="semantic-error">${message}</aside>`,
|
||||
};
|
||||
}
|
||||
|
||||
function firstClass(attrs, excluded) {
|
||||
return attrs.classes.find((className) => !excluded.has(className));
|
||||
}
|
||||
|
||||
function sourcePathForFile(file) {
|
||||
return file?.path || file?.history?.[0] || "";
|
||||
}
|
||||
|
||||
function resolveDataPath(src, file) {
|
||||
if (!src) return "";
|
||||
if (path.isAbsolute(src)) return src;
|
||||
|
||||
const sourcePath = sourcePathForFile(file);
|
||||
const baseDir = sourcePath ? path.dirname(sourcePath) : process.cwd();
|
||||
return path.resolve(baseDir, src);
|
||||
}
|
||||
|
||||
function readDataRows(src, file) {
|
||||
const resolvedPath = resolveDataPath(src, file);
|
||||
if (!resolvedPath || !existsSync(resolvedPath)) {
|
||||
throw new Error(`Data file not found: ${src}`);
|
||||
}
|
||||
|
||||
const raw = readFileSync(resolvedPath, "utf8");
|
||||
const extension = path.extname(resolvedPath).toLowerCase();
|
||||
const parsed = extension === ".json" ? JSON.parse(raw) : parseYaml(raw);
|
||||
const rows = Array.isArray(parsed) ? parsed : parsed?.items;
|
||||
|
||||
if (!Array.isArray(rows)) {
|
||||
throw new Error(
|
||||
`Data file must contain an array or an object with an items array: ${src}`,
|
||||
);
|
||||
}
|
||||
|
||||
return rows.filter((row) => row && typeof row === "object");
|
||||
}
|
||||
|
||||
const VALUE_LABELS = {
|
||||
MOC: "Map / index",
|
||||
book: "Book",
|
||||
course: "Course",
|
||||
essay: "Essay",
|
||||
other: "Other",
|
||||
software: "Software",
|
||||
youtube: "YouTube",
|
||||
};
|
||||
|
||||
function displayValue(value) {
|
||||
return VALUE_LABELS[value] || value;
|
||||
}
|
||||
|
||||
function renderCell(row, column, options) {
|
||||
const raw = row[column] ?? "";
|
||||
const display = displayValue(raw);
|
||||
const text = plainText(display);
|
||||
const sortValue = attribute(text.toLowerCase());
|
||||
const cellAttrs = `data-column="${attribute(column)}" data-sort-value="${sortValue}"`;
|
||||
|
||||
if (column === options.linkColumn && row[options.urlField]) {
|
||||
const numberValue = row[options.numberField];
|
||||
const numberLabel =
|
||||
numberValue === undefined || numberValue === null || numberValue === ""
|
||||
? ""
|
||||
: String(numberValue).padStart(2, "0");
|
||||
const prefix = options.numberField && numberLabel ? `${numberLabel}. ` : "";
|
||||
return `<td ${cellAttrs}><a href="${attribute(row[options.urlField])}">${escapeHtml(prefix)}${inlineMarkup(display)}</a></td>`;
|
||||
}
|
||||
|
||||
if (column === "type") {
|
||||
return `<td ${cellAttrs}><span class="data-table-badge">${escapeHtml(display)}</span></td>`;
|
||||
}
|
||||
|
||||
if (
|
||||
column === "summary" ||
|
||||
String(display).length > 140 ||
|
||||
String(display).includes("<br")
|
||||
) {
|
||||
return `<td ${cellAttrs}><div class="data-table-rich-cell">${inlineMarkup(display)}</div></td>`;
|
||||
}
|
||||
|
||||
return `<td ${cellAttrs}>${inlineMarkup(display)}</td>`;
|
||||
}
|
||||
|
||||
function dataTableScript(id) {
|
||||
return `<script>
|
||||
(() => {
|
||||
const root = document.getElementById(${JSON.stringify(id)});
|
||||
if (!root || root.dataset.dataTableReady) return;
|
||||
root.dataset.dataTableReady = "true";
|
||||
|
||||
const input = root.querySelector("[data-data-table-search]");
|
||||
const tbody = root.querySelector("tbody");
|
||||
const empty = root.querySelector("[data-data-table-empty]");
|
||||
const rows = Array.from(tbody?.querySelectorAll("tr") || []);
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
|
||||
|
||||
function applyFilter() {
|
||||
const query = (input?.value || "").trim().toLowerCase();
|
||||
let visible = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const matches = !query || row.dataset.search.includes(query);
|
||||
row.hidden = !matches;
|
||||
if (matches) visible += 1;
|
||||
}
|
||||
|
||||
if (empty) empty.hidden = visible !== 0;
|
||||
}
|
||||
|
||||
input?.addEventListener("input", applyFilter);
|
||||
|
||||
root.querySelectorAll("[data-data-table-sort]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const column = button.dataset.dataTableSort;
|
||||
const current = button.getAttribute("aria-sort");
|
||||
const direction = current === "ascending" ? "descending" : "ascending";
|
||||
|
||||
root.querySelectorAll("[data-data-table-sort]").forEach((other) => {
|
||||
other.removeAttribute("aria-sort");
|
||||
});
|
||||
button.setAttribute("aria-sort", direction);
|
||||
|
||||
rows
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const leftValue = left.querySelector(\`[data-column="\${column}"]\`)?.dataset.sortValue || "";
|
||||
const rightValue = right.querySelector(\`[data-column="\${column}"]\`)?.dataset.sortValue || "";
|
||||
const result = collator.compare(leftValue, rightValue);
|
||||
return direction === "ascending" ? result : -result;
|
||||
})
|
||||
.forEach((row) => tbody.appendChild(row));
|
||||
|
||||
applyFilter();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>`;
|
||||
}
|
||||
|
||||
function dataTableFromCode(node, file) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
if (!attrs.classes.includes("data-table")) return null;
|
||||
|
||||
const src = attrs.values.src || attrs.values.source || "";
|
||||
if (!src) {
|
||||
return semanticError('Data table block missing <code>src="..."</code>.');
|
||||
}
|
||||
|
||||
let rows;
|
||||
try {
|
||||
rows = readDataRows(src, file);
|
||||
} catch (error) {
|
||||
return semanticError(escapeHtml(error.message));
|
||||
}
|
||||
|
||||
const firstRow = rows[0] || {};
|
||||
const columns = csv(attrs.values.columns).length
|
||||
? csv(attrs.values.columns)
|
||||
: Object.keys(firstRow).filter((key) => key !== "link");
|
||||
const headers = keyValueList(attrs.values.headers || attrs.values.labels);
|
||||
const linkColumn =
|
||||
attrs.values.link || (columns.includes("title") ? "title" : "");
|
||||
const urlField = attrs.values.url || attrs.values.href || "link";
|
||||
const numberField = attrs.values.number || "";
|
||||
const caption = attrs.values.caption || attrs.values.title || "";
|
||||
const searchable = booleanAttr(attrs.values.search, true);
|
||||
const sortable = booleanAttr(attrs.values.sort, true);
|
||||
const placeholder =
|
||||
attrs.values["search-placeholder"] || `Search ${caption || "table"}`;
|
||||
const id =
|
||||
attrs.id ||
|
||||
`data-table-${src.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "")}`;
|
||||
const tableId = `${id}-table`;
|
||||
const searchId = `${id}-search`;
|
||||
|
||||
const headerHtml = columns
|
||||
.map((column) => {
|
||||
const label = headers[column] || labelize(column);
|
||||
const content = sortable
|
||||
? `<button type="button" data-data-table-sort="${attribute(column)}">${escapeHtml(label)}</button>`
|
||||
: escapeHtml(label);
|
||||
return `<th scope="col">${content}</th>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const bodyHtml = rows
|
||||
.map((row) => {
|
||||
const searchText = columns
|
||||
.map((column) => plainText(displayValue(row[column] ?? "")))
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
const cells = columns
|
||||
.map((column) =>
|
||||
renderCell(row, column, { linkColumn, urlField, numberField }),
|
||||
)
|
||||
.join("");
|
||||
return `<tr data-search="${attribute(searchText)}">${cells}</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
const searchHtml = searchable
|
||||
? [
|
||||
'<div class="data-table-search">',
|
||||
`<label for="${attribute(searchId)}">Search</label>`,
|
||||
`<input id="${attribute(searchId)}" type="search" autocomplete="off" placeholder="${attribute(placeholder)}" data-data-table-search>`,
|
||||
"</div>",
|
||||
].join("")
|
||||
: "";
|
||||
|
||||
return {
|
||||
type: "html",
|
||||
value: [
|
||||
`<section class="semantic-data-table" id="${attribute(id)}" data-data-table-root>`,
|
||||
caption || searchable
|
||||
? `<div class="data-table-toolbar">${caption ? `<h2>${escapeHtml(caption)}</h2>` : ""}${searchHtml}</div>`
|
||||
: "",
|
||||
'<div class="data-table-scroll">',
|
||||
`<table id="${attribute(tableId)}">`,
|
||||
caption ? `<caption>${escapeHtml(caption)}</caption>` : "",
|
||||
`<thead><tr>${headerHtml}</tr></thead>`,
|
||||
`<tbody>${bodyHtml}</tbody>`,
|
||||
"</table>",
|
||||
"</div>",
|
||||
`<p class="data-table-empty" data-data-table-empty hidden>No matching rows.</p>`,
|
||||
"</section>",
|
||||
dataTableScript(id),
|
||||
].join(""),
|
||||
};
|
||||
}
|
||||
|
||||
function interactiveFromCode(node) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
if (!attrs.classes.includes("interactive")) return null;
|
||||
|
||||
const declaredId = attrs.id || attrs.values.component || attrs.values.name || "";
|
||||
const declaredId =
|
||||
attrs.id || attrs.values.component || attrs.values.name || "";
|
||||
const componentId =
|
||||
attrs.values.component ||
|
||||
(declaredId.startsWith("int:") ? declaredId.slice("int:".length) : declaredId);
|
||||
const label = attrs.values.label || (declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`);
|
||||
(declaredId.startsWith("int:")
|
||||
? declaredId.slice("int:".length)
|
||||
: declaredId);
|
||||
const label =
|
||||
attrs.values.label ||
|
||||
(declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`);
|
||||
const caption = attrs.values.caption || attrs.values.title || "";
|
||||
const description = node.value.trim();
|
||||
const fallback = attrs.values.pdf || attrs.values.fallback || description || caption;
|
||||
const fallback =
|
||||
attrs.values.pdf || attrs.values.fallback || description || caption;
|
||||
|
||||
if (!componentId) {
|
||||
return {
|
||||
|
|
@ -141,7 +431,9 @@ function interactiveFromCode(node) {
|
|||
value: [
|
||||
`<figure class="semantic-interactive" id="${attribute(label)}" data-interactive-label="${attribute(label)}">`,
|
||||
`<div class="interactive-mount" data-interactive-id="${attribute(componentId)}" data-interactive-props="${attribute(encodedJson(props))}">`,
|
||||
fallback ? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>` : "",
|
||||
fallback
|
||||
? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>`
|
||||
: "",
|
||||
"</div>",
|
||||
caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "",
|
||||
"</figure>",
|
||||
|
|
@ -154,7 +446,10 @@ function envFromCode(node) {
|
|||
const shorthandType = firstClass(attrs, new Set(["env", "latex-env"]));
|
||||
const explicitType = attrs.values.type || shorthandType;
|
||||
const type = ENV_TYPES.has(explicitType) ? explicitType : "";
|
||||
const isEnv = attrs.classes.includes("env") || attrs.classes.includes("latex-env") || Boolean(type);
|
||||
const isEnv =
|
||||
attrs.classes.includes("env") ||
|
||||
attrs.classes.includes("latex-env") ||
|
||||
Boolean(type);
|
||||
|
||||
if (!isEnv) return null;
|
||||
|
||||
|
|
@ -168,7 +463,8 @@ function envFromCode(node) {
|
|||
|
||||
const title = attrs.values.title || attrs.values.name || "";
|
||||
const label = attrs.id || attrs.values.label || "";
|
||||
const renderMode = attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const renderMode =
|
||||
attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const heading = title ? `${ENV_LABELS[type]} (${title})` : ENV_LABELS[type];
|
||||
const parsed = fromMarkdown(node.value || "");
|
||||
const className = [
|
||||
|
|
@ -210,23 +506,26 @@ function envFromCode(node) {
|
|||
};
|
||||
}
|
||||
|
||||
function transformTree(parent) {
|
||||
function transformTree(parent, file) {
|
||||
if (!parent?.children) return;
|
||||
|
||||
for (let index = 0; index < parent.children.length; index += 1) {
|
||||
const child = parent.children[index];
|
||||
if (child.type === "code") {
|
||||
const replacement = interactiveFromCode(child) || envFromCode(child);
|
||||
const replacement =
|
||||
dataTableFromCode(child, file) ||
|
||||
interactiveFromCode(child) ||
|
||||
envFromCode(child);
|
||||
if (replacement) {
|
||||
parent.children[index] = replacement;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
transformTree(child);
|
||||
transformTree(child, file);
|
||||
}
|
||||
}
|
||||
|
||||
export default function remarkSemanticBlocks() {
|
||||
return (tree) => transformTree(tree);
|
||||
return (tree, file) => transformTree(tree, file);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue