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..dfe5a0ae 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,85 @@ 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: "" }); + +// 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 instanceof Object)) return value; + // SAFETY: JSON objects have string keys, and this value is known to be a non-array object. + const record = value as Record; + const from = record.from; + if ("lookup" in record && from instanceof Object && "data" in from) { + throw new Error( + "lookup transforms with from.data are unsupported because the table is the only data source", + ); + } + 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; + // SAFETY: chart.spec is parsed JSON, so rest is always a plain object and the sanitizer preserves that shape. + 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, + ...sanitized, + 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..55ed8c66 --- /dev/null +++ b/packages/charts/test/spec.test.ts @@ -0,0 +1,118 @@ +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: { 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