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

@ -12,6 +12,7 @@ import tailwindcss from "@tailwindcss/vite";
import remarkCallouts from "./src/remark/callouts.mjs";
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs";
import remarkTypography from "./src/remark/typography.mjs";
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
@ -19,6 +20,7 @@ const remarkPlugins = [
remarkGfm,
remarkInlineFootnotes,
remarkCodeFenceLanguages,
remarkSemanticBlocks,
remarkMath,
remarkTypography,
remarkCallouts,

View file

@ -0,0 +1,3 @@
<script>
import "@/interactives/runtime";
</script>

View file

@ -0,0 +1,77 @@
import React from "react";
import { createRoot } from "react-dom/client";
type InteractiveProps = {
id: string;
label: string;
caption?: string;
description?: string;
fallback?: string;
attrs?: Record<string, string>;
};
type InteractiveModule = {
default: React.ComponentType<InteractiveProps>;
};
const modules = import.meta.glob<InteractiveModule>("./*/index.tsx");
function readProps(element: HTMLElement): InteractiveProps {
const encoded = element.dataset.interactiveProps;
if (!encoded) {
return {
id: element.dataset.interactiveId ?? "",
label: "",
};
}
try {
return JSON.parse(decodeURIComponent(encoded)) as InteractiveProps;
} catch {
return {
id: element.dataset.interactiveId ?? "",
label: "",
fallback: element.textContent?.trim() ?? "",
};
}
}
async function mountInteractive(element: HTMLElement) {
const id = element.dataset.interactiveId;
if (!id || element.dataset.interactiveMounted) return;
const modulePath = `./${id}/index.tsx`;
const loader = modules[modulePath];
if (!loader) {
element.dataset.interactiveState = "missing-component";
return;
}
element.dataset.interactiveMounted = "true";
element.dataset.interactiveState = "loading";
try {
const props = readProps(element);
const { default: Component } = await loader();
element.replaceChildren();
createRoot(element).render(<Component {...props} />);
element.dataset.interactiveState = "ready";
} catch (error) {
element.dataset.interactiveState = "failed";
console.error(`Failed to mount interactive "${id}"`, error);
}
}
function mountAllInteractives() {
document
.querySelectorAll<HTMLElement>(".interactive-mount[data-interactive-id]")
.forEach((element) => {
void mountInteractive(element);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mountAllInteractives, { once: true });
} else {
mountAllInteractives();
}

View file

@ -1,6 +1,7 @@
---
import { getCollection, render } from 'astro:content';
import CoralComments from '@/components/CoralComments.astro';
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
import DefaultLayout from '@/layouts/DefaultLayout.astro';
import { BlogPost } from '@/components/sections/blog-post';
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
<Content />
</BlogPost>
<InteractiveRuntime />
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
</DefaultLayout>

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("&", "&amp;")
.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);
}

View file

@ -216,6 +216,48 @@
margin-bottom: 0.35rem;
}
.article-content :where(.semantic-env) {
scroll-margin-top: 5rem;
}
.article-content :where(.semantic-env-heading) {
margin: 0 0 0.75rem;
color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground)));
font-family: var(--font-mono);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.3;
text-transform: uppercase;
}
.article-content :where(.semantic-env-heading strong) {
font-weight: inherit;
}
.article-content :where(.semantic-env-plain) {
margin-block: 1.6rem;
border-left: 3px solid hsl(var(--primary));
padding: 0.15rem 0 0.15rem 1rem;
}
.article-content :where(.semantic-env-plain > *:first-child) {
margin-top: 0;
}
.article-content :where(.semantic-env-plain > *:last-child) {
margin-bottom: 0;
}
.article-content :where(.semantic-error) {
margin-block: 1.5rem;
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;
color: hsl(var(--foreground));
}
.article-content :where(.callout hr) {
margin-block: 1rem;
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
@ -248,6 +290,33 @@
margin-inline: 0;
}
.article-content :where(.semantic-interactive) {
margin-block: 2rem;
scroll-margin-top: 5rem;
}
.article-content :where(.interactive-mount) {
min-height: 12rem;
border: 1px solid hsl(var(--border));
border-radius: 0.65rem;
background: color-mix(in srgb, hsl(var(--accent)) 20%, transparent);
padding: 1rem;
}
.article-content :where(.interactive-mount[data-interactive-state="missing-component"]) {
display: grid;
min-height: 8rem;
place-items: center;
color: hsl(var(--muted-foreground));
font-size: 0.95rem;
text-align: center;
}
.article-content :where(.interactive-fallback) {
margin: 0;
color: hsl(var(--muted-foreground));
}
.article-content :where(figcaption) {
color: hsl(var(--muted-foreground));
font-size: 0.92rem;