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

@ -114,6 +114,129 @@ Quarto margin notes such as `[text]{.aside}` were converted to:
> **Aside:** text
```
## Semantic Blocks
Use Pandoc-style fenced attributes for blocks that need to render differently
in HTML and PDF. This is the preferred form because it works cleanly with both
Astro and Pandoc/LuaLaTeX.
### Interactive Blocks
An interactive block reserves a place in the post for a React component and
also supplies fallback text for PDF or non-JavaScript rendering:
````markdown
```{.interactive #int:tape-layout caption="Tape layout explorer"}
Drag files along a tape and compare the access cost of each arrangement.
```
````
The id after `#` is the semantic label. For interactives, use the `int:` prefix.
The component id is inferred by removing that prefix, so the example above
mounts this component when it exists:
```text
sites/<blog>/src/interactives/tape-layout/index.tsx
```
The component receives:
```ts
{
id: "tape-layout",
label: "int:tape-layout",
caption: "Tape layout explorer",
description: "Drag files along a tape...",
fallback: "Drag files along a tape...",
attrs: { caption: "Tape layout explorer" }
}
```
If the component is not present, Astro renders the fallback text in the
interactive frame. For a different PDF fallback, add `pdf="..."`:
````markdown
```{.interactive #int:tape-layout caption="Tape layout explorer" pdf="The HTML version contains an interactive tape layout explorer."}
Implementation notes for the interactive can go here.
```
````
Astro also accepts the shorter Obsidian-friendly form:
````markdown
```interactive id="tape-layout" caption="Tape layout explorer"
Drag files along a tape and compare the access cost.
```
````
Use the Pandoc-style form for posts that should export to PDF.
### LaTeX-Style Environments
Use `.env` plus the environment class and a label:
````markdown
```{.env .theorem #thm:hall title="Hall's theorem" html="callout"}
Every bipartite graph satisfying Hall's condition has a matching that covers
the left side.
```
````
Supported environment types are:
```text
theorem, lemma, proposition, corollary, definition, example, remark, proof
```
In Astro, `html="callout"` renders the block like a callout. `html="plain"`
renders it as a quieter left-rule block. If `html` is omitted, theorem-like
blocks default to `callout`, and `proof` defaults to `plain`.
In PDF, the same block becomes a real LuaLaTeX environment:
```tex
\begin{theorem}[Hall's theorem]
\label{thm:hall}
...
\end{theorem}
```
Astro also accepts shorter forms when PDF export is not needed:
````markdown
```theorem id="thm:hall" title="Hall's theorem"
Every bipartite graph satisfying Hall's condition has a matching...
```
````
### Linking To Semantic Blocks
Use ordinary Markdown links to semantic ids:
```markdown
The proof of [Hall's theorem](#thm:hall) is constructive.
See the [tape explorer](#int:tape-layout) for an interactive version.
```
Astro renders these as normal HTML links. The PDF pipeline converts internal
links whose target starts with `#` into `\cref{...}`, so the first link above
becomes `\cref{thm:hall}`.
Use these label prefixes consistently:
```text
thm: theorem
lem: lemma
prop: proposition
cor: corollary
def: definition
ex: example
rem: remark
prf: proof
int: interactive
fig: figure
```
## Import Rules
The import script is:
@ -168,3 +291,23 @@ Publish does three things:
1. Sync vault content into the local repo.
2. Run the configured Astro build command.
3. Commit and push to Forgejo, where Dokploy picks up the change.
## PDF Export
PDF export uses Pandoc plus LuaLaTeX:
```bash
npm run pdf:post -- sites/research/src/content/research/example/index.md --out /tmp/example.pdf
```
The command runs:
- `scripts/pandoc-filters/semantic-blocks.lua` to convert interactive and
environment fences.
- `scripts/pandoc-templates/semantic-preamble.tex` to load `amsthm`,
`cleveref`, and the shared theorem definitions.
The PDF route is intentionally separate from Astro. Astro owns HTML rendering;
Pandoc owns PDF rendering. The Markdown conventions above are the shared
contract between both outputs and can be reused for future site families such
as `neeldhara.courses` and `books.neeldhara.com`.

View file

@ -15,6 +15,7 @@
"build": "node scripts/build-site-packages.mjs",
"build:sites": "node scripts/build-site-packages.mjs",
"build:legacy": "node scripts/build-all-sites.mjs",
"pdf:post": "node scripts/export-pdf.mjs",
"preview": "npm run preview --prefix sites/research",
"astro": "astro",
"format": "prettier -w ."

64
scripts/export-pdf.mjs Normal file
View file

@ -0,0 +1,64 @@
import { existsSync, mkdirSync } from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
const filterPath = path.join(repoRoot, "scripts", "pandoc-filters", "semantic-blocks.lua");
const preamblePath = path.join(repoRoot, "scripts", "pandoc-templates", "semantic-preamble.tex");
function usage() {
console.error("Usage: node scripts/export-pdf.mjs <post.md> [--out output.pdf]");
}
function parseArgs(argv) {
const args = [...argv];
const input = args.find((arg) => !arg.startsWith("--"));
const outIndex = args.indexOf("--out");
const output = outIndex >= 0 ? args[outIndex + 1] : "";
return { input, output };
}
const { input, output } = parseArgs(process.argv.slice(2));
if (!input) {
usage();
process.exit(2);
}
const inputPath = path.resolve(input);
if (!existsSync(inputPath)) {
console.error(`Input file not found: ${inputPath}`);
process.exit(2);
}
const outputPath = path.resolve(
output || path.join(path.dirname(inputPath), `${path.basename(inputPath, path.extname(inputPath))}.pdf`),
);
mkdirSync(path.dirname(outputPath), { recursive: true });
const pandocArgs = [
inputPath,
"--from",
"markdown+fenced_code_attributes+tex_math_dollars+tex_math_single_backslash+footnotes+raw_html",
"--pdf-engine=lualatex",
"--lua-filter",
filterPath,
"--include-in-header",
preamblePath,
"--output",
outputPath,
];
const result = spawnSync("pandoc", pandocArgs, { stdio: "inherit" });
if (result.error?.code === "ENOENT") {
console.error("pandoc is not installed or is not on PATH. Install pandoc and a LuaLaTeX distribution first.");
process.exit(127);
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
console.log(`Wrote ${outputPath}`);

View file

@ -0,0 +1,131 @@
local env_types = {
theorem = true,
lemma = true,
proposition = true,
corollary = true,
definition = true,
example = true,
remark = true,
proof = true,
}
local function has_class(el, class_name)
for _, value in ipairs(el.classes) do
if value == class_name then
return true
end
end
return false
end
local function first_env_class(el)
for _, value in ipairs(el.classes) do
if env_types[value] then
return value
end
end
return nil
end
local function latex_escape(value)
value = tostring(value or "")
value = value:gsub("\\", "\\textbackslash{}")
value = value:gsub("([{}%%$&#_])", "\\%1")
value = value:gsub("%^", "\\textasciicircum{}")
value = value:gsub("~", "\\textasciitilde{}")
return value
end
local function internal_link_to_cref(el)
if FORMAT:match("latex") and el.target:sub(1, 1) == "#" then
local label = el.target:sub(2)
if label ~= "" then
return pandoc.RawInline("latex", "\\cref{" .. label .. "}")
end
end
return nil
end
local function markdown_to_latex(markdown)
local doc = pandoc.read(
markdown or "",
"markdown+fenced_code_attributes+tex_math_dollars+tex_math_single_backslash+footnotes+raw_html"
)
doc = doc:walk({ Link = internal_link_to_cref })
return pandoc.write(doc, "latex")
end
local function label_line(label)
if label and label ~= "" then
return "\\label{" .. label .. "}\n"
end
return ""
end
local function interactive_to_latex(el)
local label = el.identifier
local caption = el.attributes.caption or el.attributes.title or ""
local fallback = el.attributes.pdf or el.attributes.fallback or el.text
local fallback_latex = markdown_to_latex(fallback)
local caption_line = ""
if caption ~= "" then
caption_line = "\\caption{" .. latex_escape(caption) .. "}\n"
end
return pandoc.RawBlock(
"latex",
table.concat({
"\\begin{figure}[htbp]\n",
"\\centering\n",
"\\fbox{\\begin{minipage}{0.86\\linewidth}\n",
fallback_latex,
"\n\\end{minipage}}\n",
caption_line,
label_line(label),
"\\end{figure}",
})
)
end
local function env_to_latex(el)
local env_type = el.attributes.type or first_env_class(el)
if not env_type or not env_types[env_type] then
return nil
end
local title = el.attributes.title or el.attributes.name or ""
local begin_env = "\\begin{" .. env_type .. "}"
if title ~= "" then
begin_env = begin_env .. "[" .. latex_escape(title) .. "]"
end
return pandoc.RawBlock(
"latex",
table.concat({
begin_env,
"\n",
label_line(el.identifier),
markdown_to_latex(el.text),
"\n\\end{",
env_type,
"}",
})
)
end
function CodeBlock(el)
if FORMAT:match("latex") and has_class(el, "interactive") then
return interactive_to_latex(el)
end
if FORMAT:match("latex") and (has_class(el, "env") or has_class(el, "latex-env") or first_env_class(el)) then
return env_to_latex(el)
end
return nil
end
function Link(el)
return internal_link_to_cref(el)
end

View file

@ -0,0 +1,44 @@
\usepackage{amsmath}
\usepackage{amssymb}
\usepackage{amsthm}
\theoremstyle{plain}
\newtheorem{theorem}{Theorem}
\newtheorem{lemma}[theorem]{Lemma}
\newtheorem{proposition}[theorem]{Proposition}
\newtheorem{corollary}[theorem]{Corollary}
\theoremstyle{definition}
\newtheorem{definition}[theorem]{Definition}
\newtheorem{example}[theorem]{Example}
\theoremstyle{remark}
\newtheorem{remark}[theorem]{Remark}
\newcommand{\SemanticLoadCleveref}{%
\usepackage{cleveref}%
\crefname{theorem}{theorem}{theorems}%
\Crefname{theorem}{Theorem}{Theorems}%
\crefname{lemma}{lemma}{lemmas}%
\Crefname{lemma}{Lemma}{Lemmas}%
\crefname{proposition}{proposition}{propositions}%
\Crefname{proposition}{Proposition}{Propositions}%
\crefname{corollary}{corollary}{corollaries}%
\Crefname{corollary}{Corollary}{Corollaries}%
\crefname{definition}{definition}{definitions}%
\Crefname{definition}{Definition}{Definitions}%
\crefname{example}{example}{examples}%
\Crefname{example}{Example}{Examples}%
\crefname{remark}{remark}{remarks}%
\Crefname{remark}{Remark}{Remarks}%
\crefname{figure}{figure}{figures}%
\Crefname{figure}{Figure}{Figures}%
}
\makeatletter
\@ifpackageloaded{hyperref}{%
\SemanticLoadCleveref
}{%
\AddToHook{package/hyperref/after}{\SemanticLoadCleveref}%
}
\makeatother

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;

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';
@ -28,5 +29,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
</BlogPost>
</div>
<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;

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;

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;

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;

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;

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;