diff --git a/AGENTS.md b/AGENTS.md
index c5e45776..5a6aa072 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -33,7 +33,7 @@ A pnpm workspace. Every package in `packages/` is published to npm under its own
- `src/script.ts` is the browser script. `script()` is self-contained because the HTML renderer inlines its source. `src/client.ts` exports `start()`, which runs it with the bundled styles; Vue, React, and Svelte components call it on mount and stop it on unmount.
- `src/theme.ts` resolves the shared `--comark-*` tokens, and `comark-zoom/theme` exports it alone. `comark-github-callouts`, `comark-embed`, and `comark-tabs` have it as a dev dependency and bundle only this entry, so they don't install zoom or carry its styles.
- `src/html.ts`, `src/vue.ts`, `src/react.ts`, and `src/svelte/` are the renderers. They all output the same markup.
-- `docs/` builds the docs site with the plugins themselves. Cloudflare serves it from `docs/dist`.
+- `docs/` builds the docs site with the plugins themselves. Cloudflare serves it from `docs/dist`. Each package has a folder in `docs/pages/` with an overview, getting started, components or syntax, and guides; `docs/build.ts` lists the sections. `docs/play.ts` is the playground, bundled with Vite; its presets are the examples at the top of each page.
## Rules
diff --git a/docs/build.ts b/docs/build.ts
index 1d8142cf..814b68de 100644
--- a/docs/build.ts
+++ b/docs/build.ts
@@ -3,6 +3,7 @@ import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
+import { deflateRawSync } from "node:zlib";
import { renderHtmlFromDocument } from "@comark/html";
import shiki from "@comark/html/plugins/shiki";
import type { ElementNode, Node, NodeHandler } from "comark";
@@ -10,6 +11,7 @@ import { parseMarkdown } from "comark";
import { escapeHtml, textContent } from "comark/utils";
import dark from "shiki/themes/vitesse-dark.mjs";
import light from "shiki/themes/vitesse-light.mjs";
+import { build } from "vite";
import annotate, { components as annotateComponents } from "../packages/annotate/src/html.ts";
import carousel, { components as carouselComponents } from "../packages/carousel/src/html.ts";
import charts, { components as chartsComponents } from "../packages/charts/src/html.ts";
@@ -20,14 +22,15 @@ import githubCallouts, {
import include from "../packages/include/src/index.ts";
import tabs, { components as tabsComponents } from "../packages/tabs/src/html.ts";
import zoom, { components as zoomComponents } from "../packages/zoom/src/html.ts";
+import { highlight } from "./highlight.ts";
+import { previewDocument } from "./preview.ts";
const root = new URL("./", import.meta.url);
const dist = new URL("dist/", root);
const fonts = new URL("../node_modules/geist/dist/fonts/", root);
const site = "https://comark.onmax.me";
-// Every page sits in pages/, so includes resolve from there.
-const contentPlugins = () => [
- include({ root: fileURLToPath(new URL("pages/", root)) }),
+// The plugins that run in a browser, the playground's set.
+const browserPlugins = () => [
annotate(),
carousel(),
charts(),
@@ -36,6 +39,11 @@ const contentPlugins = () => [
tabs(),
zoom(),
];
+// Every page sits in pages/, so includes resolve from there.
+const contentPlugins = () => [
+ include({ root: fileURLToPath(new URL("pages/", root)) }),
+ ...browserPlugins(),
+];
const plugins = [...contentPlugins(), shiki({ themes: { light, dark } })];
const components = {
...annotateComponents,
@@ -47,39 +55,85 @@ const components = {
...zoomComponents,
};
-// The index first, then the guides and recipes, then one page per package, then the comparisons, each at its own path.
-const pages = [
- { slug: "", file: "index.md", label: "Overview" },
- { slug: "guide/renderers", file: "guide/renderers.md" },
- { slug: "guide/theming", file: "guide/theming.md" },
- { slug: "guide/validation", file: "guide/validation.md" },
- { slug: "guide/agents", file: "guide/agents.md" },
- { slug: "recipes/review", file: "recipes/review.md" },
- { slug: "recipes/zoom", file: "recipes/zoom.md" },
- { slug: "recipes/carousel", file: "recipes/carousel.md" },
- { slug: "annotate", file: "annotate.md", npm: "comark-annotate" },
- { slug: "zoom", file: "zoom.md", npm: "comark-zoom" },
- { slug: "carousel", file: "carousel.md", npm: "comark-carousel" },
- { slug: "tabs", file: "tabs.md", npm: "comark-tabs" },
- { slug: "github-callouts", file: "github-callouts.md", npm: "comark-github-callouts" },
- { slug: "lint", file: "lint.md", npm: "comark-lint" },
- { slug: "include", file: "include.md", npm: "comark-include" },
- { slug: "embed", file: "embed.md", npm: "comark-embed" },
- { slug: "charts", file: "charts.md", npm: "comark-charts" },
- { slug: "compare/charts", file: "compare/charts.md" },
-];
+// Lucide's icons, by name, and annotate's own: a hand-drawn loop like its marks.
+const icons = {
+ "book-open": ``,
+ "flask-conical": ``,
+ "scribble-loop": ``,
+ "chart-column": ``,
+ "tv-minimal-play": ``,
+ "message-square-warning": ``,
+ "zoom-in": ``,
+ "gallery-horizontal-end": ``,
+ "panels-top-left": ``,
+ "file-input": ``,
+ "list-checks": ``,
+ "arrow-left": ``,
+ "arrow-right": ``,
+ search: ``,
+};
+type IconName = keyof typeof icons;
+const icon = (name: IconName, size = 18) =>
+ ``;
-// The sidebar, in order. A slug with no page yet, such as one whose package is still on a branch, is left out.
-const groups = [
+// The rail, in order: the docs, the playground, then one section per package. A package whose folder isn't in
+// pages/ yet, such as one still on a branch, is left out.
+const packages = (
+ [
+ { slug: "annotate", group: "Review blocks", icon: "scribble-loop" },
+ { slug: "charts", group: "Review blocks", icon: "chart-column" },
+ { slug: "embed", group: "Review blocks", icon: "tv-minimal-play" },
+ { slug: "github-callouts", group: "Review blocks", icon: "message-square-warning" },
+ { slug: "zoom", group: "Page behavior", icon: "zoom-in" },
+ { slug: "carousel", group: "Page behavior", icon: "gallery-horizontal-end" },
+ { slug: "tabs", group: "Page behavior", icon: "panels-top-left" },
+ { slug: "include", group: "Authoring tools", icon: "file-input" },
+ { slug: "lint", group: "Authoring tools", icon: "list-checks" },
+ ] satisfies { slug: string; group: string; icon: IconName }[]
+).filter((pkg) => existsSync(new URL(`pages/${pkg.slug}/index.md`, root)));
+const fileOf = (slug: string) =>
+ existsSync(new URL(`pages/${slug || "index"}.md`, root))
+ ? `${slug || "index"}.md`
+ : `${slug}/index.md`;
+type Section = {
+ slug: string;
+ label: string;
+ group: string;
+ icon: IconName;
+ npm?: string;
+ lists: { title?: string; slugs: string[] }[];
+};
+const sections: Section[] = [
{
- title: "Guides",
- slugs: ["", "guide/renderers", "guide/theming", "guide/validation", "guide/agents"],
+ slug: "",
+ label: "Docs",
+ group: "Comark plugins",
+ icon: "book-open",
+ lists: [
+ { slugs: [""] },
+ {
+ title: "Guides",
+ slugs: ["guide/renderers", "guide/theming", "guide/validation", "guide/agents"],
+ },
+ { title: "Recipes", slugs: ["recipes/review", "recipes/zoom", "recipes/carousel"] },
+ { title: "Compared", slugs: ["compare/charts"] },
+ ],
},
- { title: "Recipes", slugs: ["recipes/review", "recipes/zoom", "recipes/carousel"] },
- { title: "Review blocks", slugs: ["annotate", "charts", "embed", "github-callouts"] },
- { title: "Page behavior", slugs: ["zoom", "carousel", "tabs"] },
- { title: "Authoring tools", slugs: ["include", "lint"] },
- { title: "Compared", slugs: ["compare/charts"] },
+ // Its column lists the presets, which come from the pages' examples.
+ { slug: "play", label: "Playground", group: "Comark plugins", icon: "flask-conical", lists: [] },
+ ...packages.map((pkg) => ({
+ ...pkg,
+ label: pkg.slug,
+ npm: `comark-${pkg.slug}`,
+ // A package's pages, in this order when it has them.
+ lists: [
+ {
+ slugs: ["", "getting-started", "components", "syntax", "guides"]
+ .map((page) => (page ? `${pkg.slug}/${page}` : pkg.slug))
+ .filter((slug) => existsSync(new URL(`pages/${fileOf(slug)}`, root))),
+ },
+ ],
+ })),
];
const isElement = (node: Node): node is ElementNode => Array.isArray(node) && node[0] !== null;
@@ -89,6 +143,10 @@ const str = (value: unknown) =>
: // SAFETY: undefined, null, objects, and arrays returned above, so only a string, number, or boolean is left.
String(value as string | number | boolean);
const isPre = (node: Node): node is ElementNode => isElement(node) && node[0] === "pre";
+const href = (slug: string) => `/${slug}${slug ? "/" : ""}`;
+// An include reads files, so the playground can't run it.
+const playable = (source: string) => !/^:+include\b/m.test(source);
+const playLink = (source: string) => `/play/#md=${deflateRawSync(source).toString("base64url")}`;
// An example shows the Markdown in its code block twice: rendered, and as source.
const example: NodeHandler = async ([, attrs, ...children], state) => {
@@ -101,8 +159,11 @@ const example: NodeHandler = async ([, attrs, ...children], state) => {
});
if (preview.meta.issues.length)
throw new Error(`Example "${str(attrs.label)}":\n${preview.meta.issues.join("\n")}`);
+ const play = playable(source)
+ ? `${icon("flask-conical", 13)}Playground`
+ : "";
return (
- `
` +
+ `
` +
`
${await state.flow(["div", {}, ...preview.nodes], state)}
` +
`
${await state.one(pre, state)}
`
@@ -141,51 +202,105 @@ const toc = (document: { nodes: Node[] }) =>
.join("");
const parsed = await Promise.all(
- pages.map(async (page) => {
- const document = await parseMarkdown(
- await readFile(new URL(`pages/${page.file}`, root), "utf8"),
- { plugins },
- );
- if (document.meta.issues.length)
- throw new Error(`docs/pages/${page.file}:\n${document.meta.issues.join("\n")}`);
- const [title, description, tagline, install] = [
- "title",
- "description",
- "tagline",
- "install",
- ].map((key) => str(document.frontmatter[key]));
- const skill =
- page.npm && existsSync(new URL(`../packages/${page.slug}/skills/${page.npm}/SKILL.md`, root))
- ? page.npm
- : "";
- const label = page.label ?? (page.npm ? page.slug : title);
- return {
- ...page,
- document,
- title,
- description,
- tagline,
- install,
- skill,
- label,
- toc: toc(document),
- };
- }),
+ sections.flatMap((section) =>
+ section.lists.flatMap((list) =>
+ list.slugs.map(async (slug) => {
+ const file = fileOf(slug);
+ const document = await parseMarkdown(
+ await readFile(new URL(`pages/${file}`, root), "utf8"),
+ { plugins },
+ );
+ if (document.meta.issues.length)
+ throw new Error(`docs/pages/${file}:\n${document.meta.issues.join("\n")}`);
+ const [title, description, tagline, install] = [
+ "title",
+ "description",
+ "tagline",
+ "install",
+ ].map((key) => str(document.frontmatter[key]));
+ const skill =
+ section.npm &&
+ existsSync(new URL(`../packages/${section.slug}/skills/${section.npm}/SKILL.md`, root))
+ ? section.npm
+ : "";
+ return {
+ slug,
+ section,
+ document,
+ title,
+ description,
+ tagline: tagline || description,
+ install,
+ skill,
+ label: slug === section.slug ? "Overview" : title,
+ toc: toc(document),
+ };
+ }),
+ ),
+ ),
+);
+type Page = (typeof parsed)[number];
+const pagesOf = (section: Section) =>
+ section.lists.map((list) => ({
+ ...list,
+ pages: list.slugs.flatMap((slug) => parsed.filter((page) => page.slug === slug)),
+ }));
+
+// The playground's presets: every page's top examples, under the section they come from.
+const presets = parsed.flatMap((page) =>
+ page.document.nodes
+ .filter(isElement)
+ .filter(([tag]) => tag === "examples")
+ .flatMap(([, , ...panels]) => panels.filter(isElement))
+ .map(([, attrs, ...children]) => ({
+ id: `${page.section.slug || "docs"}-${str(attrs.label)
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "")}`,
+ label: str(attrs.label),
+ group: page.section.label,
+ npm: page.section.npm ?? "",
+ source: textContent(children.find(isPre) ?? ["pre", {}]),
+ }))
+ .filter((preset) => playable(preset.source)),
+);
+// A small still of each preset for the playground's column: no scripts, and a gray box for each embedded player,
+// so the column loads nothing but the pages' own images. The plugins' styles go in the page once.
+const thumbnailStyles = new Set
();
+const thumbnails = new Map(
+ await Promise.all(
+ presets.map(async (preset) => {
+ const document = await parseMarkdown(preset.source, { plugins: browserPlugins() });
+ const html = (await renderHtmlFromDocument(document, { components }))
+ .replace(/
+${options.head ?? ""}
+
-
+
+${options.body}
+
+
+
+