diff --git a/CONTENT_BRIDGE.md b/CONTENT_BRIDGE.md index e1979d8..dadc0ab 100644 --- a/CONTENT_BRIDGE.md +++ b/CONTENT_BRIDGE.md @@ -114,6 +114,129 @@ Quarto margin notes such as `[text]{.aside}` were converted to: > **Aside:** text ``` +## Semantic Blocks + +Use Pandoc-style fenced attributes for blocks that need to render differently +in HTML and PDF. This is the preferred form because it works cleanly with both +Astro and Pandoc/LuaLaTeX. + +### Interactive Blocks + +An interactive block reserves a place in the post for a React component and +also supplies fallback text for PDF or non-JavaScript rendering: + +````markdown +```{.interactive #int:tape-layout caption="Tape layout explorer"} +Drag files along a tape and compare the access cost of each arrangement. +``` +```` + +The id after `#` is the semantic label. For interactives, use the `int:` prefix. +The component id is inferred by removing that prefix, so the example above +mounts this component when it exists: + +```text +sites//src/interactives/tape-layout/index.tsx +``` + +The component receives: + +```ts +{ + id: "tape-layout", + label: "int:tape-layout", + caption: "Tape layout explorer", + description: "Drag files along a tape...", + fallback: "Drag files along a tape...", + attrs: { caption: "Tape layout explorer" } +} +``` + +If the component is not present, Astro renders the fallback text in the +interactive frame. For a different PDF fallback, add `pdf="..."`: + +````markdown +```{.interactive #int:tape-layout caption="Tape layout explorer" pdf="The HTML version contains an interactive tape layout explorer."} +Implementation notes for the interactive can go here. +``` +```` + +Astro also accepts the shorter Obsidian-friendly form: + +````markdown +```interactive id="tape-layout" caption="Tape layout explorer" +Drag files along a tape and compare the access cost. +``` +```` + +Use the Pandoc-style form for posts that should export to PDF. + +### LaTeX-Style Environments + +Use `.env` plus the environment class and a label: + +````markdown +```{.env .theorem #thm:hall title="Hall's theorem" html="callout"} +Every bipartite graph satisfying Hall's condition has a matching that covers +the left side. +``` +```` + +Supported environment types are: + +```text +theorem, lemma, proposition, corollary, definition, example, remark, proof +``` + +In Astro, `html="callout"` renders the block like a callout. `html="plain"` +renders it as a quieter left-rule block. If `html` is omitted, theorem-like +blocks default to `callout`, and `proof` defaults to `plain`. + +In PDF, the same block becomes a real LuaLaTeX environment: + +```tex +\begin{theorem}[Hall's theorem] +\label{thm:hall} +... +\end{theorem} +``` + +Astro also accepts shorter forms when PDF export is not needed: + +````markdown +```theorem id="thm:hall" title="Hall's theorem" +Every bipartite graph satisfying Hall's condition has a matching... +``` +```` + +### Linking To Semantic Blocks + +Use ordinary Markdown links to semantic ids: + +```markdown +The proof of [Hall's theorem](#thm:hall) is constructive. +See the [tape explorer](#int:tape-layout) for an interactive version. +``` + +Astro renders these as normal HTML links. The PDF pipeline converts internal +links whose target starts with `#` into `\cref{...}`, so the first link above +becomes `\cref{thm:hall}`. + +Use these label prefixes consistently: + +```text +thm: theorem +lem: lemma +prop: proposition +cor: corollary +def: definition +ex: example +rem: remark +prf: proof +int: interactive +fig: figure +``` + ## Import Rules The import script is: @@ -168,3 +291,23 @@ Publish does three things: 1. Sync vault content into the local repo. 2. Run the configured Astro build command. 3. Commit and push to Forgejo, where Dokploy picks up the change. + +## PDF Export + +PDF export uses Pandoc plus LuaLaTeX: + +```bash +npm run pdf:post -- sites/research/src/content/research/example/index.md --out /tmp/example.pdf +``` + +The command runs: + +- `scripts/pandoc-filters/semantic-blocks.lua` to convert interactive and + environment fences. +- `scripts/pandoc-templates/semantic-preamble.tex` to load `amsthm`, + `cleveref`, and the shared theorem definitions. + +The PDF route is intentionally separate from Astro. Astro owns HTML rendering; +Pandoc owns PDF rendering. The Markdown conventions above are the shared +contract between both outputs and can be reused for future site families such +as `neeldhara.courses` and `books.neeldhara.com`. diff --git a/package.json b/package.json index 965a6aa..b2f8745 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "build": "node scripts/build-site-packages.mjs", "build:sites": "node scripts/build-site-packages.mjs", "build:legacy": "node scripts/build-all-sites.mjs", + "pdf:post": "node scripts/export-pdf.mjs", "preview": "npm run preview --prefix sites/research", "astro": "astro", "format": "prettier -w ." diff --git a/scripts/export-pdf.mjs b/scripts/export-pdf.mjs new file mode 100644 index 0000000..218dcb5 --- /dev/null +++ b/scripts/export-pdf.mjs @@ -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 [--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}`); diff --git a/scripts/pandoc-filters/semantic-blocks.lua b/scripts/pandoc-filters/semantic-blocks.lua new file mode 100644 index 0000000..893a018 --- /dev/null +++ b/scripts/pandoc-filters/semantic-blocks.lua @@ -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 diff --git a/scripts/pandoc-templates/semantic-preamble.tex b/scripts/pandoc-templates/semantic-preamble.tex new file mode 100644 index 0000000..821f242 --- /dev/null +++ b/scripts/pandoc-templates/semantic-preamble.tex @@ -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 diff --git a/sites/art/astro.config.mjs b/sites/art/astro.config.mjs index 17d8510..254caf9 100644 --- a/sites/art/astro.config.mjs +++ b/sites/art/astro.config.mjs @@ -12,6 +12,7 @@ import tailwindcss from "@tailwindcss/vite"; import remarkCallouts from "./src/remark/callouts.mjs"; import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs"; +import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs"; import remarkTypography from "./src/remark/typography.mjs"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; @@ -19,6 +20,7 @@ const remarkPlugins = [ remarkGfm, remarkInlineFootnotes, remarkCodeFenceLanguages, + remarkSemanticBlocks, remarkMath, remarkTypography, remarkCallouts, diff --git a/sites/art/src/components/InteractiveRuntime.astro b/sites/art/src/components/InteractiveRuntime.astro new file mode 100644 index 0000000..0ee92fc --- /dev/null +++ b/sites/art/src/components/InteractiveRuntime.astro @@ -0,0 +1,3 @@ + diff --git a/sites/art/src/interactives/runtime.tsx b/sites/art/src/interactives/runtime.tsx new file mode 100644 index 0000000..f6191e2 --- /dev/null +++ b/sites/art/src/interactives/runtime.tsx @@ -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; +}; + +type InteractiveModule = { + default: React.ComponentType; +}; + +const modules = import.meta.glob("./*/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(); + element.dataset.interactiveState = "ready"; + } catch (error) { + element.dataset.interactiveState = "failed"; + console.error(`Failed to mount interactive "${id}"`, error); + } +} + +function mountAllInteractives() { + document + .querySelectorAll(".interactive-mount[data-interactive-id]") + .forEach((element) => { + void mountInteractive(element); + }); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", mountAllInteractives, { once: true }); +} else { + mountAllInteractives(); +} diff --git a/sites/art/src/pages/[...slug].astro b/sites/art/src/pages/[...slug].astro index 4b14673..2d2e6f2 100644 --- a/sites/art/src/pages/[...slug].astro +++ b/sites/art/src/pages/[...slug].astro @@ -1,6 +1,7 @@ --- import { getCollection, render } from 'astro:content'; import CoralComments from '@/components/CoralComments.astro'; +import InteractiveRuntime from '@/components/InteractiveRuntime.astro'; import DefaultLayout from '@/layouts/DefaultLayout.astro'; import { BlogPost } from '@/components/sections/blog-post'; import { ACTIVE_BLOG_SITE } from '@/blog-sites.js'; @@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString(); + diff --git a/sites/art/src/remark/semantic-blocks.mjs b/sites/art/src/remark/semantic-blocks.mjs new file mode 100644 index 0000000..fe86b96 --- /dev/null +++ b/sites/art/src/remark/semantic-blocks.mjs @@ -0,0 +1,232 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; + +const ENV_TYPES = new Set([ + "theorem", + "lemma", + "proposition", + "corollary", + "definition", + "example", + "remark", + "proof", +]); + +const ENV_LABELS = { + theorem: "Theorem", + lemma: "Lemma", + proposition: "Proposition", + corollary: "Corollary", + definition: "Definition", + example: "Example", + remark: "Remark", + proof: "Proof", +}; + +function tokenizeInfo(info) { + return ( + info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? [] + ); +} + +function stripQuotes(value) { + if (!value) return ""; + const first = value[0]; + const last = value[value.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return value.slice(1, -1); + } + return value; +} + +function parseFenceInfo(node) { + const lang = node.lang ?? ""; + const meta = node.meta ?? ""; + const raw = `${lang} ${meta}`.trim(); + const attrs = { + classes: [], + id: "", + values: {}, + raw, + }; + + if (!raw) return attrs; + + let body = raw; + if (body.startsWith("{")) { + body = body.slice(1); + if (body.endsWith("}")) body = body.slice(0, -1); + } else if (lang) { + attrs.classes.push(lang); + body = meta; + } + + for (let token of tokenizeInfo(body.trim())) { + if (token.endsWith("}") && !token.includes("=")) token = token.slice(0, -1); + if (!token) continue; + + if (token.startsWith(".")) { + attrs.classes.push(token.slice(1)); + continue; + } + + if (token.startsWith("#")) { + attrs.id = token.slice(1); + continue; + } + + const eq = token.indexOf("="); + if (eq > 0) { + const key = token.slice(0, eq); + const value = stripQuotes(token.slice(eq + 1)); + if (key === "id") attrs.id = value; + attrs.values[key] = value; + } + } + + return attrs; +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function attribute(value) { + return escapeHtml(value).replaceAll("\n", " "); +} + +function encodedJson(value) { + return encodeURIComponent(JSON.stringify(value)); +} + +function firstClass(attrs, excluded) { + return attrs.classes.find((className) => !excluded.has(className)); +} + +function interactiveFromCode(node) { + const attrs = parseFenceInfo(node); + if (!attrs.classes.includes("interactive")) return null; + + const declaredId = attrs.id || attrs.values.component || attrs.values.name || ""; + const componentId = + attrs.values.component || + (declaredId.startsWith("int:") ? declaredId.slice("int:".length) : declaredId); + const label = attrs.values.label || (declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`); + const caption = attrs.values.caption || attrs.values.title || ""; + const description = node.value.trim(); + const fallback = attrs.values.pdf || attrs.values.fallback || description || caption; + + if (!componentId) { + return { + type: "html", + value: + '', + }; + } + + const props = { + id: componentId, + label, + caption, + description, + fallback, + attrs: attrs.values, + }; + + return { + type: "html", + value: [ + `
`, + `
`, + fallback ? `

${escapeHtml(fallback)}

` : "", + "
", + caption ? `
${escapeHtml(caption)}
` : "", + "
", + ].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: + '', + }; + } + + 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); +} diff --git a/sites/art/src/styles/article.css b/sites/art/src/styles/article.css index 798a3b9..e39264f 100644 --- a/sites/art/src/styles/article.css +++ b/sites/art/src/styles/article.css @@ -216,6 +216,48 @@ margin-bottom: 0.35rem; } +.article-content :where(.semantic-env) { + scroll-margin-top: 5rem; +} + +.article-content :where(.semantic-env-heading) { + margin: 0 0 0.75rem; + color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground))); + font-family: var(--font-mono); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + line-height: 1.3; + text-transform: uppercase; +} + +.article-content :where(.semantic-env-heading strong) { + font-weight: inherit; +} + +.article-content :where(.semantic-env-plain) { + margin-block: 1.6rem; + border-left: 3px solid hsl(var(--primary)); + padding: 0.15rem 0 0.15rem 1rem; +} + +.article-content :where(.semantic-env-plain > *:first-child) { + margin-top: 0; +} + +.article-content :where(.semantic-env-plain > *:last-child) { + margin-bottom: 0; +} + +.article-content :where(.semantic-error) { + margin-block: 1.5rem; + border: 1px solid color-mix(in srgb, hsl(var(--destructive)) 50%, hsl(var(--border))); + border-radius: 0.55rem; + background: color-mix(in srgb, hsl(var(--destructive)) 8%, transparent); + padding: 1rem; + color: hsl(var(--foreground)); +} + .article-content :where(.callout hr) { margin-block: 1rem; border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent); @@ -248,6 +290,33 @@ margin-inline: 0; } +.article-content :where(.semantic-interactive) { + margin-block: 2rem; + scroll-margin-top: 5rem; +} + +.article-content :where(.interactive-mount) { + min-height: 12rem; + border: 1px solid hsl(var(--border)); + border-radius: 0.65rem; + background: color-mix(in srgb, hsl(var(--accent)) 20%, transparent); + padding: 1rem; +} + +.article-content :where(.interactive-mount[data-interactive-state="missing-component"]) { + display: grid; + min-height: 8rem; + place-items: center; + color: hsl(var(--muted-foreground)); + font-size: 0.95rem; + text-align: center; +} + +.article-content :where(.interactive-fallback) { + margin: 0; + color: hsl(var(--muted-foreground)); +} + .article-content :where(figcaption) { color: hsl(var(--muted-foreground)); font-size: 0.92rem; diff --git a/sites/poetry/astro.config.mjs b/sites/poetry/astro.config.mjs index 17d8510..254caf9 100644 --- a/sites/poetry/astro.config.mjs +++ b/sites/poetry/astro.config.mjs @@ -12,6 +12,7 @@ import tailwindcss from "@tailwindcss/vite"; import remarkCallouts from "./src/remark/callouts.mjs"; import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs"; +import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs"; import remarkTypography from "./src/remark/typography.mjs"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; @@ -19,6 +20,7 @@ const remarkPlugins = [ remarkGfm, remarkInlineFootnotes, remarkCodeFenceLanguages, + remarkSemanticBlocks, remarkMath, remarkTypography, remarkCallouts, diff --git a/sites/poetry/src/components/InteractiveRuntime.astro b/sites/poetry/src/components/InteractiveRuntime.astro new file mode 100644 index 0000000..0ee92fc --- /dev/null +++ b/sites/poetry/src/components/InteractiveRuntime.astro @@ -0,0 +1,3 @@ + diff --git a/sites/poetry/src/interactives/runtime.tsx b/sites/poetry/src/interactives/runtime.tsx new file mode 100644 index 0000000..f6191e2 --- /dev/null +++ b/sites/poetry/src/interactives/runtime.tsx @@ -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; +}; + +type InteractiveModule = { + default: React.ComponentType; +}; + +const modules = import.meta.glob("./*/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(); + element.dataset.interactiveState = "ready"; + } catch (error) { + element.dataset.interactiveState = "failed"; + console.error(`Failed to mount interactive "${id}"`, error); + } +} + +function mountAllInteractives() { + document + .querySelectorAll(".interactive-mount[data-interactive-id]") + .forEach((element) => { + void mountInteractive(element); + }); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", mountAllInteractives, { once: true }); +} else { + mountAllInteractives(); +} diff --git a/sites/poetry/src/pages/[...slug].astro b/sites/poetry/src/pages/[...slug].astro index 60add23..32b2041 100644 --- a/sites/poetry/src/pages/[...slug].astro +++ b/sites/poetry/src/pages/[...slug].astro @@ -1,6 +1,7 @@ --- import { getCollection, render } from 'astro:content'; import CoralComments from '@/components/CoralComments.astro'; +import InteractiveRuntime from '@/components/InteractiveRuntime.astro'; import DefaultLayout from '@/layouts/DefaultLayout.astro'; import { BlogPost } from '@/components/sections/blog-post'; import { ACTIVE_BLOG_SITE } from '@/blog-sites.js'; @@ -28,5 +29,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString(); + diff --git a/sites/poetry/src/remark/semantic-blocks.mjs b/sites/poetry/src/remark/semantic-blocks.mjs new file mode 100644 index 0000000..fe86b96 --- /dev/null +++ b/sites/poetry/src/remark/semantic-blocks.mjs @@ -0,0 +1,232 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; + +const ENV_TYPES = new Set([ + "theorem", + "lemma", + "proposition", + "corollary", + "definition", + "example", + "remark", + "proof", +]); + +const ENV_LABELS = { + theorem: "Theorem", + lemma: "Lemma", + proposition: "Proposition", + corollary: "Corollary", + definition: "Definition", + example: "Example", + remark: "Remark", + proof: "Proof", +}; + +function tokenizeInfo(info) { + return ( + info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? [] + ); +} + +function stripQuotes(value) { + if (!value) return ""; + const first = value[0]; + const last = value[value.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return value.slice(1, -1); + } + return value; +} + +function parseFenceInfo(node) { + const lang = node.lang ?? ""; + const meta = node.meta ?? ""; + const raw = `${lang} ${meta}`.trim(); + const attrs = { + classes: [], + id: "", + values: {}, + raw, + }; + + if (!raw) return attrs; + + let body = raw; + if (body.startsWith("{")) { + body = body.slice(1); + if (body.endsWith("}")) body = body.slice(0, -1); + } else if (lang) { + attrs.classes.push(lang); + body = meta; + } + + for (let token of tokenizeInfo(body.trim())) { + if (token.endsWith("}") && !token.includes("=")) token = token.slice(0, -1); + if (!token) continue; + + if (token.startsWith(".")) { + attrs.classes.push(token.slice(1)); + continue; + } + + if (token.startsWith("#")) { + attrs.id = token.slice(1); + continue; + } + + const eq = token.indexOf("="); + if (eq > 0) { + const key = token.slice(0, eq); + const value = stripQuotes(token.slice(eq + 1)); + if (key === "id") attrs.id = value; + attrs.values[key] = value; + } + } + + return attrs; +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function attribute(value) { + return escapeHtml(value).replaceAll("\n", " "); +} + +function encodedJson(value) { + return encodeURIComponent(JSON.stringify(value)); +} + +function firstClass(attrs, excluded) { + return attrs.classes.find((className) => !excluded.has(className)); +} + +function interactiveFromCode(node) { + const attrs = parseFenceInfo(node); + if (!attrs.classes.includes("interactive")) return null; + + const declaredId = attrs.id || attrs.values.component || attrs.values.name || ""; + const componentId = + attrs.values.component || + (declaredId.startsWith("int:") ? declaredId.slice("int:".length) : declaredId); + const label = attrs.values.label || (declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`); + const caption = attrs.values.caption || attrs.values.title || ""; + const description = node.value.trim(); + const fallback = attrs.values.pdf || attrs.values.fallback || description || caption; + + if (!componentId) { + return { + type: "html", + value: + '', + }; + } + + const props = { + id: componentId, + label, + caption, + description, + fallback, + attrs: attrs.values, + }; + + return { + type: "html", + value: [ + `
`, + `
`, + fallback ? `

${escapeHtml(fallback)}

` : "", + "
", + caption ? `
${escapeHtml(caption)}
` : "", + "
", + ].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: + '', + }; + } + + 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); +} diff --git a/sites/poetry/src/styles/article.css b/sites/poetry/src/styles/article.css index 798a3b9..e39264f 100644 --- a/sites/poetry/src/styles/article.css +++ b/sites/poetry/src/styles/article.css @@ -216,6 +216,48 @@ margin-bottom: 0.35rem; } +.article-content :where(.semantic-env) { + scroll-margin-top: 5rem; +} + +.article-content :where(.semantic-env-heading) { + margin: 0 0 0.75rem; + color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground))); + font-family: var(--font-mono); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + line-height: 1.3; + text-transform: uppercase; +} + +.article-content :where(.semantic-env-heading strong) { + font-weight: inherit; +} + +.article-content :where(.semantic-env-plain) { + margin-block: 1.6rem; + border-left: 3px solid hsl(var(--primary)); + padding: 0.15rem 0 0.15rem 1rem; +} + +.article-content :where(.semantic-env-plain > *:first-child) { + margin-top: 0; +} + +.article-content :where(.semantic-env-plain > *:last-child) { + margin-bottom: 0; +} + +.article-content :where(.semantic-error) { + margin-block: 1.5rem; + border: 1px solid color-mix(in srgb, hsl(var(--destructive)) 50%, hsl(var(--border))); + border-radius: 0.55rem; + background: color-mix(in srgb, hsl(var(--destructive)) 8%, transparent); + padding: 1rem; + color: hsl(var(--foreground)); +} + .article-content :where(.callout hr) { margin-block: 1rem; border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent); @@ -248,6 +290,33 @@ margin-inline: 0; } +.article-content :where(.semantic-interactive) { + margin-block: 2rem; + scroll-margin-top: 5rem; +} + +.article-content :where(.interactive-mount) { + min-height: 12rem; + border: 1px solid hsl(var(--border)); + border-radius: 0.65rem; + background: color-mix(in srgb, hsl(var(--accent)) 20%, transparent); + padding: 1rem; +} + +.article-content :where(.interactive-mount[data-interactive-state="missing-component"]) { + display: grid; + min-height: 8rem; + place-items: center; + color: hsl(var(--muted-foreground)); + font-size: 0.95rem; + text-align: center; +} + +.article-content :where(.interactive-fallback) { + margin: 0; + color: hsl(var(--muted-foreground)); +} + .article-content :where(figcaption) { color: hsl(var(--muted-foreground)); font-size: 0.92rem; diff --git a/sites/puzzles/astro.config.mjs b/sites/puzzles/astro.config.mjs index 17d8510..254caf9 100644 --- a/sites/puzzles/astro.config.mjs +++ b/sites/puzzles/astro.config.mjs @@ -12,6 +12,7 @@ import tailwindcss from "@tailwindcss/vite"; import remarkCallouts from "./src/remark/callouts.mjs"; import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs"; +import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs"; import remarkTypography from "./src/remark/typography.mjs"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; @@ -19,6 +20,7 @@ const remarkPlugins = [ remarkGfm, remarkInlineFootnotes, remarkCodeFenceLanguages, + remarkSemanticBlocks, remarkMath, remarkTypography, remarkCallouts, diff --git a/sites/puzzles/src/components/InteractiveRuntime.astro b/sites/puzzles/src/components/InteractiveRuntime.astro new file mode 100644 index 0000000..0ee92fc --- /dev/null +++ b/sites/puzzles/src/components/InteractiveRuntime.astro @@ -0,0 +1,3 @@ + diff --git a/sites/puzzles/src/interactives/runtime.tsx b/sites/puzzles/src/interactives/runtime.tsx new file mode 100644 index 0000000..f6191e2 --- /dev/null +++ b/sites/puzzles/src/interactives/runtime.tsx @@ -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; +}; + +type InteractiveModule = { + default: React.ComponentType; +}; + +const modules = import.meta.glob("./*/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(); + element.dataset.interactiveState = "ready"; + } catch (error) { + element.dataset.interactiveState = "failed"; + console.error(`Failed to mount interactive "${id}"`, error); + } +} + +function mountAllInteractives() { + document + .querySelectorAll(".interactive-mount[data-interactive-id]") + .forEach((element) => { + void mountInteractive(element); + }); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", mountAllInteractives, { once: true }); +} else { + mountAllInteractives(); +} diff --git a/sites/puzzles/src/pages/[...slug].astro b/sites/puzzles/src/pages/[...slug].astro index 4b14673..2d2e6f2 100644 --- a/sites/puzzles/src/pages/[...slug].astro +++ b/sites/puzzles/src/pages/[...slug].astro @@ -1,6 +1,7 @@ --- import { getCollection, render } from 'astro:content'; import CoralComments from '@/components/CoralComments.astro'; +import InteractiveRuntime from '@/components/InteractiveRuntime.astro'; import DefaultLayout from '@/layouts/DefaultLayout.astro'; import { BlogPost } from '@/components/sections/blog-post'; import { ACTIVE_BLOG_SITE } from '@/blog-sites.js'; @@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString(); + diff --git a/sites/puzzles/src/remark/semantic-blocks.mjs b/sites/puzzles/src/remark/semantic-blocks.mjs new file mode 100644 index 0000000..fe86b96 --- /dev/null +++ b/sites/puzzles/src/remark/semantic-blocks.mjs @@ -0,0 +1,232 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; + +const ENV_TYPES = new Set([ + "theorem", + "lemma", + "proposition", + "corollary", + "definition", + "example", + "remark", + "proof", +]); + +const ENV_LABELS = { + theorem: "Theorem", + lemma: "Lemma", + proposition: "Proposition", + corollary: "Corollary", + definition: "Definition", + example: "Example", + remark: "Remark", + proof: "Proof", +}; + +function tokenizeInfo(info) { + return ( + info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? [] + ); +} + +function stripQuotes(value) { + if (!value) return ""; + const first = value[0]; + const last = value[value.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return value.slice(1, -1); + } + return value; +} + +function parseFenceInfo(node) { + const lang = node.lang ?? ""; + const meta = node.meta ?? ""; + const raw = `${lang} ${meta}`.trim(); + const attrs = { + classes: [], + id: "", + values: {}, + raw, + }; + + if (!raw) return attrs; + + let body = raw; + if (body.startsWith("{")) { + body = body.slice(1); + if (body.endsWith("}")) body = body.slice(0, -1); + } else if (lang) { + attrs.classes.push(lang); + body = meta; + } + + for (let token of tokenizeInfo(body.trim())) { + if (token.endsWith("}") && !token.includes("=")) token = token.slice(0, -1); + if (!token) continue; + + if (token.startsWith(".")) { + attrs.classes.push(token.slice(1)); + continue; + } + + if (token.startsWith("#")) { + attrs.id = token.slice(1); + continue; + } + + const eq = token.indexOf("="); + if (eq > 0) { + const key = token.slice(0, eq); + const value = stripQuotes(token.slice(eq + 1)); + if (key === "id") attrs.id = value; + attrs.values[key] = value; + } + } + + return attrs; +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function attribute(value) { + return escapeHtml(value).replaceAll("\n", " "); +} + +function encodedJson(value) { + return encodeURIComponent(JSON.stringify(value)); +} + +function firstClass(attrs, excluded) { + return attrs.classes.find((className) => !excluded.has(className)); +} + +function interactiveFromCode(node) { + const attrs = parseFenceInfo(node); + if (!attrs.classes.includes("interactive")) return null; + + const declaredId = attrs.id || attrs.values.component || attrs.values.name || ""; + const componentId = + attrs.values.component || + (declaredId.startsWith("int:") ? declaredId.slice("int:".length) : declaredId); + const label = attrs.values.label || (declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`); + const caption = attrs.values.caption || attrs.values.title || ""; + const description = node.value.trim(); + const fallback = attrs.values.pdf || attrs.values.fallback || description || caption; + + if (!componentId) { + return { + type: "html", + value: + '', + }; + } + + const props = { + id: componentId, + label, + caption, + description, + fallback, + attrs: attrs.values, + }; + + return { + type: "html", + value: [ + `
`, + `
`, + fallback ? `

${escapeHtml(fallback)}

` : "", + "
", + caption ? `
${escapeHtml(caption)}
` : "", + "
", + ].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: + '', + }; + } + + 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); +} diff --git a/sites/puzzles/src/styles/article.css b/sites/puzzles/src/styles/article.css index 798a3b9..e39264f 100644 --- a/sites/puzzles/src/styles/article.css +++ b/sites/puzzles/src/styles/article.css @@ -216,6 +216,48 @@ margin-bottom: 0.35rem; } +.article-content :where(.semantic-env) { + scroll-margin-top: 5rem; +} + +.article-content :where(.semantic-env-heading) { + margin: 0 0 0.75rem; + color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground))); + font-family: var(--font-mono); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + line-height: 1.3; + text-transform: uppercase; +} + +.article-content :where(.semantic-env-heading strong) { + font-weight: inherit; +} + +.article-content :where(.semantic-env-plain) { + margin-block: 1.6rem; + border-left: 3px solid hsl(var(--primary)); + padding: 0.15rem 0 0.15rem 1rem; +} + +.article-content :where(.semantic-env-plain > *:first-child) { + margin-top: 0; +} + +.article-content :where(.semantic-env-plain > *:last-child) { + margin-bottom: 0; +} + +.article-content :where(.semantic-error) { + margin-block: 1.5rem; + border: 1px solid color-mix(in srgb, hsl(var(--destructive)) 50%, hsl(var(--border))); + border-radius: 0.55rem; + background: color-mix(in srgb, hsl(var(--destructive)) 8%, transparent); + padding: 1rem; + color: hsl(var(--foreground)); +} + .article-content :where(.callout hr) { margin-block: 1rem; border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent); @@ -248,6 +290,33 @@ margin-inline: 0; } +.article-content :where(.semantic-interactive) { + margin-block: 2rem; + scroll-margin-top: 5rem; +} + +.article-content :where(.interactive-mount) { + min-height: 12rem; + border: 1px solid hsl(var(--border)); + border-radius: 0.65rem; + background: color-mix(in srgb, hsl(var(--accent)) 20%, transparent); + padding: 1rem; +} + +.article-content :where(.interactive-mount[data-interactive-state="missing-component"]) { + display: grid; + min-height: 8rem; + place-items: center; + color: hsl(var(--muted-foreground)); + font-size: 0.95rem; + text-align: center; +} + +.article-content :where(.interactive-fallback) { + margin: 0; + color: hsl(var(--muted-foreground)); +} + .article-content :where(figcaption) { color: hsl(var(--muted-foreground)); font-size: 0.92rem; diff --git a/sites/reflections/astro.config.mjs b/sites/reflections/astro.config.mjs index 17d8510..254caf9 100644 --- a/sites/reflections/astro.config.mjs +++ b/sites/reflections/astro.config.mjs @@ -12,6 +12,7 @@ import tailwindcss from "@tailwindcss/vite"; import remarkCallouts from "./src/remark/callouts.mjs"; import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs"; +import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs"; import remarkTypography from "./src/remark/typography.mjs"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; @@ -19,6 +20,7 @@ const remarkPlugins = [ remarkGfm, remarkInlineFootnotes, remarkCodeFenceLanguages, + remarkSemanticBlocks, remarkMath, remarkTypography, remarkCallouts, diff --git a/sites/reflections/src/components/InteractiveRuntime.astro b/sites/reflections/src/components/InteractiveRuntime.astro new file mode 100644 index 0000000..0ee92fc --- /dev/null +++ b/sites/reflections/src/components/InteractiveRuntime.astro @@ -0,0 +1,3 @@ + diff --git a/sites/reflections/src/interactives/runtime.tsx b/sites/reflections/src/interactives/runtime.tsx new file mode 100644 index 0000000..f6191e2 --- /dev/null +++ b/sites/reflections/src/interactives/runtime.tsx @@ -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; +}; + +type InteractiveModule = { + default: React.ComponentType; +}; + +const modules = import.meta.glob("./*/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(); + element.dataset.interactiveState = "ready"; + } catch (error) { + element.dataset.interactiveState = "failed"; + console.error(`Failed to mount interactive "${id}"`, error); + } +} + +function mountAllInteractives() { + document + .querySelectorAll(".interactive-mount[data-interactive-id]") + .forEach((element) => { + void mountInteractive(element); + }); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", mountAllInteractives, { once: true }); +} else { + mountAllInteractives(); +} diff --git a/sites/reflections/src/pages/[...slug].astro b/sites/reflections/src/pages/[...slug].astro index 4b14673..2d2e6f2 100644 --- a/sites/reflections/src/pages/[...slug].astro +++ b/sites/reflections/src/pages/[...slug].astro @@ -1,6 +1,7 @@ --- import { getCollection, render } from 'astro:content'; import CoralComments from '@/components/CoralComments.astro'; +import InteractiveRuntime from '@/components/InteractiveRuntime.astro'; import DefaultLayout from '@/layouts/DefaultLayout.astro'; import { BlogPost } from '@/components/sections/blog-post'; import { ACTIVE_BLOG_SITE } from '@/blog-sites.js'; @@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString(); + diff --git a/sites/reflections/src/remark/semantic-blocks.mjs b/sites/reflections/src/remark/semantic-blocks.mjs new file mode 100644 index 0000000..fe86b96 --- /dev/null +++ b/sites/reflections/src/remark/semantic-blocks.mjs @@ -0,0 +1,232 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; + +const ENV_TYPES = new Set([ + "theorem", + "lemma", + "proposition", + "corollary", + "definition", + "example", + "remark", + "proof", +]); + +const ENV_LABELS = { + theorem: "Theorem", + lemma: "Lemma", + proposition: "Proposition", + corollary: "Corollary", + definition: "Definition", + example: "Example", + remark: "Remark", + proof: "Proof", +}; + +function tokenizeInfo(info) { + return ( + info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? [] + ); +} + +function stripQuotes(value) { + if (!value) return ""; + const first = value[0]; + const last = value[value.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return value.slice(1, -1); + } + return value; +} + +function parseFenceInfo(node) { + const lang = node.lang ?? ""; + const meta = node.meta ?? ""; + const raw = `${lang} ${meta}`.trim(); + const attrs = { + classes: [], + id: "", + values: {}, + raw, + }; + + if (!raw) return attrs; + + let body = raw; + if (body.startsWith("{")) { + body = body.slice(1); + if (body.endsWith("}")) body = body.slice(0, -1); + } else if (lang) { + attrs.classes.push(lang); + body = meta; + } + + for (let token of tokenizeInfo(body.trim())) { + if (token.endsWith("}") && !token.includes("=")) token = token.slice(0, -1); + if (!token) continue; + + if (token.startsWith(".")) { + attrs.classes.push(token.slice(1)); + continue; + } + + if (token.startsWith("#")) { + attrs.id = token.slice(1); + continue; + } + + const eq = token.indexOf("="); + if (eq > 0) { + const key = token.slice(0, eq); + const value = stripQuotes(token.slice(eq + 1)); + if (key === "id") attrs.id = value; + attrs.values[key] = value; + } + } + + return attrs; +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function attribute(value) { + return escapeHtml(value).replaceAll("\n", " "); +} + +function encodedJson(value) { + return encodeURIComponent(JSON.stringify(value)); +} + +function firstClass(attrs, excluded) { + return attrs.classes.find((className) => !excluded.has(className)); +} + +function interactiveFromCode(node) { + const attrs = parseFenceInfo(node); + if (!attrs.classes.includes("interactive")) return null; + + const declaredId = attrs.id || attrs.values.component || attrs.values.name || ""; + const componentId = + attrs.values.component || + (declaredId.startsWith("int:") ? declaredId.slice("int:".length) : declaredId); + const label = attrs.values.label || (declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`); + const caption = attrs.values.caption || attrs.values.title || ""; + const description = node.value.trim(); + const fallback = attrs.values.pdf || attrs.values.fallback || description || caption; + + if (!componentId) { + return { + type: "html", + value: + '', + }; + } + + const props = { + id: componentId, + label, + caption, + description, + fallback, + attrs: attrs.values, + }; + + return { + type: "html", + value: [ + `
`, + `
`, + fallback ? `

${escapeHtml(fallback)}

` : "", + "
", + caption ? `
${escapeHtml(caption)}
` : "", + "
", + ].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: + '', + }; + } + + 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); +} diff --git a/sites/reflections/src/styles/article.css b/sites/reflections/src/styles/article.css index 798a3b9..e39264f 100644 --- a/sites/reflections/src/styles/article.css +++ b/sites/reflections/src/styles/article.css @@ -216,6 +216,48 @@ margin-bottom: 0.35rem; } +.article-content :where(.semantic-env) { + scroll-margin-top: 5rem; +} + +.article-content :where(.semantic-env-heading) { + margin: 0 0 0.75rem; + color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground))); + font-family: var(--font-mono); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + line-height: 1.3; + text-transform: uppercase; +} + +.article-content :where(.semantic-env-heading strong) { + font-weight: inherit; +} + +.article-content :where(.semantic-env-plain) { + margin-block: 1.6rem; + border-left: 3px solid hsl(var(--primary)); + padding: 0.15rem 0 0.15rem 1rem; +} + +.article-content :where(.semantic-env-plain > *:first-child) { + margin-top: 0; +} + +.article-content :where(.semantic-env-plain > *:last-child) { + margin-bottom: 0; +} + +.article-content :where(.semantic-error) { + margin-block: 1.5rem; + border: 1px solid color-mix(in srgb, hsl(var(--destructive)) 50%, hsl(var(--border))); + border-radius: 0.55rem; + background: color-mix(in srgb, hsl(var(--destructive)) 8%, transparent); + padding: 1rem; + color: hsl(var(--foreground)); +} + .article-content :where(.callout hr) { margin-block: 1rem; border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent); @@ -248,6 +290,33 @@ margin-inline: 0; } +.article-content :where(.semantic-interactive) { + margin-block: 2rem; + scroll-margin-top: 5rem; +} + +.article-content :where(.interactive-mount) { + min-height: 12rem; + border: 1px solid hsl(var(--border)); + border-radius: 0.65rem; + background: color-mix(in srgb, hsl(var(--accent)) 20%, transparent); + padding: 1rem; +} + +.article-content :where(.interactive-mount[data-interactive-state="missing-component"]) { + display: grid; + min-height: 8rem; + place-items: center; + color: hsl(var(--muted-foreground)); + font-size: 0.95rem; + text-align: center; +} + +.article-content :where(.interactive-fallback) { + margin: 0; + color: hsl(var(--muted-foreground)); +} + .article-content :where(figcaption) { color: hsl(var(--muted-foreground)); font-size: 0.92rem; diff --git a/sites/research/astro.config.mjs b/sites/research/astro.config.mjs index 17d8510..254caf9 100644 --- a/sites/research/astro.config.mjs +++ b/sites/research/astro.config.mjs @@ -12,6 +12,7 @@ import tailwindcss from "@tailwindcss/vite"; import remarkCallouts from "./src/remark/callouts.mjs"; import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs"; +import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs"; import remarkTypography from "./src/remark/typography.mjs"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; @@ -19,6 +20,7 @@ const remarkPlugins = [ remarkGfm, remarkInlineFootnotes, remarkCodeFenceLanguages, + remarkSemanticBlocks, remarkMath, remarkTypography, remarkCallouts, diff --git a/sites/research/src/components/InteractiveRuntime.astro b/sites/research/src/components/InteractiveRuntime.astro new file mode 100644 index 0000000..0ee92fc --- /dev/null +++ b/sites/research/src/components/InteractiveRuntime.astro @@ -0,0 +1,3 @@ + diff --git a/sites/research/src/interactives/runtime.tsx b/sites/research/src/interactives/runtime.tsx new file mode 100644 index 0000000..f6191e2 --- /dev/null +++ b/sites/research/src/interactives/runtime.tsx @@ -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; +}; + +type InteractiveModule = { + default: React.ComponentType; +}; + +const modules = import.meta.glob("./*/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(); + element.dataset.interactiveState = "ready"; + } catch (error) { + element.dataset.interactiveState = "failed"; + console.error(`Failed to mount interactive "${id}"`, error); + } +} + +function mountAllInteractives() { + document + .querySelectorAll(".interactive-mount[data-interactive-id]") + .forEach((element) => { + void mountInteractive(element); + }); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", mountAllInteractives, { once: true }); +} else { + mountAllInteractives(); +} diff --git a/sites/research/src/pages/[...slug].astro b/sites/research/src/pages/[...slug].astro index 4b14673..2d2e6f2 100644 --- a/sites/research/src/pages/[...slug].astro +++ b/sites/research/src/pages/[...slug].astro @@ -1,6 +1,7 @@ --- import { getCollection, render } from 'astro:content'; import CoralComments from '@/components/CoralComments.astro'; +import InteractiveRuntime from '@/components/InteractiveRuntime.astro'; import DefaultLayout from '@/layouts/DefaultLayout.astro'; import { BlogPost } from '@/components/sections/blog-post'; import { ACTIVE_BLOG_SITE } from '@/blog-sites.js'; @@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString(); + diff --git a/sites/research/src/remark/semantic-blocks.mjs b/sites/research/src/remark/semantic-blocks.mjs new file mode 100644 index 0000000..fe86b96 --- /dev/null +++ b/sites/research/src/remark/semantic-blocks.mjs @@ -0,0 +1,232 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; + +const ENV_TYPES = new Set([ + "theorem", + "lemma", + "proposition", + "corollary", + "definition", + "example", + "remark", + "proof", +]); + +const ENV_LABELS = { + theorem: "Theorem", + lemma: "Lemma", + proposition: "Proposition", + corollary: "Corollary", + definition: "Definition", + example: "Example", + remark: "Remark", + proof: "Proof", +}; + +function tokenizeInfo(info) { + return ( + info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? [] + ); +} + +function stripQuotes(value) { + if (!value) return ""; + const first = value[0]; + const last = value[value.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return value.slice(1, -1); + } + return value; +} + +function parseFenceInfo(node) { + const lang = node.lang ?? ""; + const meta = node.meta ?? ""; + const raw = `${lang} ${meta}`.trim(); + const attrs = { + classes: [], + id: "", + values: {}, + raw, + }; + + if (!raw) return attrs; + + let body = raw; + if (body.startsWith("{")) { + body = body.slice(1); + if (body.endsWith("}")) body = body.slice(0, -1); + } else if (lang) { + attrs.classes.push(lang); + body = meta; + } + + for (let token of tokenizeInfo(body.trim())) { + if (token.endsWith("}") && !token.includes("=")) token = token.slice(0, -1); + if (!token) continue; + + if (token.startsWith(".")) { + attrs.classes.push(token.slice(1)); + continue; + } + + if (token.startsWith("#")) { + attrs.id = token.slice(1); + continue; + } + + const eq = token.indexOf("="); + if (eq > 0) { + const key = token.slice(0, eq); + const value = stripQuotes(token.slice(eq + 1)); + if (key === "id") attrs.id = value; + attrs.values[key] = value; + } + } + + return attrs; +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function attribute(value) { + return escapeHtml(value).replaceAll("\n", " "); +} + +function encodedJson(value) { + return encodeURIComponent(JSON.stringify(value)); +} + +function firstClass(attrs, excluded) { + return attrs.classes.find((className) => !excluded.has(className)); +} + +function interactiveFromCode(node) { + const attrs = parseFenceInfo(node); + if (!attrs.classes.includes("interactive")) return null; + + const declaredId = attrs.id || attrs.values.component || attrs.values.name || ""; + const componentId = + attrs.values.component || + (declaredId.startsWith("int:") ? declaredId.slice("int:".length) : declaredId); + const label = attrs.values.label || (declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`); + const caption = attrs.values.caption || attrs.values.title || ""; + const description = node.value.trim(); + const fallback = attrs.values.pdf || attrs.values.fallback || description || caption; + + if (!componentId) { + return { + type: "html", + value: + '', + }; + } + + const props = { + id: componentId, + label, + caption, + description, + fallback, + attrs: attrs.values, + }; + + return { + type: "html", + value: [ + `
`, + `
`, + fallback ? `

${escapeHtml(fallback)}

` : "", + "
", + caption ? `
${escapeHtml(caption)}
` : "", + "
", + ].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: + '', + }; + } + + 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); +} diff --git a/sites/research/src/styles/article.css b/sites/research/src/styles/article.css index 798a3b9..e39264f 100644 --- a/sites/research/src/styles/article.css +++ b/sites/research/src/styles/article.css @@ -216,6 +216,48 @@ margin-bottom: 0.35rem; } +.article-content :where(.semantic-env) { + scroll-margin-top: 5rem; +} + +.article-content :where(.semantic-env-heading) { + margin: 0 0 0.75rem; + color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground))); + font-family: var(--font-mono); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + line-height: 1.3; + text-transform: uppercase; +} + +.article-content :where(.semantic-env-heading strong) { + font-weight: inherit; +} + +.article-content :where(.semantic-env-plain) { + margin-block: 1.6rem; + border-left: 3px solid hsl(var(--primary)); + padding: 0.15rem 0 0.15rem 1rem; +} + +.article-content :where(.semantic-env-plain > *:first-child) { + margin-top: 0; +} + +.article-content :where(.semantic-env-plain > *:last-child) { + margin-bottom: 0; +} + +.article-content :where(.semantic-error) { + margin-block: 1.5rem; + border: 1px solid color-mix(in srgb, hsl(var(--destructive)) 50%, hsl(var(--border))); + border-radius: 0.55rem; + background: color-mix(in srgb, hsl(var(--destructive)) 8%, transparent); + padding: 1rem; + color: hsl(var(--foreground)); +} + .article-content :where(.callout hr) { margin-block: 1rem; border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent); @@ -248,6 +290,33 @@ margin-inline: 0; } +.article-content :where(.semantic-interactive) { + margin-block: 2rem; + scroll-margin-top: 5rem; +} + +.article-content :where(.interactive-mount) { + min-height: 12rem; + border: 1px solid hsl(var(--border)); + border-radius: 0.65rem; + background: color-mix(in srgb, hsl(var(--accent)) 20%, transparent); + padding: 1rem; +} + +.article-content :where(.interactive-mount[data-interactive-state="missing-component"]) { + display: grid; + min-height: 8rem; + place-items: center; + color: hsl(var(--muted-foreground)); + font-size: 0.95rem; + text-align: center; +} + +.article-content :where(.interactive-fallback) { + margin: 0; + color: hsl(var(--muted-foreground)); +} + .article-content :where(figcaption) { color: hsl(var(--muted-foreground)); font-size: 0.92rem; diff --git a/sites/reviews/astro.config.mjs b/sites/reviews/astro.config.mjs index 17d8510..254caf9 100644 --- a/sites/reviews/astro.config.mjs +++ b/sites/reviews/astro.config.mjs @@ -12,6 +12,7 @@ import tailwindcss from "@tailwindcss/vite"; import remarkCallouts from "./src/remark/callouts.mjs"; import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs"; +import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs"; import remarkTypography from "./src/remark/typography.mjs"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; @@ -19,6 +20,7 @@ const remarkPlugins = [ remarkGfm, remarkInlineFootnotes, remarkCodeFenceLanguages, + remarkSemanticBlocks, remarkMath, remarkTypography, remarkCallouts, diff --git a/sites/reviews/src/components/InteractiveRuntime.astro b/sites/reviews/src/components/InteractiveRuntime.astro new file mode 100644 index 0000000..0ee92fc --- /dev/null +++ b/sites/reviews/src/components/InteractiveRuntime.astro @@ -0,0 +1,3 @@ + diff --git a/sites/reviews/src/interactives/runtime.tsx b/sites/reviews/src/interactives/runtime.tsx new file mode 100644 index 0000000..f6191e2 --- /dev/null +++ b/sites/reviews/src/interactives/runtime.tsx @@ -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; +}; + +type InteractiveModule = { + default: React.ComponentType; +}; + +const modules = import.meta.glob("./*/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(); + element.dataset.interactiveState = "ready"; + } catch (error) { + element.dataset.interactiveState = "failed"; + console.error(`Failed to mount interactive "${id}"`, error); + } +} + +function mountAllInteractives() { + document + .querySelectorAll(".interactive-mount[data-interactive-id]") + .forEach((element) => { + void mountInteractive(element); + }); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", mountAllInteractives, { once: true }); +} else { + mountAllInteractives(); +} diff --git a/sites/reviews/src/pages/[...slug].astro b/sites/reviews/src/pages/[...slug].astro index 4b14673..2d2e6f2 100644 --- a/sites/reviews/src/pages/[...slug].astro +++ b/sites/reviews/src/pages/[...slug].astro @@ -1,6 +1,7 @@ --- import { getCollection, render } from 'astro:content'; import CoralComments from '@/components/CoralComments.astro'; +import InteractiveRuntime from '@/components/InteractiveRuntime.astro'; import DefaultLayout from '@/layouts/DefaultLayout.astro'; import { BlogPost } from '@/components/sections/blog-post'; import { ACTIVE_BLOG_SITE } from '@/blog-sites.js'; @@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString(); + diff --git a/sites/reviews/src/remark/semantic-blocks.mjs b/sites/reviews/src/remark/semantic-blocks.mjs new file mode 100644 index 0000000..fe86b96 --- /dev/null +++ b/sites/reviews/src/remark/semantic-blocks.mjs @@ -0,0 +1,232 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; + +const ENV_TYPES = new Set([ + "theorem", + "lemma", + "proposition", + "corollary", + "definition", + "example", + "remark", + "proof", +]); + +const ENV_LABELS = { + theorem: "Theorem", + lemma: "Lemma", + proposition: "Proposition", + corollary: "Corollary", + definition: "Definition", + example: "Example", + remark: "Remark", + proof: "Proof", +}; + +function tokenizeInfo(info) { + return ( + info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? [] + ); +} + +function stripQuotes(value) { + if (!value) return ""; + const first = value[0]; + const last = value[value.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return value.slice(1, -1); + } + return value; +} + +function parseFenceInfo(node) { + const lang = node.lang ?? ""; + const meta = node.meta ?? ""; + const raw = `${lang} ${meta}`.trim(); + const attrs = { + classes: [], + id: "", + values: {}, + raw, + }; + + if (!raw) return attrs; + + let body = raw; + if (body.startsWith("{")) { + body = body.slice(1); + if (body.endsWith("}")) body = body.slice(0, -1); + } else if (lang) { + attrs.classes.push(lang); + body = meta; + } + + for (let token of tokenizeInfo(body.trim())) { + if (token.endsWith("}") && !token.includes("=")) token = token.slice(0, -1); + if (!token) continue; + + if (token.startsWith(".")) { + attrs.classes.push(token.slice(1)); + continue; + } + + if (token.startsWith("#")) { + attrs.id = token.slice(1); + continue; + } + + const eq = token.indexOf("="); + if (eq > 0) { + const key = token.slice(0, eq); + const value = stripQuotes(token.slice(eq + 1)); + if (key === "id") attrs.id = value; + attrs.values[key] = value; + } + } + + return attrs; +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function attribute(value) { + return escapeHtml(value).replaceAll("\n", " "); +} + +function encodedJson(value) { + return encodeURIComponent(JSON.stringify(value)); +} + +function firstClass(attrs, excluded) { + return attrs.classes.find((className) => !excluded.has(className)); +} + +function interactiveFromCode(node) { + const attrs = parseFenceInfo(node); + if (!attrs.classes.includes("interactive")) return null; + + const declaredId = attrs.id || attrs.values.component || attrs.values.name || ""; + const componentId = + attrs.values.component || + (declaredId.startsWith("int:") ? declaredId.slice("int:".length) : declaredId); + const label = attrs.values.label || (declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`); + const caption = attrs.values.caption || attrs.values.title || ""; + const description = node.value.trim(); + const fallback = attrs.values.pdf || attrs.values.fallback || description || caption; + + if (!componentId) { + return { + type: "html", + value: + '', + }; + } + + const props = { + id: componentId, + label, + caption, + description, + fallback, + attrs: attrs.values, + }; + + return { + type: "html", + value: [ + `
`, + `
`, + fallback ? `

${escapeHtml(fallback)}

` : "", + "
", + caption ? `
${escapeHtml(caption)}
` : "", + "
", + ].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: + '', + }; + } + + 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); +} diff --git a/sites/reviews/src/styles/article.css b/sites/reviews/src/styles/article.css index 798a3b9..e39264f 100644 --- a/sites/reviews/src/styles/article.css +++ b/sites/reviews/src/styles/article.css @@ -216,6 +216,48 @@ margin-bottom: 0.35rem; } +.article-content :where(.semantic-env) { + scroll-margin-top: 5rem; +} + +.article-content :where(.semantic-env-heading) { + margin: 0 0 0.75rem; + color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground))); + font-family: var(--font-mono); + font-size: 0.78rem; + font-weight: 700; + letter-spacing: 0.08em; + line-height: 1.3; + text-transform: uppercase; +} + +.article-content :where(.semantic-env-heading strong) { + font-weight: inherit; +} + +.article-content :where(.semantic-env-plain) { + margin-block: 1.6rem; + border-left: 3px solid hsl(var(--primary)); + padding: 0.15rem 0 0.15rem 1rem; +} + +.article-content :where(.semantic-env-plain > *:first-child) { + margin-top: 0; +} + +.article-content :where(.semantic-env-plain > *:last-child) { + margin-bottom: 0; +} + +.article-content :where(.semantic-error) { + margin-block: 1.5rem; + border: 1px solid color-mix(in srgb, hsl(var(--destructive)) 50%, hsl(var(--border))); + border-radius: 0.55rem; + background: color-mix(in srgb, hsl(var(--destructive)) 8%, transparent); + padding: 1rem; + color: hsl(var(--foreground)); +} + .article-content :where(.callout hr) { margin-block: 1rem; border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent); @@ -248,6 +290,33 @@ margin-inline: 0; } +.article-content :where(.semantic-interactive) { + margin-block: 2rem; + scroll-margin-top: 5rem; +} + +.article-content :where(.interactive-mount) { + min-height: 12rem; + border: 1px solid hsl(var(--border)); + border-radius: 0.65rem; + background: color-mix(in srgb, hsl(var(--accent)) 20%, transparent); + padding: 1rem; +} + +.article-content :where(.interactive-mount[data-interactive-state="missing-component"]) { + display: grid; + min-height: 8rem; + place-items: center; + color: hsl(var(--muted-foreground)); + font-size: 0.95rem; + text-align: center; +} + +.article-content :where(.interactive-fallback) { + margin: 0; + color: hsl(var(--muted-foreground)); +} + .article-content :where(figcaption) { color: hsl(var(--muted-foreground)); font-size: 0.92rem; diff --git a/sites/vibes/astro.config.mjs b/sites/vibes/astro.config.mjs index 17d8510..254caf9 100644 --- a/sites/vibes/astro.config.mjs +++ b/sites/vibes/astro.config.mjs @@ -12,6 +12,7 @@ import tailwindcss from "@tailwindcss/vite"; import remarkCallouts from "./src/remark/callouts.mjs"; import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs"; import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs"; +import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs"; import remarkTypography from "./src/remark/typography.mjs"; import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js"; @@ -19,6 +20,7 @@ const remarkPlugins = [ remarkGfm, remarkInlineFootnotes, remarkCodeFenceLanguages, + remarkSemanticBlocks, remarkMath, remarkTypography, remarkCallouts, diff --git a/sites/vibes/src/components/InteractiveRuntime.astro b/sites/vibes/src/components/InteractiveRuntime.astro new file mode 100644 index 0000000..0ee92fc --- /dev/null +++ b/sites/vibes/src/components/InteractiveRuntime.astro @@ -0,0 +1,3 @@ + diff --git a/sites/vibes/src/interactives/runtime.tsx b/sites/vibes/src/interactives/runtime.tsx new file mode 100644 index 0000000..f6191e2 --- /dev/null +++ b/sites/vibes/src/interactives/runtime.tsx @@ -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; +}; + +type InteractiveModule = { + default: React.ComponentType; +}; + +const modules = import.meta.glob("./*/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(); + element.dataset.interactiveState = "ready"; + } catch (error) { + element.dataset.interactiveState = "failed"; + console.error(`Failed to mount interactive "${id}"`, error); + } +} + +function mountAllInteractives() { + document + .querySelectorAll(".interactive-mount[data-interactive-id]") + .forEach((element) => { + void mountInteractive(element); + }); +} + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", mountAllInteractives, { once: true }); +} else { + mountAllInteractives(); +} diff --git a/sites/vibes/src/pages/[...slug].astro b/sites/vibes/src/pages/[...slug].astro index 4b14673..2d2e6f2 100644 --- a/sites/vibes/src/pages/[...slug].astro +++ b/sites/vibes/src/pages/[...slug].astro @@ -1,6 +1,7 @@ --- import { getCollection, render } from 'astro:content'; import CoralComments from '@/components/CoralComments.astro'; +import InteractiveRuntime from '@/components/InteractiveRuntime.astro'; import DefaultLayout from '@/layouts/DefaultLayout.astro'; import { BlogPost } from '@/components/sections/blog-post'; import { ACTIVE_BLOG_SITE } from '@/blog-sites.js'; @@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString(); + diff --git a/sites/vibes/src/remark/semantic-blocks.mjs b/sites/vibes/src/remark/semantic-blocks.mjs new file mode 100644 index 0000000..fe86b96 --- /dev/null +++ b/sites/vibes/src/remark/semantic-blocks.mjs @@ -0,0 +1,232 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; + +const ENV_TYPES = new Set([ + "theorem", + "lemma", + "proposition", + "corollary", + "definition", + "example", + "remark", + "proof", +]); + +const ENV_LABELS = { + theorem: "Theorem", + lemma: "Lemma", + proposition: "Proposition", + corollary: "Corollary", + definition: "Definition", + example: "Example", + remark: "Remark", + proof: "Proof", +}; + +function tokenizeInfo(info) { + return ( + info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? [] + ); +} + +function stripQuotes(value) { + if (!value) return ""; + const first = value[0]; + const last = value[value.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return value.slice(1, -1); + } + return value; +} + +function parseFenceInfo(node) { + const lang = node.lang ?? ""; + const meta = node.meta ?? ""; + const raw = `${lang} ${meta}`.trim(); + const attrs = { + classes: [], + id: "", + values: {}, + raw, + }; + + if (!raw) return attrs; + + let body = raw; + if (body.startsWith("{")) { + body = body.slice(1); + if (body.endsWith("}")) body = body.slice(0, -1); + } else if (lang) { + attrs.classes.push(lang); + body = meta; + } + + for (let token of tokenizeInfo(body.trim())) { + if (token.endsWith("}") && !token.includes("=")) token = token.slice(0, -1); + if (!token) continue; + + if (token.startsWith(".")) { + attrs.classes.push(token.slice(1)); + continue; + } + + if (token.startsWith("#")) { + attrs.id = token.slice(1); + continue; + } + + const eq = token.indexOf("="); + if (eq > 0) { + const key = token.slice(0, eq); + const value = stripQuotes(token.slice(eq + 1)); + if (key === "id") attrs.id = value; + attrs.values[key] = value; + } + } + + return attrs; +} + +function escapeHtml(value) { + return String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function attribute(value) { + return escapeHtml(value).replaceAll("\n", " "); +} + +function encodedJson(value) { + return encodeURIComponent(JSON.stringify(value)); +} + +function firstClass(attrs, excluded) { + return attrs.classes.find((className) => !excluded.has(className)); +} + +function interactiveFromCode(node) { + const attrs = parseFenceInfo(node); + if (!attrs.classes.includes("interactive")) return null; + + const declaredId = attrs.id || attrs.values.component || attrs.values.name || ""; + const componentId = + attrs.values.component || + (declaredId.startsWith("int:") ? declaredId.slice("int:".length) : declaredId); + const label = attrs.values.label || (declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`); + const caption = attrs.values.caption || attrs.values.title || ""; + const description = node.value.trim(); + const fallback = attrs.values.pdf || attrs.values.fallback || description || caption; + + if (!componentId) { + return { + type: "html", + value: + '', + }; + } + + const props = { + id: componentId, + label, + caption, + description, + fallback, + attrs: attrs.values, + }; + + return { + type: "html", + value: [ + `
`, + `
`, + fallback ? `

${escapeHtml(fallback)}

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