@tlockney/cdp-kernel 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/package.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /** Kept equal to package.json and deno.jsonc by test/package.test.ts. */
2
- export declare const VERSION = "0.10.0";
2
+ export declare const VERSION = "0.11.0";
3
3
  /**
4
4
  * The UTC day this version was released, set with VERSION. The publish
5
5
  * workflow refuses a tag unless it's today or yesterday, so it can't go
6
6
  * stale: see dependencyCutoff.
7
7
  */
8
- export declare const RELEASE_DATE = "2026-11-11";
8
+ export declare const RELEASE_DATE = "2026-11-13";
9
9
  /**
10
10
  * Deno blocks packages less than 24 hours old (minimum dependency age), so
11
11
  * a kernelspec relaunching a release from JSR would fail on release day. It
@@ -14,7 +14,7 @@ export declare const RELEASE_DATE = "2026-11-11";
14
14
  * late in the day its tag was pushed), and nothing newer ever is.
15
15
  */
16
16
  export declare function dependencyCutoff(releaseDate?: string): string;
17
- export declare const RUNTIME_FILES: readonly ["bootstrap.mjs", "bun-channel.mjs", "evaluator.mjs", "introspect.mjs", "workerd-worker.mjs", "display.mjs", "comm.mjs", "host.mjs", "widget.mjs", "node-hooks.mjs", "deno-compat.mjs", "node-modules-resolve.mjs"];
17
+ export declare const RUNTIME_FILES: readonly ["bootstrap.mjs", "bun-channel.mjs", "evaluator.mjs", "introspect.mjs", "workerd-worker.mjs", "display.mjs", "comm.mjs", "host.mjs", "widget.mjs", "table.mjs", "sql.mjs", "chart.mjs", "node-hooks.mjs", "deno-compat.mjs", "node-modules-resolve.mjs"];
18
18
  export type HostRuntime = "node" | "deno";
19
19
  export declare function hostRuntime(): HostRuntime;
20
20
  /** Where the CLI module was loaded from. */
package/dist/package.js CHANGED
@@ -14,13 +14,13 @@ import { join, sep } from "node:path";
14
14
  import process from "node:process";
15
15
  import { fileURLToPath } from "node:url";
16
16
  /** Kept equal to package.json and deno.jsonc by test/package.test.ts. */
17
- export const VERSION = "0.10.0";
17
+ export const VERSION = "0.11.0";
18
18
  /**
19
19
  * The UTC day this version was released, set with VERSION. The publish
20
20
  * workflow refuses a tag unless it's today or yesterday, so it can't go
21
21
  * stale: see dependencyCutoff.
22
22
  */
23
- export const RELEASE_DATE = "2026-11-11";
23
+ export const RELEASE_DATE = "2026-11-13";
24
24
  /**
25
25
  * Deno blocks packages less than 24 hours old (minimum dependency age), so
26
26
  * a kernelspec relaunching a release from JSR would fail on release day. It
@@ -45,6 +45,9 @@ export const RUNTIME_FILES = [
45
45
  "comm.mjs",
46
46
  "host.mjs",
47
47
  "widget.mjs",
48
+ "table.mjs",
49
+ "sql.mjs",
50
+ "chart.mjs",
48
51
  "node-hooks.mjs",
49
52
  "deno-compat.mjs",
50
53
  "node-modules-resolve.mjs",
@@ -35,7 +35,7 @@ import { connectUnixWebSocket } from "./ws-client.js";
35
35
  import { findWranglerConfig, startMiniflare } from "./workers-project.js";
36
36
  import { ensureInstalledAsync, findLockFile, jsrToNpm, parsePackageSpecifier } from "../runtime/node-hooks.mjs";
37
37
  /** The driver Worker's modules; the first is its main module. */
38
- export const WORKER_MODULES = ["workerd-worker.mjs", "display.mjs", "comm.mjs", "host.mjs", "widget.mjs", "evaluator.mjs", "introspect.mjs"];
38
+ export const WORKER_MODULES = ["workerd-worker.mjs", "display.mjs", "comm.mjs", "host.mjs", "widget.mjs", "table.mjs", "sql.mjs", "chart.mjs", "evaluator.mjs", "introspect.mjs"];
39
39
  /** See BunTarget: how long an interrupted cell has to show it isn't busy. */
40
40
  export const INTERRUPT_GRACE_MS = 500;
41
41
  export function restartNote(runtime) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tlockney/cdp-kernel",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Deno's Jupyter kernel, extracted to run notebooks on Node, Deno, Bun, workerd and celld",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Lockney <thomas@lockney.net>",
@@ -0,0 +1,23 @@
1
+ export const VEGA: string;
2
+ export const VEGA_LITE: string;
3
+ export const LINKEDOM: string;
4
+ export const WARN_BYTES: number;
5
+ export const VEGA_CONFIG: Record<string, unknown>;
6
+
7
+ export function isPlotModule(value: unknown): boolean;
8
+ export function isSvgElement(value: unknown): boolean;
9
+ export function isVegaSpec(spec: Record<string, unknown>): boolean;
10
+ export function specOf(value: unknown): Record<string, unknown>;
11
+ export function svgSize(svg: string): { width: number; height: number };
12
+ export function withNamespace(svg: string): string;
13
+ export function legendSVG(scale: unknown, width: number, form?: "ramp" | "swatches"): { svg: string; height: number };
14
+ export function composeSVG(
15
+ inner: string,
16
+ parts: { title?: string; subtitle?: string; caption?: string; legend?: { svg: string; height: number } },
17
+ ): string;
18
+ export function summarizePlot(element: unknown, options: Record<string, unknown>): string;
19
+ export function createChart(options: {
20
+ importModule: (specifier: string) => Promise<any>;
21
+ summarizeSpec?: (spec: Record<string, unknown>) => string | undefined;
22
+ warn?: (message: string) => void;
23
+ }): (chart: unknown, options?: unknown) => import("./display.d.mts").Displayable;
@@ -0,0 +1,359 @@
1
+ // @ts-self-types="./chart.d.mts"
2
+ // jupyter.chart (cdp-kernel#479, ganymede#747): a Vega-Lite (or Vega) spec,
3
+ // or Observable Plot options, drawn in the kernel as one SVG with its
4
+ // legend and title inside, plus a text summary. An SVG shows the same in
5
+ // every reader: the Ganymede apps, JupyterLab, VS Code, a published page,
6
+ // and an agent that gets it as a PNG. Plot draws a categorical legend as
7
+ // HTML beside its SVG, so the legend is drawn here instead, from the
8
+ // plot's color scale.
9
+ //
10
+ // vega, vega-lite and linkedom (Plot's DOM where there is none) load
11
+ // through the cell's own import on first use, as jupyter.sql's DuckDB
12
+ // does, so nothing loads for a notebook that draws no chart.
13
+ //
14
+ // No static imports: this file must load in every target.
15
+
16
+ export const VEGA = "npm:vega@6";
17
+ export const VEGA_LITE = "npm:vega-lite@6";
18
+ export const LINKEDOM = "npm:linkedom@0.18";
19
+ /** Past this much SVG, jupyter.chart warns: aggregate or sample first. */
20
+ export const WARN_BYTES = 2_000_000;
21
+
22
+ const $display = Symbol.for("Jupyter.display");
23
+ /** Plot scales with a legend option, besides color. */
24
+ const OTHER_LEGENDS = ["opacity", "r", "symbol"];
25
+ const FONT = "system-ui, sans-serif";
26
+
27
+ const rawToEntity = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
28
+ const esc = (s) => String(s).replace(/[&<>"']/g, (c) => rawToEntity[c]);
29
+
30
+ function isObject(value) {
31
+ return value !== null && typeof value === "object";
32
+ }
33
+
34
+ /** Plot's module: it has plot() and mark factories. */
35
+ export function isPlotModule(value) {
36
+ return (isObject(value) || typeof value === "function") && typeof value.plot === "function" &&
37
+ typeof value.barY === "function";
38
+ }
39
+
40
+ /** Keys one of which every Vega-Lite or Vega spec has. */
41
+ const SPEC_KEYS = ["$schema", "mark", "marks", "layer", "spec", "concat", "hconcat", "vconcat", "facet", "repeat"];
42
+
43
+ /** A spec object, or anything with toSpec() (vega-lite-api). */
44
+ export function specOf(value) {
45
+ const spec = isObject(value) && typeof value.toSpec === "function" ? value.toSpec() : value;
46
+ if (!isObject(spec) || Array.isArray(spec) || !SPEC_KEYS.some((key) => key in spec)) {
47
+ throw new TypeError(
48
+ "jupyter.chart takes a Vega-Lite or Vega spec, the Plot module and Plot options, or an SVG element",
49
+ );
50
+ }
51
+ return spec;
52
+ }
53
+
54
+ /** An SVG element already drawn, such as Plot's output without a legend. */
55
+ export function isSvgElement(value) {
56
+ return isObject(value) && typeof value.outerHTML === "string" && /^<svg\b/i.test(value.outerHTML);
57
+ }
58
+
59
+ export function isVegaSpec(spec) {
60
+ return typeof spec.$schema === "string" && /\/schema\/vega\/v\d/.test(spec.$schema);
61
+ }
62
+
63
+ // --- SVG helpers --------------------------------------------------------------
64
+
65
+ /** An SVG's width and height from its attributes or viewBox. */
66
+ export function svgSize(svg) {
67
+ const open = /<svg\b[^>]*>/i.exec(svg)?.[0] ?? "";
68
+ const attr = (name) => {
69
+ const m = new RegExp(`\\s${name}="([^"]*)"`, "i").exec(open);
70
+ return m ? m[1] : undefined;
71
+ };
72
+ let width = parseFloat(attr("width") ?? "");
73
+ let height = parseFloat(attr("height") ?? "");
74
+ const box = attr("viewBox")?.trim().split(/[\s,]+/).map(Number);
75
+ if (!Number.isFinite(width) && box?.length === 4) width = box[2];
76
+ if (!Number.isFinite(height) && box?.length === 4) height = box[3];
77
+ return { width: Number.isFinite(width) ? width : 640, height: Number.isFinite(height) ? height : 400 };
78
+ }
79
+
80
+ /** The SVG with the namespace an <img> needs (an HTML DOM leaves it out). */
81
+ export function withNamespace(svg) {
82
+ return /<svg\b[^>]*\sxmlns=/i.test(svg) ? svg : svg.replace(/<svg\b/i, '<svg xmlns="http://www.w3.org/2000/svg"');
83
+ }
84
+
85
+ /** A short FNV-1a hash of `text`, in base 36. */
86
+ function hash(text) {
87
+ let h = 0x811c9dc5;
88
+ for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193);
89
+ return (h >>> 0).toString(36);
90
+ }
91
+
92
+ const textWidth = (text, size) => String(text).length * size * 0.6;
93
+
94
+ /**
95
+ * A legend for a color scale, as SVG: swatches for a categorical scale,
96
+ * a ramp for a continuous one, or the form `form` names ("swatches" or
97
+ * "ramp", as Plot's `legend` option does). `scale` is Plot's scale
98
+ * description.
99
+ */
100
+ export function legendSVG(scale, width, form) {
101
+ if (!isObject(scale) || typeof scale.apply !== "function" || !Array.isArray(scale.domain)) return { svg: "", height: 0 };
102
+ const label = typeof scale.label === "string" && scale.label ? scale.label : "";
103
+ const parts = [];
104
+ let y = 0;
105
+ if (label) {
106
+ parts.push(`<text x="0" y="10" font-weight="bold">${esc(label)}</text>`);
107
+ y = 16;
108
+ }
109
+ const continuous = form === "ramp" ||
110
+ (form !== "swatches" &&
111
+ /^(linear|log|pow|sqrt|symlog|utc|time|sequential|diverging|quantize|threshold|quantile)/.test(String(scale.type)));
112
+ if (!continuous) {
113
+ let x = 0;
114
+ let row = y;
115
+ for (const value of scale.domain) {
116
+ const text = String(value);
117
+ const w = 14 + textWidth(text, 10) + 12;
118
+ if (x > 0 && x + w > width) {
119
+ x = 0;
120
+ row += 16;
121
+ }
122
+ parts.push(
123
+ `<rect x="${x}" y="${row}" width="10" height="10" fill="${esc(scale.apply(value))}"/>` +
124
+ `<text x="${x + 14}" y="${row + 9}">${esc(text)}</text>`,
125
+ );
126
+ x += w;
127
+ }
128
+ return { svg: parts.join(""), height: row + 14 };
129
+ }
130
+ const [lo, hi] = [scale.domain[0], scale.domain.at(-1)];
131
+ const n = 16;
132
+ const stops = [];
133
+ // Plot's interpolator runs over the scale's output (its range), so
134
+ // sampling it spaces a log or sqrt scale's colors as the chart does;
135
+ // without one, step through the domain, Dates as their times.
136
+ const range = Array.isArray(scale.range) && scale.range.length >= 2 && scale.range.every((r) => typeof r === "number")
137
+ ? [scale.range[0], scale.range.at(-1)]
138
+ : undefined;
139
+ const numeric = (v) => (v instanceof Date ? v.getTime() : typeof v === "number" ? v : NaN);
140
+ const [a, b] = [numeric(lo), numeric(hi)];
141
+ for (let i = 0; i <= n; i++) {
142
+ const t = i / n;
143
+ let color;
144
+ if (typeof scale.interpolate === "function" && range) color = scale.interpolate(range[0] + (range[1] - range[0]) * t);
145
+ else if (Number.isFinite(a) && Number.isFinite(b)) {
146
+ const v = a + (b - a) * t;
147
+ color = scale.apply(lo instanceof Date ? new Date(v) : v);
148
+ } else color = scale.apply(i === 0 ? lo : hi);
149
+ stops.push(`<stop offset="${t * 100}%" stop-color="${esc(color)}"/>`);
150
+ }
151
+ // From the stops, so the same chart gives the same SVG on every run.
152
+ const id = `ramp-${hash(stops.join(""))}`;
153
+ const rampWidth = Math.min(240, width);
154
+ const fmt = (v) => (v instanceof Date ? v.toISOString().slice(0, 10) : typeof v === "number" ? String(Number(v.toPrecision(4))) : String(v));
155
+ parts.push(
156
+ `<defs><linearGradient id="${id}">${stops.join("")}</linearGradient></defs>` +
157
+ `<rect x="0" y="${y}" width="${rampWidth}" height="10" fill="url(#${id})"/>` +
158
+ `<text x="0" y="${y + 22}">${esc(fmt(lo))}</text>` +
159
+ `<text x="${rampWidth}" y="${y + 22}" text-anchor="end">${esc(fmt(hi))}</text>`,
160
+ );
161
+ return { svg: parts.join(""), height: y + 26 };
162
+ }
163
+
164
+ /**
165
+ * Plot's SVG with a title, subtitle, legend and caption around it, all in
166
+ * one SVG.
167
+ */
168
+ export function composeSVG(inner, { title, subtitle, caption, legend }) {
169
+ const { width, height } = svgSize(inner);
170
+ const head = [];
171
+ let y = 0;
172
+ if (title) {
173
+ head.push(`<text x="0" y="${y + 16}" font-size="16" font-weight="bold">${esc(title)}</text>`);
174
+ y += 24;
175
+ }
176
+ if (subtitle) {
177
+ head.push(`<text x="0" y="${y + 13}" font-size="13">${esc(subtitle)}</text>`);
178
+ y += 20;
179
+ }
180
+ if (legend?.svg) {
181
+ head.push(`<g transform="translate(0,${y})" font-size="10">${legend.svg}</g>`);
182
+ y += legend.height + 8;
183
+ }
184
+ const body = withNamespace(inner).replace(/<svg\b/i, `<svg x="0" y="${y}"`);
185
+ let total = y + height;
186
+ let foot = "";
187
+ if (caption) {
188
+ foot = `<text x="0" y="${total + 16}" font-size="12">${esc(caption)}</text>`;
189
+ total += 24;
190
+ }
191
+ return (
192
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${total}" viewBox="0 0 ${width} ${total}" ` +
193
+ `font-family="${FONT}" fill="currentColor">${head.join("")}${body}${foot}</svg>`
194
+ );
195
+ }
196
+
197
+ // --- Summaries ----------------------------------------------------------------
198
+
199
+ function markName(mark) {
200
+ if (typeof mark?.ariaLabel === "string" && mark.ariaLabel) return mark.ariaLabel;
201
+ return mark?.constructor?.name?.toLowerCase() ?? "mark";
202
+ }
203
+
204
+ function describeScale(name, scale) {
205
+ if (!isObject(scale)) return null;
206
+ const label = typeof scale.label === "string" && scale.label ? scale.label : "";
207
+ const domain = Array.isArray(scale.domain) ? scale.domain : [];
208
+ let range = "";
209
+ if (domain.length && domain.every((v) => typeof v === "number" || v instanceof Date) && !/ordinal|band|point/.test(scale.type)) {
210
+ const fmt = (v) => (v instanceof Date ? v.toISOString().slice(0, 10) : String(Number(v.toPrecision(4))));
211
+ range = `${fmt(domain[0])} to ${fmt(domain.at(-1))}`;
212
+ } else if (domain.length) {
213
+ range = `${domain.length} ${domain.length === 1 ? "value" : "values"}`;
214
+ }
215
+ const details = [scale.type, range].filter(Boolean).join(", ");
216
+ return `${name}${label ? `=${label}` : ""}${details ? ` (${details})` : ""}`;
217
+ }
218
+
219
+ /** A few lines about a Plot chart, for readers that can't see it. */
220
+ export function summarizePlot(element, options) {
221
+ const marks = (Array.isArray(options.marks) ? options.marks.flat(Infinity) : []).filter(isObject);
222
+ const names = [...new Set(marks.map(markName))];
223
+ const scales = ["x", "y", "fx", "fy", "color", "r", "opacity", "symbol"]
224
+ .map((name) => (typeof element?.scale === "function" ? describeScale(name, element.scale(name)) : null))
225
+ .filter(Boolean);
226
+ const rows = marks.map((m) => (Array.isArray(m.data) ? m.data.length : typeof m.data?.length === "number" ? m.data.length : 0));
227
+ const most = rows.length ? Math.max(...rows) : 0;
228
+ const title = typeof options.title === "string" && options.title ? ` "${options.title}"` : "";
229
+ const parts = [...scales];
230
+ if (most) parts.push(`${most} ${most === 1 ? "row" : "rows"}`);
231
+ return `Plot ${names.length ? `${names.join(", ")} ` : ""}chart${title}${parts.length ? `: ${parts.join(", ")}` : ""}`;
232
+ }
233
+
234
+ // --- Vega's defaults, for a chart on any background ----------------------------
235
+
236
+ const INK = "currentColor";
237
+ /**
238
+ * Text and lines in `currentColor`, on no background: the page's text
239
+ * color when the SVG is inline, and in the Ganymede apps, which give an
240
+ * SVG image the page's color. In a plain <img> it is black.
241
+ */
242
+ export const VEGA_CONFIG = {
243
+ background: "transparent",
244
+ axis: { labelColor: INK, titleColor: INK, domainColor: INK, tickColor: INK, gridColor: INK, gridOpacity: 0.15 },
245
+ legend: { labelColor: INK, titleColor: INK },
246
+ title: { color: INK, subtitleColor: INK },
247
+ header: { labelColor: INK, titleColor: INK },
248
+ view: { stroke: null },
249
+ };
250
+
251
+ function mergeConfig(base, own) {
252
+ if (!isObject(own)) return base;
253
+ const out = { ...base };
254
+ for (const [key, value] of Object.entries(own)) {
255
+ out[key] = isObject(value) && isObject(base[key]) && !Array.isArray(value) ? mergeConfig(base[key], value) : value;
256
+ }
257
+ return out;
258
+ }
259
+
260
+ // --- The helper -----------------------------------------------------------------
261
+
262
+ /**
263
+ * @param {{
264
+ * importModule: (specifier: string) => Promise<any>,
265
+ * summarizeSpec?: (spec: object) => string | undefined,
266
+ * warn?: (message: string) => void,
267
+ * }} options
268
+ */
269
+ export function createChart({ importModule, summarizeSpec, warn = (m) => console.warn(m) }) {
270
+ let dom;
271
+ async function document() {
272
+ if (globalThis.document && typeof globalThis.document.createElementNS === "function") return globalThis.document;
273
+ // A failed load isn't kept, so the next chart tries again.
274
+ dom ??= importModule(LINKEDOM)
275
+ .then(({ parseHTML }) => parseHTML("<!doctype html><html><body></body></html>").document)
276
+ .catch((err) => {
277
+ dom = undefined;
278
+ throw err;
279
+ });
280
+ return dom;
281
+ }
282
+
283
+ async function renderVega(spec, options) {
284
+ const keepSpec = isObject(options) && options.spec === true;
285
+ const [vega, vl] = await Promise.all([importModule(VEGA), isVegaSpec(spec) ? null : importModule(VEGA_LITE)]);
286
+ const config = mergeConfig(VEGA_CONFIG, spec.config);
287
+ const vgSpec = vl ? vl.compile({ ...spec, config }).spec : { ...spec, config };
288
+ const view = new vega.View(vega.parse(vgSpec), { renderer: "none" });
289
+ let svg;
290
+ try {
291
+ svg = await view.toSVG();
292
+ } finally {
293
+ view.finalize();
294
+ }
295
+ const bundle = { "image/svg+xml": withNamespace(svg) };
296
+ const summary = summarizeSpec?.({ $schema: "https://vega.github.io/schema/vega-lite/v5.json", ...spec });
297
+ if (summary) bundle["text/plain"] = summary;
298
+ if (keepSpec) {
299
+ const v4 = /\/schema\/vega-lite\/v4/.test(String(spec.$schema));
300
+ bundle[vl ? (v4 ? "application/vnd.vegalite.v4+json" : "application/vnd.vegalite.v5+json") : "application/vnd.vega.v5+json"] = spec;
301
+ }
302
+ return bundle;
303
+ }
304
+
305
+ async function renderPlot(Plot, options) {
306
+ if (!isObject(options)) throw new TypeError("jupyter.chart(Plot, options): options must be Plot's options object");
307
+ const { title, subtitle, caption, ...rest } = options;
308
+ const color = isObject(rest.color) ? rest.color : undefined;
309
+ const wantLegend = Boolean(color?.legend);
310
+ const plotOptions = { ...rest, document: await document(), figure: false };
311
+ if (color) plotOptions.color = { ...color, legend: false };
312
+ // Only the color legend is drawn; Plot would put the others in an
313
+ // HTML figure, which a reader that shows only images drops.
314
+ const others = OTHER_LEGENDS.filter((name) => isObject(rest[name]) && rest[name].legend);
315
+ if (others.length) {
316
+ warn(`jupyter.chart draws only a color legend; the ${others.join(", ")} legend is left out`);
317
+ }
318
+ const element = Plot.plot(plotOptions);
319
+ const inner = element.outerHTML;
320
+ const { width } = svgSize(inner);
321
+ const scale = wantLegend && typeof element.scale === "function" ? element.scale("color") : undefined;
322
+ const form = color?.legend === "ramp" || color?.legend === "swatches" ? color.legend : undefined;
323
+ const legend = scale ? legendSVG({ ...scale, label: scale.label ?? color.label }, width, form) : undefined;
324
+ const svg = composeSVG(inner, { title, subtitle, caption, legend });
325
+ return { "image/svg+xml": withNamespace(svg), "text/plain": summarizePlot(element, options) };
326
+ }
327
+
328
+ function chart(first, second) {
329
+ // Checked now, so a wrong argument fails the call.
330
+ const spec = isPlotModule(first) || isSvgElement(first) ? undefined : specOf(first);
331
+ if (isPlotModule(first) && !isObject(second)) {
332
+ throw new TypeError("jupyter.chart(Plot, options): options must be Plot's options object");
333
+ }
334
+ if (isSvgElement(first)) {
335
+ const bundle = { "image/svg+xml": withNamespace(first.outerHTML) };
336
+ return { [$display]: () => bundle };
337
+ }
338
+ let pending;
339
+ const render = () =>
340
+ (pending ??= (async () => {
341
+ const bundle = spec ? await renderVega(spec, second) : await renderPlot(first, second);
342
+ const size = bundle["image/svg+xml"].length;
343
+ if (size > WARN_BYTES) {
344
+ warn(
345
+ `jupyter.chart: the SVG is ${(size / 1e6).toFixed(1)} MB, which makes the notebook large; ` +
346
+ "aggregate or sample the data first",
347
+ );
348
+ }
349
+ return bundle;
350
+ })().catch((err) => {
351
+ // Not kept, so displaying the chart again retries.
352
+ pending = undefined;
353
+ throw err;
354
+ }));
355
+ return { [$display]: render };
356
+ }
357
+
358
+ return chart;
359
+ }
@@ -1,7 +1,9 @@
1
1
  import type { CommApi, Comms } from "./comm.d.mts";
2
2
  import type { Host, HostCallOptions, HostError, HostErrorCode, HostNotebooks, HostOptions, HostSelf, NotebookEntry, NotebookHit, NotebookSearch } from "./host.d.mts";
3
3
  import type { Widget, WidgetOptions } from "./widget.d.mts";
4
+ import type { Sql, SqlColumn, SqlFragment, SqlResult } from "./sql.d.mts";
4
5
 
6
+ export type { Sql, SqlColumn, SqlFragment, SqlResult };
5
7
  export type { CommApi, Host, HostCallOptions, HostError, HostErrorCode, HostNotebooks, HostOptions, HostSelf, NotebookEntry, NotebookHit, NotebookSearch, Widget, WidgetOptions };
6
8
 
7
9
  /**
@@ -34,6 +36,25 @@ export const RESULT_MIME: "application/vnd.ganymede.result+json";
34
36
  /** A JSON value, as `JSON.parse` returns it. */
35
37
  export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
36
38
 
39
+ /** Options for `jupyter.table`. */
40
+ export interface TableOptions {
41
+ /** Rows to embed: default 1,000, at most 10,000, and never past 1 MB of JSON. */
42
+ limit?: number;
43
+ /** The columns to show, in order. Names an array of arrays' columns. */
44
+ columns?: string[];
45
+ /** A caption for the table. */
46
+ title?: string;
47
+ }
48
+
49
+ /** Options for `jupyter.chart` with a Vega-Lite or Vega spec. */
50
+ export interface ChartOptions {
51
+ /**
52
+ * Also keep the spec under its Vega MIME type, for a frontend that
53
+ * draws Vega itself. Default `false`: the SVG and a text summary only.
54
+ */
55
+ spec?: boolean;
56
+ }
57
+
37
58
  /** Options for `jupyter.display`. */
38
59
  export interface DisplayOptions {
39
60
  /**
@@ -332,6 +353,109 @@ export interface JupyterNamespace {
332
353
  * @see jupyter-api.md#parameters-and-named-results
333
354
  */
334
355
  result(name: string, value: unknown): void;
356
+ /**
357
+ * Show rows and columns as a table. The output has Ganymede's table
358
+ * type, `application/vnd.dataresource+json` (Table Schema fields, up to
359
+ * `limit` rows as JSON, and the total row count), which the Ganymede
360
+ * apps show with sorting and paging; a plain HTML table of the first
361
+ * and last rows for JupyterLab and VS Code; and a text summary of each
362
+ * column's type and range for agents and consoles.
363
+ *
364
+ * An Arrow table, a Polars DataFrame or a DuckDB result also shows this
365
+ * way when it is a cell's value; pass one here to set `limit`,
366
+ * `columns` or `title`.
367
+ *
368
+ * @param value An array of row objects, an array of arrays (name them
369
+ * with `columns`), an Arrow table (apache-arrow, duckdb-wasm), a
370
+ * Polars DataFrame, or a `@duckdb/node-api` result.
371
+ * @param options `limit`: rows to embed, default 1,000, at most 10,000,
372
+ * and never more than 1 MB of JSON. `columns`: which columns, in order.
373
+ * `title`: a caption.
374
+ * @throws {TypeError} `value` isn't one of those, or `limit` isn't a
375
+ * whole number.
376
+ * @example
377
+ * ```ts
378
+ * const moons = [
379
+ * { moon: "Io", radius_km: 1821.6, discovered: new Date("1610-01-08") },
380
+ * { moon: "Europa", radius_km: 1560.8, discovered: new Date("1610-01-08") },
381
+ * { moon: "Ganymede", radius_km: 2634.1, discovered: new Date("1610-01-07") },
382
+ * ];
383
+ * jupyter.table(moons, { title: "Galilean moons" });
384
+ * ```
385
+ * @see jupyter-api.md#tables
386
+ */
387
+ table(value: unknown, options?: TableOptions): Displayable;
388
+ /**
389
+ * Run SQL on an in-memory [DuckDB](https://duckdb.org) database, as a
390
+ * tagged template: each `${value}` becomes a bound parameter, never
391
+ * query text, so values can't change the query. Resolves to a
392
+ * `SqlResult`, which shows as a table and has `columns` and `rows`.
393
+ *
394
+ * The database opens on the first query and lasts until the kernel
395
+ * restarts or `jupyter.sql.close()`. DuckDB reads CSV, Parquet and JSON
396
+ * files by path or URL; `jupyter.sql.load` makes a table from rows the
397
+ * notebook already has. An interrupt stops a running query.
398
+ *
399
+ * @throws {TypeError} Called as a function rather than a tag.
400
+ * @availability Node, Deno and Bun use `@duckdb/node-api`; the browser
401
+ * target (the Ganymede apps) uses duckdb-wasm from a CDN. Not on
402
+ * workerd or celld.
403
+ * @example
404
+ * ```ts
405
+ * const min = 2000;
406
+ * await jupyter.sql`
407
+ * select moon, radius_km
408
+ * from (values ('Io', 1821.6), ('Ganymede', 2634.1), ('Callisto', 2410.3)) as m(moon, radius_km)
409
+ * where radius_km > ${min}
410
+ * order by radius_km desc`;
411
+ * ```
412
+ * @see jupyter-api.md#sql-with-duckdb
413
+ */
414
+ readonly sql: Sql;
415
+ /**
416
+ * Draw a chart in the kernel as one SVG, with its title and legend
417
+ * inside, plus a text summary for readers that can't see it. The SVG
418
+ * shows the same in the Ganymede apps, JupyterLab, VS Code and a
419
+ * published page. Text and axes are drawn in `currentColor`: the
420
+ * notebook's text color in the Ganymede apps, black in JupyterLab.
421
+ *
422
+ * Pass a [Vega-Lite](https://vega.github.io/vega-lite/) or Vega spec
423
+ * (or a vega-lite-api chart), or the
424
+ * [Observable Plot](https://observablehq.com/plot/) module and the
425
+ * options you would give `Plot.plot`. Plot's `title`, `subtitle`,
426
+ * `caption` and the color legend (`legend: true`, `"swatches"` or
427
+ * `"ramp"`) are drawn into the SVG; other scales' legends are left
428
+ * out. An SVG element shows as it is.
429
+ *
430
+ * vega and vega-lite (version 6), and linkedom for Plot where there is
431
+ * no DOM, load through the cell's import on first use. A chart past
432
+ * 2 MB of SVG logs a warning: aggregate or sample first.
433
+ *
434
+ * @param chart A Vega-Lite or Vega spec, the Plot module, or an SVG
435
+ * element.
436
+ * @param options With a spec, `ChartOptions`; with Plot, Plot's options.
437
+ * @throws {TypeError} `chart` is none of those, or Plot comes without
438
+ * options.
439
+ * @example
440
+ * ```ts
441
+ * jupyter.chart({
442
+ * title: "Galilean moons",
443
+ * data: { values: [
444
+ * { moon: "Io", radius_km: 1821.6 },
445
+ * { moon: "Europa", radius_km: 1560.8 },
446
+ * { moon: "Ganymede", radius_km: 2634.1 },
447
+ * { moon: "Callisto", radius_km: 2410.3 },
448
+ * ] },
449
+ * mark: "bar",
450
+ * encoding: {
451
+ * x: { field: "moon", sort: "-y" },
452
+ * y: { field: "radius_km", type: "quantitative" },
453
+ * },
454
+ * });
455
+ * ```
456
+ * @see jupyter-api.md#charts
457
+ */
458
+ chart(chart: object, options?: ChartOptions | Record<string, unknown>): Displayable;
335
459
  /**
336
460
  * The Ganymede server running this kernel, through its host API: read,
337
461
  * search and (when granted) run other notebooks under its root. The