From 6967491fb0b5ff845082440e5bc6e17f00ba9c00 Mon Sep 17 00:00:00 2001 From: onmax Date: Thu, 22 Oct 2026 15:15:26 +0200 Subject: [PATCH 1/3] feat(charts): draw while parsing, and render with Vue, React, and Svelte --- AGENTS.md | 1 + docs/pages/compare/charts.md | 2 +- docs/pages/guide/renderers.md | 4 +- packages/charts/README.md | 4 +- packages/charts/docs/getting-started.md | 2 +- packages/charts/docs/guides.md | 13 ++++- packages/charts/package.json | 35 ++++++++++-- packages/charts/src/client.ts | 12 +++++ packages/charts/src/html.ts | 47 +++++----------- packages/charts/src/index.ts | 70 ++++++++++++++++++++++-- packages/charts/src/react.ts | 31 +++++++++++ packages/charts/src/svelte/Chart.svelte | 18 +++++++ packages/charts/src/svelte/index.d.ts | 12 +++++ packages/charts/src/svelte/index.js | 6 +++ packages/charts/src/vue.ts | 26 +++++++++ packages/charts/test/frameworks.test.ts | 72 +++++++++++++++++++++++++ packages/charts/tsdown.config.ts | 5 +- packages/lint/src/config.ts | 11 +++- pnpm-lock.yaml | 24 +++++++++ 19 files changed, 343 insertions(+), 52 deletions(-) create mode 100644 packages/charts/src/client.ts create mode 100644 packages/charts/src/react.ts create mode 100644 packages/charts/src/svelte/Chart.svelte create mode 100644 packages/charts/src/svelte/index.d.ts create mode 100644 packages/charts/src/svelte/index.js create mode 100644 packages/charts/src/vue.ts create mode 100644 packages/charts/test/frameworks.test.ts diff --git a/AGENTS.md b/AGENTS.md index 770725b3..1a3a1d72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ A pnpm workspace. Every package in `packages/` is published to npm under its own - 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, and `src/script.ts` is the browser script the HTML renderer inlines. - `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` checks a `::chart` and its table, and the parse plugin draws it with Flint and Vega and replaces its attributes with `type`, `title`, `label`, `svg`, and `marks`, keeping only the table as content, so no renderer loads Vega. Marks need comark-annotate's figure, so only the HTML renderer draws them. - `packages/chips` is `comark-chips`. Its parse plugin turns every web link, `@user`, and `:chip` into a `chip` node with `href`, `kind` (`favicon` or `avatar`), `src`, and `title`, so no renderer reads URLs. `src/images.ts` downloads each image while parsing and inlines it as a data URI, cached per process; an HTTP error maps to its status, and any other failure keeps the URL for the reader's browser. No script: the components add the styles on mount. - `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/diff` is `comark-diff`. `src/read.ts` reads a `::diff` node, two fenced blocks before and after, into rows with a small LCS over lines and then over the words of each changed pair, and reports its issues; the plugin and the renderer both go through it. The HTML renderer draws a table with no script that doesn't zoom: its code scrolls in place. It bundles only `comark-zoom/theme`, like github-callouts. diff --git a/docs/pages/compare/charts.md b/docs/pages/compare/charts.md index 6ec57f0f..06997666 100644 --- a/docs/pages/compare/charts.md +++ b/docs/pages/compare/charts.md @@ -82,7 +82,7 @@ The other two plugins draw the same chart from a JSON spec, and the page holds o | Engine | Vega | Vega | Vega or ECharts | | **Output** | | | | | Format | SVG | SVG, image, or Vue component | SVG, image, or Vue component | -| Renderers *SVG renders in any of them* | `@comark/html` | Any, or `@comark/vue` | Any, or `@comark/vue` | +| Renderers *SVG renders in any of them* | HTML, Vue, React, and Svelte; marks in HTML | Any, or `@comark/vue` | Any, or `@comark/vue` | | **Checks** | | | | | Errors *What a broken block does* | ✓ An issue that says how to fix it | A `
` for parse-time invalid JSON/assembly | `.flint-error` for Vue render failures; `
` for parse-time invalid JSON/assembly |
 | Cloudflare Workers *Vega needs `vega-interpreter` there* | ✓ | — | Vega/Vega-Lite SVG path: —; ECharts SSR: ✓ |
diff --git a/docs/pages/guide/renderers.md b/docs/pages/guide/renderers.md
index 5b680360..57f34989 100644
--- a/docs/pages/guide/renderers.md
+++ b/docs/pages/guide/renderers.md
@@ -10,14 +10,14 @@ 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` | | | |
+| [charts](/charts/) | `comark-charts/html` | `comark-charts/vue` | `comark-charts/react` | `comark-charts/svelte` |
 | [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](/github/) | `comark-github/html` | `comark-github/vue` | `comark-github/react` | `comark-github/svelte` |
 | [tabs](/tabs/) | `comark-tabs/html` | `comark-tabs/vue` | `comark-tabs/react` | `comark-tabs/svelte` |
 | [zoom](/zoom/) | `comark-zoom/html` | `comark-zoom/vue` | `comark-zoom/react` | `comark-zoom/svelte` |
 
-Each entry exports the parse plugin as `default` and the renderer's `components`. Annotate renders only to HTML: a screenshot's notes are Markdown, and a Vue, React, or Svelte version would each copy the whole figure. [include](/include/) has no components, since what it pastes renders like the rest of the page, so it works with any renderer. [lint](/lint/) checks documents and doesn't render them.
+Each entry exports the parse plugin as `default` and the renderer's `components`. Annotate renders only to HTML: a screenshot's notes are Markdown, and a Vue, React, or Svelte version would each copy the whole figure. So marks on a chart, which annotate draws, show only in `@comark/html`; the other renderers draw the chart without them. [include](/include/) has no components, since what it pastes renders like the rest of the page, so it works with any renderer. [lint](/lint/) checks documents and doesn't render them.
 
 ## Several plugins
 
diff --git a/packages/charts/README.md b/packages/charts/README.md
index 65a4207a..9325ca4f 100644
--- a/packages/charts/README.md
+++ b/packages/charts/README.md
@@ -6,7 +6,7 @@
 [![license](https://npmx.dev/api/registry/badge/license/comark-charts)](https://npmx.dev/package/comark-charts)
 [![docs](https://img.shields.io/badge/docs-comark.onmax.me-14b8a6?labelColor=0a0a0a)](https://comark.onmax.me/charts/)
 
-Charts from a Markdown table, drawn to SVG with no script. The table stays under the chart, so people see the chart and agents read the numbers. A [Comark](https://comark.dev) plugin for HTML.
+Charts from a Markdown table, drawn to SVG with no script. The table stays under the chart, so people see the chart and agents read the numbers. A [Comark](https://comark.dev) plugin for HTML, Vue, React, and Svelte.
 
 ```sh
 pnpm add comark @comark/html comark-charts
@@ -52,7 +52,7 @@ The plugin adds every problem to `meta.issues`, so a host can reject a document
 
 ## How it renders
 
-[Flint](https://github.com/microsoft/flint-chart) turns the table into a Vega-Lite spec with a house theme, and [Vega](https://vega.github.io) draws it to SVG while rendering, on the server or at build time, so the browser runs no chart code. The parse plugin only checks the table, so parsing stays light.
+[Flint](https://github.com/microsoft/flint-chart) turns the table into a Vega-Lite spec with a house theme, and [Vega](https://vega.github.io) draws it to SVG while parsing, on the server or at build time, so the browser runs no chart code. `comark-charts/vue`, `/react`, and `/svelte` place the same SVG; marks, which come from comark-annotate, show only in `@comark/html`.
 
 The page holds a `figure.cch` with the title, the SVG, and the table folded into **Data**. Text, axes, and grid follow shadcn/ui's variables and the shared `--comark-*` tokens, so charts switch with the page's dark mode. Series take `--comark-chart-1` to `-6`, then shadcn/ui's `--chart-1` to `-6`. The rules sit in a `comark` CSS layer, so any plain rule of your own wins; see the charts' [Theming page](https://comark.onmax.me/charts/theming/).
 
diff --git a/packages/charts/docs/getting-started.md b/packages/charts/docs/getting-started.md
index 5a3e5d04..245164d0 100644
--- a/packages/charts/docs/getting-started.md
+++ b/packages/charts/docs/getting-started.md
@@ -15,7 +15,7 @@ import charts, { components } from "comark-charts/html";
 const render = createHtmlRenderer({ plugins: [charts()], components });
 ```
 
-Charts are drawn while rendering, on the server or at build time, by [Flint](https://github.com/microsoft/flint-chart) and [Vega](https://vega.github.io), which the package installs. The parse plugin only checks the table, so it stays light.
+Charts are drawn while parsing, on the server or at build time, by [Flint](https://github.com/microsoft/flint-chart) and [Vega](https://vega.github.io), which the package installs, so the browser runs no chart code. Vue, React, and Svelte have renderers too; see [Renderers](/charts/guides/#renderers).
 
 ## Agents
 
diff --git a/packages/charts/docs/guides.md b/packages/charts/docs/guides.md
index 17870a97..ab7a3e67 100644
--- a/packages/charts/docs/guides.md
+++ b/packages/charts/docs/guides.md
@@ -33,4 +33,15 @@ A chart keeps the size its text was set for, sized to fit its card with room for
 
 ## Renderers
 
-Only `@comark/html` has a renderer, `comark-charts/html`, which inlines the styles once per document. Vega draws on the server, and a Vue, React, or Svelte component would ship it to the browser. Charts are static: there are no tooltips, and the **Data** table holds every number.
+The parse plugin draws every chart, so Vega runs where you parse, on the server or at build time, and never in the browser. Each renderer only places the SVG it wrote:
+
+| Renderer | Entry |
+| --- | --- |
+| `@comark/html` | `comark-charts/html`, which inlines the styles once per document |
+| `@comark/vue` | `comark-charts/vue` |
+| `@comark/react` | `comark-charts/react` |
+| `@comark/svelte` | `comark-charts/svelte` |
+
+The Vue, React, and Svelte components add the styles when they mount; to load them with your CSS instead, so a server-rendered page doesn't shift, import `comark-charts/style.css`. Marks come from comark-annotate, which renders only to HTML, so the other renderers draw the chart without them.
+
+Charts are static: there are no tooltips, and the **Data** table holds every number. To check documents without drawing them, as comark-linter does, pass `charts({ draw: false })`.
diff --git a/packages/charts/package.json b/packages/charts/package.json
index 43cb60ac..edaa0614 100644
--- a/packages/charts/package.json
+++ b/packages/charts/package.json
@@ -1,7 +1,7 @@
 {
   "name": "comark-charts",
   "version": "0.3.0",
-  "description": "Charts from a Markdown table, drawn to SVG with no script: bars, stacked bars, lines, areas, scatter plots, dumbbells, waterfalls, and pies. The table stays in the page, so people see the chart and agents read the numbers. A Comark plugin.",
+  "description": "Charts from a Markdown table, drawn to SVG while parsing, with no script: bars, stacked bars, lines, areas, scatter plots, dumbbells, waterfalls, and pies. The table stays in the page, so people see the chart and agents read the numbers. A Comark plugin.",
   "keywords": [
     "chart",
     "comark",
@@ -31,7 +31,16 @@
   "types": "./dist/index.d.mts",
   "exports": {
     ".": "./dist/index.mjs",
+    "./client": "./dist/client.mjs",
     "./html": "./dist/html.mjs",
+    "./react": "./dist/react.mjs",
+    "./style.css": "./dist/style.css",
+    "./svelte": {
+      "types": "./dist/svelte/index.d.ts",
+      "svelte": "./dist/svelte/index.js",
+      "default": "./dist/svelte/index.js"
+    },
+    "./vue": "./dist/vue.mjs",
     "./package.json": "./package.json",
     "./skills/*": "./skills/*"
   },
@@ -54,16 +63,36 @@
   },
   "devDependencies": {
     "@comark/html": "catalog:",
+    "@comark/react": "catalog:",
+    "@comark/vue": "catalog:",
+    "@types/react": "catalog:",
+    "@types/react-dom": "catalog:",
     "comark": "catalog:",
-    "comark-zoom": "workspace:*"
+    "comark-zoom": "workspace:*",
+    "react": "catalog:",
+    "react-dom": "catalog:",
+    "svelte": "catalog:",
+    "vue": "catalog:"
   },
   "peerDependencies": {
     "@comark/html": "^0.8.0",
-    "comark": "^0.8.0"
+    "comark": "^0.8.0",
+    "react": ">=18",
+    "svelte": ">=5",
+    "vue": ">=3.5"
   },
   "peerDependenciesMeta": {
     "@comark/html": {
       "optional": true
+    },
+    "react": {
+      "optional": true
+    },
+    "svelte": {
+      "optional": true
+    },
+    "vue": {
+      "optional": true
     }
   },
   "engines": {
diff --git a/packages/charts/src/client.ts b/packages/charts/src/client.ts
new file mode 100644
index 00000000..67042de6
--- /dev/null
+++ b/packages/charts/src/client.ts
@@ -0,0 +1,12 @@
+import { css } from "./styles.ts";
+
+/** Adds the chart styles to the page once. The Vue, React, and Svelte components call it when they mount. */
+export function addStyles(): void {
+  if (document.querySelector("style[data-comark-charts]")) return;
+  const style = document.createElement("style");
+  style.dataset.comarkCharts = "";
+  style.textContent = css;
+  document.head.append(style);
+}
+
+export { css };
diff --git a/packages/charts/src/html.ts b/packages/charts/src/html.ts
index 75b5b756..fb3ff218 100644
--- a/packages/charts/src/html.ts
+++ b/packages/charts/src/html.ts
@@ -1,53 +1,32 @@
-import type { ElementNode, NodeHandler } from "comark";
+import type { NodeHandler } from "comark";
 import { annotateHtml } from "comark-annotate/html";
 import { escapeHtml } from "comark/utils";
-import type { draw } from "./draw.ts";
-import { readChart } from "./read.ts";
+import type { DrawnChart } from "./index.ts";
 import { css } from "./styles.ts";
 
 export { default } from "./index.ts";
-export type { Chart, ChartsMeta, ChartType, XKind } from "./index.ts";
+export type { Chart, ChartsMeta, ChartsOptions, ChartType, DrawnChart, XKind } from "./index.ts";
+export { css };
 
 // Each render gets a fresh `state.context`, so a document carries the styles once.
 const styled = new WeakSet();
 
-const attr = (value: string) => escapeHtml(value, { '"': """ });
-
 const chart: NodeHandler = async (node, state) => {
-  const [, , ...children] = node;
-  // The plugin reported this chart's problems already; here they only decide what can be drawn.
-  const read = readChart(node, 0, []);
-  const table = children.find(
-    (child): child is ElementNode => Array.isArray(child) && child[0] === "table",
-  );
-  if (!read || !table) return state.flow(["div", {}, ...children], state);
-
+  const [, attrs, ...children] = node;
+  // SAFETY: the parse plugin writes a DrawnChart on every chart it drew, and leaves no `svg` on the others.
+  const { type, title, label, svg, marks } = attrs as Partial;
+  if (!svg) return state.flow(["div", {}, ...children], state);
   const styles = styled.has(state.context) ? "" : ``;
   styled.add(state.context);
-  const data = await state.one(table, state);
-  const title = read.title ? `
${escapeHtml(read.title)}
` : ""; - let drawn: Awaited>; - try { - // Flint and Vega take hundreds of milliseconds to load, so a document or a lint run with no chart never loads them. - drawn = await (await import("./draw.ts")).draw(read); - } catch { - // Rendering never throws: a chart that can't be drawn still shows its numbers. - return `${styles}
${title}${data}
`; - } - const label = read.title || `${read.type} chart of ${read.series.join(", ")} by ${read.x}`; - const svg = drawn.svg.replace(" state.one(child, state)))).join(""); + const caption = title ? `
${escapeHtml(title)}
` : ""; const [, width, height] = /viewBox="0 0 ([\d.]+) ([\d.]+)"/.exec(svg) ?? []; const aspect = width && height ? Number(height) / Number(width) : undefined; - // Marks the chart couldn't place, such as one on an empty cell, are left out rather than drawn in a corner. - const marks = read.marks.flatMap((mark) => { - const box = drawn.boxes.get(mark.n); - return box ? [{ ...mark, ...box }] : []; - }); - const plot = marks.length - ? `
${await annotateHtml(svg, marks, label, state, ".cch-plot", aspect)}
` + const plot = marks?.length + ? `
${await annotateHtml(svg, marks, label ?? "", state, ".cch-plot", aspect)}
` : `
${svg}
`; return ( - `${styles}
${title}${plot}` + + `${styles}
${caption}${plot}` + `
Data${data}
` ); }; diff --git a/packages/charts/src/index.ts b/packages/charts/src/index.ts index c1f1ce28..aa984d3d 100644 --- a/packages/charts/src/index.ts +++ b/packages/charts/src/index.ts @@ -1,5 +1,8 @@ import type { ElementNode, Node } from "comark"; import { defineComarkPlugin } from "comark"; +import type { Annotation } from "comark-annotate"; +import { escapeHtml } from "comark/utils"; +import type { draw } from "./draw.ts"; import { readChart } from "./read.ts"; export type { Chart, ChartType, XKind } from "./read.ts"; @@ -9,6 +12,23 @@ export type ChartsMeta = { issues: string[]; }; +export interface ChartsOptions { + /** Draw each chart while parsing. Off, the plugin only checks them, as comark-linter does. Defaults to true. */ + draw?: boolean; +} + +/** What the plugin writes on a `::chart` it drew; the renderers read these and nothing else. */ +export interface DrawnChart { + type: string; + title: string; + /** The chart's accessible name: its title, or what it draws. */ + label: string; + svg: string; + /** Its comark-annotate marks, placed on the SVG. Only `@comark/html` draws them. */ + marks?: Annotation[]; +} + +const attr = (value: string) => escapeHtml(value, { '"': """ }); const isElement = (node: Node): node is ElementNode => Array.isArray(node) && node[0] !== null; const childrenOf = ([, , ...rest]: ElementNode): Node[] => rest; @@ -26,15 +46,55 @@ export function eachChart(nodes: Node[], visit: (node: ElementNode, number: numb } /** - * Checks every `::chart`: its props, and the Markdown table it draws. Problems are added to `meta.issues`. The - * renderer reads the same table, so the parse tree keeps the data and the chart is drawn only when rendering. + * Checks and draws every `::chart`: its props, and the Markdown table it draws. Problems are added to `meta.issues`. + * A drawn chart keeps only its table as content and carries its SVG in its attributes, so every renderer only places + * it, and Vega never reaches the browser. */ -export default defineComarkPlugin(() => ({ +export default defineComarkPlugin((options) => ({ name: "comark-charts", - post(state) { + async post(state) { const { tree } = state; const issues: string[] = []; - eachChart(tree.nodes, (node, number) => readChart(node, number, issues)); + const found: { node: ElementNode; number: number }[] = []; + eachChart(tree.nodes, (node, number) => found.push({ node, number })); + for (const { node, number } of found) { + const chart = readChart(node, number, issues); + const table = childrenOf(node).find( + (child): child is ElementNode => isElement(child) && child[0] === "table", + ); + if (options?.draw === false) continue; + if (!chart || !table) { + // Nothing to draw: the renderers show the content as it is. + node[1] = {}; + continue; + } + const label = + chart.title || `${chart.type} chart of ${chart.series.join(", ")} by ${chart.x}`; + let drawn: Awaited>; + try { + // Flint and Vega take hundreds of milliseconds to load, so a document with no chart never loads them. + drawn = await (await import("./draw.ts")).draw(chart); + } catch (error) { + issues.push( + `\`::chart\` ${number} couldn't be drawn (${error instanceof Error ? error.message : String(error)}), so it shows its table alone. Check the table, or report it at https://github.com/onmax/comark-plugins/issues.`, + ); + node.splice(1, node.length - 1, {}, table); + continue; + } + // Marks the chart couldn't place, such as one on an empty cell, are left out rather than drawn in a corner. + const marks = chart.marks.flatMap((mark) => { + const box = drawn.boxes.get(mark.n); + return box ? [{ ...mark, ...box }] : []; + }); + const attrs: DrawnChart = { + type: chart.type, + title: chart.title, + label, + svg: drawn.svg.replace(" + + +{#if svg} +
+ {#if title}
{title}
{/if} +
{@html svg}
+
Data{@render children?.()}
+
+{:else} +
{@render children?.()}
+{/if} diff --git a/packages/charts/src/svelte/index.d.ts b/packages/charts/src/svelte/index.d.ts new file mode 100644 index 00000000..e7f84662 --- /dev/null +++ b/packages/charts/src/svelte/index.d.ts @@ -0,0 +1,12 @@ +import type { Component, Snippet } from "svelte"; + +export { default } from "comark-charts"; +export type { ChartsMeta, ChartsOptions, DrawnChart } from "comark-charts"; + +export declare const Chart: Component<{ + type?: string; + title?: string; + svg?: string; + children?: Snippet; +}>; +export declare const components: { Chart: typeof Chart }; diff --git a/packages/charts/src/svelte/index.js b/packages/charts/src/svelte/index.js new file mode 100644 index 00000000..ea30f4e8 --- /dev/null +++ b/packages/charts/src/svelte/index.js @@ -0,0 +1,6 @@ +// Charts for `@comark/svelte`. Pass `components` to it; the parse plugin comes from `comark-charts`. +import Chart from "./Chart.svelte"; + +export { default } from "comark-charts"; +export { Chart }; +export const components = { Chart }; diff --git a/packages/charts/src/vue.ts b/packages/charts/src/vue.ts new file mode 100644 index 00000000..e833cb6a --- /dev/null +++ b/packages/charts/src/vue.ts @@ -0,0 +1,26 @@ +import { defineComponent, h, onMounted } from "vue"; +import { addStyles } from "./client.ts"; + +export { default } from "./index.ts"; +export type { ChartsMeta, ChartsOptions, DrawnChart } from "./index.ts"; + +/** A chart for `@comark/vue`. Its marks are drawn only by `@comark/html`. */ +export const Chart = defineComponent({ + name: "Chart", + inheritAttrs: false, + props: { type: String, title: String, svg: String }, + setup(props, { slots }) { + onMounted(addStyles); + return () => + props.svg + ? h("figure", { class: "cch", "data-type": props.type }, [ + props.title ? h("figcaption", props.title) : null, + h("div", { class: "cch-plot", innerHTML: props.svg }), + h("details", { class: "cch-data" }, [h("summary", "Data"), slots.default?.()]), + ]) + : h("div", slots.default?.()); + }, +}); + +/** Pass these as `components` to `@comark/vue`. */ +export const components = { Chart }; diff --git a/packages/charts/test/frameworks.test.ts b/packages/charts/test/frameworks.test.ts new file mode 100644 index 00000000..2d19903f --- /dev/null +++ b/packages/charts/test/frameworks.test.ts @@ -0,0 +1,72 @@ +import { readFile } from "node:fs/promises"; +import { renderHtmlFromDocument } from "@comark/html"; +import { MarkdownDocument as ReactDocument } from "@comark/react"; +import { MarkdownDocument as VueDocument } from "@comark/vue"; +import { parseMarkdown } from "comark"; +import { createElement } from "react"; +import { renderToString as renderReact } from "react-dom/server"; +import { compile } from "svelte/compiler"; +import { describe, expect, it } from "vite-plus/test"; +import { createSSRApp, h } from "vue"; +import { renderToString as renderVue } from "vue/server-renderer"; +import charts from "../src/index.ts"; +import { components as htmlComponents } from "../src/html.ts"; +import { components as reactComponents } from "../src/react.ts"; +import { components as vueComponents } from "../src/vue.ts"; + +// Drops what differs only in serialization: comments, whitespace between tags, the HTML renderer's inline styles, +// and how each framework closes an empty element. +const normalize = (html: string) => + html + .replace(//g, "") + .replace(/]*>.*?<\/style>/gs, "") + .replace(/\s*\/>/g, ">") + .replace(/>\s+<") + .trim(); + +const markdown = `::chart{title="Bundle size"} +| package | size (kB) | +| --- | --: | +| annotate | 41.2 | +| zoom | 18.1 | +::`; + +describe("comark-charts renderers", () => { + it("draws the same chart in every renderer, with Vega left on the server", async () => { + const value = await parseMarkdown(markdown, { plugins: [charts()] }); + const html = normalize(await renderHtmlFromDocument(value, { components: htmlComponents })); + expect(html).toContain( + '
Bundle size
h(VueDocument, { value, components: vueComponents }) }), + ), + ); + const react = normalize( + renderReact(createElement(ReactDocument, { value, components: reactComponents })), + ); + for (const output of [vue, react]) { + const svg = //.exec(output)?.[0]; + expect(svg).toBe(//.exec(html)?.[0]); + expect(output).toContain( + '
Bundle size
', + ); + expect(output).toContain('
Data'); + expect(output).toContain("annotate"); + } + }); + + it("checks without drawing when told not to draw", async () => { + const value = await parseMarkdown(markdown, { plugins: [charts({ draw: false })] }); + expect(value.meta.issues).toEqual([]); + expect( + normalize(await renderHtmlFromDocument(value, { components: htmlComponents })), + ).not.toContain(" { + const source = await readFile(new URL("../src/svelte/Chart.svelte", import.meta.url), "utf8"); + expect(() => compile(source, { filename: "Chart.svelte", generate: "server" })).not.toThrow(); + }); +}); diff --git a/packages/charts/tsdown.config.ts b/packages/charts/tsdown.config.ts index b423218a..9e964e14 100644 --- a/packages/charts/tsdown.config.ts +++ b/packages/charts/tsdown.config.ts @@ -1,3 +1,4 @@ -import { definePackage } from "../../tsdown.shared.ts"; +import { defineComponentsPackage } from "../../tsdown.shared.ts"; +import { css } from "./src/styles.ts"; -export default definePackage({ entry: ["src/index.ts", "src/html.ts"] }); +export default defineComponentsPackage(css); diff --git a/packages/lint/src/config.ts b/packages/lint/src/config.ts index ea4061a7..df47e1ba 100644 --- a/packages/lint/src/config.ts +++ b/packages/lint/src/config.ts @@ -12,7 +12,16 @@ const packages = [ ["comark-annotate/html", () => import("comark-annotate/html"), []], ["comark-carousel/html", () => import("comark-carousel/html"), []], ["comark-code/html", () => import("comark-code/html"), []], - ["comark-charts/html", () => import("comark-charts/html"), []], + [ + "comark-charts/html", + // Linting reads no drawings, so it loads no Vega. + () => + import("comark-charts/html").then((module) => ({ + ...module, + default: () => module.default({ draw: false }), + })), + [], + ], ["comark-embed/html", () => import("comark-embed/html"), []], [ "comark-github/html", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2556954..ca206185 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,12 +288,36 @@ importers: '@comark/html': specifier: 'catalog:' version: 0.8.1(shiki@4.5.0) + '@comark/react': + specifier: 'catalog:' + version: 0.8.1(react@19.3.1)(shiki@4.5.0) + '@comark/vue': + specifier: 'catalog:' + version: 0.8.1(shiki@4.5.0)(vue@3.5.44(typescript@6.0.3)) + '@types/react': + specifier: 'catalog:' + version: 19.3.0 + '@types/react-dom': + specifier: 'catalog:' + version: 19.3.0(@types/react@19.3.0) comark: specifier: 'catalog:' version: 0.8.1(shiki@4.5.0) comark-zoom: specifier: workspace:* version: link:../zoom + react: + specifier: 'catalog:' + version: 19.3.1 + react-dom: + specifier: 'catalog:' + version: 19.3.1(react@19.3.1) + svelte: + specifier: 'catalog:' + version: 5.57.2(@typescript-eslint/types@8.71.1) + vue: + specifier: 'catalog:' + version: 3.5.44(typescript@6.0.3) packages/chips: devDependencies: From 308aaeaee4ee159204c116747b2c146697ae65c3 Mon Sep 17 00:00:00 2001 From: onmax Date: Thu, 22 Oct 2026 15:20:29 +0200 Subject: [PATCH 2/3] feat(charts): draw any Vega-Lite chart over the table --- AGENTS.md | 2 +- docs/pages/compare/charts.md | 6 +- packages/charts/README.md | 2 +- packages/charts/docs/syntax.md | 29 +++- packages/charts/skills/comark-charts/SKILL.md | 2 + packages/charts/src/draw.ts | 98 ++++++++++--- packages/charts/src/html.ts | 10 +- packages/charts/src/index.ts | 28 ++-- packages/charts/src/read.ts | 133 +++++++++++++++++- packages/charts/test/spec.test.ts | 112 +++++++++++++++ 10 files changed, 387 insertions(+), 35 deletions(-) create mode 100644 packages/charts/test/spec.test.ts diff --git a/AGENTS.md b/AGENTS.md index 1a3a1d72..2f4ce537 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,7 +13,7 @@ A pnpm workspace. Every package in `packages/` is published to npm under its own - 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, and `src/script.ts` is the browser script the HTML renderer inlines. - `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` checks a `::chart` and its table, and the parse plugin draws it with Flint and Vega and replaces its attributes with `type`, `title`, `label`, `svg`, and `marks`, keeping only the table as content, so no renderer loads Vega. Marks need comark-annotate's figure, so only the HTML renderer draws them. +- `packages/charts` is `comark-charts`. `src/read.ts` checks a `::chart` and its table, or the `vega-lite` spec that draws any other chart over the table, and the parse plugin draws it with Flint and Vega and replaces its attributes with `type`, `title`, `label`, `svg`, and `marks`, keeping only the table as content, so no renderer loads Vega. Marks need comark-annotate's figure, so only the HTML renderer draws them. - `packages/chips` is `comark-chips`. Its parse plugin turns every web link, `@user`, and `:chip` into a `chip` node with `href`, `kind` (`favicon` or `avatar`), `src`, and `title`, so no renderer reads URLs. `src/images.ts` downloads each image while parsing and inlines it as a data URI, cached per process; an HTTP error maps to its status, and any other failure keeps the URL for the reader's browser. No script: the components add the styles on mount. - `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/diff` is `comark-diff`. `src/read.ts` reads a `::diff` node, two fenced blocks before and after, into rows with a small LCS over lines and then over the words of each changed pair, and reports its issues; the plugin and the renderer both go through it. The HTML renderer draws a table with no script that doesn't zoom: its code scrolls in place. It bundles only `comark-zoom/theme`, like github-callouts. diff --git a/docs/pages/compare/charts.md b/docs/pages/compare/charts.md index 06997666..1c07f877 100644 --- a/docs/pages/compare/charts.md +++ b/docs/pages/compare/charts.md @@ -78,7 +78,7 @@ The other two plugins draw the same chart from a JSON spec, and the page holds o | Frontmatter data *A spec bound with `:spec`* | — | ✓ | ✓ | | **Drawing** | | | | | Layout *Who picks the axes, scales, and labels* | Flint | You | Flint | -| Chart types | Eight common ones | Any Vega chart | Flint's templates | +| Chart types | Eight common ones, or any Vega-Lite chart over the table | Any Vega chart | Flint's templates | | Engine | Vega | Vega | Vega or ECharts | | **Output** | | | | | Format | SVG | SVG, image, or Vue component | SVG, image, or Vue component | @@ -90,8 +90,8 @@ The other two plugins draw the same chart from a JSON spec, and the page holds o ## Which to use -Use comark-charts for documents that agents write or check, such as pull request evidence and reports: the table is the source, and it stays in the page. It draws the common chart types and nothing else. +Use comark-charts for documents that agents write or check, such as pull request evidence and reports: the table is the source, and it stays in the page. It lays out the common chart types itself, and draws any other from a Vega-Lite spec over the same table. -Use [comark-vega](https://github.com/miguelrk/comark-packages/tree/main/packages/comark-vega) for charts those types don't cover, such as box plots, maps, and error bands, or for interactive charts in a Vue app. +Use [comark-vega](https://github.com/miguelrk/comark-packages/tree/main/packages/comark-vega) for full Vega specs, data that isn't a table, such as maps, or interactive charts in a Vue app. Use [comark-flint-chart](https://github.com/miguelrk/comark-packages/tree/main/packages/comark-flint-chart) for Flint's layouts in a Vue app, or for charts drawn with ECharts. diff --git a/packages/charts/README.md b/packages/charts/README.md index 9325ca4f..1bf5ed27 100644 --- a/packages/charts/README.md +++ b/packages/charts/README.md @@ -46,7 +46,7 @@ The espresso machine arrives. ::: ``` -Bars turn sideways when their labels are long, so file paths and test names stay whole. The [docs](https://comark.onmax.me/charts/) show the table each type expects. +Bars turn sideways when their labels are long, so file paths and test names stay whole. For any other chart, put a [Vega-Lite](https://vega.github.io/vega-lite/) spec after the table in a `vega-lite` block; the table is its data. The [docs](https://comark.onmax.me/charts/syntax/) show the table each type expects. The plugin adds every problem to `meta.issues`, so a host can reject a document before publishing it. Each issue names its chart and says how to fix it. diff --git a/packages/charts/docs/syntax.md b/packages/charts/docs/syntax.md index 0aa9a92a..ae4f4b2a 100644 --- a/packages/charts/docs/syntax.md +++ b/packages/charts/docs/syntax.md @@ -1,6 +1,6 @@ --- title: Syntax -description: The table each chart type reads, and the marks you can draw on a chart. +description: The table each chart type reads, Vega-Lite specs for any other chart, and the marks you can draw on a chart. --- ```md @@ -117,6 +117,33 @@ Bars turn sideways when their labels are long or there are many of them, so file ::: :: +## Any chart + +For a chart the types don't cover, such as a box plot, a heatmap, or error bands, put a [Vega-Lite](https://vega.github.io/vega-lite/) spec after the table in a `vega-lite` block. The table is still its data, so it stays in the page: each row becomes one data point, with a field per column named by its header. + +````md +::chart{title="Latency per request (ms)"} +| endpoint | ms | +| --- | --: | +| /search | 120 | +| /search | 410 | +| /login | 40 | +| /login | 61 | + +```vega-lite +{ + "mark": "boxplot", + "encoding": { + "x": { "field": "endpoint", "type": "nominal" }, + "y": { "field": "ms", "type": "quantitative" } + } +} +``` +:: +```` + +Leave `data` out of the spec; the table replaces it. Cells that read as numbers become numbers, empty ones null, and the rest stay text. The chart takes the same colors and type as the others wherever the spec sets none, and its own `config` wins. `type`, `x`, `y`, and marks don't apply: name the columns in the spec's `encoding`, and draw notes with a `text` or `rule` layer. The plugin reports a spec that isn't JSON, a `data` it replaced, and a field that's neither a column nor made by a transform. The spec can't load files or URLs, and links in it are dropped. + ## Marks Point at data with [comark-annotate](/annotate/)'s marks: put an `::annotation` inside the chart, after the table, and name the row it marks with `at`, the row's x value as the table writes it. Add `series` to mark one column's value rather than the whole row. diff --git a/packages/charts/skills/comark-charts/SKILL.md b/packages/charts/skills/comark-charts/SKILL.md index f93d15a9..221bf4ad 100644 --- a/packages/charts/skills/comark-charts/SKILL.md +++ b/packages/charts/skills/comark-charts/SKILL.md @@ -39,6 +39,8 @@ Props, all optional: `type` (below), `x` (the x column, default the first), `y` | How changes add up to a total | `waterfall` | the start total, each change (negative for a drop), then the end total, in one column | | Shares of one whole, five or fewer | `pie` | names, then one column of non-negative numbers | +When none of these fit, such as a box plot or a heatmap, put a Vega-Lite spec after the table in a fenced `vega-lite` block. The table is its data, so leave `data` out of the spec and name the table's headers as fields; marks don't apply. + For a before and after of one or two metrics, grouped `bar` reads best; past three, use `dumbbell`. Keep the rows in the order the reader should read them: charts draw them in table order. ## Mark what matters diff --git a/packages/charts/src/draw.ts b/packages/charts/src/draw.ts index a4c7cd2c..60987ff7 100644 --- a/packages/charts/src/draw.ts +++ b/packages/charts/src/draw.ts @@ -7,7 +7,7 @@ import type { Field } from "vega-lite/types_unstable/channeldef.js"; import type { LayerSpec, UnitSpec } from "vega-lite/types_unstable/spec/index.js"; import { compile } from "vega-lite"; import { INK, SERIES } from "./colors.ts"; -import type { Chart, XKind } from "./read.ts"; +import type { Chart, SpecChart, XKind } from "./read.ts"; const { logger, None, parse, View } = vega; @@ -346,6 +346,30 @@ function boxes(chart: Chart, view: vega.View, width: number, height: number): Ma return found; } +/** Draws a compiled Vega spec to an SVG string, and returns the view for finding where its marks sit. */ +async function render(compiled: vega.Spec, loader?: vega.Loader) { + // Vega compiles its expressions with `new Function` unless told to interpret them, and Cloudflare Workers, where + // documents like these often render, forbid that. + const options: vega.ViewOptions = { + renderer: "none", + logLevel: None, + expr: expressionInterpreter, + }; + if (loader) options.loader = loader; + const view = new View(parse(compiled, undefined, { ast: true }), options); + // Vega gives every group a role for screen readers, which the one `role="img"` hides anyway, an empty foreground + // path, and float noise such as `10.000000000000021`. Only tags change, since text holds the table's own numbers, + // and Vega escapes `>` in attributes. + const svg = (await view.toSVG()) + .replace(/]* d=""[^>]*\/>/g, "") + .replace(/<[^>]+>/g, (tag) => + tag + .replace(/ (?:role|aria-roledescription|aria-hidden)="[^"]*"/g, "") + .replace(/(\.\d\d)\d+/g, "$1"), + ); + return { view, svg }; +} + /** * Draws a chart to an SVG string at the size Flint laid it out for, so its text reads at the size it was set, and * finds where each of its marks sits. @@ -586,25 +610,63 @@ export async function draw(chart: Chart): Promise<{ svg: string; boxes: Map` in attributes. - const svg = (await view.toSVG()) - .replace(/]* d=""[^>]*\/>/g, "") - .replace(/<[^>]+>/g, (tag) => - tag - .replace(/ (?:role|aria-roledescription|aria-hidden)="[^"]*"/g, "") - .replace(/(\.\d\d)\d+/g, "$1"), - ); + const { view, svg } = await render(compile(spec, { logger: quiet }).spec); const [, width = "0", height = "0"] = /viewBox="0 0 ([\d.]+) ([\d.]+)"/.exec(svg) ?? []; const found = boxes(chart, view, Number(width), Number(height)); view.finalize(); return { svg, boxes: found }; } + +// The house look, set where a spec leaves it: shadcn/ui's charts, in the inks the stylesheet swaps for the page's. +const SPEC_CONFIG = { + aria: false, + font: "sans-serif", + view: { stroke: null }, + range: { category: [...SERIES] }, + mark: { color: SERIES[0] }, + bar: { cornerRadiusEnd: 4 }, + line: { strokeWidth: 2 }, + axis: { + domain: false, + ticks: false, + grid: true, + gridColor: INK.border, + labelColor: INK.muted, + titleColor: INK.muted, + labelFontSize: 12, + titleFontSize: 12, + titleFontWeight: "normal", + labelPadding: 8, + titlePadding: 10, + }, + axisX: { grid: false }, + legend: { labelColor: INK.muted, titleColor: INK.muted, labelFontSize: 12, titleFontSize: 12 }, + text: { color: INK.foreground, fontSize: 12 }, + title: { color: INK.foreground }, +} as const; + +// A document's spec may not reach the network or the disk where it's parsed: the table is its only data. +const sealed = vega.loader(); +sealed.load = () => + Promise.reject(new Error("a chart's spec can't load files or URLs; its data is the table")); +// An empty href loads no image and makes no link. +sealed.sanitize = () => Promise.resolve({ href: "" }); + +/** Draws a Vega-Lite spec over the chart's table, in the house look wherever the spec sets none. */ +export async function drawSpec(chart: SpecChart): Promise<{ svg: string }> { + const { config, ...rest } = chart.spec; + // The spec came from JSON, and compile() checks it at runtime and throws on what it can't draw. + const spec: TopLevelSpec = JSON.parse( + JSON.stringify({ + background: "transparent", + padding: PADDING, + ...rest, + data: { values: chart.values }, + config: Object.assign({}, SPEC_CONFIG, config), + }), + ); + const { view, svg } = await render(compile(spec, { logger: quiet }).spec, sealed); + view.finalize(); + // A link in the drawing would run whatever URL the spec gives it, `javascript:` included. + return { svg: svg.replace(/<\/?a\b[^>]*>/g, "") }; +} diff --git a/packages/charts/src/html.ts b/packages/charts/src/html.ts index fb3ff218..26d4daff 100644 --- a/packages/charts/src/html.ts +++ b/packages/charts/src/html.ts @@ -5,7 +5,15 @@ import type { DrawnChart } from "./index.ts"; import { css } from "./styles.ts"; export { default } from "./index.ts"; -export type { Chart, ChartsMeta, ChartsOptions, ChartType, DrawnChart, XKind } from "./index.ts"; +export type { + Chart, + ChartsMeta, + ChartsOptions, + ChartType, + DrawnChart, + SpecChart, + XKind, +} from "./index.ts"; export { css }; // Each render gets a fresh `state.context`, so a document carries the styles once. diff --git a/packages/charts/src/index.ts b/packages/charts/src/index.ts index aa984d3d..603728ed 100644 --- a/packages/charts/src/index.ts +++ b/packages/charts/src/index.ts @@ -5,7 +5,7 @@ import { escapeHtml } from "comark/utils"; import type { draw } from "./draw.ts"; import { readChart } from "./read.ts"; -export type { Chart, ChartType, XKind } from "./read.ts"; +export type { Chart, ChartType, SpecChart, XKind } from "./read.ts"; export type ChartsMeta = { /** One readable sentence per problem with a `::chart`. Other plugins add theirs too. */ @@ -68,24 +68,36 @@ export default defineComarkPlugin((options) => node[1] = {}; continue; } + const spec = chart.type === "vega-lite"; const label = - chart.title || `${chart.type} chart of ${chart.series.join(", ")} by ${chart.x}`; + chart.title || + (spec + ? `chart of ${chart.columns.join(", ")}` + : `${chart.type} chart of ${chart.series.join(", ")} by ${chart.x}`); let drawn: Awaited>; try { // Flint and Vega take hundreds of milliseconds to load, so a document with no chart never loads them. - drawn = await (await import("./draw.ts")).draw(chart); + const module = await import("./draw.ts"); + drawn = spec + ? { ...(await module.drawSpec(chart)), boxes: new Map() } + : await module.draw(chart); } catch (error) { + const fix = spec + ? "Check its Vega-Lite spec against https://vega.github.io/vega-lite/docs/" + : "Check the table, or report it at https://github.com/onmax/comark-plugins/issues"; issues.push( - `\`::chart\` ${number} couldn't be drawn (${error instanceof Error ? error.message : String(error)}), so it shows its table alone. Check the table, or report it at https://github.com/onmax/comark-plugins/issues.`, + `\`::chart\` ${number} couldn't be drawn (${error instanceof Error ? error.message : String(error)}), so it shows its table alone. ${fix}.`, ); node.splice(1, node.length - 1, {}, table); continue; } // Marks the chart couldn't place, such as one on an empty cell, are left out rather than drawn in a corner. - const marks = chart.marks.flatMap((mark) => { - const box = drawn.boxes.get(mark.n); - return box ? [{ ...mark, ...box }] : []; - }); + const marks = spec + ? [] + : chart.marks.flatMap((mark) => { + const box = drawn.boxes.get(mark.n); + return box ? [{ ...mark, ...box }] : []; + }); const attrs: DrawnChart = { type: chart.type, title: chart.title, diff --git a/packages/charts/src/read.ts b/packages/charts/src/read.ts index 598a879e..8a176145 100644 --- a/packages/charts/src/read.ts +++ b/packages/charts/src/read.ts @@ -39,6 +39,17 @@ export interface ChartMark extends Omit { series?: string; } +/** A chart drawn from a Vega-Lite spec over the table: any chart Vega-Lite draws, with the table as its data. */ +export interface SpecChart { + type: "vega-lite"; + title: string; + /** The table's headers, in order. */ + columns: string[]; + /** One object per row, keyed by header: a number where the cell reads as one, else its text, and null when empty. */ + values: Record[]; + spec: Record; +} + export const TYPES: readonly ChartType[] = [ "bar", "stacked", @@ -64,6 +75,8 @@ const NUMBER = /^[+-]?(?:\d+|\d{1,3}(?:,\d{3})+)(?:\.\d+)?$/; const isElement = (node: Node): node is ElementNode => Array.isArray(node) && node[0] !== null; const childrenOf = ([, , ...rest]: ElementNode): Node[] => rest; const isContent = (node: Node) => (Array.isArray(node) ? node[0] !== null : node.trim() !== ""); +const isSpec = (node: Node): node is ElementNode => + isElement(node) && node[0] === "pre" && node[1].language === "vega-lite"; const elements = (node: ElementNode, tag: string) => childrenOf(node).filter((child): child is ElementNode => isElement(child) && child[0] === tag); const text = (value: unknown) => @@ -90,11 +103,17 @@ function kindOf(values: string[]): XKind { * Reads a `::chart` node, its props and the Markdown table inside it. Returns undefined when there's nothing to draw; * every problem goes to `issues` as one sentence that names the block and the fix. */ -export function readChart(node: ElementNode, number: number, issues: string[]): Chart | undefined { +export function readChart( + node: ElementNode, + number: number, + issues: string[], +): Chart | SpecChart | undefined { const name = `\`::chart\` ${number}`; const attrs = node[1]; + // A chart drawn from a spec checks its props itself. + const spec = childrenOf(node).find(isSpec); for (const key of Object.keys(attrs)) - if (!PROPS.has(key)) + if (!spec && !PROPS.has(key)) issues.push( `${name}: ${code(key)} isn't a prop of \`::chart\`. Valid props: type, x, y, title.`, ); @@ -112,6 +131,8 @@ export function readChart(node: ElementNode, number: number, issues: string[]): const annotations = content.filter( (child): child is ElementNode => isElement(child) && child[0] === "annotation", ); + if (spec) + return readSpec(name, attrs, table, spec, content.length - 2, annotations.length, issues); if (content.length > 1 + annotations.length) issues.push( `${name} drops everything inside it but its table and annotations. Name the chart with \`title\`, and write other text outside the block.`, @@ -304,3 +325,111 @@ export function readChart(node: ElementNode, number: number, issues: string[]): marks, }; } + +/** Every value of `key` in an object, however deep: the fields a spec reads, or the ones its transforms make. */ +function collect(value: unknown, key: string, found: string[] = []): string[] { + if (Array.isArray(value)) for (const item of value) collect(item, key, found); + else if (value instanceof Object) + for (const [name, item] of Object.entries(value)) { + if (name === key) + for (const part of [item].flat()) { + const field = text(part); + if (field) found.push(field); + } + collect(item, key, found); + } + return found; +} + +function readSpec( + name: string, + attrs: ElementNode[1], + table: ElementNode, + pre: ElementNode, + others: number, + annotations: number, + issues: string[], +): SpecChart | undefined { + for (const key of Object.keys(attrs)) + if (key !== "title" && key !== "class" && key !== "id") + issues.push( + `${name}: ${code(key)} doesn't apply to a chart drawn from a Vega-Lite spec. Name the columns in the spec's \`encoding\` instead.`, + ); + if (annotations) + issues.push( + `${name}: annotations mark charts comark-charts lays out, not one drawn from a Vega-Lite spec. Remove them, or draw the note with a \`text\` or \`rule\` layer in the spec.`, + ); + if (others > annotations) + issues.push( + `${name} drops everything inside it but its table and its Vega-Lite spec. Name the chart with \`title\`, and write other text outside the block.`, + ); + let spec: unknown; + try { + spec = JSON.parse(textContent(pre)); + } catch (error) { + issues.push( + `${name}: its Vega-Lite spec isn't valid JSON (${error instanceof Error ? error.message : String(error)}). Fix it, or remove the \`vega-lite\` block to let comark-charts choose the chart.`, + ); + return; + } + if (!(spec instanceof Object) || Array.isArray(spec)) { + issues.push( + `${name}: its Vega-Lite spec must be one JSON object, such as \`{"mark": "bar", "encoding": {...}}\`.`, + ); + return; + } + const head = elements(table, "thead")[0]; + const body = elements(table, "tbody")[0]; + const columns = (head ? elements(head, "tr").flatMap((row) => elements(row, "th")) : []).map( + (cell) => textContent(cell).trim(), + ); + const rows = (body ? elements(body, "tr") : []).map((row) => + elements(row, "td").map((cell) => textContent(cell).trim()), + ); + const unnamed = columns.findIndex((column) => !column); + const repeated = columns.find((column, index) => columns.indexOf(column) !== index); + if (unnamed !== -1 || repeated || !rows.length) { + issues.push( + unnamed !== -1 + ? `${name}: column ${unnamed + 1} of its table has no header. Name it, since the spec reads columns by name.` + : repeated + ? `${name}: its table has two columns named ${code(repeated)}. Give each column its own name.` + : `${name}: its table has no rows. Add one row per data point; the spec draws the table.`, + ); + return; + } + if ("data" in spec) + issues.push( + `${name}: its Vega-Lite spec has \`data\`, but a chart's data is its table, so the table replaces it. Remove \`data\` from the spec.`, + ); + // Fields made by transforms, such as a fold's `key` and `value`, are as real as the table's. + const made = new Set([ + ...collect(spec, "as"), + ...(collect(spec, "fold").length ? ["key", "value"] : []), + ]); + // A dot or a bracket reaches into nested data, which a table has none of, so only plain names are checked. + const unknown = [...new Set(collect(spec, "field"))].filter( + (field) => !columns.includes(field) && !made.has(field) && !/[.[\\]/.test(field), + ); + if (unknown.length) + issues.push( + `${name}: its Vega-Lite spec reads ${unknown.length > 1 ? "fields" : "field"} ${list(unknown.map(code))}, but its table has ${list(columns.map(code))}. Use the table's headers as field names.`, + ); + // SAFETY: JSON.parse returned an object that isn't an array, so its keys are strings. + const rest = { ...(spec as Record) }; + delete rest.data; + return { + type: "vega-lite", + title: text(attrs.title), + columns, + values: rows.map((row) => + Object.fromEntries( + columns.map((column, index) => { + const cell = row[index] ?? ""; + return [column, cell ? (readNumber(cell) ?? cell) : null]; + }), + ), + ), + spec: rest, + }; +} diff --git a/packages/charts/test/spec.test.ts b/packages/charts/test/spec.test.ts new file mode 100644 index 00000000..d8e874a5 --- /dev/null +++ b/packages/charts/test/spec.test.ts @@ -0,0 +1,112 @@ +import { createHtmlRenderer } from "@comark/html"; +import { parseMarkdown } from "comark"; +import { describe, expect, it } from "vite-plus/test"; +import charts, { components } from "../src/html.ts"; + +const render = createHtmlRenderer({ plugins: [charts()], components }); +const issues = async (markdown: string) => + (await parseMarkdown(markdown, { plugins: [charts()] })).meta.issues; +const chart = (spec: unknown, props = "", table = latency) => + `::chart${props}\n${table.trim()}\n\n\`\`\`vega-lite\n${typeof spec === "string" ? spec : JSON.stringify(spec, null, 2)}\n\`\`\`\n::`; + +const latency = ` +| endpoint | ms | +| --- | --: | +| /search | 120 | +| /search | 180 | +| /search | 410 | +| /login | 40 | +| /login | 55 | +| /login | 61 |`; +const boxplot = { + mark: "boxplot", + encoding: { + x: { field: "endpoint", type: "nominal" }, + y: { field: "ms", type: "quantitative" }, + }, +}; + +describe("comark-charts with a Vega-Lite spec", () => { + it("draws any Vega-Lite chart over the table, which stays its data", async () => { + const markdown = chart(boxplot, '{title="Latency per request"}'); + expect(await issues(markdown)).toEqual([]); + const html = await render(markdown); + expect(html).toContain( + '
Latency per request
/search"); + // In the house inks, which the stylesheet swaps for the page's. + expect(html).toContain('fill="#3b82f6"'); + expect(html).toContain('
Data
'); + expect(html).toContain(""); + expect(html).not.toContain(" { + expect(await issues(chart("{ mark: bar }"))).toEqual([ + expect.stringMatching( + /^`::chart` 1: its Vega-Lite spec isn't valid JSON \(.+\)\. Fix it, or remove the `vega-lite` block to let comark-charts choose the chart\.$/, + ), + ]); + expect( + await issues( + chart( + { + ...boxplot, + data: { url: "data.csv" }, + encoding: { ...boxplot.encoding, color: { field: "region" } }, + }, + '{type="bar"}', + ), + ), + ).toEqual([ + "`::chart` 1: `type` doesn't apply to a chart drawn from a Vega-Lite spec. Name the columns in the spec's `encoding` instead.", + "`::chart` 1: its Vega-Lite spec has `data`, but a chart's data is its table, so the table replaces it. Remove `data` from the spec.", + "`::chart` 1: its Vega-Lite spec reads field `region`, but its table has `endpoint` and `ms`. Use the table's headers as field names.", + ]); + }); + + it("knows the fields a spec's transforms make", async () => { + const spec = { + transform: [ + { fold: ["before", "after"] }, + { calculate: "datum.value / 1000", as: "seconds" }, + ], + mark: "bar", + encoding: { + x: { field: "key", type: "nominal" }, + y: { field: "seconds", type: "quantitative" }, + color: { field: "value", type: "quantitative" }, + }, + }; + const table = "| step | before | after |\n| --- | --: | --: |\n| build | 1200 | 800 |"; + expect(await issues(chart(spec, "", table))).toEqual([]); + }); + + it("reports what Vega can't draw, and shows the table", async () => { + const markdown = chart({ mark: "nonsense" }); + expect(await issues(markdown)).toEqual([ + expect.stringMatching( + /^`::chart` 1 couldn't be drawn \(.+\), so it shows its table alone\. Check its Vega-Lite spec against https:\/\/vega\.github\.io\/vega-lite\/docs\/\.$/, + ), + ]); + const html = await render(markdown); + expect(html).not.toContain("/login"); + }); + + it("loads nothing a spec names, and draws no links", async () => { + const html = await render( + chart({ + layer: [ + { ...boxplot, encoding: { ...boxplot.encoding, href: { value: "javascript:alert(1)" } } }, + { data: { url: "https://example.com/secret.csv" }, mark: "point" }, + { mark: { type: "image", url: "file:///etc/passwd" } }, + ], + }), + ); + expect(html).not.toContain("javascript:"); + expect(html).not.toContain(" Date: Thu, 22 Oct 2026 15:42:11 +0200 Subject: [PATCH 3/3] fix(charts): enforce table data for nested Vega-Lite views --- packages/charts/src/draw.ts | 15 ++++++++++++++- packages/charts/test/spec.test.ts | 6 ++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/charts/src/draw.ts b/packages/charts/src/draw.ts index 60987ff7..5b1fa040 100644 --- a/packages/charts/src/draw.ts +++ b/packages/charts/src/draw.ts @@ -652,15 +652,28 @@ sealed.load = () => // An empty href loads no image and makes no link. sealed.sanitize = () => Promise.resolve({ href: "" }); +// Composed Vega-Lite views can carry their own data declarations. The table is the sole data source, +// so remove those declarations at every depth before injecting the table values at the root. +function withoutData(value: unknown): unknown { + if (Array.isArray(value)) return value.map(withoutData); + if (!value || typeof value !== "object") return value; + return Object.fromEntries( + Object.entries(value) + .filter(([key]) => key !== "data") + .map(([key, child]) => [key, withoutData(child)]), + ); +} + /** Draws a Vega-Lite spec over the chart's table, in the house look wherever the spec sets none. */ export async function drawSpec(chart: SpecChart): Promise<{ svg: string }> { const { config, ...rest } = chart.spec; + const sanitized = withoutData(rest) as Record; // The spec came from JSON, and compile() checks it at runtime and throws on what it can't draw. const spec: TopLevelSpec = JSON.parse( JSON.stringify({ background: "transparent", padding: PADDING, - ...rest, + ...sanitized, data: { values: chart.values }, config: Object.assign({}, SPEC_CONFIG, config), }), diff --git a/packages/charts/test/spec.test.ts b/packages/charts/test/spec.test.ts index d8e874a5..55ed8c66 100644 --- a/packages/charts/test/spec.test.ts +++ b/packages/charts/test/spec.test.ts @@ -100,12 +100,18 @@ describe("comark-charts with a Vega-Lite spec", () => { chart({ layer: [ { ...boxplot, encoding: { ...boxplot.encoding, href: { value: "javascript:alert(1)" } } }, + { + data: { values: [{ endpoint: "SECRET", ms: 999 }] }, + mark: "text", + encoding: { text: { field: "endpoint" } }, + }, { data: { url: "https://example.com/secret.csv" }, mark: "point" }, { mark: { type: "image", url: "file:///etc/passwd" } }, ], }), ); expect(html).not.toContain("javascript:"); + expect(html).not.toContain("SECRET"); expect(html).not.toContain("
/login