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
11 changes: 2 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# comark-plugins

Comark plugins for review documents: annotated screenshots, before/after comparisons, suggested code changes, and charts. Agents write most of the documents they render, often as PR evidence. Because of that, the issue messages are as important as the HTML: every one names its block and says how to fix it.
Comark plugins for review documents: annotated screenshots, before/after comparisons, and suggested code changes. Agents write most of the documents they render, often as PR evidence. Because of that, the issue messages are as important as the HTML: every one names its block and says how to fix it.

## Shape

Expand All @@ -12,15 +12,8 @@ A pnpm workspace. Every package in `packages/` is published to npm under its own
- `src/html.ts` is the `@comark/html` renderer. The output reads without a script; the zoom and carousel scripts and all styles are inlined once per document. A screenshot zooms with `comark-zoom`, and `::compare` is a two-column `comark-carousel`. Marks are not links, so a click anywhere on a screenshot zooms it. `::diff` is a table of before and after lines with no script, and doesn't zoom: its code scrolls in place.
- Only the HTML renderer exists: notes are Markdown, and Vue, React, and Svelte versions would each copy the whole figure template.
- `src/marks.ts` holds the hand-drawn marks, taken from AnnotationKit.
- `readMark` (from the root entry) and `annotateHtml` (from `/html`) are public so comark-charts can mark charts: one checks an annotation's mark, color, side, and note without its box, and the other draws annotations over any HTML.
- `packages/carousel` is `comark-carousel`. Its parse plugin turns `::carousel` content into explicit `slide` nodes with their position, so no renderer splits children. Its script is built like zoom's, and a carousel is a zoom gallery.
- `packages/charts` is `comark-charts`.
- `src/read.ts` reads a `::chart` node and its Markdown table and reports their issues. The plugin (`src/index.ts`) and the renderer both go through it; the parse tree keeps the table as the data, so agents reading the Markdown or the page get numbers, not a spec.
- `src/draw.ts` turns the table into a Flint spec with a house theme and draws it to SVG with Vega, when rendering, so the parse plugin stays light. Field names are fixed (`x`, `y`, `series`) because Vega-Lite reads dots in a name as a path.
- `src/styles.ts` swaps the SVG's inks for the `--comark-*` tokens and `--chart-N` with attribute selectors, since CSS beats presentation attributes. Keep `INK` and `SERIES` in `draw.ts` in step with it.
- Marks are comark-annotate annotations with `at` (a row's x) and `series` instead of a box. `read.ts` checks them with annotate's `readMark`; `draw.ts` finds each one's box in Vega's scenegraph (lines and areas get an invisible point layer) and `html.ts` draws them with annotate's `annotateHtml`, so they look and behave like marks on a screenshot. annotate skips `::chart` blocks when it checks annotations.
- Only the HTML renderer exists: Vega draws on the server, and a framework component would ship it to the browser.
- `skills/comark-charts/SKILL.md` is its agent skill. `docs/build.ts` serves every package's skill for `npx skills add`; add a new skill's folder there.
- `packages/code` is `comark-code`. Its components replace `pre`: each wraps the block, rendered as it would be without them, in `.ck` with a bar of buttons, so Shiki's output and code groups work in any plugin order. The bar's markup is one string from `buttons()` in `src/client.ts` that every renderer inlines. Its parse plugin only writes the configured `actions` onto matching `pre` nodes; the script sends a `comark-code-action` event for them and does nothing else.
- `packages/embed` is `comark-embed`. Its parse plugin reads the player's `src` from the `::embed` link alone, with no fetch, and replaces the node's attributes with `provider`, `src`, `label`, `size`, `href`, `title`, and `host`, so no renderer parses URLs. `src/providers.ts` holds one entry per provider. A link with no player becomes a card, and one that isn't https renders nothing. No script: the components add the styles on mount.
- `packages/github-callouts` is `comark-github-callouts`. Its parse plugin is named `alert`, so Comark's dedupe swaps it in for the built-in alert plugin and it always sees the raw `[!TYPE]` marker. It turns those blockquotes into `github-callout` nodes with `type` and `title`; the renderers only draw them. No script: the components add the styles on mount.
- `packages/lint` is `comark-lint`, a library, CLI, and Vite Doctor extension that lints Comark documents.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
| --- | --- |
| [`comark-annotate`](./packages/annotate) | Hand-drawn marks, numbered notes, and arrows on screenshots, and before/after comparisons. |
| [`comark-carousel`](./packages/carousel) | A carousel of any Markdown content, with arrows, a counter, keys, and zoom. |
| [`comark-charts`](./packages/charts) | Bars, lines, areas, scatter plots, dumbbells, waterfalls, and pies from a Markdown table, drawn to SVG with the table kept under them. |
| [`comark-charts`](./packages/charts) | Charts from Markdown tables. |
| [`comark-code`](./packages/code) | Copy and Wrap buttons on code blocks, and buttons of your own that hand the code to your app. |
| [`comark-embed`](./packages/embed) | YouTube, Vimeo, Loom, Spotify, CodePen, StackBlitz, and Figma players from a link, with no network fetch. |
| [`comark-github-callouts`](./packages/github-callouts) | GitHub's `> [!NOTE]` alerts as callouts with an icon and a title. |
| [`comark-lint`](./packages/lint) | Finds unclosed blocks, unknown components, broken anchors, and plugin issues in Comark documents, from a script, the command line, or Vite Doctor. |
Expand Down
26 changes: 9 additions & 17 deletions docs/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ 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 code, { components as codeComponents } from "../packages/code/src/html.ts";
import charts, { components as chartsComponents } from "../packages/charts/src/html.ts";
import embed, { components as embedComponents } from "../packages/embed/src/html.ts";
import githubCallouts, {
Expand All @@ -33,6 +34,7 @@ const site = "https://comark.onmax.me";
const browserPlugins = () => [
annotate(),
carousel(),
code(),
charts(),
embed(),
githubCallouts(),
Expand All @@ -48,6 +50,7 @@ const plugins = [...contentPlugins(), shiki({ themes: { light, dark } })];
const components = {
...annotateComponents,
...carouselComponents,
...codeComponents,
...chartsComponents,
...embedComponents,
...githubCalloutsComponents,
Expand Down Expand Up @@ -87,6 +90,7 @@ const packages = (
{ 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: "code", 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 }[]
Expand Down Expand Up @@ -189,8 +193,6 @@ const examples: NodeHandler = async ([, , ...children], state) => {
return `<div class="examples"><div class="tablist" role="tablist">${labels.join("")}</div>${bodies.join("")}</div>`;
};

// A comparison table, which the styles lay out: a row with only its first cell filled heads a group, and italics in
// a first cell describe the row.
const matrix: NodeHandler = async ([, , ...children], state) =>
`<div class="matrix">${(await Promise.all(children.map((child) => state.one(child, state)))).join("")}</div>`;

Expand Down Expand Up @@ -437,7 +439,7 @@ const npmxLink = (npm?: string) =>
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.
// The example tabs, the Preview/Code switch, the install command's copy button, and the active sidebar link. Without it the page still reads top to bottom.
const script = `
const reduce = matchMedia("(prefers-reduced-motion: reduce)");
// One indicator per tab row or switch slides to the chosen item. A hidden row measures zero, so it's placed again,
Expand Down Expand Up @@ -481,7 +483,6 @@ for (const box of document.querySelectorAll(".installer")) {
for (const b of document.querySelectorAll(".card-copy")) b.addEventListener("click", async () => {
await navigator.clipboard.writeText(b.dataset.copy); b.classList.add("copied"); setTimeout(() => b.classList.remove("copied"), 1200);
});
// The index's window types its Markdown once, then wipes to the rendered page. A tab chosen first stops it.
for (const win of document.querySelectorAll(".window")) {
const tabs = [...win.querySelectorAll("[role=tab]")], panels = [...win.querySelectorAll("[role=tabpanel]")];
const place = indicate(win.querySelector(".window-tabs"), "[aria-selected=true]");
Expand All @@ -494,23 +495,14 @@ for (const win of document.querySelectorAll(".window")) {
const [from, to] = [panels[1 - i], panels[i]];
to.toggleAttribute("data-active", true); to.inert = false; from.inert = true;
const done = () => { if (currentTransition === transition) from.removeAttribute("data-active") };
if (wipe && !reduce.matches) to.animate({ clipPath: ["inset(0 100% 0 0)", "inset(0)"] }, { duration: 700, easing: "cubic-bezier(0.77, 0, 0.175, 1)" }).finished.then(done);
else done();
if (wipe && !reduce.matches) to.animate({ clipPath: ["inset(0 100% 0 0)", "inset(0)"] }, { duration: 700, easing: "cubic-bezier(0.77, 0, 0.175, 1)" }).finished.then(done); else done();
place();
};
win.querySelector(".window-tabs").addEventListener("click", e => { const t = e.target.closest("[role=tab]"); if (t) show(tabs.indexOf(t), true) });
win.querySelector(".window-tabs").addEventListener("keydown", e => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
const i = tabs.indexOf(e.target) === 0 ? 1 : 0; show(i, true); tabs[i].focus();
});
if (reduce.matches) show(1);
else { win.dataset.typing = ""; timer = setTimeout(() => show(1, true), parseFloat(getComputedStyle(win).getPropertyValue("--typed")) + 900) }
}
for (const pre of document.querySelectorAll("main pre:not(.window pre)")) {
const b = document.createElement("button"); b.className = "copy"; b.type = "button"; b.textContent = "Copy";
b.onclick = async () => { await navigator.clipboard.writeText(pre.querySelector("code").innerText.trimEnd()); b.textContent = "Copied"; setTimeout(() => b.textContent = "Copy", 1500) };
pre.append(b);
win.querySelector(".window-tabs").addEventListener("keydown", e => { if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; const i = tabs.indexOf(e.target) === 0 ? 1 : 0; show(i, true); tabs[i].focus() });
if (reduce.matches) show(1); else { win.dataset.typing = ""; timer = setTimeout(() => show(1, true), parseFloat(getComputedStyle(win).getPropertyValue("--typed")) + 900) }
}

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);
Expand Down
73 changes: 73 additions & 0 deletions docs/pages/code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
title: comark-code
description: Copy and wrap buttons on code blocks.
tagline: Copy and Wrap on every code block, and buttons of your own, such as Run, that hand the code to your app.
install: pnpm add comark @comark/html comark-code
---

::examples
:::example{label="Copy and wrap"}
````md
```sh
pnpm add comark @comark/html comark-code && pnpm exec comark-lint docs/**/*.md --max-warnings 0 --format text
```
````
:::
::

Point at a code block to show its buttons, which stay visible on touch screens. Wrap breaks long lines in that block only, and Copy puts its code on the clipboard. Without a script, the block shows as it would without the plugin, with no buttons.

## Usage

Register the plugin and pass the renderer's `components`:

```ts
import { createHtmlRenderer } from "@comark/html";
import code, { components } from "comark-code/html";

const render = createHtmlRenderer({ plugins: [code()], components });
```

The components replace how `pre` renders. The block keeps its own markup, so Shiki's highlighting and your `pre` styles still apply, and it works in a [code group](/tabs/#components-code-group) whichever plugin comes first.

For Vue, React, or Svelte, import from `comark-code/vue`, `comark-code/react`, or `comark-code/svelte` (Svelte 5.20 or later). [Renderers](/guide/renderers/) covers server rendering and using it with other plugins.

## Guides

### Actions

Add buttons of your own with `actions`. Each one goes on the code blocks in its `languages`, or on every block when you leave them out, before Wrap and Copy:

```ts
const render = createHtmlRenderer({
plugins: [code({ actions: [{ id: "run", label: "Run", languages: ["sh", "bash"] }] })],
components,
});
```

A click sends a `comark-code-action` event from the block, which bubbles up to the page. Its `detail` holds the action's `id`, the block's code, and its language, and your app decides what the action does:

```ts
document.addEventListener("comark-code-action", (event) => {
const { action, code, language } = event.detail;
if (action === "run") terminal.run(code);
});
```

The plugin does nothing else, so `code()` with no actions only adds Copy and Wrap, which come from the components.

### Copy

Copy takes the code's text as written, without the highlighting. A check confirms it, and a status line tells screen readers. When the page can't write to the clipboard, such as over plain http, it selects the code instead and says to press Ctrl+C or ⌘C.

### Wrap

Wrap breaks long lines in its own block and stays on until it's pressed again, and it isn't saved for the next visit.

### Theming

The buttons follow the shared tokens in [Theming](/guide/theming/): they sit on `--comark-background` with a `--comark-border` frame, and turn `--comark-accent` on hover and while Wrap is on. Wrap's rule is the one outside the `comark` layer, so it beats a page's own `white-space`; move the bar with a plain rule, such as `.ck-bar { top: 0.75rem }`.

## Credits

The icons are from [Lucide](https://lucide.dev).
4 changes: 3 additions & 1 deletion docs/pages/guide/renderers.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ tagline: Register every plugin you use in one renderer. The same markup comes ou
| --- | --- | --- | --- | --- |
| [annotate](/annotate/) | `comark-annotate/html` | | | |
| [carousel](/carousel/) | `comark-carousel/html` | `comark-carousel/vue` | `comark-carousel/react` | `comark-carousel/svelte` |
| [charts](/charts/) | `comark-charts/html` | | | |
| [code](/code/) | `comark-code/html` | `comark-code/vue` | `comark-code/react` | `comark-code/svelte` |
| [embed](/embed/) | `comark-embed/html` | `comark-embed/vue` | `comark-embed/react` | `comark-embed/svelte` |
| [github-callouts](/github-callouts/) | `comark-github-callouts/html` | `comark-github-callouts/vue` | `comark-github-callouts/react` | `comark-github-callouts/svelte` |
| [tabs](/tabs/) | `comark-tabs/html` | `comark-tabs/vue` | `comark-tabs/react` | `comark-tabs/svelte` |
Expand Down Expand Up @@ -46,7 +48,7 @@ Put `include` first: it pastes files into the Markdown before the others read it

## Scripts and styles

Nothing needs a script to read. Zoom, carousel, tabs, and annotate add behavior on top: zooming, arrows and keys, switching tabs, hover and copy. Callouts and embeds need only styles.
Nothing needs a script to read. Zoom, carousel, tabs, code, and annotate add behavior on top: zooming, arrows and keys, switching tabs, copying and wrapping code, hover and copy. Callouts and embeds need only styles.

The HTML renderers inline each plugin's styles, and its script if it has one, once per document. The Vue, React, and Svelte components start the script when they mount and stop it when the last one unmounts, and add the styles the same way. Import each package's `style.css`, such as `comark-zoom/style.css`, with your styles when you render on a server, so the page doesn't shift when the components mount.

Expand Down
4 changes: 2 additions & 2 deletions docs/pages/guide/theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ shadcn/ui before Tailwind 4 stores colors as bare HSL numbers, so map those thro

## Tokens

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

| Token | Falls back to | Used for |
| --- | --- | --- |
Expand All @@ -32,7 +32,7 @@ Set the `--comark-*` tokens to theme annotate, carousel, embed, github-callouts,
}
```

To theme one part only, set the tokens on its root instead: `.ca` for annotate, `.cc` for a carousel, `.ce` and `.ce-card` for embeds, `.cgc` for callouts, `.ct` for tabs, and `.cz-dialog` for the zoomed view.
To theme one part only, set the tokens on its root instead: `.ca` for annotate, `.cc` for a carousel, `.ck` for code buttons, `.ce` and `.ce-card` for embeds, `.cgc` for callouts, `.ct` for tabs, and `.cz-dialog` for the zoomed view.

## Your own rules

Expand Down
Loading
Loading