Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 70 additions & 22 deletions docs/build.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Renders docs/pages with the plugins themselves into docs/dist, a static site served by Cloudflare at comark.onmax.me.
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 { renderHtmlFromDocument } from "@comark/html";
Expand Down Expand Up @@ -46,9 +47,16 @@ const components = {
...zoomComponents,
};

// The index first, then one page per package, each at its own path.
// The index first, then the guides and recipes, then one page per package, each at its own path.
const pages = [
{ slug: "", file: "index.md" },
{ 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" },
Expand All @@ -60,6 +68,18 @@ const pages = [
{ slug: "charts", file: "charts.md", npm: "comark-charts" },
];

// 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 = [
{
title: "Guides",
slugs: ["", "guide/renderers", "guide/theming", "guide/validation", "guide/agents"],
},
{ 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"] },
];

const isElement = (node: Node): node is ElementNode => Array.isArray(node) && node[0] !== null;
const str = (value: unknown) =>
value === undefined || value === null || value instanceof Object
Expand Down Expand Up @@ -103,7 +123,7 @@ const examples: NodeHandler = async ([, , ...children], state) => {
return `<div class="examples"><div class="tablist" role="tablist">${labels.join("")}</div>${bodies.join("")}</div>`;
};

const nav = (document: { nodes: Node[] }) =>
const toc = (document: { nodes: Node[] }) =>
document.nodes
.filter(isElement)
.filter(([tag]) => tag === "h2" || tag === "h3")
Expand All @@ -127,50 +147,76 @@ const parsed = await Promise.all(
"tagline",
"install",
].map((key) => str(document.frontmatter[key]));
return { ...page, document, title, description, tagline, install };
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),
};
}),
);
const grouped = groups.map((group) => ({
...group,
pages: group.slugs.flatMap((slug) => parsed.filter((page) => page.slug === slug)),
}));

// The index lists every package from its own page's front matter.
const pluginCards: NodeHandler = () =>
`<div class="cards">${parsed
.filter((page) => page.npm)
// The index lists every other page of a group from its own front matter.
const cards: NodeHandler = ([, attrs]) =>
`<div class="cards">${(grouped.find((group) => group.title === str(attrs.group))?.pages ?? [])
.filter((page) => page.slug)
.map(
(page) =>
`<a class="card" href="/${page.slug}/"><strong>${escapeHtml(page.title)}</strong><span>${escapeHtml(page.description)}</span><code>${escapeHtml(page.install.replace(/^pnpm add (comark @comark\/html )?/, "pnpm add "))}</code></a>`,
`<a class="card" href="/${page.slug}${page.slug ? "/" : ""}"><strong${page.npm ? ' class="mono"' : ""}>${escapeHtml(page.npm ? page.title : page.label)}</strong><span>${escapeHtml(page.description)}</span>${page.npm ? `<code>${escapeHtml(page.install.replace(/^pnpm add (comark @comark\/html )?/, "pnpm add "))}</code>` : ""}</a>`,
)
.join("")}</div>`;

const github = `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg>`;
const userIcon = `<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8"/></svg>`;
const botIcon = `<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M12 8V4H8M4 8h16v12H4zM2 14h2M20 14h2M15 13v2M9 13v2"/></svg>`;
const copyIcon = `<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M8 8h12v12H8zM16 8V4H4v12h4"/></svg>`;
// Humans install the package with their package manager; agents install the skill from this site.
function installerFor(install: string) {
// Humans install the package with their package manager; agents install the skill from this site, the package's
// own when it has one.
function installerFor(install: string, skill: string) {
const packages = install.replace(/^pnpm add /, "");
const commands = {
pnpm: `pnpm add ${packages}`,
npm: `npm install ${packages}`,
yarn: `yarn add ${packages}`,
bun: `bun add ${packages}`,
agents: `npx skills add ${site}`,
agents: `npx skills add ${site}${skill ? ` --skill ${skill}` : ""}`,
} satisfies Record<string, string>;
return `<div class="installer" data-commands='${escapeHtml(JSON.stringify(commands))}'>
<div class="installer-bar"><div class="seg" role="group" aria-label="Audience"><button type="button" data-audience="humans" aria-pressed="true">${userIcon}For humans</button><button type="button" data-audience="agents" aria-pressed="false">${botIcon}For agents</button></div>
<div class="seg installer-pm" role="group" aria-label="Package manager">${["pnpm", "npm", "yarn", "bun"].map((pm, i) => `<button type="button" data-pm="${pm}" aria-pressed="${i === 0}">${pm}</button>`).join("")}</div></div>
<button type="button" class="install" data-copy="${escapeHtml(commands.pnpm)}" aria-label="Copy the install command"><span aria-hidden="true">$</span><code>${escapeHtml(commands.pnpm)}</code><span class="install-hint">${copyIcon}</span></button>
</div>`;
}
const links = (current: string) =>
pages
.filter((page) => page.npm)
const sitemap = (current: string) =>
grouped
.filter((group) => group.pages.length)
.map(
(page) =>
`<a class="link"${page.slug === current ? ' aria-current="page"' : ""} href="/${page.slug}/">${page.slug}</a>`,
(group) =>
`<p class="nav-group">${group.title}</p>${group.pages
.map(
(page) =>
`<a${page.npm ? ' class="mono"' : ""}${page.slug === current ? ' aria-current="page"' : ""} href="/${page.slug}${page.slug ? "/" : ""}">${escapeHtml(page.label)}</a>`,
)
.join("")}`,
)
.join("");
const npmx = `<svg viewBox="0 0 153 153" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M17.6 107.2h24.1v23.7h-24zM119.8.8 116.2 11 69.9 142l-3.6 10.2h15.5l3.6-10.2 11.7-33.2L135.4.8z"/></svg>`;
const logo = `<span class="logo" aria-hidden="true">1</span>`;
const menuIcon = `<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" d="M4 6h16M4 12h16M4 18h16"/></svg>`;

// The example tabs, the Preview/Code switch, copy buttons, and the active sidebar link. Without it the page still reads top to bottom.
const script = `
Expand All @@ -196,7 +242,7 @@ for (const pre of document.querySelectorAll("pre")) {
b.onclick = async () => { await navigator.clipboard.writeText(pre.querySelector("code").innerText.trimEnd()); b.textContent = "Copied"; setTimeout(() => b.textContent = "Copy", 1500) };
pre.append(b);
}
const links = [...document.querySelectorAll(".sidebar a")];
const links = [...document.querySelectorAll(".toc a")];
const spy = new IntersectionObserver(entries => {
for (const e of entries) if (e.isIntersecting) for (const a of links) a.toggleAttribute("data-active", a.hash === "#" + e.target.id);
}, { rootMargin: "0px 0px -70% 0px" });
Expand Down Expand Up @@ -271,16 +317,18 @@ for (const page of parsed) {
<body>
<header class="top">
<a class="name" href="/">${logo}comark plugins</a>
<nav>${links(page.slug)}${page.npm ? `<a class="icon" href="https://npmx.dev/package/${page.npm}" aria-label="${page.npm} on npmx" title="npmx">${npmx}</a>` : ""}<a class="icon" href="https://github.com/onmax/comark-plugins" aria-label="comark-plugins on GitHub" title="GitHub">${github}</a></nav>
<nav>${page.npm ? `<a class="icon" href="https://npmx.dev/package/${page.npm}" aria-label="${page.npm} on npmx" title="npmx">${npmx}</a>` : ""}<a class="icon" href="https://github.com/onmax/comark-plugins" aria-label="comark-plugins on GitHub" title="GitHub">${github}</a></nav>
<details class="menu"><summary aria-label="Menu" title="Menu">${menuIcon}</summary><nav aria-label="Site">${sitemap(page.slug)}</nav></details>
</header>
<div class="layout">
<aside class="sidebar"><nav aria-label="On this page"><a class="nav-h2" href="#">Introduction</a>${nav(page.document)}</nav></aside>
<aside class="sidebar"><nav aria-label="Site">${sitemap(page.slug)}</nav></aside>
<div class="content">
<div class="hero enter"><h1>${page.title}</h1><p>${escapeHtml(page.tagline)}</p>
${page.install ? installerFor(page.install) : ""}</div>
<main class="enter" style="--i:1">${await renderHtmlFromDocument(page.document, { components: { ...components, example, examples, plugins: pluginCards } })}</main>
${page.install ? installerFor(page.install, page.skill) : ""}</div>
<main class="enter" style="--i:1">${await renderHtmlFromDocument(page.document, { components: { ...components, example, examples, cards } })}</main>
<footer><span>Built on <a href="https://comark.dev">Comark</a> by the <a href="https://github.com/comarkdown">Comark team</a>. Marks from <a href="https://annotationkit.vercel.app">AnnotationKit</a>.</span><span>Made by <a href="https://onmax.me">Maxi García</a></span></footer>
</div>
${page.toc ? `<aside class="toc"><nav aria-label="On this page"><p class="nav-group">On this page</p>${page.toc}</nav></aside>` : ""}
</div>
<script>${script.trim()}</script>
</body>
Expand Down
66 changes: 6 additions & 60 deletions docs/pages/annotate.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ Notes without a `side` stay in the list. **Apply** waits until every filter is s
:::
::

Hover a mark or a note to highlight the other, and click a screenshot to zoom it. To share one, use its Copy button or press Ctrl+C (⌘C on a Mac) while it's focused; you get a PNG of the screenshot with its marks and notes. The browser's own Copy Image on the screenshot copies the same PNG once the pointer has been over it. If the image comes from another origin, its server has to send CORS headers. In an iframe, the frame needs `allow="clipboard-write"`. A comparison shows its screenshots side by side, or one at a time on a narrow page, and they zoom as one gallery; a slider lays one over the other, and you drag the divider between them. The page reads the same without a script; [zoom](/zoom) and [carousel](/carousel) come with the package.
Hover a mark or a note to highlight the other, and click a screenshot to zoom it. To share one, use its Copy button or press Ctrl+C (⌘C on a Mac) while it's focused; you get a PNG of the screenshot with its marks and notes. The browser's own Copy Image on the screenshot copies the same PNG once the pointer has been over it. If the image comes from another origin, its server has to send CORS headers. In an iframe, the frame needs `allow="clipboard-write"`. A comparison shows its screenshots side by side, or one at a time on a narrow page, and they zoom as one gallery; a slider lays one over the other, and you drag the divider between them. The page reads the same without a script; [zoom](/zoom/) and [carousel](/carousel/) come with the package. To page through screenshots spread across a page, or show a flow one step at a time, see the [recipes](/recipes/zoom/).

## Usage

Expand All @@ -135,7 +135,7 @@ const render = createHtmlRenderer({ plugins: [annotate()], components });
const html = await render(markdown);
```

The renderer inlines its styles once per document; [Theming](#guides-theming) covers how to change them. Positions use `style` attributes, so a strict CSP needs `style-src 'unsafe-inline'`. Notes are ordinary Markdown, so raw HTML in them renders like anywhere else in the document; for untrusted input, add Comark's `security` plugin and list the tags to drop, such as `security({ blockedTags: ["script", "style", "iframe"] })`; it cleans notes too.
The renderer inlines its styles and script once per document. Annotate renders only to HTML; [Renderers](/guide/renderers/) covers using it with other plugins, a strict Content Security Policy, and untrusted notes.

## Components

Expand Down Expand Up @@ -272,75 +272,21 @@ For a full-page screenshot, add the scroll offset and divide by the page size in

### Validation

The plugin checks every block while parsing and adds the problems to `meta.issues`, so you can reject a document before publishing it. Zoom and carousel add theirs to the same list.

```ts
import { parseMarkdown } from "comark";
import annotate from "comark-annotate";

const { meta } = await parseMarkdown(markdown, { plugins: [annotate()] });
if (meta.issues.length) throw new Error(meta.issues.join("\n"));
```

Each issue names its block and says how to fix it. A screenshot goes by its `src`, or by its position and `alt` when another screenshot shares it; an annotation goes by its position in its screenshot:
The plugin adds a problem with any block to `meta.issues`, as [Validation](/guide/validation/) describes. A screenshot goes by its `src`, or by its position and `alt` when another screenshot shares it; an annotation goes by its position in its screenshot:

```txt
screenshot "after.png", annotation 2: the box runs past the image edge. Keep x + w and y + h within 100.
```

Unknown props are reported with the valid ones, and so is a `:bound` prop that points at missing frontmatter. Bindings to `data` or `props` are left to the renderer.

Rendering never throws.

### Theming

The styles follow [shadcn/ui](https://ui.shadcn.com). On a site that already uses its CSS variables, screenshots and comparisons match it with no setup, and a `.dark` class anywhere above them switches the theme. Elsewhere they use shadcn's neutral theme and follow the system's light or dark mode.

Set the `--comark-*` tokens to theme zoom, carousel, and annotate at once. Each one wins over its shadcn variable, and works on `:root` or on any ancestor:

| Token | Falls back to | Used for |
| --- | --- | --- |
| `--comark-background` | `--background` | Button faces, the zoom backdrop, the ring around stamps |
| `--comark-foreground` | `--foreground` | Text, icons, the current thumbnail, slide panels |
| `--comark-muted` | `--muted-foreground` | The zoom and carousel counters, diff line numbers |
| `--comark-border` | `--border` | Button, thumbnail, screenshot, and diff borders |
| `--comark-accent` | `--accent` | Hovered buttons, the diff header |
| `--comark-ring` | `--ring` | Focus rings, the edge of a highlighted diff line |
| `--comark-radius` | `--radius` | Corners, a little smaller on buttons and images |
| `--comark-font` | `--font-sans` | The zoomed view's header |

```css
:root {
--comark-accent: oklch(0.95 0.03 250);
--comark-radius: 0.25rem;
}
```

The rules sit in a `comark` CSS layer, so any plain rule of your own wins without `!important`, such as `.cc-step { border-radius: 0 }`. shadcn/ui before Tailwind 4 stores colors as bare HSL numbers, so map those: `--comark-border: hsl(var(--border))`.

[Nuxt UI](https://ui.nuxt.com) names its variables differently. Map them once:

```css
:root {
--comark-background: var(--ui-bg);
--comark-foreground: var(--ui-text-highlighted);
--comark-muted: var(--ui-text-muted);
--comark-border: var(--ui-border);
--comark-accent: var(--ui-bg-elevated);
--comark-ring: var(--ui-primary);
--comark-radius: calc(var(--ui-radius) * 2);
}
```

The mark colors are separate from the theme, because each one means something: a plain `.ca { --ca-color: #0070f3; --ca-solid: #0070f3; --ca-ink: #fff }` changes the default marks, arrows, and notes (`--ca-color`), stamps (`--ca-solid`), and stamp numbers (`--ca-ink`), and `--ca-red`, `--ca-amber`, `--ca-green`, `--ca-blue`, and `--ca-gray` change the named ones. Side notes and labels use `--ca-note-font`; this page sets it to Caveat for the handwritten look. A diff's code uses `--ca-code-font`, and its colors come from `--ca-red` and `--ca-green`.
Screenshots, comparisons, and diffs follow the shared tokens in [Theming](/guide/theming/). The mark colors are separate from the theme, because each one means something: a plain `.ca { --ca-color: #0070f3; --ca-solid: #0070f3; --ca-ink: #fff }` changes the default marks, arrows, and notes (`--ca-color`), stamps (`--ca-solid`), and stamp numbers (`--ca-ink`), and `--ca-red`, `--ca-amber`, `--ca-green`, `--ca-blue`, and `--ca-gray` change the named ones. Side notes and labels use `--ca-note-font`; this page sets it to Caveat for the handwritten look. A diff's code uses `--ca-code-font`, and its colors come from `--ca-red` and `--ca-green`.

## Agents

The package ships an agent skill with the syntax, how to measure marks from the page, and how to check the result. Install it from this site:

```sh
npx skills add https://comark.onmax.me
```
The package ships an agent skill with the syntax, how to measure marks from the page, and how to check the result. The **For agents** button above installs it, and [Agents](/guide/agents/) covers the rest.

It also comes with a small CLI that knows zoom and carousel too. `check` lists every problem in one or more files and exits with 1 if there are any; `render` writes a standalone page next to each file, which you can open and screenshot to confirm each mark sits on its target.

Expand All @@ -351,4 +297,4 @@ npx comark-annotate render report.md -o report.html

## Credits

[Comark](https://comark.dev), by the [Comark team](https://github.com/comarkdown), does the hard part: the parser, the component syntax, and the HTML renderer this plugin builds on. The hand-drawn marks come from [AnnotationKit](https://annotationkit.vercel.app).
The hand-drawn marks come from [AnnotationKit](https://annotationkit.vercel.app).
Loading
Loading