Add semantic block parser and PDF export
Some checks are pending
Build / build (push) Waiting to run
Some checks are pending
Build / build (push) Waiting to run
This commit is contained in:
parent
632543db4d
commit
58310e1944
47 changed files with 3078 additions and 0 deletions
|
|
@ -114,6 +114,129 @@ Quarto margin notes such as `[text]{.aside}` were converted to:
|
|||
> **Aside:** text
|
||||
```
|
||||
|
||||
## Semantic Blocks
|
||||
|
||||
Use Pandoc-style fenced attributes for blocks that need to render differently
|
||||
in HTML and PDF. This is the preferred form because it works cleanly with both
|
||||
Astro and Pandoc/LuaLaTeX.
|
||||
|
||||
### Interactive Blocks
|
||||
|
||||
An interactive block reserves a place in the post for a React component and
|
||||
also supplies fallback text for PDF or non-JavaScript rendering:
|
||||
|
||||
````markdown
|
||||
```{.interactive #int:tape-layout caption="Tape layout explorer"}
|
||||
Drag files along a tape and compare the access cost of each arrangement.
|
||||
```
|
||||
````
|
||||
|
||||
The id after `#` is the semantic label. For interactives, use the `int:` prefix.
|
||||
The component id is inferred by removing that prefix, so the example above
|
||||
mounts this component when it exists:
|
||||
|
||||
```text
|
||||
sites/<blog>/src/interactives/tape-layout/index.tsx
|
||||
```
|
||||
|
||||
The component receives:
|
||||
|
||||
```ts
|
||||
{
|
||||
id: "tape-layout",
|
||||
label: "int:tape-layout",
|
||||
caption: "Tape layout explorer",
|
||||
description: "Drag files along a tape...",
|
||||
fallback: "Drag files along a tape...",
|
||||
attrs: { caption: "Tape layout explorer" }
|
||||
}
|
||||
```
|
||||
|
||||
If the component is not present, Astro renders the fallback text in the
|
||||
interactive frame. For a different PDF fallback, add `pdf="..."`:
|
||||
|
||||
````markdown
|
||||
```{.interactive #int:tape-layout caption="Tape layout explorer" pdf="The HTML version contains an interactive tape layout explorer."}
|
||||
Implementation notes for the interactive can go here.
|
||||
```
|
||||
````
|
||||
|
||||
Astro also accepts the shorter Obsidian-friendly form:
|
||||
|
||||
````markdown
|
||||
```interactive id="tape-layout" caption="Tape layout explorer"
|
||||
Drag files along a tape and compare the access cost.
|
||||
```
|
||||
````
|
||||
|
||||
Use the Pandoc-style form for posts that should export to PDF.
|
||||
|
||||
### LaTeX-Style Environments
|
||||
|
||||
Use `.env` plus the environment class and a label:
|
||||
|
||||
````markdown
|
||||
```{.env .theorem #thm:hall title="Hall's theorem" html="callout"}
|
||||
Every bipartite graph satisfying Hall's condition has a matching that covers
|
||||
the left side.
|
||||
```
|
||||
````
|
||||
|
||||
Supported environment types are:
|
||||
|
||||
```text
|
||||
theorem, lemma, proposition, corollary, definition, example, remark, proof
|
||||
```
|
||||
|
||||
In Astro, `html="callout"` renders the block like a callout. `html="plain"`
|
||||
renders it as a quieter left-rule block. If `html` is omitted, theorem-like
|
||||
blocks default to `callout`, and `proof` defaults to `plain`.
|
||||
|
||||
In PDF, the same block becomes a real LuaLaTeX environment:
|
||||
|
||||
```tex
|
||||
\begin{theorem}[Hall's theorem]
|
||||
\label{thm:hall}
|
||||
...
|
||||
\end{theorem}
|
||||
```
|
||||
|
||||
Astro also accepts shorter forms when PDF export is not needed:
|
||||
|
||||
````markdown
|
||||
```theorem id="thm:hall" title="Hall's theorem"
|
||||
Every bipartite graph satisfying Hall's condition has a matching...
|
||||
```
|
||||
````
|
||||
|
||||
### Linking To Semantic Blocks
|
||||
|
||||
Use ordinary Markdown links to semantic ids:
|
||||
|
||||
```markdown
|
||||
The proof of [Hall's theorem](#thm:hall) is constructive.
|
||||
See the [tape explorer](#int:tape-layout) for an interactive version.
|
||||
```
|
||||
|
||||
Astro renders these as normal HTML links. The PDF pipeline converts internal
|
||||
links whose target starts with `#` into `\cref{...}`, so the first link above
|
||||
becomes `\cref{thm:hall}`.
|
||||
|
||||
Use these label prefixes consistently:
|
||||
|
||||
```text
|
||||
thm: theorem
|
||||
lem: lemma
|
||||
prop: proposition
|
||||
cor: corollary
|
||||
def: definition
|
||||
ex: example
|
||||
rem: remark
|
||||
prf: proof
|
||||
int: interactive
|
||||
fig: figure
|
||||
```
|
||||
|
||||
## Import Rules
|
||||
|
||||
The import script is:
|
||||
|
|
@ -168,3 +291,23 @@ Publish does three things:
|
|||
1. Sync vault content into the local repo.
|
||||
2. Run the configured Astro build command.
|
||||
3. Commit and push to Forgejo, where Dokploy picks up the change.
|
||||
|
||||
## PDF Export
|
||||
|
||||
PDF export uses Pandoc plus LuaLaTeX:
|
||||
|
||||
```bash
|
||||
npm run pdf:post -- sites/research/src/content/research/example/index.md --out /tmp/example.pdf
|
||||
```
|
||||
|
||||
The command runs:
|
||||
|
||||
- `scripts/pandoc-filters/semantic-blocks.lua` to convert interactive and
|
||||
environment fences.
|
||||
- `scripts/pandoc-templates/semantic-preamble.tex` to load `amsthm`,
|
||||
`cleveref`, and the shared theorem definitions.
|
||||
|
||||
The PDF route is intentionally separate from Astro. Astro owns HTML rendering;
|
||||
Pandoc owns PDF rendering. The Markdown conventions above are the shared
|
||||
contract between both outputs and can be reused for future site families such
|
||||
as `neeldhara.courses` and `books.neeldhara.com`.
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
"build": "node scripts/build-site-packages.mjs",
|
||||
"build:sites": "node scripts/build-site-packages.mjs",
|
||||
"build:legacy": "node scripts/build-all-sites.mjs",
|
||||
"pdf:post": "node scripts/export-pdf.mjs",
|
||||
"preview": "npm run preview --prefix sites/research",
|
||||
"astro": "astro",
|
||||
"format": "prettier -w ."
|
||||
|
|
|
|||
64
scripts/export-pdf.mjs
Normal file
64
scripts/export-pdf.mjs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { existsSync, mkdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const filterPath = path.join(repoRoot, "scripts", "pandoc-filters", "semantic-blocks.lua");
|
||||
const preamblePath = path.join(repoRoot, "scripts", "pandoc-templates", "semantic-preamble.tex");
|
||||
|
||||
function usage() {
|
||||
console.error("Usage: node scripts/export-pdf.mjs <post.md> [--out output.pdf]");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = [...argv];
|
||||
const input = args.find((arg) => !arg.startsWith("--"));
|
||||
const outIndex = args.indexOf("--out");
|
||||
const output = outIndex >= 0 ? args[outIndex + 1] : "";
|
||||
return { input, output };
|
||||
}
|
||||
|
||||
const { input, output } = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!input) {
|
||||
usage();
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const inputPath = path.resolve(input);
|
||||
if (!existsSync(inputPath)) {
|
||||
console.error(`Input file not found: ${inputPath}`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const outputPath = path.resolve(
|
||||
output || path.join(path.dirname(inputPath), `${path.basename(inputPath, path.extname(inputPath))}.pdf`),
|
||||
);
|
||||
mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
const pandocArgs = [
|
||||
inputPath,
|
||||
"--from",
|
||||
"markdown+fenced_code_attributes+tex_math_dollars+tex_math_single_backslash+footnotes+raw_html",
|
||||
"--pdf-engine=lualatex",
|
||||
"--lua-filter",
|
||||
filterPath,
|
||||
"--include-in-header",
|
||||
preamblePath,
|
||||
"--output",
|
||||
outputPath,
|
||||
];
|
||||
|
||||
const result = spawnSync("pandoc", pandocArgs, { stdio: "inherit" });
|
||||
|
||||
if (result.error?.code === "ENOENT") {
|
||||
console.error("pandoc is not installed or is not on PATH. Install pandoc and a LuaLaTeX distribution first.");
|
||||
process.exit(127);
|
||||
}
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
console.log(`Wrote ${outputPath}`);
|
||||
131
scripts/pandoc-filters/semantic-blocks.lua
Normal file
131
scripts/pandoc-filters/semantic-blocks.lua
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
local env_types = {
|
||||
theorem = true,
|
||||
lemma = true,
|
||||
proposition = true,
|
||||
corollary = true,
|
||||
definition = true,
|
||||
example = true,
|
||||
remark = true,
|
||||
proof = true,
|
||||
}
|
||||
|
||||
local function has_class(el, class_name)
|
||||
for _, value in ipairs(el.classes) do
|
||||
if value == class_name then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function first_env_class(el)
|
||||
for _, value in ipairs(el.classes) do
|
||||
if env_types[value] then
|
||||
return value
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function latex_escape(value)
|
||||
value = tostring(value or "")
|
||||
value = value:gsub("\\", "\\textbackslash{}")
|
||||
value = value:gsub("([{}%%$&#_])", "\\%1")
|
||||
value = value:gsub("%^", "\\textasciicircum{}")
|
||||
value = value:gsub("~", "\\textasciitilde{}")
|
||||
return value
|
||||
end
|
||||
|
||||
local function internal_link_to_cref(el)
|
||||
if FORMAT:match("latex") and el.target:sub(1, 1) == "#" then
|
||||
local label = el.target:sub(2)
|
||||
if label ~= "" then
|
||||
return pandoc.RawInline("latex", "\\cref{" .. label .. "}")
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function markdown_to_latex(markdown)
|
||||
local doc = pandoc.read(
|
||||
markdown or "",
|
||||
"markdown+fenced_code_attributes+tex_math_dollars+tex_math_single_backslash+footnotes+raw_html"
|
||||
)
|
||||
doc = doc:walk({ Link = internal_link_to_cref })
|
||||
return pandoc.write(doc, "latex")
|
||||
end
|
||||
|
||||
local function label_line(label)
|
||||
if label and label ~= "" then
|
||||
return "\\label{" .. label .. "}\n"
|
||||
end
|
||||
return ""
|
||||
end
|
||||
|
||||
local function interactive_to_latex(el)
|
||||
local label = el.identifier
|
||||
local caption = el.attributes.caption or el.attributes.title or ""
|
||||
local fallback = el.attributes.pdf or el.attributes.fallback or el.text
|
||||
local fallback_latex = markdown_to_latex(fallback)
|
||||
local caption_line = ""
|
||||
|
||||
if caption ~= "" then
|
||||
caption_line = "\\caption{" .. latex_escape(caption) .. "}\n"
|
||||
end
|
||||
|
||||
return pandoc.RawBlock(
|
||||
"latex",
|
||||
table.concat({
|
||||
"\\begin{figure}[htbp]\n",
|
||||
"\\centering\n",
|
||||
"\\fbox{\\begin{minipage}{0.86\\linewidth}\n",
|
||||
fallback_latex,
|
||||
"\n\\end{minipage}}\n",
|
||||
caption_line,
|
||||
label_line(label),
|
||||
"\\end{figure}",
|
||||
})
|
||||
)
|
||||
end
|
||||
|
||||
local function env_to_latex(el)
|
||||
local env_type = el.attributes.type or first_env_class(el)
|
||||
if not env_type or not env_types[env_type] then
|
||||
return nil
|
||||
end
|
||||
|
||||
local title = el.attributes.title or el.attributes.name or ""
|
||||
local begin_env = "\\begin{" .. env_type .. "}"
|
||||
if title ~= "" then
|
||||
begin_env = begin_env .. "[" .. latex_escape(title) .. "]"
|
||||
end
|
||||
|
||||
return pandoc.RawBlock(
|
||||
"latex",
|
||||
table.concat({
|
||||
begin_env,
|
||||
"\n",
|
||||
label_line(el.identifier),
|
||||
markdown_to_latex(el.text),
|
||||
"\n\\end{",
|
||||
env_type,
|
||||
"}",
|
||||
})
|
||||
)
|
||||
end
|
||||
|
||||
function CodeBlock(el)
|
||||
if FORMAT:match("latex") and has_class(el, "interactive") then
|
||||
return interactive_to_latex(el)
|
||||
end
|
||||
|
||||
if FORMAT:match("latex") and (has_class(el, "env") or has_class(el, "latex-env") or first_env_class(el)) then
|
||||
return env_to_latex(el)
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
function Link(el)
|
||||
return internal_link_to_cref(el)
|
||||
end
|
||||
44
scripts/pandoc-templates/semantic-preamble.tex
Normal file
44
scripts/pandoc-templates/semantic-preamble.tex
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
\usepackage{amsmath}
|
||||
\usepackage{amssymb}
|
||||
\usepackage{amsthm}
|
||||
|
||||
\theoremstyle{plain}
|
||||
\newtheorem{theorem}{Theorem}
|
||||
\newtheorem{lemma}[theorem]{Lemma}
|
||||
\newtheorem{proposition}[theorem]{Proposition}
|
||||
\newtheorem{corollary}[theorem]{Corollary}
|
||||
|
||||
\theoremstyle{definition}
|
||||
\newtheorem{definition}[theorem]{Definition}
|
||||
\newtheorem{example}[theorem]{Example}
|
||||
|
||||
\theoremstyle{remark}
|
||||
\newtheorem{remark}[theorem]{Remark}
|
||||
|
||||
\newcommand{\SemanticLoadCleveref}{%
|
||||
\usepackage{cleveref}%
|
||||
\crefname{theorem}{theorem}{theorems}%
|
||||
\Crefname{theorem}{Theorem}{Theorems}%
|
||||
\crefname{lemma}{lemma}{lemmas}%
|
||||
\Crefname{lemma}{Lemma}{Lemmas}%
|
||||
\crefname{proposition}{proposition}{propositions}%
|
||||
\Crefname{proposition}{Proposition}{Propositions}%
|
||||
\crefname{corollary}{corollary}{corollaries}%
|
||||
\Crefname{corollary}{Corollary}{Corollaries}%
|
||||
\crefname{definition}{definition}{definitions}%
|
||||
\Crefname{definition}{Definition}{Definitions}%
|
||||
\crefname{example}{example}{examples}%
|
||||
\Crefname{example}{Example}{Examples}%
|
||||
\crefname{remark}{remark}{remarks}%
|
||||
\Crefname{remark}{Remark}{Remarks}%
|
||||
\crefname{figure}{figure}{figures}%
|
||||
\Crefname{figure}{Figure}{Figures}%
|
||||
}
|
||||
|
||||
\makeatletter
|
||||
\@ifpackageloaded{hyperref}{%
|
||||
\SemanticLoadCleveref
|
||||
}{%
|
||||
\AddToHook{package/hyperref/after}{\SemanticLoadCleveref}%
|
||||
}
|
||||
\makeatother
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
3
sites/art/src/components/InteractiveRuntime.astro
Normal file
3
sites/art/src/components/InteractiveRuntime.astro
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<script>
|
||||
import "@/interactives/runtime";
|
||||
</script>
|
||||
77
sites/art/src/interactives/runtime.tsx
Normal file
77
sites/art/src/interactives/runtime.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
type InteractiveProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
caption?: string;
|
||||
description?: string;
|
||||
fallback?: string;
|
||||
attrs?: Record<string, string>;
|
||||
};
|
||||
|
||||
type InteractiveModule = {
|
||||
default: React.ComponentType<InteractiveProps>;
|
||||
};
|
||||
|
||||
const modules = import.meta.glob<InteractiveModule>("./*/index.tsx");
|
||||
|
||||
function readProps(element: HTMLElement): InteractiveProps {
|
||||
const encoded = element.dataset.interactiveProps;
|
||||
if (!encoded) {
|
||||
return {
|
||||
id: element.dataset.interactiveId ?? "",
|
||||
label: "",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(encoded)) as InteractiveProps;
|
||||
} catch {
|
||||
return {
|
||||
id: element.dataset.interactiveId ?? "",
|
||||
label: "",
|
||||
fallback: element.textContent?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function mountInteractive(element: HTMLElement) {
|
||||
const id = element.dataset.interactiveId;
|
||||
if (!id || element.dataset.interactiveMounted) return;
|
||||
|
||||
const modulePath = `./${id}/index.tsx`;
|
||||
const loader = modules[modulePath];
|
||||
if (!loader) {
|
||||
element.dataset.interactiveState = "missing-component";
|
||||
return;
|
||||
}
|
||||
|
||||
element.dataset.interactiveMounted = "true";
|
||||
element.dataset.interactiveState = "loading";
|
||||
|
||||
try {
|
||||
const props = readProps(element);
|
||||
const { default: Component } = await loader();
|
||||
element.replaceChildren();
|
||||
createRoot(element).render(<Component {...props} />);
|
||||
element.dataset.interactiveState = "ready";
|
||||
} catch (error) {
|
||||
element.dataset.interactiveState = "failed";
|
||||
console.error(`Failed to mount interactive "${id}"`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function mountAllInteractives() {
|
||||
document
|
||||
.querySelectorAll<HTMLElement>(".interactive-mount[data-interactive-id]")
|
||||
.forEach((element) => {
|
||||
void mountInteractive(element);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", mountAllInteractives, { once: true });
|
||||
} else {
|
||||
mountAllInteractives();
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import CoralComments from '@/components/CoralComments.astro';
|
||||
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
|
||||
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
||||
import { BlogPost } from '@/components/sections/blog-post';
|
||||
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
||||
|
|
@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
|
|||
<Content />
|
||||
</BlogPost>
|
||||
|
||||
<InteractiveRuntime />
|
||||
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
||||
</DefaultLayout>
|
||||
|
|
|
|||
232
sites/art/src/remark/semantic-blocks.mjs
Normal file
232
sites/art/src/remark/semantic-blocks.mjs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
|
||||
const ENV_TYPES = new Set([
|
||||
"theorem",
|
||||
"lemma",
|
||||
"proposition",
|
||||
"corollary",
|
||||
"definition",
|
||||
"example",
|
||||
"remark",
|
||||
"proof",
|
||||
]);
|
||||
|
||||
const ENV_LABELS = {
|
||||
theorem: "Theorem",
|
||||
lemma: "Lemma",
|
||||
proposition: "Proposition",
|
||||
corollary: "Corollary",
|
||||
definition: "Definition",
|
||||
example: "Example",
|
||||
remark: "Remark",
|
||||
proof: "Proof",
|
||||
};
|
||||
|
||||
function tokenizeInfo(info) {
|
||||
return (
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function stripQuotes(value) {
|
||||
if (!value) return "";
|
||||
const first = value[0];
|
||||
const last = value[value.length - 1];
|
||||
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseFenceInfo(node) {
|
||||
const lang = node.lang ?? "";
|
||||
const meta = node.meta ?? "";
|
||||
const raw = `${lang} ${meta}`.trim();
|
||||
const attrs = {
|
||||
classes: [],
|
||||
id: "",
|
||||
values: {},
|
||||
raw,
|
||||
};
|
||||
|
||||
if (!raw) return attrs;
|
||||
|
||||
let body = raw;
|
||||
if (body.startsWith("{")) {
|
||||
body = body.slice(1);
|
||||
if (body.endsWith("}")) body = body.slice(0, -1);
|
||||
} else if (lang) {
|
||||
attrs.classes.push(lang);
|
||||
body = meta;
|
||||
}
|
||||
|
||||
for (let token of tokenizeInfo(body.trim())) {
|
||||
if (token.endsWith("}") && !token.includes("=")) token = token.slice(0, -1);
|
||||
if (!token) continue;
|
||||
|
||||
if (token.startsWith(".")) {
|
||||
attrs.classes.push(token.slice(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith("#")) {
|
||||
attrs.id = token.slice(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const eq = token.indexOf("=");
|
||||
if (eq > 0) {
|
||||
const key = token.slice(0, eq);
|
||||
const value = stripQuotes(token.slice(eq + 1));
|
||||
if (key === "id") attrs.id = value;
|
||||
attrs.values[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.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:
|
||||
'<aside class="semantic-error">Interactive block missing an id. Add <code>#int:name</code> or <code>id="name"</code>.</aside>',
|
||||
};
|
||||
}
|
||||
|
||||
const props = {
|
||||
id: componentId,
|
||||
label,
|
||||
caption,
|
||||
description,
|
||||
fallback,
|
||||
attrs: attrs.values,
|
||||
};
|
||||
|
||||
return {
|
||||
type: "html",
|
||||
value: [
|
||||
`<figure class="semantic-interactive" id="${attribute(label)}" data-interactive-label="${attribute(label)}">`,
|
||||
`<div class="interactive-mount" data-interactive-id="${attribute(componentId)}" data-interactive-props="${attribute(encodedJson(props))}">`,
|
||||
fallback ? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>` : "",
|
||||
"</div>",
|
||||
caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "",
|
||||
"</figure>",
|
||||
].join(""),
|
||||
};
|
||||
}
|
||||
|
||||
function envFromCode(node) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
const shorthandType = firstClass(attrs, new Set(["env", "latex-env"]));
|
||||
const explicitType = attrs.values.type || shorthandType;
|
||||
const type = ENV_TYPES.has(explicitType) ? explicitType : "";
|
||||
const isEnv = attrs.classes.includes("env") || attrs.classes.includes("latex-env") || Boolean(type);
|
||||
|
||||
if (!isEnv) return null;
|
||||
|
||||
if (!type) {
|
||||
return {
|
||||
type: "html",
|
||||
value:
|
||||
'<aside class="semantic-error">Environment block missing a type. Add <code>.theorem</code>, <code>.definition</code>, or <code>type="theorem"</code>.</aside>',
|
||||
};
|
||||
}
|
||||
|
||||
const title = attrs.values.title || attrs.values.name || "";
|
||||
const label = attrs.id || attrs.values.label || "";
|
||||
const renderMode = attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const heading = title ? `${ENV_LABELS[type]} (${title})` : ENV_LABELS[type];
|
||||
const parsed = fromMarkdown(node.value || "");
|
||||
const className = [
|
||||
"semantic-env",
|
||||
`semantic-env-${type}`,
|
||||
`semantic-env-${renderMode}`,
|
||||
renderMode === "callout" ? "callout" : "",
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
type: "blockquote",
|
||||
data: {
|
||||
hName: "section",
|
||||
hProperties: {
|
||||
id: label || undefined,
|
||||
className,
|
||||
"data-env-type": type,
|
||||
"data-env-title": title || undefined,
|
||||
"data-env-render": renderMode,
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "paragraph",
|
||||
data: {
|
||||
hProperties: {
|
||||
className: ["semantic-env-heading"],
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "strong",
|
||||
children: [{ type: "text", value: heading }],
|
||||
},
|
||||
],
|
||||
},
|
||||
...parsed.children,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function transformTree(parent) {
|
||||
if (!parent?.children) return;
|
||||
|
||||
for (let index = 0; index < parent.children.length; index += 1) {
|
||||
const child = parent.children[index];
|
||||
if (child.type === "code") {
|
||||
const replacement = interactiveFromCode(child) || envFromCode(child);
|
||||
if (replacement) {
|
||||
parent.children[index] = replacement;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
transformTree(child);
|
||||
}
|
||||
}
|
||||
|
||||
export default function remarkSemanticBlocks() {
|
||||
return (tree) => transformTree(tree);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
3
sites/poetry/src/components/InteractiveRuntime.astro
Normal file
3
sites/poetry/src/components/InteractiveRuntime.astro
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<script>
|
||||
import "@/interactives/runtime";
|
||||
</script>
|
||||
77
sites/poetry/src/interactives/runtime.tsx
Normal file
77
sites/poetry/src/interactives/runtime.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
type InteractiveProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
caption?: string;
|
||||
description?: string;
|
||||
fallback?: string;
|
||||
attrs?: Record<string, string>;
|
||||
};
|
||||
|
||||
type InteractiveModule = {
|
||||
default: React.ComponentType<InteractiveProps>;
|
||||
};
|
||||
|
||||
const modules = import.meta.glob<InteractiveModule>("./*/index.tsx");
|
||||
|
||||
function readProps(element: HTMLElement): InteractiveProps {
|
||||
const encoded = element.dataset.interactiveProps;
|
||||
if (!encoded) {
|
||||
return {
|
||||
id: element.dataset.interactiveId ?? "",
|
||||
label: "",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(encoded)) as InteractiveProps;
|
||||
} catch {
|
||||
return {
|
||||
id: element.dataset.interactiveId ?? "",
|
||||
label: "",
|
||||
fallback: element.textContent?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function mountInteractive(element: HTMLElement) {
|
||||
const id = element.dataset.interactiveId;
|
||||
if (!id || element.dataset.interactiveMounted) return;
|
||||
|
||||
const modulePath = `./${id}/index.tsx`;
|
||||
const loader = modules[modulePath];
|
||||
if (!loader) {
|
||||
element.dataset.interactiveState = "missing-component";
|
||||
return;
|
||||
}
|
||||
|
||||
element.dataset.interactiveMounted = "true";
|
||||
element.dataset.interactiveState = "loading";
|
||||
|
||||
try {
|
||||
const props = readProps(element);
|
||||
const { default: Component } = await loader();
|
||||
element.replaceChildren();
|
||||
createRoot(element).render(<Component {...props} />);
|
||||
element.dataset.interactiveState = "ready";
|
||||
} catch (error) {
|
||||
element.dataset.interactiveState = "failed";
|
||||
console.error(`Failed to mount interactive "${id}"`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function mountAllInteractives() {
|
||||
document
|
||||
.querySelectorAll<HTMLElement>(".interactive-mount[data-interactive-id]")
|
||||
.forEach((element) => {
|
||||
void mountInteractive(element);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", mountAllInteractives, { once: true });
|
||||
} else {
|
||||
mountAllInteractives();
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import CoralComments from '@/components/CoralComments.astro';
|
||||
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
|
||||
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
||||
import { BlogPost } from '@/components/sections/blog-post';
|
||||
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
||||
|
|
@ -28,5 +29,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
|
|||
</BlogPost>
|
||||
</div>
|
||||
|
||||
<InteractiveRuntime />
|
||||
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
||||
</DefaultLayout>
|
||||
|
|
|
|||
232
sites/poetry/src/remark/semantic-blocks.mjs
Normal file
232
sites/poetry/src/remark/semantic-blocks.mjs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
|
||||
const ENV_TYPES = new Set([
|
||||
"theorem",
|
||||
"lemma",
|
||||
"proposition",
|
||||
"corollary",
|
||||
"definition",
|
||||
"example",
|
||||
"remark",
|
||||
"proof",
|
||||
]);
|
||||
|
||||
const ENV_LABELS = {
|
||||
theorem: "Theorem",
|
||||
lemma: "Lemma",
|
||||
proposition: "Proposition",
|
||||
corollary: "Corollary",
|
||||
definition: "Definition",
|
||||
example: "Example",
|
||||
remark: "Remark",
|
||||
proof: "Proof",
|
||||
};
|
||||
|
||||
function tokenizeInfo(info) {
|
||||
return (
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function stripQuotes(value) {
|
||||
if (!value) return "";
|
||||
const first = value[0];
|
||||
const last = value[value.length - 1];
|
||||
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseFenceInfo(node) {
|
||||
const lang = node.lang ?? "";
|
||||
const meta = node.meta ?? "";
|
||||
const raw = `${lang} ${meta}`.trim();
|
||||
const attrs = {
|
||||
classes: [],
|
||||
id: "",
|
||||
values: {},
|
||||
raw,
|
||||
};
|
||||
|
||||
if (!raw) return attrs;
|
||||
|
||||
let body = raw;
|
||||
if (body.startsWith("{")) {
|
||||
body = body.slice(1);
|
||||
if (body.endsWith("}")) body = body.slice(0, -1);
|
||||
} else if (lang) {
|
||||
attrs.classes.push(lang);
|
||||
body = meta;
|
||||
}
|
||||
|
||||
for (let token of tokenizeInfo(body.trim())) {
|
||||
if (token.endsWith("}") && !token.includes("=")) token = token.slice(0, -1);
|
||||
if (!token) continue;
|
||||
|
||||
if (token.startsWith(".")) {
|
||||
attrs.classes.push(token.slice(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith("#")) {
|
||||
attrs.id = token.slice(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const eq = token.indexOf("=");
|
||||
if (eq > 0) {
|
||||
const key = token.slice(0, eq);
|
||||
const value = stripQuotes(token.slice(eq + 1));
|
||||
if (key === "id") attrs.id = value;
|
||||
attrs.values[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.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:
|
||||
'<aside class="semantic-error">Interactive block missing an id. Add <code>#int:name</code> or <code>id="name"</code>.</aside>',
|
||||
};
|
||||
}
|
||||
|
||||
const props = {
|
||||
id: componentId,
|
||||
label,
|
||||
caption,
|
||||
description,
|
||||
fallback,
|
||||
attrs: attrs.values,
|
||||
};
|
||||
|
||||
return {
|
||||
type: "html",
|
||||
value: [
|
||||
`<figure class="semantic-interactive" id="${attribute(label)}" data-interactive-label="${attribute(label)}">`,
|
||||
`<div class="interactive-mount" data-interactive-id="${attribute(componentId)}" data-interactive-props="${attribute(encodedJson(props))}">`,
|
||||
fallback ? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>` : "",
|
||||
"</div>",
|
||||
caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "",
|
||||
"</figure>",
|
||||
].join(""),
|
||||
};
|
||||
}
|
||||
|
||||
function envFromCode(node) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
const shorthandType = firstClass(attrs, new Set(["env", "latex-env"]));
|
||||
const explicitType = attrs.values.type || shorthandType;
|
||||
const type = ENV_TYPES.has(explicitType) ? explicitType : "";
|
||||
const isEnv = attrs.classes.includes("env") || attrs.classes.includes("latex-env") || Boolean(type);
|
||||
|
||||
if (!isEnv) return null;
|
||||
|
||||
if (!type) {
|
||||
return {
|
||||
type: "html",
|
||||
value:
|
||||
'<aside class="semantic-error">Environment block missing a type. Add <code>.theorem</code>, <code>.definition</code>, or <code>type="theorem"</code>.</aside>',
|
||||
};
|
||||
}
|
||||
|
||||
const title = attrs.values.title || attrs.values.name || "";
|
||||
const label = attrs.id || attrs.values.label || "";
|
||||
const renderMode = attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const heading = title ? `${ENV_LABELS[type]} (${title})` : ENV_LABELS[type];
|
||||
const parsed = fromMarkdown(node.value || "");
|
||||
const className = [
|
||||
"semantic-env",
|
||||
`semantic-env-${type}`,
|
||||
`semantic-env-${renderMode}`,
|
||||
renderMode === "callout" ? "callout" : "",
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
type: "blockquote",
|
||||
data: {
|
||||
hName: "section",
|
||||
hProperties: {
|
||||
id: label || undefined,
|
||||
className,
|
||||
"data-env-type": type,
|
||||
"data-env-title": title || undefined,
|
||||
"data-env-render": renderMode,
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "paragraph",
|
||||
data: {
|
||||
hProperties: {
|
||||
className: ["semantic-env-heading"],
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "strong",
|
||||
children: [{ type: "text", value: heading }],
|
||||
},
|
||||
],
|
||||
},
|
||||
...parsed.children,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function transformTree(parent) {
|
||||
if (!parent?.children) return;
|
||||
|
||||
for (let index = 0; index < parent.children.length; index += 1) {
|
||||
const child = parent.children[index];
|
||||
if (child.type === "code") {
|
||||
const replacement = interactiveFromCode(child) || envFromCode(child);
|
||||
if (replacement) {
|
||||
parent.children[index] = replacement;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
transformTree(child);
|
||||
}
|
||||
}
|
||||
|
||||
export default function remarkSemanticBlocks() {
|
||||
return (tree) => transformTree(tree);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
3
sites/puzzles/src/components/InteractiveRuntime.astro
Normal file
3
sites/puzzles/src/components/InteractiveRuntime.astro
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<script>
|
||||
import "@/interactives/runtime";
|
||||
</script>
|
||||
77
sites/puzzles/src/interactives/runtime.tsx
Normal file
77
sites/puzzles/src/interactives/runtime.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
type InteractiveProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
caption?: string;
|
||||
description?: string;
|
||||
fallback?: string;
|
||||
attrs?: Record<string, string>;
|
||||
};
|
||||
|
||||
type InteractiveModule = {
|
||||
default: React.ComponentType<InteractiveProps>;
|
||||
};
|
||||
|
||||
const modules = import.meta.glob<InteractiveModule>("./*/index.tsx");
|
||||
|
||||
function readProps(element: HTMLElement): InteractiveProps {
|
||||
const encoded = element.dataset.interactiveProps;
|
||||
if (!encoded) {
|
||||
return {
|
||||
id: element.dataset.interactiveId ?? "",
|
||||
label: "",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(encoded)) as InteractiveProps;
|
||||
} catch {
|
||||
return {
|
||||
id: element.dataset.interactiveId ?? "",
|
||||
label: "",
|
||||
fallback: element.textContent?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function mountInteractive(element: HTMLElement) {
|
||||
const id = element.dataset.interactiveId;
|
||||
if (!id || element.dataset.interactiveMounted) return;
|
||||
|
||||
const modulePath = `./${id}/index.tsx`;
|
||||
const loader = modules[modulePath];
|
||||
if (!loader) {
|
||||
element.dataset.interactiveState = "missing-component";
|
||||
return;
|
||||
}
|
||||
|
||||
element.dataset.interactiveMounted = "true";
|
||||
element.dataset.interactiveState = "loading";
|
||||
|
||||
try {
|
||||
const props = readProps(element);
|
||||
const { default: Component } = await loader();
|
||||
element.replaceChildren();
|
||||
createRoot(element).render(<Component {...props} />);
|
||||
element.dataset.interactiveState = "ready";
|
||||
} catch (error) {
|
||||
element.dataset.interactiveState = "failed";
|
||||
console.error(`Failed to mount interactive "${id}"`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function mountAllInteractives() {
|
||||
document
|
||||
.querySelectorAll<HTMLElement>(".interactive-mount[data-interactive-id]")
|
||||
.forEach((element) => {
|
||||
void mountInteractive(element);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", mountAllInteractives, { once: true });
|
||||
} else {
|
||||
mountAllInteractives();
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import CoralComments from '@/components/CoralComments.astro';
|
||||
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
|
||||
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
||||
import { BlogPost } from '@/components/sections/blog-post';
|
||||
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
||||
|
|
@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
|
|||
<Content />
|
||||
</BlogPost>
|
||||
|
||||
<InteractiveRuntime />
|
||||
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
||||
</DefaultLayout>
|
||||
|
|
|
|||
232
sites/puzzles/src/remark/semantic-blocks.mjs
Normal file
232
sites/puzzles/src/remark/semantic-blocks.mjs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
|
||||
const ENV_TYPES = new Set([
|
||||
"theorem",
|
||||
"lemma",
|
||||
"proposition",
|
||||
"corollary",
|
||||
"definition",
|
||||
"example",
|
||||
"remark",
|
||||
"proof",
|
||||
]);
|
||||
|
||||
const ENV_LABELS = {
|
||||
theorem: "Theorem",
|
||||
lemma: "Lemma",
|
||||
proposition: "Proposition",
|
||||
corollary: "Corollary",
|
||||
definition: "Definition",
|
||||
example: "Example",
|
||||
remark: "Remark",
|
||||
proof: "Proof",
|
||||
};
|
||||
|
||||
function tokenizeInfo(info) {
|
||||
return (
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function stripQuotes(value) {
|
||||
if (!value) return "";
|
||||
const first = value[0];
|
||||
const last = value[value.length - 1];
|
||||
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseFenceInfo(node) {
|
||||
const lang = node.lang ?? "";
|
||||
const meta = node.meta ?? "";
|
||||
const raw = `${lang} ${meta}`.trim();
|
||||
const attrs = {
|
||||
classes: [],
|
||||
id: "",
|
||||
values: {},
|
||||
raw,
|
||||
};
|
||||
|
||||
if (!raw) return attrs;
|
||||
|
||||
let body = raw;
|
||||
if (body.startsWith("{")) {
|
||||
body = body.slice(1);
|
||||
if (body.endsWith("}")) body = body.slice(0, -1);
|
||||
} else if (lang) {
|
||||
attrs.classes.push(lang);
|
||||
body = meta;
|
||||
}
|
||||
|
||||
for (let token of tokenizeInfo(body.trim())) {
|
||||
if (token.endsWith("}") && !token.includes("=")) token = token.slice(0, -1);
|
||||
if (!token) continue;
|
||||
|
||||
if (token.startsWith(".")) {
|
||||
attrs.classes.push(token.slice(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith("#")) {
|
||||
attrs.id = token.slice(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const eq = token.indexOf("=");
|
||||
if (eq > 0) {
|
||||
const key = token.slice(0, eq);
|
||||
const value = stripQuotes(token.slice(eq + 1));
|
||||
if (key === "id") attrs.id = value;
|
||||
attrs.values[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.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:
|
||||
'<aside class="semantic-error">Interactive block missing an id. Add <code>#int:name</code> or <code>id="name"</code>.</aside>',
|
||||
};
|
||||
}
|
||||
|
||||
const props = {
|
||||
id: componentId,
|
||||
label,
|
||||
caption,
|
||||
description,
|
||||
fallback,
|
||||
attrs: attrs.values,
|
||||
};
|
||||
|
||||
return {
|
||||
type: "html",
|
||||
value: [
|
||||
`<figure class="semantic-interactive" id="${attribute(label)}" data-interactive-label="${attribute(label)}">`,
|
||||
`<div class="interactive-mount" data-interactive-id="${attribute(componentId)}" data-interactive-props="${attribute(encodedJson(props))}">`,
|
||||
fallback ? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>` : "",
|
||||
"</div>",
|
||||
caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "",
|
||||
"</figure>",
|
||||
].join(""),
|
||||
};
|
||||
}
|
||||
|
||||
function envFromCode(node) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
const shorthandType = firstClass(attrs, new Set(["env", "latex-env"]));
|
||||
const explicitType = attrs.values.type || shorthandType;
|
||||
const type = ENV_TYPES.has(explicitType) ? explicitType : "";
|
||||
const isEnv = attrs.classes.includes("env") || attrs.classes.includes("latex-env") || Boolean(type);
|
||||
|
||||
if (!isEnv) return null;
|
||||
|
||||
if (!type) {
|
||||
return {
|
||||
type: "html",
|
||||
value:
|
||||
'<aside class="semantic-error">Environment block missing a type. Add <code>.theorem</code>, <code>.definition</code>, or <code>type="theorem"</code>.</aside>',
|
||||
};
|
||||
}
|
||||
|
||||
const title = attrs.values.title || attrs.values.name || "";
|
||||
const label = attrs.id || attrs.values.label || "";
|
||||
const renderMode = attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const heading = title ? `${ENV_LABELS[type]} (${title})` : ENV_LABELS[type];
|
||||
const parsed = fromMarkdown(node.value || "");
|
||||
const className = [
|
||||
"semantic-env",
|
||||
`semantic-env-${type}`,
|
||||
`semantic-env-${renderMode}`,
|
||||
renderMode === "callout" ? "callout" : "",
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
type: "blockquote",
|
||||
data: {
|
||||
hName: "section",
|
||||
hProperties: {
|
||||
id: label || undefined,
|
||||
className,
|
||||
"data-env-type": type,
|
||||
"data-env-title": title || undefined,
|
||||
"data-env-render": renderMode,
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "paragraph",
|
||||
data: {
|
||||
hProperties: {
|
||||
className: ["semantic-env-heading"],
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "strong",
|
||||
children: [{ type: "text", value: heading }],
|
||||
},
|
||||
],
|
||||
},
|
||||
...parsed.children,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function transformTree(parent) {
|
||||
if (!parent?.children) return;
|
||||
|
||||
for (let index = 0; index < parent.children.length; index += 1) {
|
||||
const child = parent.children[index];
|
||||
if (child.type === "code") {
|
||||
const replacement = interactiveFromCode(child) || envFromCode(child);
|
||||
if (replacement) {
|
||||
parent.children[index] = replacement;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
transformTree(child);
|
||||
}
|
||||
}
|
||||
|
||||
export default function remarkSemanticBlocks() {
|
||||
return (tree) => transformTree(tree);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
<script>
|
||||
import "@/interactives/runtime";
|
||||
</script>
|
||||
77
sites/reflections/src/interactives/runtime.tsx
Normal file
77
sites/reflections/src/interactives/runtime.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
type InteractiveProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
caption?: string;
|
||||
description?: string;
|
||||
fallback?: string;
|
||||
attrs?: Record<string, string>;
|
||||
};
|
||||
|
||||
type InteractiveModule = {
|
||||
default: React.ComponentType<InteractiveProps>;
|
||||
};
|
||||
|
||||
const modules = import.meta.glob<InteractiveModule>("./*/index.tsx");
|
||||
|
||||
function readProps(element: HTMLElement): InteractiveProps {
|
||||
const encoded = element.dataset.interactiveProps;
|
||||
if (!encoded) {
|
||||
return {
|
||||
id: element.dataset.interactiveId ?? "",
|
||||
label: "",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(encoded)) as InteractiveProps;
|
||||
} catch {
|
||||
return {
|
||||
id: element.dataset.interactiveId ?? "",
|
||||
label: "",
|
||||
fallback: element.textContent?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function mountInteractive(element: HTMLElement) {
|
||||
const id = element.dataset.interactiveId;
|
||||
if (!id || element.dataset.interactiveMounted) return;
|
||||
|
||||
const modulePath = `./${id}/index.tsx`;
|
||||
const loader = modules[modulePath];
|
||||
if (!loader) {
|
||||
element.dataset.interactiveState = "missing-component";
|
||||
return;
|
||||
}
|
||||
|
||||
element.dataset.interactiveMounted = "true";
|
||||
element.dataset.interactiveState = "loading";
|
||||
|
||||
try {
|
||||
const props = readProps(element);
|
||||
const { default: Component } = await loader();
|
||||
element.replaceChildren();
|
||||
createRoot(element).render(<Component {...props} />);
|
||||
element.dataset.interactiveState = "ready";
|
||||
} catch (error) {
|
||||
element.dataset.interactiveState = "failed";
|
||||
console.error(`Failed to mount interactive "${id}"`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function mountAllInteractives() {
|
||||
document
|
||||
.querySelectorAll<HTMLElement>(".interactive-mount[data-interactive-id]")
|
||||
.forEach((element) => {
|
||||
void mountInteractive(element);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", mountAllInteractives, { once: true });
|
||||
} else {
|
||||
mountAllInteractives();
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import CoralComments from '@/components/CoralComments.astro';
|
||||
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
|
||||
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
||||
import { BlogPost } from '@/components/sections/blog-post';
|
||||
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
||||
|
|
@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
|
|||
<Content />
|
||||
</BlogPost>
|
||||
|
||||
<InteractiveRuntime />
|
||||
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
||||
</DefaultLayout>
|
||||
|
|
|
|||
232
sites/reflections/src/remark/semantic-blocks.mjs
Normal file
232
sites/reflections/src/remark/semantic-blocks.mjs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
|
||||
const ENV_TYPES = new Set([
|
||||
"theorem",
|
||||
"lemma",
|
||||
"proposition",
|
||||
"corollary",
|
||||
"definition",
|
||||
"example",
|
||||
"remark",
|
||||
"proof",
|
||||
]);
|
||||
|
||||
const ENV_LABELS = {
|
||||
theorem: "Theorem",
|
||||
lemma: "Lemma",
|
||||
proposition: "Proposition",
|
||||
corollary: "Corollary",
|
||||
definition: "Definition",
|
||||
example: "Example",
|
||||
remark: "Remark",
|
||||
proof: "Proof",
|
||||
};
|
||||
|
||||
function tokenizeInfo(info) {
|
||||
return (
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function stripQuotes(value) {
|
||||
if (!value) return "";
|
||||
const first = value[0];
|
||||
const last = value[value.length - 1];
|
||||
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseFenceInfo(node) {
|
||||
const lang = node.lang ?? "";
|
||||
const meta = node.meta ?? "";
|
||||
const raw = `${lang} ${meta}`.trim();
|
||||
const attrs = {
|
||||
classes: [],
|
||||
id: "",
|
||||
values: {},
|
||||
raw,
|
||||
};
|
||||
|
||||
if (!raw) return attrs;
|
||||
|
||||
let body = raw;
|
||||
if (body.startsWith("{")) {
|
||||
body = body.slice(1);
|
||||
if (body.endsWith("}")) body = body.slice(0, -1);
|
||||
} else if (lang) {
|
||||
attrs.classes.push(lang);
|
||||
body = meta;
|
||||
}
|
||||
|
||||
for (let token of tokenizeInfo(body.trim())) {
|
||||
if (token.endsWith("}") && !token.includes("=")) token = token.slice(0, -1);
|
||||
if (!token) continue;
|
||||
|
||||
if (token.startsWith(".")) {
|
||||
attrs.classes.push(token.slice(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith("#")) {
|
||||
attrs.id = token.slice(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const eq = token.indexOf("=");
|
||||
if (eq > 0) {
|
||||
const key = token.slice(0, eq);
|
||||
const value = stripQuotes(token.slice(eq + 1));
|
||||
if (key === "id") attrs.id = value;
|
||||
attrs.values[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.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:
|
||||
'<aside class="semantic-error">Interactive block missing an id. Add <code>#int:name</code> or <code>id="name"</code>.</aside>',
|
||||
};
|
||||
}
|
||||
|
||||
const props = {
|
||||
id: componentId,
|
||||
label,
|
||||
caption,
|
||||
description,
|
||||
fallback,
|
||||
attrs: attrs.values,
|
||||
};
|
||||
|
||||
return {
|
||||
type: "html",
|
||||
value: [
|
||||
`<figure class="semantic-interactive" id="${attribute(label)}" data-interactive-label="${attribute(label)}">`,
|
||||
`<div class="interactive-mount" data-interactive-id="${attribute(componentId)}" data-interactive-props="${attribute(encodedJson(props))}">`,
|
||||
fallback ? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>` : "",
|
||||
"</div>",
|
||||
caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "",
|
||||
"</figure>",
|
||||
].join(""),
|
||||
};
|
||||
}
|
||||
|
||||
function envFromCode(node) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
const shorthandType = firstClass(attrs, new Set(["env", "latex-env"]));
|
||||
const explicitType = attrs.values.type || shorthandType;
|
||||
const type = ENV_TYPES.has(explicitType) ? explicitType : "";
|
||||
const isEnv = attrs.classes.includes("env") || attrs.classes.includes("latex-env") || Boolean(type);
|
||||
|
||||
if (!isEnv) return null;
|
||||
|
||||
if (!type) {
|
||||
return {
|
||||
type: "html",
|
||||
value:
|
||||
'<aside class="semantic-error">Environment block missing a type. Add <code>.theorem</code>, <code>.definition</code>, or <code>type="theorem"</code>.</aside>',
|
||||
};
|
||||
}
|
||||
|
||||
const title = attrs.values.title || attrs.values.name || "";
|
||||
const label = attrs.id || attrs.values.label || "";
|
||||
const renderMode = attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const heading = title ? `${ENV_LABELS[type]} (${title})` : ENV_LABELS[type];
|
||||
const parsed = fromMarkdown(node.value || "");
|
||||
const className = [
|
||||
"semantic-env",
|
||||
`semantic-env-${type}`,
|
||||
`semantic-env-${renderMode}`,
|
||||
renderMode === "callout" ? "callout" : "",
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
type: "blockquote",
|
||||
data: {
|
||||
hName: "section",
|
||||
hProperties: {
|
||||
id: label || undefined,
|
||||
className,
|
||||
"data-env-type": type,
|
||||
"data-env-title": title || undefined,
|
||||
"data-env-render": renderMode,
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "paragraph",
|
||||
data: {
|
||||
hProperties: {
|
||||
className: ["semantic-env-heading"],
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "strong",
|
||||
children: [{ type: "text", value: heading }],
|
||||
},
|
||||
],
|
||||
},
|
||||
...parsed.children,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function transformTree(parent) {
|
||||
if (!parent?.children) return;
|
||||
|
||||
for (let index = 0; index < parent.children.length; index += 1) {
|
||||
const child = parent.children[index];
|
||||
if (child.type === "code") {
|
||||
const replacement = interactiveFromCode(child) || envFromCode(child);
|
||||
if (replacement) {
|
||||
parent.children[index] = replacement;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
transformTree(child);
|
||||
}
|
||||
}
|
||||
|
||||
export default function remarkSemanticBlocks() {
|
||||
return (tree) => transformTree(tree);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
3
sites/research/src/components/InteractiveRuntime.astro
Normal file
3
sites/research/src/components/InteractiveRuntime.astro
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<script>
|
||||
import "@/interactives/runtime";
|
||||
</script>
|
||||
77
sites/research/src/interactives/runtime.tsx
Normal file
77
sites/research/src/interactives/runtime.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
type InteractiveProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
caption?: string;
|
||||
description?: string;
|
||||
fallback?: string;
|
||||
attrs?: Record<string, string>;
|
||||
};
|
||||
|
||||
type InteractiveModule = {
|
||||
default: React.ComponentType<InteractiveProps>;
|
||||
};
|
||||
|
||||
const modules = import.meta.glob<InteractiveModule>("./*/index.tsx");
|
||||
|
||||
function readProps(element: HTMLElement): InteractiveProps {
|
||||
const encoded = element.dataset.interactiveProps;
|
||||
if (!encoded) {
|
||||
return {
|
||||
id: element.dataset.interactiveId ?? "",
|
||||
label: "",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(encoded)) as InteractiveProps;
|
||||
} catch {
|
||||
return {
|
||||
id: element.dataset.interactiveId ?? "",
|
||||
label: "",
|
||||
fallback: element.textContent?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function mountInteractive(element: HTMLElement) {
|
||||
const id = element.dataset.interactiveId;
|
||||
if (!id || element.dataset.interactiveMounted) return;
|
||||
|
||||
const modulePath = `./${id}/index.tsx`;
|
||||
const loader = modules[modulePath];
|
||||
if (!loader) {
|
||||
element.dataset.interactiveState = "missing-component";
|
||||
return;
|
||||
}
|
||||
|
||||
element.dataset.interactiveMounted = "true";
|
||||
element.dataset.interactiveState = "loading";
|
||||
|
||||
try {
|
||||
const props = readProps(element);
|
||||
const { default: Component } = await loader();
|
||||
element.replaceChildren();
|
||||
createRoot(element).render(<Component {...props} />);
|
||||
element.dataset.interactiveState = "ready";
|
||||
} catch (error) {
|
||||
element.dataset.interactiveState = "failed";
|
||||
console.error(`Failed to mount interactive "${id}"`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function mountAllInteractives() {
|
||||
document
|
||||
.querySelectorAll<HTMLElement>(".interactive-mount[data-interactive-id]")
|
||||
.forEach((element) => {
|
||||
void mountInteractive(element);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", mountAllInteractives, { once: true });
|
||||
} else {
|
||||
mountAllInteractives();
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import CoralComments from '@/components/CoralComments.astro';
|
||||
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
|
||||
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
||||
import { BlogPost } from '@/components/sections/blog-post';
|
||||
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
||||
|
|
@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
|
|||
<Content />
|
||||
</BlogPost>
|
||||
|
||||
<InteractiveRuntime />
|
||||
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
||||
</DefaultLayout>
|
||||
|
|
|
|||
232
sites/research/src/remark/semantic-blocks.mjs
Normal file
232
sites/research/src/remark/semantic-blocks.mjs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
|
||||
const ENV_TYPES = new Set([
|
||||
"theorem",
|
||||
"lemma",
|
||||
"proposition",
|
||||
"corollary",
|
||||
"definition",
|
||||
"example",
|
||||
"remark",
|
||||
"proof",
|
||||
]);
|
||||
|
||||
const ENV_LABELS = {
|
||||
theorem: "Theorem",
|
||||
lemma: "Lemma",
|
||||
proposition: "Proposition",
|
||||
corollary: "Corollary",
|
||||
definition: "Definition",
|
||||
example: "Example",
|
||||
remark: "Remark",
|
||||
proof: "Proof",
|
||||
};
|
||||
|
||||
function tokenizeInfo(info) {
|
||||
return (
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function stripQuotes(value) {
|
||||
if (!value) return "";
|
||||
const first = value[0];
|
||||
const last = value[value.length - 1];
|
||||
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseFenceInfo(node) {
|
||||
const lang = node.lang ?? "";
|
||||
const meta = node.meta ?? "";
|
||||
const raw = `${lang} ${meta}`.trim();
|
||||
const attrs = {
|
||||
classes: [],
|
||||
id: "",
|
||||
values: {},
|
||||
raw,
|
||||
};
|
||||
|
||||
if (!raw) return attrs;
|
||||
|
||||
let body = raw;
|
||||
if (body.startsWith("{")) {
|
||||
body = body.slice(1);
|
||||
if (body.endsWith("}")) body = body.slice(0, -1);
|
||||
} else if (lang) {
|
||||
attrs.classes.push(lang);
|
||||
body = meta;
|
||||
}
|
||||
|
||||
for (let token of tokenizeInfo(body.trim())) {
|
||||
if (token.endsWith("}") && !token.includes("=")) token = token.slice(0, -1);
|
||||
if (!token) continue;
|
||||
|
||||
if (token.startsWith(".")) {
|
||||
attrs.classes.push(token.slice(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith("#")) {
|
||||
attrs.id = token.slice(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const eq = token.indexOf("=");
|
||||
if (eq > 0) {
|
||||
const key = token.slice(0, eq);
|
||||
const value = stripQuotes(token.slice(eq + 1));
|
||||
if (key === "id") attrs.id = value;
|
||||
attrs.values[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.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:
|
||||
'<aside class="semantic-error">Interactive block missing an id. Add <code>#int:name</code> or <code>id="name"</code>.</aside>',
|
||||
};
|
||||
}
|
||||
|
||||
const props = {
|
||||
id: componentId,
|
||||
label,
|
||||
caption,
|
||||
description,
|
||||
fallback,
|
||||
attrs: attrs.values,
|
||||
};
|
||||
|
||||
return {
|
||||
type: "html",
|
||||
value: [
|
||||
`<figure class="semantic-interactive" id="${attribute(label)}" data-interactive-label="${attribute(label)}">`,
|
||||
`<div class="interactive-mount" data-interactive-id="${attribute(componentId)}" data-interactive-props="${attribute(encodedJson(props))}">`,
|
||||
fallback ? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>` : "",
|
||||
"</div>",
|
||||
caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "",
|
||||
"</figure>",
|
||||
].join(""),
|
||||
};
|
||||
}
|
||||
|
||||
function envFromCode(node) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
const shorthandType = firstClass(attrs, new Set(["env", "latex-env"]));
|
||||
const explicitType = attrs.values.type || shorthandType;
|
||||
const type = ENV_TYPES.has(explicitType) ? explicitType : "";
|
||||
const isEnv = attrs.classes.includes("env") || attrs.classes.includes("latex-env") || Boolean(type);
|
||||
|
||||
if (!isEnv) return null;
|
||||
|
||||
if (!type) {
|
||||
return {
|
||||
type: "html",
|
||||
value:
|
||||
'<aside class="semantic-error">Environment block missing a type. Add <code>.theorem</code>, <code>.definition</code>, or <code>type="theorem"</code>.</aside>',
|
||||
};
|
||||
}
|
||||
|
||||
const title = attrs.values.title || attrs.values.name || "";
|
||||
const label = attrs.id || attrs.values.label || "";
|
||||
const renderMode = attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const heading = title ? `${ENV_LABELS[type]} (${title})` : ENV_LABELS[type];
|
||||
const parsed = fromMarkdown(node.value || "");
|
||||
const className = [
|
||||
"semantic-env",
|
||||
`semantic-env-${type}`,
|
||||
`semantic-env-${renderMode}`,
|
||||
renderMode === "callout" ? "callout" : "",
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
type: "blockquote",
|
||||
data: {
|
||||
hName: "section",
|
||||
hProperties: {
|
||||
id: label || undefined,
|
||||
className,
|
||||
"data-env-type": type,
|
||||
"data-env-title": title || undefined,
|
||||
"data-env-render": renderMode,
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "paragraph",
|
||||
data: {
|
||||
hProperties: {
|
||||
className: ["semantic-env-heading"],
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "strong",
|
||||
children: [{ type: "text", value: heading }],
|
||||
},
|
||||
],
|
||||
},
|
||||
...parsed.children,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function transformTree(parent) {
|
||||
if (!parent?.children) return;
|
||||
|
||||
for (let index = 0; index < parent.children.length; index += 1) {
|
||||
const child = parent.children[index];
|
||||
if (child.type === "code") {
|
||||
const replacement = interactiveFromCode(child) || envFromCode(child);
|
||||
if (replacement) {
|
||||
parent.children[index] = replacement;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
transformTree(child);
|
||||
}
|
||||
}
|
||||
|
||||
export default function remarkSemanticBlocks() {
|
||||
return (tree) => transformTree(tree);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
3
sites/reviews/src/components/InteractiveRuntime.astro
Normal file
3
sites/reviews/src/components/InteractiveRuntime.astro
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<script>
|
||||
import "@/interactives/runtime";
|
||||
</script>
|
||||
77
sites/reviews/src/interactives/runtime.tsx
Normal file
77
sites/reviews/src/interactives/runtime.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
type InteractiveProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
caption?: string;
|
||||
description?: string;
|
||||
fallback?: string;
|
||||
attrs?: Record<string, string>;
|
||||
};
|
||||
|
||||
type InteractiveModule = {
|
||||
default: React.ComponentType<InteractiveProps>;
|
||||
};
|
||||
|
||||
const modules = import.meta.glob<InteractiveModule>("./*/index.tsx");
|
||||
|
||||
function readProps(element: HTMLElement): InteractiveProps {
|
||||
const encoded = element.dataset.interactiveProps;
|
||||
if (!encoded) {
|
||||
return {
|
||||
id: element.dataset.interactiveId ?? "",
|
||||
label: "",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(encoded)) as InteractiveProps;
|
||||
} catch {
|
||||
return {
|
||||
id: element.dataset.interactiveId ?? "",
|
||||
label: "",
|
||||
fallback: element.textContent?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function mountInteractive(element: HTMLElement) {
|
||||
const id = element.dataset.interactiveId;
|
||||
if (!id || element.dataset.interactiveMounted) return;
|
||||
|
||||
const modulePath = `./${id}/index.tsx`;
|
||||
const loader = modules[modulePath];
|
||||
if (!loader) {
|
||||
element.dataset.interactiveState = "missing-component";
|
||||
return;
|
||||
}
|
||||
|
||||
element.dataset.interactiveMounted = "true";
|
||||
element.dataset.interactiveState = "loading";
|
||||
|
||||
try {
|
||||
const props = readProps(element);
|
||||
const { default: Component } = await loader();
|
||||
element.replaceChildren();
|
||||
createRoot(element).render(<Component {...props} />);
|
||||
element.dataset.interactiveState = "ready";
|
||||
} catch (error) {
|
||||
element.dataset.interactiveState = "failed";
|
||||
console.error(`Failed to mount interactive "${id}"`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function mountAllInteractives() {
|
||||
document
|
||||
.querySelectorAll<HTMLElement>(".interactive-mount[data-interactive-id]")
|
||||
.forEach((element) => {
|
||||
void mountInteractive(element);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", mountAllInteractives, { once: true });
|
||||
} else {
|
||||
mountAllInteractives();
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import CoralComments from '@/components/CoralComments.astro';
|
||||
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
|
||||
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
||||
import { BlogPost } from '@/components/sections/blog-post';
|
||||
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
||||
|
|
@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
|
|||
<Content />
|
||||
</BlogPost>
|
||||
|
||||
<InteractiveRuntime />
|
||||
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
||||
</DefaultLayout>
|
||||
|
|
|
|||
232
sites/reviews/src/remark/semantic-blocks.mjs
Normal file
232
sites/reviews/src/remark/semantic-blocks.mjs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
|
||||
const ENV_TYPES = new Set([
|
||||
"theorem",
|
||||
"lemma",
|
||||
"proposition",
|
||||
"corollary",
|
||||
"definition",
|
||||
"example",
|
||||
"remark",
|
||||
"proof",
|
||||
]);
|
||||
|
||||
const ENV_LABELS = {
|
||||
theorem: "Theorem",
|
||||
lemma: "Lemma",
|
||||
proposition: "Proposition",
|
||||
corollary: "Corollary",
|
||||
definition: "Definition",
|
||||
example: "Example",
|
||||
remark: "Remark",
|
||||
proof: "Proof",
|
||||
};
|
||||
|
||||
function tokenizeInfo(info) {
|
||||
return (
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function stripQuotes(value) {
|
||||
if (!value) return "";
|
||||
const first = value[0];
|
||||
const last = value[value.length - 1];
|
||||
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseFenceInfo(node) {
|
||||
const lang = node.lang ?? "";
|
||||
const meta = node.meta ?? "";
|
||||
const raw = `${lang} ${meta}`.trim();
|
||||
const attrs = {
|
||||
classes: [],
|
||||
id: "",
|
||||
values: {},
|
||||
raw,
|
||||
};
|
||||
|
||||
if (!raw) return attrs;
|
||||
|
||||
let body = raw;
|
||||
if (body.startsWith("{")) {
|
||||
body = body.slice(1);
|
||||
if (body.endsWith("}")) body = body.slice(0, -1);
|
||||
} else if (lang) {
|
||||
attrs.classes.push(lang);
|
||||
body = meta;
|
||||
}
|
||||
|
||||
for (let token of tokenizeInfo(body.trim())) {
|
||||
if (token.endsWith("}") && !token.includes("=")) token = token.slice(0, -1);
|
||||
if (!token) continue;
|
||||
|
||||
if (token.startsWith(".")) {
|
||||
attrs.classes.push(token.slice(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith("#")) {
|
||||
attrs.id = token.slice(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const eq = token.indexOf("=");
|
||||
if (eq > 0) {
|
||||
const key = token.slice(0, eq);
|
||||
const value = stripQuotes(token.slice(eq + 1));
|
||||
if (key === "id") attrs.id = value;
|
||||
attrs.values[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.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:
|
||||
'<aside class="semantic-error">Interactive block missing an id. Add <code>#int:name</code> or <code>id="name"</code>.</aside>',
|
||||
};
|
||||
}
|
||||
|
||||
const props = {
|
||||
id: componentId,
|
||||
label,
|
||||
caption,
|
||||
description,
|
||||
fallback,
|
||||
attrs: attrs.values,
|
||||
};
|
||||
|
||||
return {
|
||||
type: "html",
|
||||
value: [
|
||||
`<figure class="semantic-interactive" id="${attribute(label)}" data-interactive-label="${attribute(label)}">`,
|
||||
`<div class="interactive-mount" data-interactive-id="${attribute(componentId)}" data-interactive-props="${attribute(encodedJson(props))}">`,
|
||||
fallback ? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>` : "",
|
||||
"</div>",
|
||||
caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "",
|
||||
"</figure>",
|
||||
].join(""),
|
||||
};
|
||||
}
|
||||
|
||||
function envFromCode(node) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
const shorthandType = firstClass(attrs, new Set(["env", "latex-env"]));
|
||||
const explicitType = attrs.values.type || shorthandType;
|
||||
const type = ENV_TYPES.has(explicitType) ? explicitType : "";
|
||||
const isEnv = attrs.classes.includes("env") || attrs.classes.includes("latex-env") || Boolean(type);
|
||||
|
||||
if (!isEnv) return null;
|
||||
|
||||
if (!type) {
|
||||
return {
|
||||
type: "html",
|
||||
value:
|
||||
'<aside class="semantic-error">Environment block missing a type. Add <code>.theorem</code>, <code>.definition</code>, or <code>type="theorem"</code>.</aside>',
|
||||
};
|
||||
}
|
||||
|
||||
const title = attrs.values.title || attrs.values.name || "";
|
||||
const label = attrs.id || attrs.values.label || "";
|
||||
const renderMode = attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const heading = title ? `${ENV_LABELS[type]} (${title})` : ENV_LABELS[type];
|
||||
const parsed = fromMarkdown(node.value || "");
|
||||
const className = [
|
||||
"semantic-env",
|
||||
`semantic-env-${type}`,
|
||||
`semantic-env-${renderMode}`,
|
||||
renderMode === "callout" ? "callout" : "",
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
type: "blockquote",
|
||||
data: {
|
||||
hName: "section",
|
||||
hProperties: {
|
||||
id: label || undefined,
|
||||
className,
|
||||
"data-env-type": type,
|
||||
"data-env-title": title || undefined,
|
||||
"data-env-render": renderMode,
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "paragraph",
|
||||
data: {
|
||||
hProperties: {
|
||||
className: ["semantic-env-heading"],
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "strong",
|
||||
children: [{ type: "text", value: heading }],
|
||||
},
|
||||
],
|
||||
},
|
||||
...parsed.children,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function transformTree(parent) {
|
||||
if (!parent?.children) return;
|
||||
|
||||
for (let index = 0; index < parent.children.length; index += 1) {
|
||||
const child = parent.children[index];
|
||||
if (child.type === "code") {
|
||||
const replacement = interactiveFromCode(child) || envFromCode(child);
|
||||
if (replacement) {
|
||||
parent.children[index] = replacement;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
transformTree(child);
|
||||
}
|
||||
}
|
||||
|
||||
export default function remarkSemanticBlocks() {
|
||||
return (tree) => transformTree(tree);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
3
sites/vibes/src/components/InteractiveRuntime.astro
Normal file
3
sites/vibes/src/components/InteractiveRuntime.astro
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<script>
|
||||
import "@/interactives/runtime";
|
||||
</script>
|
||||
77
sites/vibes/src/interactives/runtime.tsx
Normal file
77
sites/vibes/src/interactives/runtime.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
type InteractiveProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
caption?: string;
|
||||
description?: string;
|
||||
fallback?: string;
|
||||
attrs?: Record<string, string>;
|
||||
};
|
||||
|
||||
type InteractiveModule = {
|
||||
default: React.ComponentType<InteractiveProps>;
|
||||
};
|
||||
|
||||
const modules = import.meta.glob<InteractiveModule>("./*/index.tsx");
|
||||
|
||||
function readProps(element: HTMLElement): InteractiveProps {
|
||||
const encoded = element.dataset.interactiveProps;
|
||||
if (!encoded) {
|
||||
return {
|
||||
id: element.dataset.interactiveId ?? "",
|
||||
label: "",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(encoded)) as InteractiveProps;
|
||||
} catch {
|
||||
return {
|
||||
id: element.dataset.interactiveId ?? "",
|
||||
label: "",
|
||||
fallback: element.textContent?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function mountInteractive(element: HTMLElement) {
|
||||
const id = element.dataset.interactiveId;
|
||||
if (!id || element.dataset.interactiveMounted) return;
|
||||
|
||||
const modulePath = `./${id}/index.tsx`;
|
||||
const loader = modules[modulePath];
|
||||
if (!loader) {
|
||||
element.dataset.interactiveState = "missing-component";
|
||||
return;
|
||||
}
|
||||
|
||||
element.dataset.interactiveMounted = "true";
|
||||
element.dataset.interactiveState = "loading";
|
||||
|
||||
try {
|
||||
const props = readProps(element);
|
||||
const { default: Component } = await loader();
|
||||
element.replaceChildren();
|
||||
createRoot(element).render(<Component {...props} />);
|
||||
element.dataset.interactiveState = "ready";
|
||||
} catch (error) {
|
||||
element.dataset.interactiveState = "failed";
|
||||
console.error(`Failed to mount interactive "${id}"`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function mountAllInteractives() {
|
||||
document
|
||||
.querySelectorAll<HTMLElement>(".interactive-mount[data-interactive-id]")
|
||||
.forEach((element) => {
|
||||
void mountInteractive(element);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", mountAllInteractives, { once: true });
|
||||
} else {
|
||||
mountAllInteractives();
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import CoralComments from '@/components/CoralComments.astro';
|
||||
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
|
||||
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
||||
import { BlogPost } from '@/components/sections/blog-post';
|
||||
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
||||
|
|
@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
|
|||
<Content />
|
||||
</BlogPost>
|
||||
|
||||
<InteractiveRuntime />
|
||||
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
||||
</DefaultLayout>
|
||||
|
|
|
|||
232
sites/vibes/src/remark/semantic-blocks.mjs
Normal file
232
sites/vibes/src/remark/semantic-blocks.mjs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
|
||||
const ENV_TYPES = new Set([
|
||||
"theorem",
|
||||
"lemma",
|
||||
"proposition",
|
||||
"corollary",
|
||||
"definition",
|
||||
"example",
|
||||
"remark",
|
||||
"proof",
|
||||
]);
|
||||
|
||||
const ENV_LABELS = {
|
||||
theorem: "Theorem",
|
||||
lemma: "Lemma",
|
||||
proposition: "Proposition",
|
||||
corollary: "Corollary",
|
||||
definition: "Definition",
|
||||
example: "Example",
|
||||
remark: "Remark",
|
||||
proof: "Proof",
|
||||
};
|
||||
|
||||
function tokenizeInfo(info) {
|
||||
return (
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function stripQuotes(value) {
|
||||
if (!value) return "";
|
||||
const first = value[0];
|
||||
const last = value[value.length - 1];
|
||||
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseFenceInfo(node) {
|
||||
const lang = node.lang ?? "";
|
||||
const meta = node.meta ?? "";
|
||||
const raw = `${lang} ${meta}`.trim();
|
||||
const attrs = {
|
||||
classes: [],
|
||||
id: "",
|
||||
values: {},
|
||||
raw,
|
||||
};
|
||||
|
||||
if (!raw) return attrs;
|
||||
|
||||
let body = raw;
|
||||
if (body.startsWith("{")) {
|
||||
body = body.slice(1);
|
||||
if (body.endsWith("}")) body = body.slice(0, -1);
|
||||
} else if (lang) {
|
||||
attrs.classes.push(lang);
|
||||
body = meta;
|
||||
}
|
||||
|
||||
for (let token of tokenizeInfo(body.trim())) {
|
||||
if (token.endsWith("}") && !token.includes("=")) token = token.slice(0, -1);
|
||||
if (!token) continue;
|
||||
|
||||
if (token.startsWith(".")) {
|
||||
attrs.classes.push(token.slice(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith("#")) {
|
||||
attrs.id = token.slice(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const eq = token.indexOf("=");
|
||||
if (eq > 0) {
|
||||
const key = token.slice(0, eq);
|
||||
const value = stripQuotes(token.slice(eq + 1));
|
||||
if (key === "id") attrs.id = value;
|
||||
attrs.values[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.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:
|
||||
'<aside class="semantic-error">Interactive block missing an id. Add <code>#int:name</code> or <code>id="name"</code>.</aside>',
|
||||
};
|
||||
}
|
||||
|
||||
const props = {
|
||||
id: componentId,
|
||||
label,
|
||||
caption,
|
||||
description,
|
||||
fallback,
|
||||
attrs: attrs.values,
|
||||
};
|
||||
|
||||
return {
|
||||
type: "html",
|
||||
value: [
|
||||
`<figure class="semantic-interactive" id="${attribute(label)}" data-interactive-label="${attribute(label)}">`,
|
||||
`<div class="interactive-mount" data-interactive-id="${attribute(componentId)}" data-interactive-props="${attribute(encodedJson(props))}">`,
|
||||
fallback ? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>` : "",
|
||||
"</div>",
|
||||
caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "",
|
||||
"</figure>",
|
||||
].join(""),
|
||||
};
|
||||
}
|
||||
|
||||
function envFromCode(node) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
const shorthandType = firstClass(attrs, new Set(["env", "latex-env"]));
|
||||
const explicitType = attrs.values.type || shorthandType;
|
||||
const type = ENV_TYPES.has(explicitType) ? explicitType : "";
|
||||
const isEnv = attrs.classes.includes("env") || attrs.classes.includes("latex-env") || Boolean(type);
|
||||
|
||||
if (!isEnv) return null;
|
||||
|
||||
if (!type) {
|
||||
return {
|
||||
type: "html",
|
||||
value:
|
||||
'<aside class="semantic-error">Environment block missing a type. Add <code>.theorem</code>, <code>.definition</code>, or <code>type="theorem"</code>.</aside>',
|
||||
};
|
||||
}
|
||||
|
||||
const title = attrs.values.title || attrs.values.name || "";
|
||||
const label = attrs.id || attrs.values.label || "";
|
||||
const renderMode = attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const heading = title ? `${ENV_LABELS[type]} (${title})` : ENV_LABELS[type];
|
||||
const parsed = fromMarkdown(node.value || "");
|
||||
const className = [
|
||||
"semantic-env",
|
||||
`semantic-env-${type}`,
|
||||
`semantic-env-${renderMode}`,
|
||||
renderMode === "callout" ? "callout" : "",
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
type: "blockquote",
|
||||
data: {
|
||||
hName: "section",
|
||||
hProperties: {
|
||||
id: label || undefined,
|
||||
className,
|
||||
"data-env-type": type,
|
||||
"data-env-title": title || undefined,
|
||||
"data-env-render": renderMode,
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "paragraph",
|
||||
data: {
|
||||
hProperties: {
|
||||
className: ["semantic-env-heading"],
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "strong",
|
||||
children: [{ type: "text", value: heading }],
|
||||
},
|
||||
],
|
||||
},
|
||||
...parsed.children,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function transformTree(parent) {
|
||||
if (!parent?.children) return;
|
||||
|
||||
for (let index = 0; index < parent.children.length; index += 1) {
|
||||
const child = parent.children[index];
|
||||
if (child.type === "code") {
|
||||
const replacement = interactiveFromCode(child) || envFromCode(child);
|
||||
if (replacement) {
|
||||
parent.children[index] = replacement;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
transformTree(child);
|
||||
}
|
||||
}
|
||||
|
||||
export default function remarkSemanticBlocks() {
|
||||
return (tree) => transformTree(tree);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue