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 > **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 ## Import Rules
The import script is: The import script is:
@ -168,3 +291,23 @@ Publish does three things:
1. Sync vault content into the local repo. 1. Sync vault content into the local repo.
2. Run the configured Astro build command. 2. Run the configured Astro build command.
3. Commit and push to Forgejo, where Dokploy picks up the change. 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": "node scripts/build-site-packages.mjs",
"build:sites": "node scripts/build-site-packages.mjs", "build:sites": "node scripts/build-site-packages.mjs",
"build:legacy": "node scripts/build-all-sites.mjs", "build:legacy": "node scripts/build-all-sites.mjs",
"pdf:post": "node scripts/export-pdf.mjs",
"preview": "npm run preview --prefix sites/research", "preview": "npm run preview --prefix sites/research",
"astro": "astro", "astro": "astro",
"format": "prettier -w ." "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 remarkCallouts from "./src/remark/callouts.mjs";
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
import remarkInlineFootnotes from "./src/remark/inline-footnotes.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 remarkTypography from "./src/remark/typography.mjs";
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
@ -19,6 +20,7 @@ const remarkPlugins = [
remarkGfm, remarkGfm,
remarkInlineFootnotes, remarkInlineFootnotes,
remarkCodeFenceLanguages, remarkCodeFenceLanguages,
remarkSemanticBlocks,
remarkMath, remarkMath,
remarkTypography, remarkTypography,
remarkCallouts, 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 { getCollection, render } from 'astro:content';
import CoralComments from '@/components/CoralComments.astro'; import CoralComments from '@/components/CoralComments.astro';
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
import DefaultLayout from '@/layouts/DefaultLayout.astro'; import DefaultLayout from '@/layouts/DefaultLayout.astro';
import { BlogPost } from '@/components/sections/blog-post'; import { BlogPost } from '@/components/sections/blog-post';
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js'; import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
<Content /> <Content />
</BlogPost> </BlogPost>
<InteractiveRuntime />
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} /> <CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
</DefaultLayout> </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; 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) { .article-content :where(.callout hr) {
margin-block: 1rem; margin-block: 1rem;
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent); border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
@ -248,6 +290,33 @@
margin-inline: 0; 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) { .article-content :where(figcaption) {
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
font-size: 0.92rem; font-size: 0.92rem;

View file

@ -12,6 +12,7 @@ import tailwindcss from "@tailwindcss/vite";
import remarkCallouts from "./src/remark/callouts.mjs"; import remarkCallouts from "./src/remark/callouts.mjs";
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
import remarkInlineFootnotes from "./src/remark/inline-footnotes.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 remarkTypography from "./src/remark/typography.mjs";
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
@ -19,6 +20,7 @@ const remarkPlugins = [
remarkGfm, remarkGfm,
remarkInlineFootnotes, remarkInlineFootnotes,
remarkCodeFenceLanguages, remarkCodeFenceLanguages,
remarkSemanticBlocks,
remarkMath, remarkMath,
remarkTypography, remarkTypography,
remarkCallouts, 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 { getCollection, render } from 'astro:content';
import CoralComments from '@/components/CoralComments.astro'; import CoralComments from '@/components/CoralComments.astro';
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
import DefaultLayout from '@/layouts/DefaultLayout.astro'; import DefaultLayout from '@/layouts/DefaultLayout.astro';
import { BlogPost } from '@/components/sections/blog-post'; import { BlogPost } from '@/components/sections/blog-post';
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js'; import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
@ -28,5 +29,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
</BlogPost> </BlogPost>
</div> </div>
<InteractiveRuntime />
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} /> <CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
</DefaultLayout> </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; 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) { .article-content :where(.callout hr) {
margin-block: 1rem; margin-block: 1rem;
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent); border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
@ -248,6 +290,33 @@
margin-inline: 0; 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) { .article-content :where(figcaption) {
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
font-size: 0.92rem; font-size: 0.92rem;

View file

@ -12,6 +12,7 @@ import tailwindcss from "@tailwindcss/vite";
import remarkCallouts from "./src/remark/callouts.mjs"; import remarkCallouts from "./src/remark/callouts.mjs";
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
import remarkInlineFootnotes from "./src/remark/inline-footnotes.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 remarkTypography from "./src/remark/typography.mjs";
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
@ -19,6 +20,7 @@ const remarkPlugins = [
remarkGfm, remarkGfm,
remarkInlineFootnotes, remarkInlineFootnotes,
remarkCodeFenceLanguages, remarkCodeFenceLanguages,
remarkSemanticBlocks,
remarkMath, remarkMath,
remarkTypography, remarkTypography,
remarkCallouts, 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 { getCollection, render } from 'astro:content';
import CoralComments from '@/components/CoralComments.astro'; import CoralComments from '@/components/CoralComments.astro';
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
import DefaultLayout from '@/layouts/DefaultLayout.astro'; import DefaultLayout from '@/layouts/DefaultLayout.astro';
import { BlogPost } from '@/components/sections/blog-post'; import { BlogPost } from '@/components/sections/blog-post';
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js'; import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
<Content /> <Content />
</BlogPost> </BlogPost>
<InteractiveRuntime />
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} /> <CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
</DefaultLayout> </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; 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) { .article-content :where(.callout hr) {
margin-block: 1rem; margin-block: 1rem;
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent); border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
@ -248,6 +290,33 @@
margin-inline: 0; 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) { .article-content :where(figcaption) {
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
font-size: 0.92rem; font-size: 0.92rem;

View file

@ -12,6 +12,7 @@ import tailwindcss from "@tailwindcss/vite";
import remarkCallouts from "./src/remark/callouts.mjs"; import remarkCallouts from "./src/remark/callouts.mjs";
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
import remarkInlineFootnotes from "./src/remark/inline-footnotes.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 remarkTypography from "./src/remark/typography.mjs";
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
@ -19,6 +20,7 @@ const remarkPlugins = [
remarkGfm, remarkGfm,
remarkInlineFootnotes, remarkInlineFootnotes,
remarkCodeFenceLanguages, remarkCodeFenceLanguages,
remarkSemanticBlocks,
remarkMath, remarkMath,
remarkTypography, remarkTypography,
remarkCallouts, 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 { getCollection, render } from 'astro:content';
import CoralComments from '@/components/CoralComments.astro'; import CoralComments from '@/components/CoralComments.astro';
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
import DefaultLayout from '@/layouts/DefaultLayout.astro'; import DefaultLayout from '@/layouts/DefaultLayout.astro';
import { BlogPost } from '@/components/sections/blog-post'; import { BlogPost } from '@/components/sections/blog-post';
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js'; import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
<Content /> <Content />
</BlogPost> </BlogPost>
<InteractiveRuntime />
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} /> <CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
</DefaultLayout> </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; 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) { .article-content :where(.callout hr) {
margin-block: 1rem; margin-block: 1rem;
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent); border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
@ -248,6 +290,33 @@
margin-inline: 0; 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) { .article-content :where(figcaption) {
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
font-size: 0.92rem; font-size: 0.92rem;

View file

@ -12,6 +12,7 @@ import tailwindcss from "@tailwindcss/vite";
import remarkCallouts from "./src/remark/callouts.mjs"; import remarkCallouts from "./src/remark/callouts.mjs";
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
import remarkInlineFootnotes from "./src/remark/inline-footnotes.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 remarkTypography from "./src/remark/typography.mjs";
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
@ -19,6 +20,7 @@ const remarkPlugins = [
remarkGfm, remarkGfm,
remarkInlineFootnotes, remarkInlineFootnotes,
remarkCodeFenceLanguages, remarkCodeFenceLanguages,
remarkSemanticBlocks,
remarkMath, remarkMath,
remarkTypography, remarkTypography,
remarkCallouts, 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 { getCollection, render } from 'astro:content';
import CoralComments from '@/components/CoralComments.astro'; import CoralComments from '@/components/CoralComments.astro';
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
import DefaultLayout from '@/layouts/DefaultLayout.astro'; import DefaultLayout from '@/layouts/DefaultLayout.astro';
import { BlogPost } from '@/components/sections/blog-post'; import { BlogPost } from '@/components/sections/blog-post';
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js'; import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
<Content /> <Content />
</BlogPost> </BlogPost>
<InteractiveRuntime />
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} /> <CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
</DefaultLayout> </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; 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) { .article-content :where(.callout hr) {
margin-block: 1rem; margin-block: 1rem;
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent); border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
@ -248,6 +290,33 @@
margin-inline: 0; 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) { .article-content :where(figcaption) {
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
font-size: 0.92rem; font-size: 0.92rem;

View file

@ -12,6 +12,7 @@ import tailwindcss from "@tailwindcss/vite";
import remarkCallouts from "./src/remark/callouts.mjs"; import remarkCallouts from "./src/remark/callouts.mjs";
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
import remarkInlineFootnotes from "./src/remark/inline-footnotes.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 remarkTypography from "./src/remark/typography.mjs";
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
@ -19,6 +20,7 @@ const remarkPlugins = [
remarkGfm, remarkGfm,
remarkInlineFootnotes, remarkInlineFootnotes,
remarkCodeFenceLanguages, remarkCodeFenceLanguages,
remarkSemanticBlocks,
remarkMath, remarkMath,
remarkTypography, remarkTypography,
remarkCallouts, 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 { getCollection, render } from 'astro:content';
import CoralComments from '@/components/CoralComments.astro'; import CoralComments from '@/components/CoralComments.astro';
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
import DefaultLayout from '@/layouts/DefaultLayout.astro'; import DefaultLayout from '@/layouts/DefaultLayout.astro';
import { BlogPost } from '@/components/sections/blog-post'; import { BlogPost } from '@/components/sections/blog-post';
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js'; import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
<Content /> <Content />
</BlogPost> </BlogPost>
<InteractiveRuntime />
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} /> <CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
</DefaultLayout> </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; 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) { .article-content :where(.callout hr) {
margin-block: 1rem; margin-block: 1rem;
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent); border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
@ -248,6 +290,33 @@
margin-inline: 0; 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) { .article-content :where(figcaption) {
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
font-size: 0.92rem; font-size: 0.92rem;

View file

@ -12,6 +12,7 @@ import tailwindcss from "@tailwindcss/vite";
import remarkCallouts from "./src/remark/callouts.mjs"; import remarkCallouts from "./src/remark/callouts.mjs";
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
import remarkInlineFootnotes from "./src/remark/inline-footnotes.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 remarkTypography from "./src/remark/typography.mjs";
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
@ -19,6 +20,7 @@ const remarkPlugins = [
remarkGfm, remarkGfm,
remarkInlineFootnotes, remarkInlineFootnotes,
remarkCodeFenceLanguages, remarkCodeFenceLanguages,
remarkSemanticBlocks,
remarkMath, remarkMath,
remarkTypography, remarkTypography,
remarkCallouts, 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 { getCollection, render } from 'astro:content';
import CoralComments from '@/components/CoralComments.astro'; import CoralComments from '@/components/CoralComments.astro';
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
import DefaultLayout from '@/layouts/DefaultLayout.astro'; import DefaultLayout from '@/layouts/DefaultLayout.astro';
import { BlogPost } from '@/components/sections/blog-post'; import { BlogPost } from '@/components/sections/blog-post';
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js'; import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
<Content /> <Content />
</BlogPost> </BlogPost>
<InteractiveRuntime />
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} /> <CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
</DefaultLayout> </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; 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) { .article-content :where(.callout hr) {
margin-block: 1rem; margin-block: 1rem;
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent); border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
@ -248,6 +290,33 @@
margin-inline: 0; 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) { .article-content :where(figcaption) {
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
font-size: 0.92rem; font-size: 0.92rem;