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
|
> **Aside:** text
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Semantic Blocks
|
||||||
|
|
||||||
|
Use Pandoc-style fenced attributes for blocks that need to render differently
|
||||||
|
in HTML and PDF. This is the preferred form because it works cleanly with both
|
||||||
|
Astro and Pandoc/LuaLaTeX.
|
||||||
|
|
||||||
|
### Interactive Blocks
|
||||||
|
|
||||||
|
An interactive block reserves a place in the post for a React component and
|
||||||
|
also supplies fallback text for PDF or non-JavaScript rendering:
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
```{.interactive #int:tape-layout caption="Tape layout explorer"}
|
||||||
|
Drag files along a tape and compare the access cost of each arrangement.
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
The id after `#` is the semantic label. For interactives, use the `int:` prefix.
|
||||||
|
The component id is inferred by removing that prefix, so the example above
|
||||||
|
mounts this component when it exists:
|
||||||
|
|
||||||
|
```text
|
||||||
|
sites/<blog>/src/interactives/tape-layout/index.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
The component receives:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
id: "tape-layout",
|
||||||
|
label: "int:tape-layout",
|
||||||
|
caption: "Tape layout explorer",
|
||||||
|
description: "Drag files along a tape...",
|
||||||
|
fallback: "Drag files along a tape...",
|
||||||
|
attrs: { caption: "Tape layout explorer" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If the component is not present, Astro renders the fallback text in the
|
||||||
|
interactive frame. For a different PDF fallback, add `pdf="..."`:
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
```{.interactive #int:tape-layout caption="Tape layout explorer" pdf="The HTML version contains an interactive tape layout explorer."}
|
||||||
|
Implementation notes for the interactive can go here.
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
Astro also accepts the shorter Obsidian-friendly form:
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
```interactive id="tape-layout" caption="Tape layout explorer"
|
||||||
|
Drag files along a tape and compare the access cost.
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
Use the Pandoc-style form for posts that should export to PDF.
|
||||||
|
|
||||||
|
### LaTeX-Style Environments
|
||||||
|
|
||||||
|
Use `.env` plus the environment class and a label:
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
```{.env .theorem #thm:hall title="Hall's theorem" html="callout"}
|
||||||
|
Every bipartite graph satisfying Hall's condition has a matching that covers
|
||||||
|
the left side.
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
Supported environment types are:
|
||||||
|
|
||||||
|
```text
|
||||||
|
theorem, lemma, proposition, corollary, definition, example, remark, proof
|
||||||
|
```
|
||||||
|
|
||||||
|
In Astro, `html="callout"` renders the block like a callout. `html="plain"`
|
||||||
|
renders it as a quieter left-rule block. If `html` is omitted, theorem-like
|
||||||
|
blocks default to `callout`, and `proof` defaults to `plain`.
|
||||||
|
|
||||||
|
In PDF, the same block becomes a real LuaLaTeX environment:
|
||||||
|
|
||||||
|
```tex
|
||||||
|
\begin{theorem}[Hall's theorem]
|
||||||
|
\label{thm:hall}
|
||||||
|
...
|
||||||
|
\end{theorem}
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro also accepts shorter forms when PDF export is not needed:
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
```theorem id="thm:hall" title="Hall's theorem"
|
||||||
|
Every bipartite graph satisfying Hall's condition has a matching...
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
### Linking To Semantic Blocks
|
||||||
|
|
||||||
|
Use ordinary Markdown links to semantic ids:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
The proof of [Hall's theorem](#thm:hall) is constructive.
|
||||||
|
See the [tape explorer](#int:tape-layout) for an interactive version.
|
||||||
|
```
|
||||||
|
|
||||||
|
Astro renders these as normal HTML links. The PDF pipeline converts internal
|
||||||
|
links whose target starts with `#` into `\cref{...}`, so the first link above
|
||||||
|
becomes `\cref{thm:hall}`.
|
||||||
|
|
||||||
|
Use these label prefixes consistently:
|
||||||
|
|
||||||
|
```text
|
||||||
|
thm: theorem
|
||||||
|
lem: lemma
|
||||||
|
prop: proposition
|
||||||
|
cor: corollary
|
||||||
|
def: definition
|
||||||
|
ex: example
|
||||||
|
rem: remark
|
||||||
|
prf: proof
|
||||||
|
int: interactive
|
||||||
|
fig: figure
|
||||||
|
```
|
||||||
|
|
||||||
## Import Rules
|
## Import Rules
|
||||||
|
|
||||||
The import script is:
|
The import script is:
|
||||||
|
|
@ -168,3 +291,23 @@ Publish does three things:
|
||||||
1. Sync vault content into the local repo.
|
1. Sync vault content into the local repo.
|
||||||
2. Run the configured Astro build command.
|
2. Run the configured Astro build command.
|
||||||
3. Commit and push to Forgejo, where Dokploy picks up the change.
|
3. Commit and push to Forgejo, where Dokploy picks up the change.
|
||||||
|
|
||||||
|
## PDF Export
|
||||||
|
|
||||||
|
PDF export uses Pandoc plus LuaLaTeX:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run pdf:post -- sites/research/src/content/research/example/index.md --out /tmp/example.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
The command runs:
|
||||||
|
|
||||||
|
- `scripts/pandoc-filters/semantic-blocks.lua` to convert interactive and
|
||||||
|
environment fences.
|
||||||
|
- `scripts/pandoc-templates/semantic-preamble.tex` to load `amsthm`,
|
||||||
|
`cleveref`, and the shared theorem definitions.
|
||||||
|
|
||||||
|
The PDF route is intentionally separate from Astro. Astro owns HTML rendering;
|
||||||
|
Pandoc owns PDF rendering. The Markdown conventions above are the shared
|
||||||
|
contract between both outputs and can be reused for future site families such
|
||||||
|
as `neeldhara.courses` and `books.neeldhara.com`.
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"build": "node scripts/build-site-packages.mjs",
|
"build": "node scripts/build-site-packages.mjs",
|
||||||
"build:sites": "node scripts/build-site-packages.mjs",
|
"build:sites": "node scripts/build-site-packages.mjs",
|
||||||
"build:legacy": "node scripts/build-all-sites.mjs",
|
"build:legacy": "node scripts/build-all-sites.mjs",
|
||||||
|
"pdf:post": "node scripts/export-pdf.mjs",
|
||||||
"preview": "npm run preview --prefix sites/research",
|
"preview": "npm run preview --prefix sites/research",
|
||||||
"astro": "astro",
|
"astro": "astro",
|
||||||
"format": "prettier -w ."
|
"format": "prettier -w ."
|
||||||
|
|
|
||||||
64
scripts/export-pdf.mjs
Normal file
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 remarkCallouts from "./src/remark/callouts.mjs";
|
||||||
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
|
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
|
||||||
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
|
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
|
||||||
|
import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs";
|
||||||
import remarkTypography from "./src/remark/typography.mjs";
|
import remarkTypography from "./src/remark/typography.mjs";
|
||||||
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
|
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
|
||||||
|
|
||||||
|
|
@ -19,6 +20,7 @@ const remarkPlugins = [
|
||||||
remarkGfm,
|
remarkGfm,
|
||||||
remarkInlineFootnotes,
|
remarkInlineFootnotes,
|
||||||
remarkCodeFenceLanguages,
|
remarkCodeFenceLanguages,
|
||||||
|
remarkSemanticBlocks,
|
||||||
remarkMath,
|
remarkMath,
|
||||||
remarkTypography,
|
remarkTypography,
|
||||||
remarkCallouts,
|
remarkCallouts,
|
||||||
|
|
|
||||||
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 { getCollection, render } from 'astro:content';
|
||||||
import CoralComments from '@/components/CoralComments.astro';
|
import CoralComments from '@/components/CoralComments.astro';
|
||||||
|
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
|
||||||
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
||||||
import { BlogPost } from '@/components/sections/blog-post';
|
import { BlogPost } from '@/components/sections/blog-post';
|
||||||
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
||||||
|
|
@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
|
||||||
<Content />
|
<Content />
|
||||||
</BlogPost>
|
</BlogPost>
|
||||||
|
|
||||||
|
<InteractiveRuntime />
|
||||||
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
|
|
|
||||||
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;
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env) {
|
||||||
|
scroll-margin-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-heading) {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground)));
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-heading strong) {
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-plain) {
|
||||||
|
margin-block: 1.6rem;
|
||||||
|
border-left: 3px solid hsl(var(--primary));
|
||||||
|
padding: 0.15rem 0 0.15rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-plain > *:first-child) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-plain > *:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-error) {
|
||||||
|
margin-block: 1.5rem;
|
||||||
|
border: 1px solid color-mix(in srgb, hsl(var(--destructive)) 50%, hsl(var(--border)));
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
background: color-mix(in srgb, hsl(var(--destructive)) 8%, transparent);
|
||||||
|
padding: 1rem;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
.article-content :where(.callout hr) {
|
.article-content :where(.callout hr) {
|
||||||
margin-block: 1rem;
|
margin-block: 1rem;
|
||||||
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
|
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
|
||||||
|
|
@ -248,6 +290,33 @@
|
||||||
margin-inline: 0;
|
margin-inline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-interactive) {
|
||||||
|
margin-block: 2rem;
|
||||||
|
scroll-margin-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.interactive-mount) {
|
||||||
|
min-height: 12rem;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
background: color-mix(in srgb, hsl(var(--accent)) 20%, transparent);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.interactive-mount[data-interactive-state="missing-component"]) {
|
||||||
|
display: grid;
|
||||||
|
min-height: 8rem;
|
||||||
|
place-items: center;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.interactive-fallback) {
|
||||||
|
margin: 0;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
.article-content :where(figcaption) {
|
.article-content :where(figcaption) {
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import tailwindcss from "@tailwindcss/vite";
|
||||||
import remarkCallouts from "./src/remark/callouts.mjs";
|
import remarkCallouts from "./src/remark/callouts.mjs";
|
||||||
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
|
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
|
||||||
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
|
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
|
||||||
|
import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs";
|
||||||
import remarkTypography from "./src/remark/typography.mjs";
|
import remarkTypography from "./src/remark/typography.mjs";
|
||||||
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
|
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
|
||||||
|
|
||||||
|
|
@ -19,6 +20,7 @@ const remarkPlugins = [
|
||||||
remarkGfm,
|
remarkGfm,
|
||||||
remarkInlineFootnotes,
|
remarkInlineFootnotes,
|
||||||
remarkCodeFenceLanguages,
|
remarkCodeFenceLanguages,
|
||||||
|
remarkSemanticBlocks,
|
||||||
remarkMath,
|
remarkMath,
|
||||||
remarkTypography,
|
remarkTypography,
|
||||||
remarkCallouts,
|
remarkCallouts,
|
||||||
|
|
|
||||||
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 { getCollection, render } from 'astro:content';
|
||||||
import CoralComments from '@/components/CoralComments.astro';
|
import CoralComments from '@/components/CoralComments.astro';
|
||||||
|
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
|
||||||
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
||||||
import { BlogPost } from '@/components/sections/blog-post';
|
import { BlogPost } from '@/components/sections/blog-post';
|
||||||
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
||||||
|
|
@ -28,5 +29,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
|
||||||
</BlogPost>
|
</BlogPost>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<InteractiveRuntime />
|
||||||
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
|
|
|
||||||
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;
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env) {
|
||||||
|
scroll-margin-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-heading) {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground)));
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-heading strong) {
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-plain) {
|
||||||
|
margin-block: 1.6rem;
|
||||||
|
border-left: 3px solid hsl(var(--primary));
|
||||||
|
padding: 0.15rem 0 0.15rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-plain > *:first-child) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-plain > *:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-error) {
|
||||||
|
margin-block: 1.5rem;
|
||||||
|
border: 1px solid color-mix(in srgb, hsl(var(--destructive)) 50%, hsl(var(--border)));
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
background: color-mix(in srgb, hsl(var(--destructive)) 8%, transparent);
|
||||||
|
padding: 1rem;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
.article-content :where(.callout hr) {
|
.article-content :where(.callout hr) {
|
||||||
margin-block: 1rem;
|
margin-block: 1rem;
|
||||||
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
|
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
|
||||||
|
|
@ -248,6 +290,33 @@
|
||||||
margin-inline: 0;
|
margin-inline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-interactive) {
|
||||||
|
margin-block: 2rem;
|
||||||
|
scroll-margin-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.interactive-mount) {
|
||||||
|
min-height: 12rem;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
background: color-mix(in srgb, hsl(var(--accent)) 20%, transparent);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.interactive-mount[data-interactive-state="missing-component"]) {
|
||||||
|
display: grid;
|
||||||
|
min-height: 8rem;
|
||||||
|
place-items: center;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.interactive-fallback) {
|
||||||
|
margin: 0;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
.article-content :where(figcaption) {
|
.article-content :where(figcaption) {
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import tailwindcss from "@tailwindcss/vite";
|
||||||
import remarkCallouts from "./src/remark/callouts.mjs";
|
import remarkCallouts from "./src/remark/callouts.mjs";
|
||||||
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
|
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
|
||||||
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
|
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
|
||||||
|
import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs";
|
||||||
import remarkTypography from "./src/remark/typography.mjs";
|
import remarkTypography from "./src/remark/typography.mjs";
|
||||||
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
|
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
|
||||||
|
|
||||||
|
|
@ -19,6 +20,7 @@ const remarkPlugins = [
|
||||||
remarkGfm,
|
remarkGfm,
|
||||||
remarkInlineFootnotes,
|
remarkInlineFootnotes,
|
||||||
remarkCodeFenceLanguages,
|
remarkCodeFenceLanguages,
|
||||||
|
remarkSemanticBlocks,
|
||||||
remarkMath,
|
remarkMath,
|
||||||
remarkTypography,
|
remarkTypography,
|
||||||
remarkCallouts,
|
remarkCallouts,
|
||||||
|
|
|
||||||
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 { getCollection, render } from 'astro:content';
|
||||||
import CoralComments from '@/components/CoralComments.astro';
|
import CoralComments from '@/components/CoralComments.astro';
|
||||||
|
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
|
||||||
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
||||||
import { BlogPost } from '@/components/sections/blog-post';
|
import { BlogPost } from '@/components/sections/blog-post';
|
||||||
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
||||||
|
|
@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
|
||||||
<Content />
|
<Content />
|
||||||
</BlogPost>
|
</BlogPost>
|
||||||
|
|
||||||
|
<InteractiveRuntime />
|
||||||
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
|
|
|
||||||
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;
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env) {
|
||||||
|
scroll-margin-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-heading) {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground)));
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-heading strong) {
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-plain) {
|
||||||
|
margin-block: 1.6rem;
|
||||||
|
border-left: 3px solid hsl(var(--primary));
|
||||||
|
padding: 0.15rem 0 0.15rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-plain > *:first-child) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-plain > *:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-error) {
|
||||||
|
margin-block: 1.5rem;
|
||||||
|
border: 1px solid color-mix(in srgb, hsl(var(--destructive)) 50%, hsl(var(--border)));
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
background: color-mix(in srgb, hsl(var(--destructive)) 8%, transparent);
|
||||||
|
padding: 1rem;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
.article-content :where(.callout hr) {
|
.article-content :where(.callout hr) {
|
||||||
margin-block: 1rem;
|
margin-block: 1rem;
|
||||||
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
|
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
|
||||||
|
|
@ -248,6 +290,33 @@
|
||||||
margin-inline: 0;
|
margin-inline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-interactive) {
|
||||||
|
margin-block: 2rem;
|
||||||
|
scroll-margin-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.interactive-mount) {
|
||||||
|
min-height: 12rem;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
background: color-mix(in srgb, hsl(var(--accent)) 20%, transparent);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.interactive-mount[data-interactive-state="missing-component"]) {
|
||||||
|
display: grid;
|
||||||
|
min-height: 8rem;
|
||||||
|
place-items: center;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.interactive-fallback) {
|
||||||
|
margin: 0;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
.article-content :where(figcaption) {
|
.article-content :where(figcaption) {
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import tailwindcss from "@tailwindcss/vite";
|
||||||
import remarkCallouts from "./src/remark/callouts.mjs";
|
import remarkCallouts from "./src/remark/callouts.mjs";
|
||||||
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
|
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
|
||||||
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
|
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
|
||||||
|
import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs";
|
||||||
import remarkTypography from "./src/remark/typography.mjs";
|
import remarkTypography from "./src/remark/typography.mjs";
|
||||||
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
|
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
|
||||||
|
|
||||||
|
|
@ -19,6 +20,7 @@ const remarkPlugins = [
|
||||||
remarkGfm,
|
remarkGfm,
|
||||||
remarkInlineFootnotes,
|
remarkInlineFootnotes,
|
||||||
remarkCodeFenceLanguages,
|
remarkCodeFenceLanguages,
|
||||||
|
remarkSemanticBlocks,
|
||||||
remarkMath,
|
remarkMath,
|
||||||
remarkTypography,
|
remarkTypography,
|
||||||
remarkCallouts,
|
remarkCallouts,
|
||||||
|
|
|
||||||
|
|
@ -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 { getCollection, render } from 'astro:content';
|
||||||
import CoralComments from '@/components/CoralComments.astro';
|
import CoralComments from '@/components/CoralComments.astro';
|
||||||
|
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
|
||||||
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
||||||
import { BlogPost } from '@/components/sections/blog-post';
|
import { BlogPost } from '@/components/sections/blog-post';
|
||||||
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
||||||
|
|
@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
|
||||||
<Content />
|
<Content />
|
||||||
</BlogPost>
|
</BlogPost>
|
||||||
|
|
||||||
|
<InteractiveRuntime />
|
||||||
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
|
|
|
||||||
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;
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env) {
|
||||||
|
scroll-margin-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-heading) {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground)));
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-heading strong) {
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-plain) {
|
||||||
|
margin-block: 1.6rem;
|
||||||
|
border-left: 3px solid hsl(var(--primary));
|
||||||
|
padding: 0.15rem 0 0.15rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-plain > *:first-child) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-plain > *:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-error) {
|
||||||
|
margin-block: 1.5rem;
|
||||||
|
border: 1px solid color-mix(in srgb, hsl(var(--destructive)) 50%, hsl(var(--border)));
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
background: color-mix(in srgb, hsl(var(--destructive)) 8%, transparent);
|
||||||
|
padding: 1rem;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
.article-content :where(.callout hr) {
|
.article-content :where(.callout hr) {
|
||||||
margin-block: 1rem;
|
margin-block: 1rem;
|
||||||
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
|
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
|
||||||
|
|
@ -248,6 +290,33 @@
|
||||||
margin-inline: 0;
|
margin-inline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-interactive) {
|
||||||
|
margin-block: 2rem;
|
||||||
|
scroll-margin-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.interactive-mount) {
|
||||||
|
min-height: 12rem;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
background: color-mix(in srgb, hsl(var(--accent)) 20%, transparent);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.interactive-mount[data-interactive-state="missing-component"]) {
|
||||||
|
display: grid;
|
||||||
|
min-height: 8rem;
|
||||||
|
place-items: center;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.interactive-fallback) {
|
||||||
|
margin: 0;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
.article-content :where(figcaption) {
|
.article-content :where(figcaption) {
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import tailwindcss from "@tailwindcss/vite";
|
||||||
import remarkCallouts from "./src/remark/callouts.mjs";
|
import remarkCallouts from "./src/remark/callouts.mjs";
|
||||||
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
|
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
|
||||||
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
|
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
|
||||||
|
import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs";
|
||||||
import remarkTypography from "./src/remark/typography.mjs";
|
import remarkTypography from "./src/remark/typography.mjs";
|
||||||
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
|
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
|
||||||
|
|
||||||
|
|
@ -19,6 +20,7 @@ const remarkPlugins = [
|
||||||
remarkGfm,
|
remarkGfm,
|
||||||
remarkInlineFootnotes,
|
remarkInlineFootnotes,
|
||||||
remarkCodeFenceLanguages,
|
remarkCodeFenceLanguages,
|
||||||
|
remarkSemanticBlocks,
|
||||||
remarkMath,
|
remarkMath,
|
||||||
remarkTypography,
|
remarkTypography,
|
||||||
remarkCallouts,
|
remarkCallouts,
|
||||||
|
|
|
||||||
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 { getCollection, render } from 'astro:content';
|
||||||
import CoralComments from '@/components/CoralComments.astro';
|
import CoralComments from '@/components/CoralComments.astro';
|
||||||
|
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
|
||||||
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
||||||
import { BlogPost } from '@/components/sections/blog-post';
|
import { BlogPost } from '@/components/sections/blog-post';
|
||||||
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
||||||
|
|
@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
|
||||||
<Content />
|
<Content />
|
||||||
</BlogPost>
|
</BlogPost>
|
||||||
|
|
||||||
|
<InteractiveRuntime />
|
||||||
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
|
|
|
||||||
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;
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env) {
|
||||||
|
scroll-margin-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-heading) {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground)));
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-heading strong) {
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-plain) {
|
||||||
|
margin-block: 1.6rem;
|
||||||
|
border-left: 3px solid hsl(var(--primary));
|
||||||
|
padding: 0.15rem 0 0.15rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-plain > *:first-child) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-plain > *:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-error) {
|
||||||
|
margin-block: 1.5rem;
|
||||||
|
border: 1px solid color-mix(in srgb, hsl(var(--destructive)) 50%, hsl(var(--border)));
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
background: color-mix(in srgb, hsl(var(--destructive)) 8%, transparent);
|
||||||
|
padding: 1rem;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
.article-content :where(.callout hr) {
|
.article-content :where(.callout hr) {
|
||||||
margin-block: 1rem;
|
margin-block: 1rem;
|
||||||
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
|
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
|
||||||
|
|
@ -248,6 +290,33 @@
|
||||||
margin-inline: 0;
|
margin-inline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-interactive) {
|
||||||
|
margin-block: 2rem;
|
||||||
|
scroll-margin-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.interactive-mount) {
|
||||||
|
min-height: 12rem;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
background: color-mix(in srgb, hsl(var(--accent)) 20%, transparent);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.interactive-mount[data-interactive-state="missing-component"]) {
|
||||||
|
display: grid;
|
||||||
|
min-height: 8rem;
|
||||||
|
place-items: center;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.interactive-fallback) {
|
||||||
|
margin: 0;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
.article-content :where(figcaption) {
|
.article-content :where(figcaption) {
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import tailwindcss from "@tailwindcss/vite";
|
||||||
import remarkCallouts from "./src/remark/callouts.mjs";
|
import remarkCallouts from "./src/remark/callouts.mjs";
|
||||||
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
|
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
|
||||||
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
|
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
|
||||||
|
import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs";
|
||||||
import remarkTypography from "./src/remark/typography.mjs";
|
import remarkTypography from "./src/remark/typography.mjs";
|
||||||
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
|
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
|
||||||
|
|
||||||
|
|
@ -19,6 +20,7 @@ const remarkPlugins = [
|
||||||
remarkGfm,
|
remarkGfm,
|
||||||
remarkInlineFootnotes,
|
remarkInlineFootnotes,
|
||||||
remarkCodeFenceLanguages,
|
remarkCodeFenceLanguages,
|
||||||
|
remarkSemanticBlocks,
|
||||||
remarkMath,
|
remarkMath,
|
||||||
remarkTypography,
|
remarkTypography,
|
||||||
remarkCallouts,
|
remarkCallouts,
|
||||||
|
|
|
||||||
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 { getCollection, render } from 'astro:content';
|
||||||
import CoralComments from '@/components/CoralComments.astro';
|
import CoralComments from '@/components/CoralComments.astro';
|
||||||
|
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
|
||||||
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
||||||
import { BlogPost } from '@/components/sections/blog-post';
|
import { BlogPost } from '@/components/sections/blog-post';
|
||||||
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
||||||
|
|
@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
|
||||||
<Content />
|
<Content />
|
||||||
</BlogPost>
|
</BlogPost>
|
||||||
|
|
||||||
|
<InteractiveRuntime />
|
||||||
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
|
|
|
||||||
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;
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env) {
|
||||||
|
scroll-margin-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-heading) {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground)));
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-heading strong) {
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-plain) {
|
||||||
|
margin-block: 1.6rem;
|
||||||
|
border-left: 3px solid hsl(var(--primary));
|
||||||
|
padding: 0.15rem 0 0.15rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-plain > *:first-child) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-plain > *:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-error) {
|
||||||
|
margin-block: 1.5rem;
|
||||||
|
border: 1px solid color-mix(in srgb, hsl(var(--destructive)) 50%, hsl(var(--border)));
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
background: color-mix(in srgb, hsl(var(--destructive)) 8%, transparent);
|
||||||
|
padding: 1rem;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
.article-content :where(.callout hr) {
|
.article-content :where(.callout hr) {
|
||||||
margin-block: 1rem;
|
margin-block: 1rem;
|
||||||
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
|
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
|
||||||
|
|
@ -248,6 +290,33 @@
|
||||||
margin-inline: 0;
|
margin-inline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-interactive) {
|
||||||
|
margin-block: 2rem;
|
||||||
|
scroll-margin-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.interactive-mount) {
|
||||||
|
min-height: 12rem;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
background: color-mix(in srgb, hsl(var(--accent)) 20%, transparent);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.interactive-mount[data-interactive-state="missing-component"]) {
|
||||||
|
display: grid;
|
||||||
|
min-height: 8rem;
|
||||||
|
place-items: center;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.interactive-fallback) {
|
||||||
|
margin: 0;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
.article-content :where(figcaption) {
|
.article-content :where(figcaption) {
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import tailwindcss from "@tailwindcss/vite";
|
||||||
import remarkCallouts from "./src/remark/callouts.mjs";
|
import remarkCallouts from "./src/remark/callouts.mjs";
|
||||||
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
|
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
|
||||||
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
|
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
|
||||||
|
import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs";
|
||||||
import remarkTypography from "./src/remark/typography.mjs";
|
import remarkTypography from "./src/remark/typography.mjs";
|
||||||
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
|
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
|
||||||
|
|
||||||
|
|
@ -19,6 +20,7 @@ const remarkPlugins = [
|
||||||
remarkGfm,
|
remarkGfm,
|
||||||
remarkInlineFootnotes,
|
remarkInlineFootnotes,
|
||||||
remarkCodeFenceLanguages,
|
remarkCodeFenceLanguages,
|
||||||
|
remarkSemanticBlocks,
|
||||||
remarkMath,
|
remarkMath,
|
||||||
remarkTypography,
|
remarkTypography,
|
||||||
remarkCallouts,
|
remarkCallouts,
|
||||||
|
|
|
||||||
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 { getCollection, render } from 'astro:content';
|
||||||
import CoralComments from '@/components/CoralComments.astro';
|
import CoralComments from '@/components/CoralComments.astro';
|
||||||
|
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
|
||||||
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
||||||
import { BlogPost } from '@/components/sections/blog-post';
|
import { BlogPost } from '@/components/sections/blog-post';
|
||||||
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
||||||
|
|
@ -26,5 +27,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
|
||||||
<Content />
|
<Content />
|
||||||
</BlogPost>
|
</BlogPost>
|
||||||
|
|
||||||
|
<InteractiveRuntime />
|
||||||
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
|
|
|
||||||
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;
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env) {
|
||||||
|
scroll-margin-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-heading) {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground)));
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
line-height: 1.3;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-heading strong) {
|
||||||
|
font-weight: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-plain) {
|
||||||
|
margin-block: 1.6rem;
|
||||||
|
border-left: 3px solid hsl(var(--primary));
|
||||||
|
padding: 0.15rem 0 0.15rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-plain > *:first-child) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-env-plain > *:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-error) {
|
||||||
|
margin-block: 1.5rem;
|
||||||
|
border: 1px solid color-mix(in srgb, hsl(var(--destructive)) 50%, hsl(var(--border)));
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
background: color-mix(in srgb, hsl(var(--destructive)) 8%, transparent);
|
||||||
|
padding: 1rem;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
.article-content :where(.callout hr) {
|
.article-content :where(.callout hr) {
|
||||||
margin-block: 1rem;
|
margin-block: 1rem;
|
||||||
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
|
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
|
||||||
|
|
@ -248,6 +290,33 @@
|
||||||
margin-inline: 0;
|
margin-inline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.article-content :where(.semantic-interactive) {
|
||||||
|
margin-block: 2rem;
|
||||||
|
scroll-margin-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.interactive-mount) {
|
||||||
|
min-height: 12rem;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 0.65rem;
|
||||||
|
background: color-mix(in srgb, hsl(var(--accent)) 20%, transparent);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.interactive-mount[data-interactive-state="missing-component"]) {
|
||||||
|
display: grid;
|
||||||
|
min-height: 8rem;
|
||||||
|
place-items: center;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 0.95rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content :where(.interactive-fallback) {
|
||||||
|
margin: 0;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
.article-content :where(figcaption) {
|
.article-content :where(figcaption) {
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
font-size: 0.92rem;
|
font-size: 0.92rem;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue