Add semantic block parser and PDF export
Some checks are pending
Build / build (push) Waiting to run

This commit is contained in:
Neeldhara Misra 2026-06-25 12:35:40 +05:30
parent 632543db4d
commit 58310e1944
47 changed files with 3078 additions and 0 deletions

View 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("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
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);
}