Add semantic block parser and PDF export
Some checks are pending
Build / build (push) Waiting to run
Some checks are pending
Build / build (push) Waiting to run
This commit is contained in:
parent
632543db4d
commit
58310e1944
47 changed files with 3078 additions and 0 deletions
232
sites/vibes/src/remark/semantic-blocks.mjs
Normal file
232
sites/vibes/src/remark/semantic-blocks.mjs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
|
||||
const ENV_TYPES = new Set([
|
||||
"theorem",
|
||||
"lemma",
|
||||
"proposition",
|
||||
"corollary",
|
||||
"definition",
|
||||
"example",
|
||||
"remark",
|
||||
"proof",
|
||||
]);
|
||||
|
||||
const ENV_LABELS = {
|
||||
theorem: "Theorem",
|
||||
lemma: "Lemma",
|
||||
proposition: "Proposition",
|
||||
corollary: "Corollary",
|
||||
definition: "Definition",
|
||||
example: "Example",
|
||||
remark: "Remark",
|
||||
proof: "Proof",
|
||||
};
|
||||
|
||||
function tokenizeInfo(info) {
|
||||
return (
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function stripQuotes(value) {
|
||||
if (!value) return "";
|
||||
const first = value[0];
|
||||
const last = value[value.length - 1];
|
||||
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseFenceInfo(node) {
|
||||
const lang = node.lang ?? "";
|
||||
const meta = node.meta ?? "";
|
||||
const raw = `${lang} ${meta}`.trim();
|
||||
const attrs = {
|
||||
classes: [],
|
||||
id: "",
|
||||
values: {},
|
||||
raw,
|
||||
};
|
||||
|
||||
if (!raw) return attrs;
|
||||
|
||||
let body = raw;
|
||||
if (body.startsWith("{")) {
|
||||
body = body.slice(1);
|
||||
if (body.endsWith("}")) body = body.slice(0, -1);
|
||||
} else if (lang) {
|
||||
attrs.classes.push(lang);
|
||||
body = meta;
|
||||
}
|
||||
|
||||
for (let token of tokenizeInfo(body.trim())) {
|
||||
if (token.endsWith("}") && !token.includes("=")) token = token.slice(0, -1);
|
||||
if (!token) continue;
|
||||
|
||||
if (token.startsWith(".")) {
|
||||
attrs.classes.push(token.slice(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith("#")) {
|
||||
attrs.id = token.slice(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const eq = token.indexOf("=");
|
||||
if (eq > 0) {
|
||||
const key = token.slice(0, eq);
|
||||
const value = stripQuotes(token.slice(eq + 1));
|
||||
if (key === "id") attrs.id = value;
|
||||
attrs.values[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function attribute(value) {
|
||||
return escapeHtml(value).replaceAll("\n", " ");
|
||||
}
|
||||
|
||||
function encodedJson(value) {
|
||||
return encodeURIComponent(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function firstClass(attrs, excluded) {
|
||||
return attrs.classes.find((className) => !excluded.has(className));
|
||||
}
|
||||
|
||||
function interactiveFromCode(node) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
if (!attrs.classes.includes("interactive")) return null;
|
||||
|
||||
const declaredId = attrs.id || attrs.values.component || attrs.values.name || "";
|
||||
const componentId =
|
||||
attrs.values.component ||
|
||||
(declaredId.startsWith("int:") ? declaredId.slice("int:".length) : declaredId);
|
||||
const label = attrs.values.label || (declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`);
|
||||
const caption = attrs.values.caption || attrs.values.title || "";
|
||||
const description = node.value.trim();
|
||||
const fallback = attrs.values.pdf || attrs.values.fallback || description || caption;
|
||||
|
||||
if (!componentId) {
|
||||
return {
|
||||
type: "html",
|
||||
value:
|
||||
'<aside class="semantic-error">Interactive block missing an id. Add <code>#int:name</code> or <code>id="name"</code>.</aside>',
|
||||
};
|
||||
}
|
||||
|
||||
const props = {
|
||||
id: componentId,
|
||||
label,
|
||||
caption,
|
||||
description,
|
||||
fallback,
|
||||
attrs: attrs.values,
|
||||
};
|
||||
|
||||
return {
|
||||
type: "html",
|
||||
value: [
|
||||
`<figure class="semantic-interactive" id="${attribute(label)}" data-interactive-label="${attribute(label)}">`,
|
||||
`<div class="interactive-mount" data-interactive-id="${attribute(componentId)}" data-interactive-props="${attribute(encodedJson(props))}">`,
|
||||
fallback ? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>` : "",
|
||||
"</div>",
|
||||
caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "",
|
||||
"</figure>",
|
||||
].join(""),
|
||||
};
|
||||
}
|
||||
|
||||
function envFromCode(node) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
const shorthandType = firstClass(attrs, new Set(["env", "latex-env"]));
|
||||
const explicitType = attrs.values.type || shorthandType;
|
||||
const type = ENV_TYPES.has(explicitType) ? explicitType : "";
|
||||
const isEnv = attrs.classes.includes("env") || attrs.classes.includes("latex-env") || Boolean(type);
|
||||
|
||||
if (!isEnv) return null;
|
||||
|
||||
if (!type) {
|
||||
return {
|
||||
type: "html",
|
||||
value:
|
||||
'<aside class="semantic-error">Environment block missing a type. Add <code>.theorem</code>, <code>.definition</code>, or <code>type="theorem"</code>.</aside>',
|
||||
};
|
||||
}
|
||||
|
||||
const title = attrs.values.title || attrs.values.name || "";
|
||||
const label = attrs.id || attrs.values.label || "";
|
||||
const renderMode = attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const heading = title ? `${ENV_LABELS[type]} (${title})` : ENV_LABELS[type];
|
||||
const parsed = fromMarkdown(node.value || "");
|
||||
const className = [
|
||||
"semantic-env",
|
||||
`semantic-env-${type}`,
|
||||
`semantic-env-${renderMode}`,
|
||||
renderMode === "callout" ? "callout" : "",
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
type: "blockquote",
|
||||
data: {
|
||||
hName: "section",
|
||||
hProperties: {
|
||||
id: label || undefined,
|
||||
className,
|
||||
"data-env-type": type,
|
||||
"data-env-title": title || undefined,
|
||||
"data-env-render": renderMode,
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "paragraph",
|
||||
data: {
|
||||
hProperties: {
|
||||
className: ["semantic-env-heading"],
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "strong",
|
||||
children: [{ type: "text", value: heading }],
|
||||
},
|
||||
],
|
||||
},
|
||||
...parsed.children,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function transformTree(parent) {
|
||||
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);
|
||||
if (replacement) {
|
||||
parent.children[index] = replacement;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
transformTree(child);
|
||||
}
|
||||
}
|
||||
|
||||
export default function remarkSemanticBlocks() {
|
||||
return (tree) => transformTree(tree);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue