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
|
|
@ -12,6 +12,7 @@ import tailwindcss from "@tailwindcss/vite";
|
|||
import remarkCallouts from "./src/remark/callouts.mjs";
|
||||
import remarkCodeFenceLanguages from "./src/remark/code-fence-languages.mjs";
|
||||
import remarkInlineFootnotes from "./src/remark/inline-footnotes.mjs";
|
||||
import remarkSemanticBlocks from "./src/remark/semantic-blocks.mjs";
|
||||
import remarkTypography from "./src/remark/typography.mjs";
|
||||
import { ACTIVE_BLOG_SITE } from "./src/blog-sites.js";
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ const remarkPlugins = [
|
|||
remarkGfm,
|
||||
remarkInlineFootnotes,
|
||||
remarkCodeFenceLanguages,
|
||||
remarkSemanticBlocks,
|
||||
remarkMath,
|
||||
remarkTypography,
|
||||
remarkCallouts,
|
||||
|
|
|
|||
3
sites/poetry/src/components/InteractiveRuntime.astro
Normal file
3
sites/poetry/src/components/InteractiveRuntime.astro
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<script>
|
||||
import "@/interactives/runtime";
|
||||
</script>
|
||||
77
sites/poetry/src/interactives/runtime.tsx
Normal file
77
sites/poetry/src/interactives/runtime.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
type InteractiveProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
caption?: string;
|
||||
description?: string;
|
||||
fallback?: string;
|
||||
attrs?: Record<string, string>;
|
||||
};
|
||||
|
||||
type InteractiveModule = {
|
||||
default: React.ComponentType<InteractiveProps>;
|
||||
};
|
||||
|
||||
const modules = import.meta.glob<InteractiveModule>("./*/index.tsx");
|
||||
|
||||
function readProps(element: HTMLElement): InteractiveProps {
|
||||
const encoded = element.dataset.interactiveProps;
|
||||
if (!encoded) {
|
||||
return {
|
||||
id: element.dataset.interactiveId ?? "",
|
||||
label: "",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(decodeURIComponent(encoded)) as InteractiveProps;
|
||||
} catch {
|
||||
return {
|
||||
id: element.dataset.interactiveId ?? "",
|
||||
label: "",
|
||||
fallback: element.textContent?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function mountInteractive(element: HTMLElement) {
|
||||
const id = element.dataset.interactiveId;
|
||||
if (!id || element.dataset.interactiveMounted) return;
|
||||
|
||||
const modulePath = `./${id}/index.tsx`;
|
||||
const loader = modules[modulePath];
|
||||
if (!loader) {
|
||||
element.dataset.interactiveState = "missing-component";
|
||||
return;
|
||||
}
|
||||
|
||||
element.dataset.interactiveMounted = "true";
|
||||
element.dataset.interactiveState = "loading";
|
||||
|
||||
try {
|
||||
const props = readProps(element);
|
||||
const { default: Component } = await loader();
|
||||
element.replaceChildren();
|
||||
createRoot(element).render(<Component {...props} />);
|
||||
element.dataset.interactiveState = "ready";
|
||||
} catch (error) {
|
||||
element.dataset.interactiveState = "failed";
|
||||
console.error(`Failed to mount interactive "${id}"`, error);
|
||||
}
|
||||
}
|
||||
|
||||
function mountAllInteractives() {
|
||||
document
|
||||
.querySelectorAll<HTMLElement>(".interactive-mount[data-interactive-id]")
|
||||
.forEach((element) => {
|
||||
void mountInteractive(element);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", mountAllInteractives, { once: true });
|
||||
} else {
|
||||
mountAllInteractives();
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
---
|
||||
import { getCollection, render } from 'astro:content';
|
||||
import CoralComments from '@/components/CoralComments.astro';
|
||||
import InteractiveRuntime from '@/components/InteractiveRuntime.astro';
|
||||
import DefaultLayout from '@/layouts/DefaultLayout.astro';
|
||||
import { BlogPost } from '@/components/sections/blog-post';
|
||||
import { ACTIVE_BLOG_SITE } from '@/blog-sites.js';
|
||||
|
|
@ -28,5 +29,6 @@ const coralStoryURL = new URL(`/${postSlug}/`, ACTIVE_BLOG_SITE.url).toString();
|
|||
</BlogPost>
|
||||
</div>
|
||||
|
||||
<InteractiveRuntime />
|
||||
<CoralComments storyID={coralStoryID} storyURL={coralStoryURL} />
|
||||
</DefaultLayout>
|
||||
|
|
|
|||
232
sites/poetry/src/remark/semantic-blocks.mjs
Normal file
232
sites/poetry/src/remark/semantic-blocks.mjs
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { fromMarkdown } from "mdast-util-from-markdown";
|
||||
|
||||
const ENV_TYPES = new Set([
|
||||
"theorem",
|
||||
"lemma",
|
||||
"proposition",
|
||||
"corollary",
|
||||
"definition",
|
||||
"example",
|
||||
"remark",
|
||||
"proof",
|
||||
]);
|
||||
|
||||
const ENV_LABELS = {
|
||||
theorem: "Theorem",
|
||||
lemma: "Lemma",
|
||||
proposition: "Proposition",
|
||||
corollary: "Corollary",
|
||||
definition: "Definition",
|
||||
example: "Example",
|
||||
remark: "Remark",
|
||||
proof: "Proof",
|
||||
};
|
||||
|
||||
function tokenizeInfo(info) {
|
||||
return (
|
||||
info.match(/[^\s=]+=(?:"[^"]*"|'[^']*'|[^\s]+)|"[^"]*"|'[^']*'|[^\s]+/g) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
function stripQuotes(value) {
|
||||
if (!value) return "";
|
||||
const first = value[0];
|
||||
const last = value[value.length - 1];
|
||||
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseFenceInfo(node) {
|
||||
const lang = node.lang ?? "";
|
||||
const meta = node.meta ?? "";
|
||||
const raw = `${lang} ${meta}`.trim();
|
||||
const attrs = {
|
||||
classes: [],
|
||||
id: "",
|
||||
values: {},
|
||||
raw,
|
||||
};
|
||||
|
||||
if (!raw) return attrs;
|
||||
|
||||
let body = raw;
|
||||
if (body.startsWith("{")) {
|
||||
body = body.slice(1);
|
||||
if (body.endsWith("}")) body = body.slice(0, -1);
|
||||
} else if (lang) {
|
||||
attrs.classes.push(lang);
|
||||
body = meta;
|
||||
}
|
||||
|
||||
for (let token of tokenizeInfo(body.trim())) {
|
||||
if (token.endsWith("}") && !token.includes("=")) token = token.slice(0, -1);
|
||||
if (!token) continue;
|
||||
|
||||
if (token.startsWith(".")) {
|
||||
attrs.classes.push(token.slice(1));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.startsWith("#")) {
|
||||
attrs.id = token.slice(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
const eq = token.indexOf("=");
|
||||
if (eq > 0) {
|
||||
const key = token.slice(0, eq);
|
||||
const value = stripQuotes(token.slice(eq + 1));
|
||||
if (key === "id") attrs.id = value;
|
||||
attrs.values[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
function attribute(value) {
|
||||
return escapeHtml(value).replaceAll("\n", " ");
|
||||
}
|
||||
|
||||
function encodedJson(value) {
|
||||
return encodeURIComponent(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function firstClass(attrs, excluded) {
|
||||
return attrs.classes.find((className) => !excluded.has(className));
|
||||
}
|
||||
|
||||
function interactiveFromCode(node) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
if (!attrs.classes.includes("interactive")) return null;
|
||||
|
||||
const declaredId = attrs.id || attrs.values.component || attrs.values.name || "";
|
||||
const componentId =
|
||||
attrs.values.component ||
|
||||
(declaredId.startsWith("int:") ? declaredId.slice("int:".length) : declaredId);
|
||||
const label = attrs.values.label || (declaredId.startsWith("int:") ? declaredId : `int:${declaredId}`);
|
||||
const caption = attrs.values.caption || attrs.values.title || "";
|
||||
const description = node.value.trim();
|
||||
const fallback = attrs.values.pdf || attrs.values.fallback || description || caption;
|
||||
|
||||
if (!componentId) {
|
||||
return {
|
||||
type: "html",
|
||||
value:
|
||||
'<aside class="semantic-error">Interactive block missing an id. Add <code>#int:name</code> or <code>id="name"</code>.</aside>',
|
||||
};
|
||||
}
|
||||
|
||||
const props = {
|
||||
id: componentId,
|
||||
label,
|
||||
caption,
|
||||
description,
|
||||
fallback,
|
||||
attrs: attrs.values,
|
||||
};
|
||||
|
||||
return {
|
||||
type: "html",
|
||||
value: [
|
||||
`<figure class="semantic-interactive" id="${attribute(label)}" data-interactive-label="${attribute(label)}">`,
|
||||
`<div class="interactive-mount" data-interactive-id="${attribute(componentId)}" data-interactive-props="${attribute(encodedJson(props))}">`,
|
||||
fallback ? `<p class="interactive-fallback">${escapeHtml(fallback)}</p>` : "",
|
||||
"</div>",
|
||||
caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "",
|
||||
"</figure>",
|
||||
].join(""),
|
||||
};
|
||||
}
|
||||
|
||||
function envFromCode(node) {
|
||||
const attrs = parseFenceInfo(node);
|
||||
const shorthandType = firstClass(attrs, new Set(["env", "latex-env"]));
|
||||
const explicitType = attrs.values.type || shorthandType;
|
||||
const type = ENV_TYPES.has(explicitType) ? explicitType : "";
|
||||
const isEnv = attrs.classes.includes("env") || attrs.classes.includes("latex-env") || Boolean(type);
|
||||
|
||||
if (!isEnv) return null;
|
||||
|
||||
if (!type) {
|
||||
return {
|
||||
type: "html",
|
||||
value:
|
||||
'<aside class="semantic-error">Environment block missing a type. Add <code>.theorem</code>, <code>.definition</code>, or <code>type="theorem"</code>.</aside>',
|
||||
};
|
||||
}
|
||||
|
||||
const title = attrs.values.title || attrs.values.name || "";
|
||||
const label = attrs.id || attrs.values.label || "";
|
||||
const renderMode = attrs.values.html || (type === "proof" ? "plain" : "callout");
|
||||
const heading = title ? `${ENV_LABELS[type]} (${title})` : ENV_LABELS[type];
|
||||
const parsed = fromMarkdown(node.value || "");
|
||||
const className = [
|
||||
"semantic-env",
|
||||
`semantic-env-${type}`,
|
||||
`semantic-env-${renderMode}`,
|
||||
renderMode === "callout" ? "callout" : "",
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
type: "blockquote",
|
||||
data: {
|
||||
hName: "section",
|
||||
hProperties: {
|
||||
id: label || undefined,
|
||||
className,
|
||||
"data-env-type": type,
|
||||
"data-env-title": title || undefined,
|
||||
"data-env-render": renderMode,
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "paragraph",
|
||||
data: {
|
||||
hProperties: {
|
||||
className: ["semantic-env-heading"],
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "strong",
|
||||
children: [{ type: "text", value: heading }],
|
||||
},
|
||||
],
|
||||
},
|
||||
...parsed.children,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function transformTree(parent) {
|
||||
if (!parent?.children) return;
|
||||
|
||||
for (let index = 0; index < parent.children.length; index += 1) {
|
||||
const child = parent.children[index];
|
||||
if (child.type === "code") {
|
||||
const replacement = interactiveFromCode(child) || envFromCode(child);
|
||||
if (replacement) {
|
||||
parent.children[index] = replacement;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
transformTree(child);
|
||||
}
|
||||
}
|
||||
|
||||
export default function remarkSemanticBlocks() {
|
||||
return (tree) => transformTree(tree);
|
||||
}
|
||||
|
|
@ -216,6 +216,48 @@
|
|||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.article-content :where(.semantic-env) {
|
||||
scroll-margin-top: 5rem;
|
||||
}
|
||||
|
||||
.article-content :where(.semantic-env-heading) {
|
||||
margin: 0 0 0.75rem;
|
||||
color: color-mix(in srgb, hsl(var(--primary)) 78%, hsl(var(--foreground)));
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.3;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.article-content :where(.semantic-env-heading strong) {
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
.article-content :where(.semantic-env-plain) {
|
||||
margin-block: 1.6rem;
|
||||
border-left: 3px solid hsl(var(--primary));
|
||||
padding: 0.15rem 0 0.15rem 1rem;
|
||||
}
|
||||
|
||||
.article-content :where(.semantic-env-plain > *:first-child) {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.article-content :where(.semantic-env-plain > *:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.article-content :where(.semantic-error) {
|
||||
margin-block: 1.5rem;
|
||||
border: 1px solid color-mix(in srgb, hsl(var(--destructive)) 50%, hsl(var(--border)));
|
||||
border-radius: 0.55rem;
|
||||
background: color-mix(in srgb, hsl(var(--destructive)) 8%, transparent);
|
||||
padding: 1rem;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.article-content :where(.callout hr) {
|
||||
margin-block: 1rem;
|
||||
border-top-color: color-mix(in srgb, hsl(var(--border)) 70%, transparent);
|
||||
|
|
@ -248,6 +290,33 @@
|
|||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.article-content :where(.semantic-interactive) {
|
||||
margin-block: 2rem;
|
||||
scroll-margin-top: 5rem;
|
||||
}
|
||||
|
||||
.article-content :where(.interactive-mount) {
|
||||
min-height: 12rem;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 0.65rem;
|
||||
background: color-mix(in srgb, hsl(var(--accent)) 20%, transparent);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.article-content :where(.interactive-mount[data-interactive-state="missing-component"]) {
|
||||
display: grid;
|
||||
min-height: 8rem;
|
||||
place-items: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.95rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.article-content :where(.interactive-fallback) {
|
||||
margin: 0;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.article-content :where(figcaption) {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.92rem;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue