plotleaf 0.3.1 → 0.4.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/cli.js CHANGED
@@ -12,6 +12,7 @@ import { realpath as realpath2 } from "node:fs/promises";
12
12
  // src/compiler/diagnostics.ts
13
13
  import path from "node:path";
14
14
 
15
+ // src/compiler/diagnostic-core.ts
15
16
  class Diagnostics {
16
17
  file;
17
18
  items = [];
@@ -51,9 +52,6 @@ class Diagnostics {
51
52
  return [...this.items].sort((a, b) => a.file.localeCompare(b.file, "en") || a.line - b.line || a.column - b.column || a.code.localeCompare(b.code, "en"));
52
53
  }
53
54
  }
54
- function displayPath(filename) {
55
- return path.relative(process.cwd(), filename) || path.basename(filename);
56
- }
57
55
  function humanDiagnostics(items) {
58
56
  return items.map((d) => `${d.file}:${d.line}:${d.column} ${d.code}${d.severity === "warning" ? " (warning)" : ""}
59
57
  ${d.message}${d.hint ? `
@@ -62,9 +60,89 @@ ${d.hint}` : ""}`).join(`
62
60
  `);
63
61
  }
64
62
 
65
- // src/compiler/files.ts
66
- import path2 from "node:path";
67
- import { open, realpath, stat } from "node:fs/promises";
63
+ // src/compiler/diagnostics.ts
64
+ function displayPath(filename) {
65
+ return path.relative(process.cwd(), filename) || path.basename(filename);
66
+ }
67
+
68
+ // src/compiler/exploration-schema.ts
69
+ import * as z from "zod";
70
+
71
+ // src/shared/exploration.ts
72
+ var valueKey = (value) => typeof value === "object" ? `${value.start}/${value.end}` : `${typeof value}:${String(value)}`;
73
+ var explorationLimits = {
74
+ inputs: 6,
75
+ options: 20,
76
+ views: 32,
77
+ explorations: 4,
78
+ components: 512,
79
+ rows: 20000,
80
+ dataBytes: 8 * 1024 ** 2,
81
+ markdownBytes: 2 * 1024 ** 2,
82
+ jsonDepth: 32,
83
+ jsonContainers: 1e5
84
+ };
85
+
86
+ // src/compiler/exploration-schema.ts
87
+ var id = z.string().regex(/^[a-z][a-z0-9_-]{0,47}$/).refine((value) => !["constructor", "prototype"].includes(value), "Reserved identifier.");
88
+ var label = z.string().min(1).max(160);
89
+ var date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine((value) => {
90
+ const timestamp = Date.parse(`${value}T00:00:00Z`);
91
+ return Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === value;
92
+ }, "Use a real ISO calendar date.");
93
+ var range = z.strictObject({ start: date, end: date }).refine((value) => value.start <= value.end, "Range start must not follow its end.");
94
+ var common = { id, label };
95
+ var explorationInputSchema = z.discriminatedUnion("type", [
96
+ z.strictObject({
97
+ ...common,
98
+ type: z.enum(["select", "segmented", "text"]),
99
+ default: z.string().max(120),
100
+ options: z.array(z.strictObject({ value: z.string().max(120), label })).min(1).max(explorationLimits.options)
101
+ }),
102
+ z.strictObject({
103
+ ...common,
104
+ type: z.literal("number"),
105
+ default: z.number().finite(),
106
+ options: z.array(z.strictObject({ value: z.number().finite(), label })).min(1).max(explorationLimits.options)
107
+ }),
108
+ z.strictObject({
109
+ ...common,
110
+ type: z.literal("toggle"),
111
+ default: z.boolean()
112
+ }),
113
+ z.strictObject({
114
+ ...common,
115
+ type: z.literal("date-range"),
116
+ default: range,
117
+ options: z.array(z.strictObject({ value: range, label })).min(1).max(explorationLimits.options)
118
+ })
119
+ ]);
120
+ var explorationSchema = z.strictObject({
121
+ inputs: z.array(explorationInputSchema).min(1).max(explorationLimits.inputs),
122
+ views: z.array(z.strictObject({
123
+ id,
124
+ label,
125
+ when: z.record(id, z.union([
126
+ z.string().max(120),
127
+ z.number().finite(),
128
+ z.boolean(),
129
+ range
130
+ ])),
131
+ markdown: z.string().min(1).max(1024 ** 2),
132
+ datasets: z.record(z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]*\.json$/), z.array(z.record(z.string(), z.unknown())).max(1e4)).default({})
133
+ })).min(1).max(explorationLimits.views)
134
+ });
135
+
136
+ // src/compiler/parse.ts
137
+ import { unified } from "unified";
138
+ import remarkParse from "remark-parse";
139
+ import remarkDirective from "remark-directive";
140
+ import remarkFrontmatter from "remark-frontmatter";
141
+ import { parseDocument, visit } from "yaml";
142
+ import * as z3 from "zod";
143
+
144
+ // src/compiler/registry.ts
145
+ import * as z2 from "zod";
68
146
 
69
147
  // src/shared/limits.ts
70
148
  var limits = {
@@ -85,182 +163,6 @@ var limits = {
85
163
  imagePixels: 4000000
86
164
  };
87
165
 
88
- // src/compiler/files.ts
89
- class LocalFileError extends Error {
90
- kind;
91
- constructor(kind, message) {
92
- super(message);
93
- this.kind = kind;
94
- }
95
- }
96
- function contained(root, filename) {
97
- const relative = path2.relative(root, filename);
98
- return relative === "" || !path2.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path2.sep}`);
99
- }
100
- async function readBytesBounded(filename, maximum) {
101
- if (!(await stat(filename)).isFile())
102
- throw new Error("Expected a regular file.");
103
- const file = await open(filename, "r");
104
- try {
105
- const info = await file.stat();
106
- if (!info.isFile())
107
- throw new Error("Expected a regular file.");
108
- if (info.size > maximum)
109
- throw new Error(`File exceeds the ${Math.floor(maximum / 1024)} KiB size limit.`);
110
- const chunks = [];
111
- let bytes = 0;
112
- while (bytes <= maximum) {
113
- const buffer = Buffer.alloc(Math.min(64 * 1024, maximum + 1 - bytes));
114
- const result = await file.read(buffer, 0, buffer.length, null);
115
- if (!result.bytesRead)
116
- break;
117
- chunks.push(buffer.subarray(0, result.bytesRead));
118
- bytes += result.bytesRead;
119
- }
120
- if (bytes > maximum)
121
- throw new Error("File grew beyond the size limit while reading.");
122
- return Buffer.concat(chunks, bytes);
123
- } finally {
124
- await file.close();
125
- }
126
- }
127
- async function readBounded(filename, maximum) {
128
- const bytes = await readBytesBounded(filename, maximum);
129
- return {
130
- text: new TextDecoder("utf-8", { fatal: true }).decode(bytes),
131
- bytes: bytes.length
132
- };
133
- }
134
-
135
- class ReportFiles {
136
- root;
137
- dependencies;
138
- usedBytes;
139
- constructor(root, dependencies, usedBytes) {
140
- this.root = root;
141
- this.dependencies = dependencies;
142
- this.usedBytes = usedBytes;
143
- }
144
- async resolve(reference, extensions) {
145
- const fail = (message) => {
146
- throw new LocalFileError("path", message);
147
- };
148
- if (path2.isAbsolute(reference) || /^[A-Za-z][A-Za-z\d+.-]*:/.test(reference) || reference.includes("\\") || /%[\da-f]{2}/i.test(reference) || reference.includes("\x00"))
149
- fail(`Use a plain relative local ${extensions.join("/")} path.`);
150
- const candidate = path2.resolve(this.root, reference);
151
- const hidden = (filename) => path2.relative(this.root, filename).split(path2.sep).some((part) => part.startsWith("."));
152
- if (!contained(this.root, candidate))
153
- fail("Reference escapes the report directory.");
154
- if (hidden(candidate))
155
- fail("Hidden files and directories are not allowed.");
156
- if (!extensions.includes(path2.extname(candidate).toLowerCase()))
157
- fail(`Use a ${extensions.join("/")} file.`);
158
- this.dependencies.add(candidate);
159
- let canonical;
160
- try {
161
- canonical = await realpath(candidate);
162
- } catch {
163
- throw new LocalFileError("read", "Cannot read the referenced file. Create it or correct the reference.");
164
- }
165
- if (!contained(this.root, canonical) || hidden(canonical))
166
- fail("The resolved file or symlink escapes the permitted report files.");
167
- if (!extensions.includes(path2.extname(canonical).toLowerCase()))
168
- fail(`The resolved file must also have a ${extensions.join("/")} extension.`);
169
- this.dependencies.add(canonical);
170
- return canonical;
171
- }
172
- async read(filename, maximum) {
173
- const bytes = await readBytesBounded(filename, Math.min(maximum, limits.totalBytes - this.usedBytes));
174
- this.usedBytes += bytes.length;
175
- return bytes;
176
- }
177
- }
178
-
179
- // src/compiler/images.ts
180
- import { createHash } from "node:crypto";
181
- import path3 from "node:path";
182
- import { imageSize } from "image-size";
183
- function formatOf(bytes) {
184
- if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
185
- let offset = 8;
186
- let data = false;
187
- while (offset + 12 <= bytes.length) {
188
- const size = bytes.readUInt32BE(offset);
189
- const type = bytes.toString("ascii", offset + 4, offset + 8);
190
- if (offset + size + 12 > bytes.length)
191
- break;
192
- if (offset === 8 && (type !== "IHDR" || size !== 13))
193
- break;
194
- if (type === "acTL")
195
- throw new Error("Animated PNG is not supported; provide a static image.");
196
- if (type === "IDAT" && size > 0)
197
- data = true;
198
- offset += size + 12;
199
- if (type === "IEND") {
200
- if (data && size === 0 && offset === bytes.length)
201
- return "png";
202
- break;
203
- }
204
- }
205
- throw new Error("Incomplete or invalid PNG container.");
206
- }
207
- if (bytes.length >= 4 && bytes.readUInt16BE(0) === 65496 && bytes.readUInt16BE(bytes.length - 2) === 65497)
208
- return "jpg";
209
- throw new Error("Expected a static PNG or JPEG image with a matching file extension.");
210
- }
211
-
212
- class ImageResolver {
213
- files;
214
- attempted = new Set;
215
- filesRead = new Map;
216
- embedded = new Map;
217
- constructor(files) {
218
- this.files = files;
219
- }
220
- async load(reference) {
221
- const requested = path3.resolve(this.files.root, reference);
222
- if (!this.attempted.has(requested) && this.attempted.size >= limits.imageFiles)
223
- throw new Error(`At most ${limits.imageFiles} local image references are supported.`);
224
- this.attempted.add(requested);
225
- const canonical = await this.files.resolve(reference, [
226
- ".png",
227
- ".jpg",
228
- ".jpeg"
229
- ]);
230
- const cached = this.filesRead.get(canonical);
231
- if (cached) {
232
- if (path3.extname(reference).toLowerCase() === ".png" !== (cached.mime === "image/png"))
233
- throw new Error("Image signature does not match its file extension.");
234
- return cached;
235
- }
236
- if (this.filesRead.size >= limits.imageFiles)
237
- throw new Error(`At most ${limits.imageFiles} local image files are supported.`);
238
- const bytes = await this.files.read(canonical, limits.imageBytes);
239
- const format = formatOf(bytes);
240
- const expected = (name) => path3.extname(name).toLowerCase() === ".png" ? "png" : "jpg";
241
- if (format !== expected(reference) || format !== expected(canonical))
242
- throw new Error("Image signature does not match its file extension.");
243
- let size;
244
- try {
245
- size = imageSize(bytes);
246
- } catch {
247
- throw new Error("Unsupported or damaged PNG/JPEG header.");
248
- }
249
- if (size.type !== format || !Number.isSafeInteger(size.width) || !Number.isSafeInteger(size.height) || size.width < 1 || size.height < 1)
250
- throw new Error("Image must have valid positive raster dimensions.");
251
- if (size.width > limits.imageDimension || size.height > limits.imageDimension || size.width * size.height > limits.imagePixels)
252
- throw new Error(`Image exceeds ${limits.imageDimension} pixels per side or ${limits.imagePixels.toLocaleString("en-GB")} pixels in total. Resize the prepared asset.`);
253
- const id = `plotleaf-image-${createHash("sha256").update(bytes).digest("hex")}`;
254
- const image = this.embedded.get(id) ?? {
255
- id,
256
- mime: format === "png" ? "image/png" : "image/jpeg",
257
- base64: bytes.toString("base64")
258
- };
259
- this.filesRead.set(canonical, image);
260
- this.embedded.set(id, image);
261
- return image;
262
- }
263
- }
264
166
  // src/shared/payload.ts
265
167
  var funnelStatuses = {
266
168
  "on-target": "On target",
@@ -269,62 +171,682 @@ var funnelStatuses = {
269
171
  neutral: "Neutral"
270
172
  };
271
173
 
272
- // src/compiler/data.ts
273
- var reserved = new Set(["__proto__", "constructor", "prototype"]);
274
- function parseDate(value) {
275
- const date = value.slice(0, 10);
276
- if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
277
- return;
278
- const day = Date.parse(`${date}T00:00:00.000Z`);
279
- if (!Number.isFinite(day) || new Date(day).toISOString().slice(0, 10) !== date)
280
- return;
281
- if (value.length === 10)
282
- return day;
283
- if (!/^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,3})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.test(value))
284
- return;
285
- const timestamp = Date.parse(value);
286
- return Number.isFinite(timestamp) ? timestamp : undefined;
287
- }
288
-
289
- class DataResolver {
290
- root;
291
- diagnostics;
292
- dependencies;
293
- cache = new Map;
294
- files;
295
- images;
296
- constructor(root, diagnostics, dependencies, totalBytes = 0) {
297
- this.root = root;
298
- this.diagnostics = diagnostics;
299
- this.dependencies = dependencies;
300
- this.files = new ReportFiles(root, dependencies, totalBytes);
301
- this.images = new ImageResolver(this.files);
302
- }
303
- async load(reference, block) {
304
- const fail = (code, message, hint) => {
305
- this.diagnostics.add(code, `${reference}: ${message}`, block.position, hint);
306
- return;
307
- };
308
- let canonical;
309
- try {
310
- canonical = await this.files.resolve(reference, [".json"]);
311
- } catch (error) {
312
- return fail(error instanceof LocalFileError && error.kind === "path" ? "E_DATA_PATH" : "E_DATA_READ", error instanceof Error ? error.message : "Cannot resolve the dataset.", "Place prepared JSON within the report directory and correct the data property.");
313
- }
314
- let dataset = this.cache.get(canonical);
315
- if (!dataset) {
316
- if (this.cache.size >= limits.datasets)
317
- return fail("E_DATA_LIMIT", `At most ${limits.datasets} datasets are supported.`);
318
- let text;
319
- try {
320
- text = new TextDecoder("utf-8", { fatal: true }).decode(await this.files.read(canonical, limits.jsonBytes));
321
- } catch (error) {
322
- return fail("E_DATA_READ", error instanceof Error ? error.message : "Could not read data.");
323
- }
324
- let parsed;
325
- try {
326
- parsed = JSON.parse(text);
327
- } catch {
174
+ // src/compiler/registry.ts
175
+ var text = z2.string().min(1).max(1000);
176
+ var format = z2.string().regex(/^(number|percent|percentage-points|currency:[A-Z]{3})$/, "Use number, percent, percentage-points or currency:GBP (an ISO currency code).");
177
+ var chart = {
178
+ data: text,
179
+ x: text,
180
+ y: text,
181
+ title: text,
182
+ format: format.default("number")
183
+ };
184
+ var line = z2.strictObject({
185
+ ...chart,
186
+ xType: z2.enum(["category", "date", "number"]).default("category"),
187
+ target: z2.number().finite().optional(),
188
+ targetLabel: text.optional()
189
+ });
190
+ var registry = {
191
+ explore: {
192
+ forms: ["leaf"],
193
+ children: [],
194
+ description: "Scoped native controls select complete agent-prepared views. No analytics or report JavaScript.",
195
+ schema: z2.strictObject({ data: text, title: text }),
196
+ dataRules: {
197
+ schema: z2.toJSONSchema(explorationSchema),
198
+ resolution: "Relative JSON bundle; inline view datasets override the same plain filename only within that view.",
199
+ limits: explorationLimits,
200
+ binding: "Every input affects exactly this exploration. Every view declares all input values. No matching combination hides the previous view."
201
+ },
202
+ example: '::explore{data="./regional-views.json" title="Regional performance"}'
203
+ },
204
+ grid: {
205
+ forms: ["container"],
206
+ children: [
207
+ "metric",
208
+ "line",
209
+ "area",
210
+ "bar",
211
+ "donut",
212
+ "funnel",
213
+ "table",
214
+ "callout"
215
+ ],
216
+ description: "A responsive row of one to four components.",
217
+ schema: z2.strictObject({
218
+ columns: z2.number().int().min(1).max(4).default(2)
219
+ }),
220
+ example: `:::grid{columns="4"}
221
+ ::metric{label="Teams" value="1492"}
222
+ :::`
223
+ },
224
+ metric: {
225
+ forms: ["leaf"],
226
+ children: [],
227
+ description: "A prepared numeric value with optional comparison, direction and sentiment.",
228
+ schema: z2.strictObject({
229
+ label: text,
230
+ value: z2.number().finite(),
231
+ format: format.default("number"),
232
+ comparison: text.optional(),
233
+ direction: z2.enum(["up", "down", "flat"]).optional(),
234
+ sentiment: z2.enum(["positive", "negative", "neutral"]).default("neutral")
235
+ }),
236
+ example: '::metric{label="Conversion" value="0.103" format="percent"}'
237
+ },
238
+ line: {
239
+ forms: ["leaf"],
240
+ children: [],
241
+ description: "An interactive single-series line chart from local JSON.",
242
+ schema: line,
243
+ example: '::line{data="./usage.json" x="date" y="teams" xType="date" title="Active teams"}'
244
+ },
245
+ area: {
246
+ forms: ["leaf"],
247
+ children: [],
248
+ description: "A line chart with a restrained area fill.",
249
+ schema: line,
250
+ example: '::area{data="./usage.json" x="date" y="teams" xType="date" title="Active teams" target="1600"}'
251
+ },
252
+ bar: {
253
+ forms: ["leaf"],
254
+ children: [],
255
+ description: "An interactive category bar chart with a zero baseline.",
256
+ schema: z2.strictObject(chart),
257
+ example: '::bar{data="./errors.json" x="category" y="count" title="Errors by category"}'
258
+ },
259
+ donut: {
260
+ forms: ["leaf"],
261
+ children: [],
262
+ description: "A part-to-whole donut with a readable legend and native data table.",
263
+ schema: z2.strictObject({
264
+ data: text,
265
+ category: text,
266
+ value: text,
267
+ title: text,
268
+ format: format.default("number")
269
+ }),
270
+ dataRules: {
271
+ category: "Unique nonblank strings of at most 1000 characters; source order is preserved.",
272
+ value: "Finite nonnegative numbers with a finite sum. Nulls and numeric strings are invalid. All-zero values show an empty state.",
273
+ maximumRows: limits.donutSlices
274
+ },
275
+ example: '::donut{data="./plans.json" category="plan" value="teams" title="Teams by plan"}'
276
+ },
277
+ funnel: {
278
+ forms: ["leaf"],
279
+ children: [],
280
+ description: "Ordered stage/progress rows with prepared values, denominators, shares and optional explicit status. Works without JavaScript.",
281
+ schema: z2.strictObject({
282
+ data: text,
283
+ stage: text,
284
+ value: text,
285
+ total: text,
286
+ share: text,
287
+ status: text.optional(),
288
+ title: text,
289
+ format: format.default("number")
290
+ }),
291
+ dataRules: {
292
+ stage: "Unique nonblank strings of at most 1000 characters; source order is preserved.",
293
+ value: "Finite nonnegative number no greater than its row's total.",
294
+ total: "Finite positive denominator supplied per row. No denominator is inferred from adjacent stages.",
295
+ share: "Prepared ratio from 0 to 1, matching value / total to within 0.0005 (rounding to 0.1 percentage point).",
296
+ status: Object.keys(funnelStatuses),
297
+ maximumRows: limits.funnelStages
298
+ },
299
+ example: '::funnel{data="./funnel.json" stage="stage" value="teams" total="baseline" share="share" status="status" title="Funnel by stage"}'
300
+ },
301
+ table: {
302
+ forms: ["leaf", "container"],
303
+ children: ["column"],
304
+ description: "A semantic sortable table, with inferred or explicitly formatted columns.",
305
+ schema: z2.strictObject({ data: text, title: text.optional() }),
306
+ example: `:::table{data="./plans.json" title="By plan"}
307
+ ::column{field="plan" label="Plan"}
308
+ ::column{field="change" label="Δ QoQ" format="percent" sign="always"}
309
+ :::`
310
+ },
311
+ column: {
312
+ forms: ["leaf"],
313
+ children: [],
314
+ description: "A scalar, local image or bounded sparkline column. Omitted kind means scalar.",
315
+ schema: z2.strictObject({
316
+ field: text,
317
+ label: text.optional(),
318
+ kind: z2.enum(["scalar", "image", "sparkline"]).optional(),
319
+ alt: text.optional(),
320
+ sortBy: text.optional(),
321
+ format: format.optional(),
322
+ sign: z2.enum(["auto", "always"]).default("auto"),
323
+ semantics: z2.enum(["neutral", "higher-is-better", "lower-is-better"]).default("neutral")
324
+ }),
325
+ example: '::column{field="change" label="Δ QoQ" format="percent" sign="always" semantics="higher-is-better"}',
326
+ dataRules: {
327
+ image: "Local static PNG/JPEG path or null. alt names a nonblank string field for each image. Fixed 64 × 48 display box; deduplicated and embedded offline. Paths resolve from the report directory.",
328
+ sparkline: "Only explicitly declared sparkline fields may contain arrays: at most 64 finite numbers/nulls, in sample order. Nulls are gaps. Shared observed y scale per column; no aggregation or resampling.",
329
+ sortBy: "Optional scalar field for image/sparkline sorting. Without it a rich column has a plain header. Scalar columns sort their own raw values. Sort keys are readable in the export."
330
+ }
331
+ },
332
+ callout: {
333
+ forms: ["container"],
334
+ children: ["markdown"],
335
+ description: "A contextual note or warning containing ordinary Markdown.",
336
+ schema: z2.strictObject({
337
+ tone: z2.enum(["note", "warning"]).default("note"),
338
+ title: text.optional()
339
+ }),
340
+ example: `:::callout{title="Coverage"}
341
+ Seven complete days of prepared data.
342
+ :::`
343
+ }
344
+ };
345
+ function componentSpec() {
346
+ return {
347
+ version: 1,
348
+ syntax: "markdown-directives",
349
+ attributes: "Literal strings decoded according to each property schema. No JavaScript expressions.",
350
+ limits,
351
+ data: {
352
+ shape: "component datasets are arrays of flat JSON records; explicit table sparkline columns permit bounded numeric/null arrays only in their declared fields; explore bundles use their component-specific inputs/views schema",
353
+ resolution: "relative to the report directory; local .json files only",
354
+ percent: "ratios: 0.12 displays as 12%",
355
+ percentagePoints: "point values: 0.8 displays as 0.8 pt",
356
+ currency: "major units; omit .00 on whole amounts, preserve nonzero cents",
357
+ locale: "en-GB",
358
+ timeZone: "UTC"
359
+ },
360
+ components: Object.fromEntries(Object.entries(registry).map(([name, entry]) => [
361
+ name,
362
+ {
363
+ description: entry.description,
364
+ forms: entry.forms,
365
+ children: entry.children,
366
+ props: z2.toJSONSchema(entry.schema),
367
+ example: entry.example,
368
+ ..."dataRules" in entry ? { dataRules: entry.dataRules } : {}
369
+ }
370
+ ])),
371
+ rules: [
372
+ "Use explicit closing fences for containers.",
373
+ "Components are standalone blocks, not inline/list/blockquote content.",
374
+ "column is only allowed inside table; grids cannot nest.",
375
+ "direction and explicitly supplied sentiment require metric comparison text.",
376
+ "targetLabel requires target.",
377
+ "Dataset fields are literal own-property keys, not expressions.",
378
+ "Charts preserve source order; date/number x values must increase.",
379
+ "Line/area null y values create gaps; bar null y values are invalid.",
380
+ "Donut category/value fields must differ; category count is bounded, and all-zero values do not create equal slices.",
381
+ "Funnel fields must differ; shares and denominators are supplied, validated and displayed per row, with no sorting or status inference.",
382
+ "Only explicit image columns can load local static PNG/JPEG; every resource stays within the report root and shared byte limits.",
383
+ "explore selects finite agent-prepared views using scoped native controls; nested explorations, arbitrary row queries, maps and executable components are not supported."
384
+ ]
385
+ };
386
+ }
387
+ var specs = componentSpec().components;
388
+ var numericLiteral = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
389
+ function decodeProps(name, attributes) {
390
+ const decoded = Object.create(null);
391
+ const properties = specs[name].props.properties ?? {};
392
+ for (const [key, value] of Object.entries(attributes)) {
393
+ const property = properties[key];
394
+ if (property?.type === "number" || property?.type === "integer")
395
+ decoded[key] = typeof value === "string" && numericLiteral.test(value) ? Number(value) : value;
396
+ else
397
+ decoded[key] = value;
398
+ }
399
+ const result = registry[name].schema.safeParse(decoded);
400
+ if (!result.success)
401
+ return {
402
+ errors: result.error.issues.map((issue) => `${issue.path.join(".") || name}: ${issue.message}`)
403
+ };
404
+ const props = result.data;
405
+ const errors = [];
406
+ if (name === "column") {
407
+ const column = props;
408
+ const kind = column.kind ?? "scalar";
409
+ if (kind === "image" && !column.alt)
410
+ errors.push("image columns require an alt field.");
411
+ if (kind !== "image" && column.alt)
412
+ errors.push("alt is only supported on image columns.");
413
+ if (kind === "image" && column.format)
414
+ errors.push("Image columns cannot use numeric formatting.");
415
+ if (kind !== "scalar" && (column.sign !== "auto" || column.semantics !== "neutral"))
416
+ errors.push("sign and semantics apply only to scalar columns.");
417
+ if (kind === "scalar" && column.sortBy)
418
+ errors.push("Scalar columns sort their own field; sortBy is for image/sparkline columns.");
419
+ if (column.alt === column.field || column.sortBy === column.field)
420
+ errors.push("alt and sortBy must reference fields different from the rich cell field.");
421
+ }
422
+ if (name === "donut" || name === "funnel") {
423
+ const fields = Object.entries(props).filter(([key, value]) => ["category", "stage", "value", "total", "share", "status"].includes(key) && value !== undefined).map(([, value]) => value);
424
+ if (new Set(fields).size !== fields.length)
425
+ errors.push(`${name} field properties must reference different dataset fields.`);
426
+ }
427
+ if ("format" in props && props.format?.startsWith("currency:") && !Intl.supportedValuesOf("currency").includes(props.format.slice(9)))
428
+ errors.push(`Unknown ISO currency code ${props.format.slice(9)}.`);
429
+ if (name === "metric" && !props.comparison && (attributes.direction !== undefined || attributes.sentiment !== undefined))
430
+ errors.push("direction and sentiment require comparison text.");
431
+ if ((name === "line" || name === "area") && "targetLabel" in props && props.targetLabel !== undefined && (!("target" in props) || props.target === undefined))
432
+ errors.push("targetLabel requires a numeric target.");
433
+ return { props, errors };
434
+ }
435
+
436
+ // src/compiler/parse.ts
437
+ var parser = unified().use(remarkParse).use(remarkFrontmatter).use(remarkDirective);
438
+ var metadataSchema = z3.strictObject({
439
+ title: z3.string().min(1).max(500).optional(),
440
+ description: z3.string().max(2000).optional()
441
+ });
442
+ function textOf(node) {
443
+ return node.value ?? node.children?.map(textOf).join("") ?? "";
444
+ }
445
+ function parseReport(source, fallbackTitle, diagnostics) {
446
+ const tree = parser.parse(source);
447
+ const lines = source.split(/\r?\n/);
448
+ const recognized = new Set;
449
+ const inert = new Set;
450
+ const definitions = new Map;
451
+ let metadata = { title: fallbackTitle };
452
+ const firstHeading = tree.children.find((node) => node.type === "heading" && node.depth === 1);
453
+ if (firstHeading)
454
+ metadata.title = textOf(firstHeading);
455
+ function inspect(node, depth = 0) {
456
+ if (depth > 64) {
457
+ diagnostics.add("E_REPORT_DEPTH", "Markdown nesting exceeds 64 levels.", node.position);
458
+ return;
459
+ }
460
+ if (node.type === "code" || node.type === "yaml") {
461
+ for (let line = node.position?.start.line ?? 0;line <= (node.position?.end.line ?? 0); line++)
462
+ inert.add(line);
463
+ return;
464
+ }
465
+ if (node.type === "html")
466
+ diagnostics.add("E_RAW_HTML", "Raw HTML is not supported in reports.", node.position, "Use Markdown or a registered directive. Put HTML examples inside code fences.");
467
+ if (node.type === "image" || node.type === "imageReference")
468
+ diagnostics.add("E_IMAGE_UNSUPPORTED", "Markdown images are not supported; use an explicit image column in a table.", node.position, 'Use ::column{field="image" kind="image" alt="description"} inside a table for local PNG/JPEG images, or use a text link.');
469
+ if (node.type === "link" || node.type === "definition") {
470
+ const url = node.url ?? "";
471
+ let allowed = url.startsWith("#");
472
+ try {
473
+ allowed ||= ["https:", "http:", "mailto:"].includes(new URL(url).protocol);
474
+ } catch {}
475
+ if (!allowed)
476
+ diagnostics.add("E_LINK_URL", `Unsupported link destination ${JSON.stringify(url)}.`, node.position, "Use an https:, http:, mailto: link or a same-document #anchor.");
477
+ if (node.type === "definition")
478
+ definitions.set(String(node.identifier), url);
479
+ }
480
+ node.children?.forEach((child) => inspect(child, depth + 1));
481
+ }
482
+ inspect(tree);
483
+ function blocks(nodes, parent = "root", depth = 0) {
484
+ if (depth > 32) {
485
+ diagnostics.add("E_REPORT_DEPTH", "Component nesting exceeds 32 levels.");
486
+ return [];
487
+ }
488
+ const output = [];
489
+ for (const node of nodes) {
490
+ if (node.type === "yaml") {
491
+ try {
492
+ const document = parseDocument(node.value ?? "", {
493
+ uniqueKeys: true,
494
+ strict: true
495
+ });
496
+ if (document.errors.length || document.warnings.length)
497
+ throw new Error([...document.errors, ...document.warnings].map((error) => error.message).join("; "));
498
+ visit(document, {
499
+ Alias() {
500
+ throw new Error("YAML aliases are not supported.");
501
+ }
502
+ });
503
+ const result = metadataSchema.safeParse(document.toJS({ maxAliasCount: 0 }) ?? {});
504
+ if (!result.success)
505
+ throw new Error(result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; "));
506
+ metadata = { ...metadata, ...result.data };
507
+ } catch (error) {
508
+ diagnostics.add("E_FRONTMATTER", error instanceof Error ? error.message : "Invalid frontmatter.", node.position);
509
+ }
510
+ continue;
511
+ }
512
+ if (["containerDirective", "leafDirective", "textDirective"].includes(node.type)) {
513
+ const name = node.name ?? "";
514
+ recognized.add(node.position?.start.line ?? 0);
515
+ if (node.type === "containerDirective") {
516
+ const closingLine = node.position?.end.line ?? 0;
517
+ const closing = lines[closingLine - 1]?.trim() ?? "";
518
+ if (!/^:{3,}$/.test(closing))
519
+ diagnostics.add("E_DIRECTIVE_CLOSE", `:::${name} needs an explicit closing colon fence.`, node.position, "Close the component with ::: on its own line. Use a longer outer fence when nesting containers.");
520
+ else
521
+ recognized.add(closingLine);
522
+ }
523
+ if (!Object.hasOwn(registry, name)) {
524
+ diagnostics.add("E_COMPONENT_UNKNOWN", `Unknown component ${JSON.stringify(name)}.`, node.position, `Supported components: ${Object.keys(registry).join(", ")}. Run plotleaf schema for their properties.`);
525
+ continue;
526
+ }
527
+ const componentName = name;
528
+ const entry = registry[componentName];
529
+ const form = node.type === "containerDirective" ? "container" : node.type === "leafDirective" ? "leaf" : "text";
530
+ if (!entry.forms.includes(form))
531
+ diagnostics.add("E_COMPONENT_FORM", `${name} does not support the ${form} directive form.`, node.position, `Use ${entry.forms.includes("container") ? `:::${name} … :::` : `::${name}{…}`}.`);
532
+ const allowed = parent === "root" ? name !== "column" : registry[parent]?.children?.includes(name);
533
+ if (!allowed)
534
+ diagnostics.add("E_COMPONENT_CONTEXT", `${name} is not allowed inside ${parent}.`, node.position, name === "column" ? "Place column directives inside a table container." : "Place standalone components at the report root or inside a grid.");
535
+ const opening = lines[(node.position?.start.line ?? 1) - 1] ?? "";
536
+ const names = new Set;
537
+ for (const match of opening.matchAll(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|([A-Za-z][\w-]*)\s*=/g)) {
538
+ if (!match[1])
539
+ continue;
540
+ if (names.has(match[1]))
541
+ diagnostics.add("E_PROP_DUPLICATE", `Duplicate property ${JSON.stringify(match[1])} on ${name}.`, node.position, "Specify each property once.");
542
+ names.add(match[1]);
543
+ }
544
+ if (node.type === "leafDirective" && node.children?.length)
545
+ diagnostics.add("E_COMPONENT_LABEL", `${name} does not accept a directive [label].`, node.position, "Use the label or title property instead.");
546
+ const decoded = decodeProps(componentName, node.attributes ?? {});
547
+ for (const message of decoded.errors)
548
+ diagnostics.add("E_PROP_VALUE", message, node.position, `Run plotleaf schema for the ${name} contract.`);
549
+ const children = blocks(node.children ?? [], name, depth + 1);
550
+ if (decoded.props)
551
+ output.push({
552
+ type: "component",
553
+ name: componentName,
554
+ props: decoded.props,
555
+ children,
556
+ position: node.position
557
+ });
558
+ continue;
559
+ }
560
+ if (parent !== "root" && !registry[parent]?.children?.includes("markdown"))
561
+ diagnostics.add("E_COMPONENT_CHILD", `${parent} only accepts its declared component children.`, node.position);
562
+ const nestedDirectives = (candidate, nesting = 0) => {
563
+ if (nesting > 64)
564
+ return;
565
+ if (candidate.type.endsWith("Directive"))
566
+ diagnostics.add("E_COMPONENT_CONTEXT", "Components must be standalone blocks, not inline/list/blockquote content.", candidate.position);
567
+ candidate.children?.forEach((child) => nestedDirectives(child, nesting + 1));
568
+ };
569
+ nestedDirectives(node);
570
+ const last = output.at(-1);
571
+ if (last?.type === "markdown")
572
+ last.tree.children.push(node);
573
+ else
574
+ output.push({
575
+ type: "markdown",
576
+ tree: { type: "root", children: [node] }
577
+ });
578
+ }
579
+ return output;
580
+ }
581
+ const reportBlocks = blocks(tree.children);
582
+ function attachDefinitions(items) {
583
+ for (const block of items) {
584
+ if (block.type === "markdown") {
585
+ for (const [identifier, url] of definitions)
586
+ if (!block.tree.children.some((node) => node.type === "definition" && node.identifier === identifier))
587
+ block.tree.children.push({ type: "definition", identifier, url });
588
+ } else
589
+ attachDefinitions(block.children);
590
+ }
591
+ }
592
+ attachDefinitions(reportBlocks);
593
+ for (let index = 0;index < lines.length; index++) {
594
+ if (/^\s*::/.test(lines[index] ?? "") && !recognized.has(index + 1) && !inert.has(index + 1)) {
595
+ const position = {
596
+ start: { line: index + 1, column: 1 },
597
+ end: { line: index + 1, column: 1 }
598
+ };
599
+ diagnostics.add("E_DIRECTIVE_SYNTAX", "Malformed or misplaced component directive.", position, 'Use ::name{key="literal"} on one line, or a balanced :::name container. JavaScript expressions are not supported.');
600
+ }
601
+ }
602
+ return { metadata, blocks: reportBlocks };
603
+ }
604
+
605
+ // src/compiler/sources.ts
606
+ class LocalFileError extends Error {
607
+ kind;
608
+ constructor(kind, message) {
609
+ super(message);
610
+ this.kind = kind;
611
+ }
612
+ }
613
+
614
+ // src/compiler/exploration.ts
615
+ var expansionBudget = () => ({
616
+ explorations: 0,
617
+ components: 0,
618
+ rows: 0,
619
+ markdownBytes: 0,
620
+ dataBytes: 0
621
+ });
622
+ async function resolveExploration(block, files, diagnostics, resolve, budget) {
623
+ const fail = (message, code = "E_EXPLORE_SCHEMA") => diagnostics.add(code, `${block.props.data}: ${message}`, block.position, "Correct the prepared-view bundle; every view must declare all input values.");
624
+ try {
625
+ if (++budget.explorations > explorationLimits.explorations) {
626
+ fail("At most four explorations are supported.", "E_EXPLORE_LIMIT");
627
+ return;
628
+ }
629
+ const filename = await files.resolve(block.props.data, [".json"]);
630
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(await files.read(filename, limits.jsonBytes));
631
+ let raw;
632
+ try {
633
+ raw = JSON.parse(text);
634
+ } catch {
635
+ fail("Invalid JSON.", "E_EXPLORE_JSON");
636
+ return;
637
+ }
638
+ const pending = [
639
+ { value: raw, depth: 0 }
640
+ ];
641
+ let containers = 0;
642
+ while (pending.length) {
643
+ const { value, depth } = pending.pop();
644
+ if (value === null || typeof value !== "object")
645
+ continue;
646
+ if (depth > explorationLimits.jsonDepth || ++containers > explorationLimits.jsonContainers) {
647
+ fail("JSON exceeds 32 nesting levels or 100,000 containers.", "E_EXPLORE_LIMIT");
648
+ return;
649
+ }
650
+ for (const [key, child] of Object.entries(value)) {
651
+ if (["__proto__", "constructor", "prototype"].includes(key)) {
652
+ fail(`Reserved object key ${JSON.stringify(key)}.`, "E_EXPLORE_KEY");
653
+ return;
654
+ }
655
+ if (child !== null && typeof child === "object")
656
+ pending.push({ value: child, depth: depth + 1 });
657
+ }
658
+ }
659
+ const parsed = explorationSchema.safeParse(raw);
660
+ if (!parsed.success) {
661
+ for (const issue of parsed.error.issues.slice(0, 12))
662
+ fail(`${issue.path.join(".")}: ${issue.message}`);
663
+ return;
664
+ }
665
+ const bundle = parsed.data;
666
+ const ids = bundle.inputs.map((input) => input.id);
667
+ if (new Set(ids).size !== ids.length) {
668
+ fail("Input IDs must be unique.");
669
+ return;
670
+ }
671
+ const domains = new Map;
672
+ for (const input of bundle.inputs) {
673
+ const values = input.type === "toggle" ? [false, true] : input.options.map((option) => option.value);
674
+ const domain = new Set(values.map(valueKey));
675
+ if (domain.size !== values.length || !domain.has(valueKey(input.default))) {
676
+ fail(`Input ${input.id} has duplicate choices or an unsupported default.`);
677
+ return;
678
+ }
679
+ domains.set(input.id, domain);
680
+ }
681
+ const combinations = new Set, viewIds = new Set;
682
+ const key = (when) => JSON.stringify(ids.map((id) => valueKey(when[id])));
683
+ const initial = Object.fromEntries(bundle.inputs.map((input) => [input.id, input.default]));
684
+ for (const view of bundle.views) {
685
+ if (viewIds.has(view.id)) {
686
+ fail(`Duplicate view ID ${view.id}.`);
687
+ return;
688
+ }
689
+ viewIds.add(view.id);
690
+ if (Object.keys(view.when).length !== ids.length || ids.some((id) => !Object.hasOwn(view.when, id)) || Object.keys(view.when).some((id) => !domains.has(id))) {
691
+ fail(`View ${view.id} must declare exactly the scoped input IDs.`);
692
+ return;
693
+ }
694
+ if (ids.some((id) => !domains.get(id).has(valueKey(view.when[id])))) {
695
+ fail(`View ${view.id} contains an unsupported input value or value type.`);
696
+ return;
697
+ }
698
+ const combination = key(view.when);
699
+ if (combinations.has(combination)) {
700
+ fail(`View ${view.id} duplicates another input combination.`);
701
+ return;
702
+ }
703
+ combinations.add(combination);
704
+ }
705
+ const defaultView = bundle.views.find((view) => key(view.when) === key(initial));
706
+ if (!defaultView) {
707
+ fail("Declared defaults must select one prepared view.");
708
+ return;
709
+ }
710
+ const views = [];
711
+ for (const view of bundle.views) {
712
+ let inspect = function(blocks) {
713
+ for (const child of blocks) {
714
+ if (child.type !== "component")
715
+ continue;
716
+ if (child.name === "explore") {
717
+ fail(`View ${view.id}: nested explorations are unsupported.`);
718
+ return false;
719
+ }
720
+ if (++budget.components > explorationLimits.components) {
721
+ fail("Expanded components exceed 512.", "E_EXPLORE_LIMIT");
722
+ return false;
723
+ }
724
+ if (!inspect(child.children))
725
+ return false;
726
+ }
727
+ return true;
728
+ }, countRows = function(blocks) {
729
+ for (const child of blocks)
730
+ if (child.type === "component") {
731
+ budget.rows += child.rows?.length ?? 0;
732
+ budget.dataBytes += child.rows ? new TextEncoder().encode(JSON.stringify(child.rows)).length : 0;
733
+ countRows(child.children);
734
+ }
735
+ };
736
+ budget.markdownBytes += new TextEncoder().encode(view.markdown).length;
737
+ if (budget.markdownBytes > explorationLimits.markdownBytes) {
738
+ fail("Expanded Markdown exceeds 2 MiB.", "E_EXPLORE_LIMIT");
739
+ return;
740
+ }
741
+ const local = new Diagnostics(`${block.props.data}#${view.id}`);
742
+ const report = parseReport(view.markdown, view.label, local);
743
+ if (!inspect(report.blocks))
744
+ return;
745
+ const inline = new Map(Object.entries(view.datasets).map(([name, rows]) => [
746
+ `plotleaf-inline:${budget.explorations}-${view.id}:${name}`,
747
+ new TextEncoder().encode(JSON.stringify(rows))
748
+ ]));
749
+ const virtual = {
750
+ async resolve(reference, extensions) {
751
+ const name = reference.startsWith("./") ? reference.slice(2) : reference;
752
+ const key = `plotleaf-inline:${budget.explorations}-${view.id}:${name}`;
753
+ return inline.has(key) ? key : files.resolve(reference, extensions);
754
+ },
755
+ async read(name, maximum) {
756
+ const bytes = inline.get(name);
757
+ if (!bytes)
758
+ return files.read(name, maximum);
759
+ if (bytes.length > maximum)
760
+ throw new Error("Prepared dataset exceeds the file size limit.");
761
+ return bytes;
762
+ }
763
+ };
764
+ await resolve(report.blocks, virtual, local);
765
+ countRows(report.blocks);
766
+ if (budget.rows > explorationLimits.rows) {
767
+ fail("Expanded component rows exceed 20,000.", "E_EXPLORE_LIMIT");
768
+ return;
769
+ }
770
+ if (budget.dataBytes > explorationLimits.dataBytes) {
771
+ fail("Expanded prepared data exceeds 8 MiB.", "E_EXPLORE_LIMIT");
772
+ return;
773
+ }
774
+ for (const item of local.sorted())
775
+ diagnostics.add(item.code, `View ${view.id}, Markdown ${item.line}:${item.column}: ${item.message}`, block.position, item.hint, item.severity);
776
+ views.push({
777
+ id: view.id,
778
+ label: view.label,
779
+ when: view.when,
780
+ blocks: report.blocks
781
+ });
782
+ }
783
+ block.exploration = {
784
+ inputs: bundle.inputs,
785
+ defaultView: defaultView.id,
786
+ views
787
+ };
788
+ } catch (error) {
789
+ fail(error instanceof Error ? error.message : "Could not read the exploration.", error instanceof LocalFileError && error.kind === "path" ? "E_EXPLORE_PATH" : "E_EXPLORE_READ");
790
+ }
791
+ }
792
+
793
+ // src/shared/date.ts
794
+ function parseDate(value) {
795
+ const date = value.slice(0, 10);
796
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
797
+ return;
798
+ const day = Date.parse(`${date}T00:00:00.000Z`);
799
+ if (!Number.isFinite(day) || new Date(day).toISOString().slice(0, 10) !== date)
800
+ return;
801
+ if (value.length === 10)
802
+ return day;
803
+ if (!/^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,3})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/.test(value))
804
+ return;
805
+ const timestamp = Date.parse(value);
806
+ return Number.isFinite(timestamp) ? timestamp : undefined;
807
+ }
808
+
809
+ // src/compiler/resolve-data.ts
810
+ var reserved = new Set(["__proto__", "constructor", "prototype"]);
811
+
812
+ class DataResolver {
813
+ diagnostics;
814
+ files;
815
+ images;
816
+ expansion;
817
+ cache;
818
+ constructor(diagnostics, files, images, expansion = expansionBudget(), cache = new Map) {
819
+ this.diagnostics = diagnostics;
820
+ this.files = files;
821
+ this.images = images;
822
+ this.expansion = expansion;
823
+ this.cache = cache;
824
+ }
825
+ async load(reference, block) {
826
+ const fail = (code, message, hint) => {
827
+ this.diagnostics.add(code, `${reference}: ${message}`, block.position, hint);
828
+ return;
829
+ };
830
+ let canonical;
831
+ try {
832
+ canonical = await this.files.resolve(reference, [".json"]);
833
+ } catch (error) {
834
+ return fail(error instanceof LocalFileError && error.kind === "path" ? "E_DATA_PATH" : "E_DATA_READ", error instanceof Error ? error.message : "Cannot resolve the dataset.", "Place prepared JSON within the report directory and correct the data property.");
835
+ }
836
+ let dataset = this.cache.get(canonical);
837
+ if (!dataset) {
838
+ if (this.cache.size >= limits.datasets)
839
+ return fail("E_DATA_LIMIT", `At most ${limits.datasets} datasets are supported.`);
840
+ let text;
841
+ try {
842
+ text = new TextDecoder("utf-8", { fatal: true }).decode(await this.files.read(canonical, limits.jsonBytes));
843
+ } catch (error) {
844
+ return fail("E_DATA_READ", error instanceof Error ? error.message : "Could not read data.");
845
+ }
846
+ let parsed;
847
+ try {
848
+ parsed = JSON.parse(text);
849
+ } catch {
328
850
  return fail("E_DATA_JSON", "Invalid JSON.", "Use a JSON array of records, without comments or trailing commas.");
329
851
  }
330
852
  if (!Array.isArray(parsed))
@@ -362,6 +884,10 @@ class DataResolver {
362
884
  for (const block of blocks) {
363
885
  if (block.type !== "component")
364
886
  continue;
887
+ if (block.name === "explore") {
888
+ await resolveExploration(block, this.files, this.diagnostics, (children, files, diagnostics) => new DataResolver(diagnostics, files, this.images, this.expansion, this.cache).resolve(children), this.expansion);
889
+ continue;
890
+ }
365
891
  if (block.name === "line" || block.name === "area" || block.name === "bar" || block.name === "donut" || block.name === "funnel" || block.name === "table") {
366
892
  const rows = await this.load(block.props.data, block);
367
893
  if (rows) {
@@ -539,493 +1065,248 @@ class DataResolver {
539
1065
  fail("E_TABLE_SORT", index, "sortBy must name a scalar field.");
540
1066
  continue;
541
1067
  }
542
- if (sort !== undefined && sort !== null) {
543
- if (sortType && sortType !== typeof sort) {
544
- fail("E_TABLE_SORT", index, "sortBy values must have consistent non-null types.");
545
- continue;
546
- }
547
- sortType = typeof sort;
548
- }
549
- if (kind === "scalar") {
550
- prepared[index][column.field] = value;
551
- continue;
552
- }
553
- let rich;
554
- if (kind === "sparkline") {
555
- if (value !== null && !Array.isArray(value)) {
556
- fail("E_DATA_TYPE", index, "Sparkline values must be a bounded numeric/null array or null.");
557
- continue;
558
- }
559
- rich = {
560
- kind: "sparkline",
561
- values: value ?? [],
562
- ...column.sortBy ? { sort } : {}
563
- };
564
- } else {
565
- const alt = row[column.alt];
566
- if (value !== null && (typeof alt !== "string" || !alt.trim() || alt.length > 1000) || value === null && alt !== null && typeof alt !== "string") {
567
- fail("E_TABLE_IMAGE", index, "alt must name a nonblank string of at most 1000 characters for each image.");
568
- continue;
569
- }
570
- if (value === null) {
571
- prepared[index][column.field] = {
572
- kind: "image",
573
- imageId: null,
574
- alt: "",
575
- ...column.sortBy ? { sort } : {}
576
- };
577
- continue;
578
- }
579
- if (typeof value !== "string" || !value.trim()) {
580
- fail("E_TABLE_IMAGE", index, "Image values must be a plain local PNG/JPEG path or null.");
581
- continue;
582
- }
583
- try {
584
- const image = await this.images.load(value);
585
- rich = {
586
- kind: "image",
587
- imageId: image.id,
588
- alt,
589
- ...column.sortBy ? { sort } : {}
590
- };
591
- } catch (error) {
592
- fail(error instanceof LocalFileError ? error.kind === "path" ? "E_ASSET_PATH" : "E_ASSET_READ" : "E_ASSET_IMAGE", index, `${value}: ${error instanceof Error ? error.message : "Cannot read image."}`, "Use a bounded static PNG/JPEG inside the report directory, with a matching extension.");
593
- continue;
594
- }
595
- }
596
- prepared[index][column.field] = rich;
597
- }
598
- }
599
- block.columns = columns;
600
- block.rows = prepared;
601
- }
602
- }
603
-
604
- // src/compiler/parse.ts
605
- import { unified } from "unified";
606
- import remarkParse from "remark-parse";
607
- import remarkDirective from "remark-directive";
608
- import remarkFrontmatter from "remark-frontmatter";
609
- import { parseDocument, visit } from "yaml";
610
- import * as z2 from "zod";
611
-
612
- // src/compiler/registry.ts
613
- import * as z from "zod";
614
- var text = z.string().min(1).max(1000);
615
- var format = z.string().regex(/^(number|percent|percentage-points|currency:[A-Z]{3})$/, "Use number, percent, percentage-points or currency:GBP (an ISO currency code).");
616
- var chart = {
617
- data: text,
618
- x: text,
619
- y: text,
620
- title: text,
621
- format: format.default("number")
622
- };
623
- var line = z.strictObject({
624
- ...chart,
625
- xType: z.enum(["category", "date", "number"]).default("category"),
626
- target: z.number().finite().optional(),
627
- targetLabel: text.optional()
628
- });
629
- var registry = {
630
- grid: {
631
- forms: ["container"],
632
- children: [
633
- "metric",
634
- "line",
635
- "area",
636
- "bar",
637
- "donut",
638
- "funnel",
639
- "table",
640
- "callout"
641
- ],
642
- description: "A responsive row of one to four components.",
643
- schema: z.strictObject({
644
- columns: z.number().int().min(1).max(4).default(2)
645
- }),
646
- example: `:::grid{columns="4"}
647
- ::metric{label="Teams" value="1492"}
648
- :::`
649
- },
650
- metric: {
651
- forms: ["leaf"],
652
- children: [],
653
- description: "A prepared numeric value with optional comparison, direction and sentiment.",
654
- schema: z.strictObject({
655
- label: text,
656
- value: z.number().finite(),
657
- format: format.default("number"),
658
- comparison: text.optional(),
659
- direction: z.enum(["up", "down", "flat"]).optional(),
660
- sentiment: z.enum(["positive", "negative", "neutral"]).default("neutral")
661
- }),
662
- example: '::metric{label="Conversion" value="0.103" format="percent"}'
663
- },
664
- line: {
665
- forms: ["leaf"],
666
- children: [],
667
- description: "An interactive single-series line chart from local JSON.",
668
- schema: line,
669
- example: '::line{data="./usage.json" x="date" y="teams" xType="date" title="Active teams"}'
670
- },
671
- area: {
672
- forms: ["leaf"],
673
- children: [],
674
- description: "A line chart with a restrained area fill.",
675
- schema: line,
676
- example: '::area{data="./usage.json" x="date" y="teams" xType="date" title="Active teams" target="1600"}'
677
- },
678
- bar: {
679
- forms: ["leaf"],
680
- children: [],
681
- description: "An interactive category bar chart with a zero baseline.",
682
- schema: z.strictObject(chart),
683
- example: '::bar{data="./errors.json" x="category" y="count" title="Errors by category"}'
684
- },
685
- donut: {
686
- forms: ["leaf"],
687
- children: [],
688
- description: "A part-to-whole donut with a readable legend and native data table.",
689
- schema: z.strictObject({
690
- data: text,
691
- category: text,
692
- value: text,
693
- title: text,
694
- format: format.default("number")
695
- }),
696
- dataRules: {
697
- category: "Unique nonblank strings of at most 1000 characters; source order is preserved.",
698
- value: "Finite nonnegative numbers with a finite sum. Nulls and numeric strings are invalid. All-zero values show an empty state.",
699
- maximumRows: limits.donutSlices
700
- },
701
- example: '::donut{data="./plans.json" category="plan" value="teams" title="Teams by plan"}'
702
- },
703
- funnel: {
704
- forms: ["leaf"],
705
- children: [],
706
- description: "Ordered stage/progress rows with prepared values, denominators, shares and optional explicit status. Works without JavaScript.",
707
- schema: z.strictObject({
708
- data: text,
709
- stage: text,
710
- value: text,
711
- total: text,
712
- share: text,
713
- status: text.optional(),
714
- title: text,
715
- format: format.default("number")
716
- }),
717
- dataRules: {
718
- stage: "Unique nonblank strings of at most 1000 characters; source order is preserved.",
719
- value: "Finite nonnegative number no greater than its row's total.",
720
- total: "Finite positive denominator supplied per row. No denominator is inferred from adjacent stages.",
721
- share: "Prepared ratio from 0 to 1, matching value / total to within 0.0005 (rounding to 0.1 percentage point).",
722
- status: Object.keys(funnelStatuses),
723
- maximumRows: limits.funnelStages
724
- },
725
- example: '::funnel{data="./funnel.json" stage="stage" value="teams" total="baseline" share="share" status="status" title="Funnel by stage"}'
726
- },
727
- table: {
728
- forms: ["leaf", "container"],
729
- children: ["column"],
730
- description: "A semantic sortable table, with inferred or explicitly formatted columns.",
731
- schema: z.strictObject({ data: text, title: text.optional() }),
732
- example: `:::table{data="./plans.json" title="By plan"}
733
- ::column{field="plan" label="Plan"}
734
- ::column{field="change" label="Δ QoQ" format="percent" sign="always"}
735
- :::`
736
- },
737
- column: {
738
- forms: ["leaf"],
739
- children: [],
740
- description: "A scalar, local image or bounded sparkline column. Omitted kind means scalar.",
741
- schema: z.strictObject({
742
- field: text,
743
- label: text.optional(),
744
- kind: z.enum(["scalar", "image", "sparkline"]).optional(),
745
- alt: text.optional(),
746
- sortBy: text.optional(),
747
- format: format.optional(),
748
- sign: z.enum(["auto", "always"]).default("auto"),
749
- semantics: z.enum(["neutral", "higher-is-better", "lower-is-better"]).default("neutral")
750
- }),
751
- example: '::column{field="change" label="Δ QoQ" format="percent" sign="always" semantics="higher-is-better"}',
752
- dataRules: {
753
- image: "Local static PNG/JPEG path or null. alt names a nonblank string field for each image. Fixed 64 × 48 display box; deduplicated and embedded offline. Paths resolve from the report directory.",
754
- sparkline: "Only explicitly declared sparkline fields may contain arrays: at most 64 finite numbers/nulls, in sample order. Nulls are gaps. Shared observed y scale per column; no aggregation or resampling.",
755
- sortBy: "Optional scalar field for image/sparkline sorting. Without it a rich column has a plain header. Scalar columns sort their own raw values. Sort keys are readable in the export."
1068
+ if (sort !== undefined && sort !== null) {
1069
+ if (sortType && sortType !== typeof sort) {
1070
+ fail("E_TABLE_SORT", index, "sortBy values must have consistent non-null types.");
1071
+ continue;
1072
+ }
1073
+ sortType = typeof sort;
1074
+ }
1075
+ if (kind === "scalar") {
1076
+ prepared[index][column.field] = value;
1077
+ continue;
1078
+ }
1079
+ let rich;
1080
+ if (kind === "sparkline") {
1081
+ if (value !== null && !Array.isArray(value)) {
1082
+ fail("E_DATA_TYPE", index, "Sparkline values must be a bounded numeric/null array or null.");
1083
+ continue;
1084
+ }
1085
+ rich = {
1086
+ kind: "sparkline",
1087
+ values: value ?? [],
1088
+ ...column.sortBy ? { sort } : {}
1089
+ };
1090
+ } else {
1091
+ const alt = row[column.alt];
1092
+ if (value !== null && (typeof alt !== "string" || !alt.trim() || alt.length > 1000) || value === null && alt !== null && typeof alt !== "string") {
1093
+ fail("E_TABLE_IMAGE", index, "alt must name a nonblank string of at most 1000 characters for each image.");
1094
+ continue;
1095
+ }
1096
+ if (value === null) {
1097
+ prepared[index][column.field] = {
1098
+ kind: "image",
1099
+ imageId: null,
1100
+ alt: "",
1101
+ ...column.sortBy ? { sort } : {}
1102
+ };
1103
+ continue;
1104
+ }
1105
+ if (typeof value !== "string" || !value.trim()) {
1106
+ fail("E_TABLE_IMAGE", index, "Image values must be a plain local PNG/JPEG path or null.");
1107
+ continue;
1108
+ }
1109
+ try {
1110
+ const image = await this.images.load(value);
1111
+ rich = {
1112
+ kind: "image",
1113
+ imageId: image.id,
1114
+ alt,
1115
+ ...column.sortBy ? { sort } : {}
1116
+ };
1117
+ } catch (error) {
1118
+ fail(error instanceof LocalFileError ? error.kind === "path" ? "E_ASSET_PATH" : "E_ASSET_READ" : "E_ASSET_IMAGE", index, `${value}: ${error instanceof Error ? error.message : "Cannot read image."}`, "Use a bounded static PNG/JPEG inside the report directory, with a matching extension.");
1119
+ continue;
1120
+ }
1121
+ }
1122
+ prepared[index][column.field] = rich;
1123
+ }
756
1124
  }
757
- },
758
- callout: {
759
- forms: ["container"],
760
- children: ["markdown"],
761
- description: "A contextual note or warning containing ordinary Markdown.",
762
- schema: z.strictObject({
763
- tone: z.enum(["note", "warning"]).default("note"),
764
- title: text.optional()
765
- }),
766
- example: `:::callout{title="Coverage"}
767
- Seven complete days of prepared data.
768
- :::`
1125
+ block.columns = columns;
1126
+ block.rows = prepared;
769
1127
  }
770
- };
771
- function componentSpec() {
1128
+ }
1129
+
1130
+ // src/compiler/files.ts
1131
+ import path2 from "node:path";
1132
+ import { open, realpath, stat } from "node:fs/promises";
1133
+ function contained(root, filename) {
1134
+ const relative = path2.relative(root, filename);
1135
+ return relative === "" || !path2.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path2.sep}`);
1136
+ }
1137
+ async function readBytesBounded(filename, maximum) {
1138
+ if (!(await stat(filename)).isFile())
1139
+ throw new Error("Expected a regular file.");
1140
+ const file = await open(filename, "r");
1141
+ try {
1142
+ const info = await file.stat();
1143
+ if (!info.isFile())
1144
+ throw new Error("Expected a regular file.");
1145
+ if (info.size > maximum)
1146
+ throw new Error(`File exceeds the ${Math.floor(maximum / 1024)} KiB size limit.`);
1147
+ const chunks = [];
1148
+ let bytes = 0;
1149
+ while (bytes <= maximum) {
1150
+ const buffer = Buffer.alloc(Math.min(64 * 1024, maximum + 1 - bytes));
1151
+ const result = await file.read(buffer, 0, buffer.length, null);
1152
+ if (!result.bytesRead)
1153
+ break;
1154
+ chunks.push(buffer.subarray(0, result.bytesRead));
1155
+ bytes += result.bytesRead;
1156
+ }
1157
+ if (bytes > maximum)
1158
+ throw new Error("File grew beyond the size limit while reading.");
1159
+ return Buffer.concat(chunks, bytes);
1160
+ } finally {
1161
+ await file.close();
1162
+ }
1163
+ }
1164
+ async function readBounded(filename, maximum) {
1165
+ const bytes = await readBytesBounded(filename, maximum);
772
1166
  return {
773
- version: 1,
774
- syntax: "markdown-directives",
775
- attributes: "Literal strings decoded according to each property schema. No JavaScript expressions.",
776
- limits,
777
- data: {
778
- shape: "array of flat JSON records; explicit table sparkline columns permit bounded numeric/null arrays only in their declared fields",
779
- resolution: "relative to the report directory; local .json files only",
780
- percent: "ratios: 0.12 displays as 12%",
781
- percentagePoints: "point values: 0.8 displays as 0.8 pt",
782
- currency: "major units; omit .00 on whole amounts, preserve nonzero cents",
783
- locale: "en-GB",
784
- timeZone: "UTC"
785
- },
786
- components: Object.fromEntries(Object.entries(registry).map(([name, entry]) => [
787
- name,
788
- {
789
- description: entry.description,
790
- forms: entry.forms,
791
- children: entry.children,
792
- props: z.toJSONSchema(entry.schema),
793
- example: entry.example,
794
- ..."dataRules" in entry ? { dataRules: entry.dataRules } : {}
795
- }
796
- ])),
797
- rules: [
798
- "Use explicit closing fences for containers.",
799
- "Components are standalone blocks, not inline/list/blockquote content.",
800
- "column is only allowed inside table; grids cannot nest.",
801
- "direction and explicitly supplied sentiment require metric comparison text.",
802
- "targetLabel requires target.",
803
- "Dataset fields are literal own-property keys, not expressions.",
804
- "Charts preserve source order; date/number x values must increase.",
805
- "Line/area null y values create gaps; bar null y values are invalid.",
806
- "Donut category/value fields must differ; category count is bounded, and all-zero values do not create equal slices.",
807
- "Funnel fields must differ; shares and denominators are supplied, validated and displayed per row, with no sorting or status inference.",
808
- "Only explicit image columns can load local static PNG/JPEG; every resource stays within the report root and shared byte limits.",
809
- "Filters, maps, arbitrary nested data and executable components are not supported in this release."
810
- ]
1167
+ text: new TextDecoder("utf-8", { fatal: true }).decode(bytes),
1168
+ bytes: bytes.length
811
1169
  };
812
1170
  }
813
- var specs = componentSpec().components;
814
- var numericLiteral = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
815
- function decodeProps(name, attributes) {
816
- const decoded = Object.create(null);
817
- const properties = specs[name].props.properties ?? {};
818
- for (const [key, value] of Object.entries(attributes)) {
819
- const property = properties[key];
820
- if (property?.type === "number" || property?.type === "integer")
821
- decoded[key] = typeof value === "string" && numericLiteral.test(value) ? Number(value) : value;
822
- else
823
- decoded[key] = value;
1171
+
1172
+ class ReportFiles {
1173
+ root;
1174
+ dependencies;
1175
+ usedBytes;
1176
+ constructor(root, dependencies, usedBytes) {
1177
+ this.root = root;
1178
+ this.dependencies = dependencies;
1179
+ this.usedBytes = usedBytes;
824
1180
  }
825
- const result = registry[name].schema.safeParse(decoded);
826
- if (!result.success)
827
- return {
828
- errors: result.error.issues.map((issue) => `${issue.path.join(".") || name}: ${issue.message}`)
1181
+ async resolve(reference, extensions) {
1182
+ const fail = (message) => {
1183
+ throw new LocalFileError("path", message);
829
1184
  };
830
- const props = result.data;
831
- const errors = [];
832
- if (name === "column") {
833
- const column = props;
834
- const kind = column.kind ?? "scalar";
835
- if (kind === "image" && !column.alt)
836
- errors.push("image columns require an alt field.");
837
- if (kind !== "image" && column.alt)
838
- errors.push("alt is only supported on image columns.");
839
- if (kind === "image" && column.format)
840
- errors.push("Image columns cannot use numeric formatting.");
841
- if (kind !== "scalar" && (column.sign !== "auto" || column.semantics !== "neutral"))
842
- errors.push("sign and semantics apply only to scalar columns.");
843
- if (kind === "scalar" && column.sortBy)
844
- errors.push("Scalar columns sort their own field; sortBy is for image/sparkline columns.");
845
- if (column.alt === column.field || column.sortBy === column.field)
846
- errors.push("alt and sortBy must reference fields different from the rich cell field.");
847
- }
848
- if (name === "donut" || name === "funnel") {
849
- const fields = Object.entries(props).filter(([key, value]) => ["category", "stage", "value", "total", "share", "status"].includes(key) && value !== undefined).map(([, value]) => value);
850
- if (new Set(fields).size !== fields.length)
851
- errors.push(`${name} field properties must reference different dataset fields.`);
852
- }
853
- if ("format" in props && props.format?.startsWith("currency:") && !Intl.supportedValuesOf("currency").includes(props.format.slice(9)))
854
- errors.push(`Unknown ISO currency code ${props.format.slice(9)}.`);
855
- if (name === "metric" && !props.comparison && (attributes.direction !== undefined || attributes.sentiment !== undefined))
856
- errors.push("direction and sentiment require comparison text.");
857
- if ((name === "line" || name === "area") && "targetLabel" in props && props.targetLabel !== undefined && (!("target" in props) || props.target === undefined))
858
- errors.push("targetLabel requires a numeric target.");
859
- return { props, errors };
860
- }
861
-
862
- // src/compiler/parse.ts
863
- var parser = unified().use(remarkParse).use(remarkFrontmatter).use(remarkDirective);
864
- var metadataSchema = z2.strictObject({
865
- title: z2.string().min(1).max(500).optional(),
866
- description: z2.string().max(2000).optional()
867
- });
868
- function textOf(node) {
869
- return node.value ?? node.children?.map(textOf).join("") ?? "";
870
- }
871
- function parseReport(source, fallbackTitle, diagnostics) {
872
- const tree = parser.parse(source);
873
- const lines = source.split(/\r?\n/);
874
- const recognized = new Set;
875
- const inert = new Set;
876
- const definitions = new Map;
877
- let metadata = { title: fallbackTitle };
878
- const firstHeading = tree.children.find((node) => node.type === "heading" && node.depth === 1);
879
- if (firstHeading)
880
- metadata.title = textOf(firstHeading);
881
- function inspect(node, depth = 0) {
882
- if (depth > 64) {
883
- diagnostics.add("E_REPORT_DEPTH", "Markdown nesting exceeds 64 levels.", node.position);
884
- return;
885
- }
886
- if (node.type === "code" || node.type === "yaml") {
887
- for (let line = node.position?.start.line ?? 0;line <= (node.position?.end.line ?? 0); line++)
888
- inert.add(line);
889
- return;
890
- }
891
- if (node.type === "html")
892
- diagnostics.add("E_RAW_HTML", "Raw HTML is not supported in reports.", node.position, "Use Markdown or a registered directive. Put HTML examples inside code fences.");
893
- if (node.type === "image" || node.type === "imageReference")
894
- diagnostics.add("E_IMAGE_UNSUPPORTED", "Markdown images are not supported; use an explicit image column in a table.", node.position, 'Use ::column{field="image" kind="image" alt="description"} inside a table for local PNG/JPEG images, or use a text link.');
895
- if (node.type === "link" || node.type === "definition") {
896
- const url = node.url ?? "";
897
- let allowed = url.startsWith("#");
898
- try {
899
- allowed ||= ["https:", "http:", "mailto:"].includes(new URL(url).protocol);
900
- } catch {}
901
- if (!allowed)
902
- diagnostics.add("E_LINK_URL", `Unsupported link destination ${JSON.stringify(url)}.`, node.position, "Use an https:, http:, mailto: link or a same-document #anchor.");
903
- if (node.type === "definition")
904
- definitions.set(String(node.identifier), url);
1185
+ if (path2.isAbsolute(reference) || /^[A-Za-z][A-Za-z\d+.-]*:/.test(reference) || reference.includes("\\") || /%[\da-f]{2}/i.test(reference) || reference.includes("\x00"))
1186
+ fail(`Use a plain relative local ${extensions.join("/")} path.`);
1187
+ const candidate = path2.resolve(this.root, reference);
1188
+ const hidden = (filename) => path2.relative(this.root, filename).split(path2.sep).some((part) => part.startsWith("."));
1189
+ if (!contained(this.root, candidate))
1190
+ fail("Reference escapes the report directory.");
1191
+ if (hidden(candidate))
1192
+ fail("Hidden files and directories are not allowed.");
1193
+ if (!extensions.includes(path2.extname(candidate).toLowerCase()))
1194
+ fail(`Use a ${extensions.join("/")} file.`);
1195
+ this.dependencies.add(candidate);
1196
+ let canonical;
1197
+ try {
1198
+ canonical = await realpath(candidate);
1199
+ } catch {
1200
+ throw new LocalFileError("read", "Cannot read the referenced file. Create it or correct the reference.");
905
1201
  }
906
- node.children?.forEach((child) => inspect(child, depth + 1));
1202
+ if (!contained(this.root, canonical) || hidden(canonical))
1203
+ fail("The resolved file or symlink escapes the permitted report files.");
1204
+ if (!extensions.includes(path2.extname(canonical).toLowerCase()))
1205
+ fail(`The resolved file must also have a ${extensions.join("/")} extension.`);
1206
+ this.dependencies.add(canonical);
1207
+ return canonical;
907
1208
  }
908
- inspect(tree);
909
- function blocks(nodes, parent = "root", depth = 0) {
910
- if (depth > 32) {
911
- diagnostics.add("E_REPORT_DEPTH", "Component nesting exceeds 32 levels.");
912
- return [];
913
- }
914
- const output = [];
915
- for (const node of nodes) {
916
- if (node.type === "yaml") {
917
- try {
918
- const document = parseDocument(node.value ?? "", {
919
- uniqueKeys: true,
920
- strict: true
921
- });
922
- if (document.errors.length || document.warnings.length)
923
- throw new Error([...document.errors, ...document.warnings].map((error) => error.message).join("; "));
924
- visit(document, {
925
- Alias() {
926
- throw new Error("YAML aliases are not supported.");
927
- }
928
- });
929
- const result = metadataSchema.safeParse(document.toJS({ maxAliasCount: 0 }) ?? {});
930
- if (!result.success)
931
- throw new Error(result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; "));
932
- metadata = { ...metadata, ...result.data };
933
- } catch (error) {
934
- diagnostics.add("E_FRONTMATTER", error instanceof Error ? error.message : "Invalid frontmatter.", node.position);
935
- }
936
- continue;
937
- }
938
- if (["containerDirective", "leafDirective", "textDirective"].includes(node.type)) {
939
- const name = node.name ?? "";
940
- recognized.add(node.position?.start.line ?? 0);
941
- if (node.type === "containerDirective") {
942
- const closingLine = node.position?.end.line ?? 0;
943
- const closing = lines[closingLine - 1]?.trim() ?? "";
944
- if (!/^:{3,}$/.test(closing))
945
- diagnostics.add("E_DIRECTIVE_CLOSE", `:::${name} needs an explicit closing colon fence.`, node.position, "Close the component with ::: on its own line. Use a longer outer fence when nesting containers.");
946
- else
947
- recognized.add(closingLine);
948
- }
949
- if (!Object.hasOwn(registry, name)) {
950
- diagnostics.add("E_COMPONENT_UNKNOWN", `Unknown component ${JSON.stringify(name)}.`, node.position, `Supported components: ${Object.keys(registry).join(", ")}. Run plotleaf schema for their properties.`);
951
- continue;
952
- }
953
- const componentName = name;
954
- const entry = registry[componentName];
955
- const form = node.type === "containerDirective" ? "container" : node.type === "leafDirective" ? "leaf" : "text";
956
- if (!entry.forms.includes(form))
957
- diagnostics.add("E_COMPONENT_FORM", `${name} does not support the ${form} directive form.`, node.position, `Use ${entry.forms.includes("container") ? `:::${name} … :::` : `::${name}{…}`}.`);
958
- const allowed = parent === "root" ? name !== "column" : registry[parent]?.children?.includes(name);
959
- if (!allowed)
960
- diagnostics.add("E_COMPONENT_CONTEXT", `${name} is not allowed inside ${parent}.`, node.position, name === "column" ? "Place column directives inside a table container." : "Place standalone components at the report root or inside a grid.");
961
- const opening = lines[(node.position?.start.line ?? 1) - 1] ?? "";
962
- const names = new Set;
963
- for (const match of opening.matchAll(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|([A-Za-z][\w-]*)\s*=/g)) {
964
- if (!match[1])
965
- continue;
966
- if (names.has(match[1]))
967
- diagnostics.add("E_PROP_DUPLICATE", `Duplicate property ${JSON.stringify(match[1])} on ${name}.`, node.position, "Specify each property once.");
968
- names.add(match[1]);
969
- }
970
- if (node.type === "leafDirective" && node.children?.length)
971
- diagnostics.add("E_COMPONENT_LABEL", `${name} does not accept a directive [label].`, node.position, "Use the label or title property instead.");
972
- const decoded = decodeProps(componentName, node.attributes ?? {});
973
- for (const message of decoded.errors)
974
- diagnostics.add("E_PROP_VALUE", message, node.position, `Run plotleaf schema for the ${name} contract.`);
975
- const children = blocks(node.children ?? [], name, depth + 1);
976
- if (decoded.props)
977
- output.push({
978
- type: "component",
979
- name: componentName,
980
- props: decoded.props,
981
- children,
982
- position: node.position
983
- });
984
- continue;
1209
+ async read(filename, maximum) {
1210
+ const bytes = await readBytesBounded(filename, Math.min(maximum, limits.totalBytes - this.usedBytes));
1211
+ this.usedBytes += bytes.length;
1212
+ return bytes;
1213
+ }
1214
+ }
1215
+
1216
+ // src/compiler/images.ts
1217
+ import { createHash } from "node:crypto";
1218
+ import path3 from "node:path";
1219
+ import { imageSize } from "image-size";
1220
+ function formatOf(bytes) {
1221
+ if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
1222
+ let offset = 8;
1223
+ let data = false;
1224
+ while (offset + 12 <= bytes.length) {
1225
+ const size = bytes.readUInt32BE(offset);
1226
+ const type = bytes.toString("ascii", offset + 4, offset + 8);
1227
+ if (offset + size + 12 > bytes.length)
1228
+ break;
1229
+ if (offset === 8 && (type !== "IHDR" || size !== 13))
1230
+ break;
1231
+ if (type === "acTL")
1232
+ throw new Error("Animated PNG is not supported; provide a static image.");
1233
+ if (type === "IDAT" && size > 0)
1234
+ data = true;
1235
+ offset += size + 12;
1236
+ if (type === "IEND") {
1237
+ if (data && size === 0 && offset === bytes.length)
1238
+ return "png";
1239
+ break;
985
1240
  }
986
- if (parent !== "root" && !registry[parent]?.children?.includes("markdown"))
987
- diagnostics.add("E_COMPONENT_CHILD", `${parent} only accepts its declared component children.`, node.position);
988
- const nestedDirectives = (candidate, nesting = 0) => {
989
- if (nesting > 64)
990
- return;
991
- if (candidate.type.endsWith("Directive"))
992
- diagnostics.add("E_COMPONENT_CONTEXT", "Components must be standalone blocks, not inline/list/blockquote content.", candidate.position);
993
- candidate.children?.forEach((child) => nestedDirectives(child, nesting + 1));
994
- };
995
- nestedDirectives(node);
996
- const last = output.at(-1);
997
- if (last?.type === "markdown")
998
- last.tree.children.push(node);
999
- else
1000
- output.push({
1001
- type: "markdown",
1002
- tree: { type: "root", children: [node] }
1003
- });
1004
1241
  }
1005
- return output;
1242
+ throw new Error("Incomplete or invalid PNG container.");
1006
1243
  }
1007
- const reportBlocks = blocks(tree.children);
1008
- function attachDefinitions(items) {
1009
- for (const block of items) {
1010
- if (block.type === "markdown") {
1011
- for (const [identifier, url] of definitions)
1012
- if (!block.tree.children.some((node) => node.type === "definition" && node.identifier === identifier))
1013
- block.tree.children.push({ type: "definition", identifier, url });
1014
- } else
1015
- attachDefinitions(block.children);
1016
- }
1244
+ if (bytes.length >= 4 && bytes.readUInt16BE(0) === 65496 && bytes.readUInt16BE(bytes.length - 2) === 65497)
1245
+ return "jpg";
1246
+ throw new Error("Expected a static PNG or JPEG image with a matching file extension.");
1247
+ }
1248
+
1249
+ class ImageResolver {
1250
+ files;
1251
+ attempted = new Set;
1252
+ filesRead = new Map;
1253
+ embedded = new Map;
1254
+ constructor(files) {
1255
+ this.files = files;
1017
1256
  }
1018
- attachDefinitions(reportBlocks);
1019
- for (let index = 0;index < lines.length; index++) {
1020
- if (/^\s*::/.test(lines[index] ?? "") && !recognized.has(index + 1) && !inert.has(index + 1)) {
1021
- const position = {
1022
- start: { line: index + 1, column: 1 },
1023
- end: { line: index + 1, column: 1 }
1024
- };
1025
- diagnostics.add("E_DIRECTIVE_SYNTAX", "Malformed or misplaced component directive.", position, 'Use ::name{key="literal"} on one line, or a balanced :::name container. JavaScript expressions are not supported.');
1257
+ async load(reference) {
1258
+ const requested = path3.resolve(this.files.root, reference);
1259
+ if (!this.attempted.has(requested) && this.attempted.size >= limits.imageFiles)
1260
+ throw new Error(`At most ${limits.imageFiles} local image references are supported.`);
1261
+ this.attempted.add(requested);
1262
+ const canonical = await this.files.resolve(reference, [
1263
+ ".png",
1264
+ ".jpg",
1265
+ ".jpeg"
1266
+ ]);
1267
+ const cached = this.filesRead.get(canonical);
1268
+ if (cached) {
1269
+ if (path3.extname(reference).toLowerCase() === ".png" !== (cached.mime === "image/png"))
1270
+ throw new Error("Image signature does not match its file extension.");
1271
+ return cached;
1272
+ }
1273
+ if (this.filesRead.size >= limits.imageFiles)
1274
+ throw new Error(`At most ${limits.imageFiles} local image files are supported.`);
1275
+ const bytes = await this.files.read(canonical, limits.imageBytes);
1276
+ const format = formatOf(bytes);
1277
+ const expected = (name) => path3.extname(name).toLowerCase() === ".png" ? "png" : "jpg";
1278
+ if (format !== expected(reference) || format !== expected(canonical))
1279
+ throw new Error("Image signature does not match its file extension.");
1280
+ let size;
1281
+ try {
1282
+ size = imageSize(bytes);
1283
+ } catch {
1284
+ throw new Error("Unsupported or damaged PNG/JPEG header.");
1026
1285
  }
1286
+ if (size.type !== format || !Number.isSafeInteger(size.width) || !Number.isSafeInteger(size.height) || size.width < 1 || size.height < 1)
1287
+ throw new Error("Image must have valid positive raster dimensions.");
1288
+ if (size.width > limits.imageDimension || size.height > limits.imageDimension || size.width * size.height > limits.imagePixels)
1289
+ throw new Error(`Image exceeds ${limits.imageDimension} pixels per side or ${limits.imagePixels.toLocaleString("en-GB")} pixels in total. Resize the prepared asset.`);
1290
+ const id = `plotleaf-image-${createHash("sha256").update(bytes).digest("hex")}`;
1291
+ const image = this.embedded.get(id) ?? {
1292
+ id,
1293
+ mime: format === "png" ? "image/png" : "image/jpeg",
1294
+ base64: bytes.toString("base64")
1295
+ };
1296
+ this.filesRead.set(canonical, image);
1297
+ this.embedded.set(id, image);
1298
+ return image;
1299
+ }
1300
+ }
1301
+
1302
+ // src/compiler/data.ts
1303
+ class DataResolver2 extends DataResolver {
1304
+ root;
1305
+ constructor(root, diagnostics, dependencies, totalBytes = 0) {
1306
+ const files = new ReportFiles(root, dependencies, totalBytes);
1307
+ super(diagnostics, files, new ImageResolver(files));
1308
+ this.root = root;
1027
1309
  }
1028
- return { metadata, blocks: reportBlocks };
1029
1310
  }
1030
1311
 
1031
1312
  // src/compiler/compile.ts
@@ -1047,7 +1328,7 @@ async function compileReport(input) {
1047
1328
  dependencies.add(filename);
1048
1329
  const { text, bytes } = await readBounded(filename, limits.markdownBytes);
1049
1330
  const report = parseReport(text, path4.basename(filename, path4.extname(filename)), diagnostics);
1050
- const resolver = new DataResolver(path4.dirname(filename), diagnostics, dependencies, bytes);
1331
+ const resolver = new DataResolver2(path4.dirname(filename), diagnostics, dependencies, bytes);
1051
1332
  await resolver.resolve(report.blocks);
1052
1333
  if (resolver.images.embedded.size)
1053
1334
  report.images = [...resolver.images.embedded.values()];
@@ -1061,12 +1342,6 @@ async function compileReport(input) {
1061
1342
  // src/render/html.ts
1062
1343
  import { createHash as createHash2 } from "node:crypto";
1063
1344
 
1064
- // src/render/content.ts
1065
- import { unified as unified2 } from "unified";
1066
- import remarkRehype from "remark-rehype";
1067
- import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
1068
- import rehypeStringify from "rehype-stringify";
1069
-
1070
1345
  // src/render/tree.ts
1071
1346
  var t = (value) => ({
1072
1347
  type: "text",
@@ -1074,6 +1349,122 @@ var t = (value) => ({
1074
1349
  });
1075
1350
  var h = (tagName, properties = {}, children = []) => ({ type: "element", tagName, properties, children });
1076
1351
 
1352
+ // src/render/exploration.ts
1353
+ function exploration(block, id, render, configs) {
1354
+ const data = block.exploration;
1355
+ if (!data)
1356
+ return [];
1357
+ configs.push({
1358
+ id,
1359
+ inputs: data.inputs,
1360
+ views: data.views.map(({ id, label, when }) => ({ id, label, when }))
1361
+ });
1362
+ const controls = data.inputs.map((input, inputIndex) => {
1363
+ const controlId = `${id}-input-${inputIndex}`;
1364
+ const attrs = {
1365
+ disabled: true,
1366
+ dataPlotleafInput: input.id,
1367
+ id: controlId
1368
+ };
1369
+ if (input.type === "segmented")
1370
+ return h("fieldset", { className: ["explore-segmented"] }, [
1371
+ h("legend", {}, [t(input.label)]),
1372
+ h("div", { className: ["explore-segment-options"] }, input.options.map((option, index) => h("label", {}, [
1373
+ h("input", {
1374
+ ...attrs,
1375
+ id: `${controlId}-${index}`,
1376
+ type: "radio",
1377
+ name: controlId,
1378
+ value: String(index),
1379
+ checked: valueKey(option.value) === valueKey(input.default)
1380
+ }),
1381
+ t(option.label)
1382
+ ])))
1383
+ ]);
1384
+ if (input.type === "date-range")
1385
+ return h("fieldset", { className: ["explore-dates"] }, [
1386
+ h("legend", {}, [t(input.label)]),
1387
+ ...["start", "end"].map((part) => h("label", {}, [
1388
+ t(part === "start" ? "From" : "Through"),
1389
+ h("input", {
1390
+ ...attrs,
1391
+ id: `${controlId}-${part}`,
1392
+ type: "date",
1393
+ dataRangePart: part,
1394
+ ariaDescribedBy: [`${controlId}-ranges`],
1395
+ value: input.default[part]
1396
+ })
1397
+ ]))
1398
+ ]);
1399
+ if (input.type === "toggle")
1400
+ return h("label", { className: ["explore-toggle"] }, [
1401
+ h("input", { ...attrs, type: "checkbox", checked: input.default }),
1402
+ t(input.label)
1403
+ ]);
1404
+ if (input.type === "select")
1405
+ return h("div", { className: ["explore-field"] }, [
1406
+ h("label", { htmlFor: [controlId] }, [t(input.label)]),
1407
+ h("select", attrs, input.options.map((option, index) => h("option", {
1408
+ value: String(index),
1409
+ selected: valueKey(option.value) === valueKey(input.default)
1410
+ }, [t(option.label)])))
1411
+ ]);
1412
+ return h("div", { className: ["explore-field"] }, [
1413
+ h("label", { htmlFor: [controlId] }, [t(input.label)]),
1414
+ h("input", {
1415
+ ...attrs,
1416
+ type: input.type === "number" ? "number" : "text",
1417
+ value: String(input.default),
1418
+ ...input.type === "number" ? { step: "any" } : {},
1419
+ list: `${controlId}-choices`
1420
+ }),
1421
+ h("datalist", { id: `${controlId}-choices` }, input.options.map((option) => h("option", { value: String(option.value) }, [t(option.label)])))
1422
+ ]);
1423
+ });
1424
+ const selected = data.views.find((view) => view.id === data.defaultView);
1425
+ return [
1426
+ h("section", {
1427
+ className: ["exploration"],
1428
+ dataPlotleafExplore: id,
1429
+ ariaLabel: block.props.title
1430
+ }, [
1431
+ h("h2", { id: `${id}-title` }, [t(block.props.title)]),
1432
+ h("div", { className: ["explore-controls"] }, [
1433
+ ...controls,
1434
+ h("button", { type: "button", disabled: true, dataPlotleafReset: true }, [t("Reset")])
1435
+ ]),
1436
+ ...data.inputs.flatMap((input, inputIndex) => input.type === "date-range" ? [
1437
+ h("p", {
1438
+ id: `${id}-input-${inputIndex}-ranges`,
1439
+ className: ["explore-supported"]
1440
+ }, [
1441
+ t(`${input.label} — prepared ranges: ${input.options.map((option) => option.label).join(" · ")}`)
1442
+ ])
1443
+ ] : []),
1444
+ h("p", {
1445
+ className: ["explore-status"],
1446
+ dataPlotleafStatus: true,
1447
+ role: "status",
1448
+ ariaLive: "polite"
1449
+ }, [t(`Showing ${selected.label} · Prepared view`)]),
1450
+ h("noscript", {}, [
1451
+ t("JavaScript is disabled. The default prepared view is shown; controls are unavailable.")
1452
+ ]),
1453
+ ...data.views.map((view) => h("section", {
1454
+ dataPlotleafView: view.id,
1455
+ ariaLabel: view.label,
1456
+ hidden: view.id !== data.defaultView
1457
+ }, render(view.blocks, `${id}-${view.id}-`)))
1458
+ ])
1459
+ ];
1460
+ }
1461
+
1462
+ // src/render/content.ts
1463
+ import { unified as unified2 } from "unified";
1464
+ import remarkRehype from "remark-rehype";
1465
+ import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
1466
+ import rehypeStringify from "rehype-stringify";
1467
+
1077
1468
  // src/shared/format.ts
1078
1469
  var numbers = new Map;
1079
1470
  function formatValue(value, format = "number", sign = "auto", compact = false) {
@@ -1213,6 +1604,14 @@ var serializer = unified2().use(rehypeSanitize, {
1213
1604
  clobberPrefix: "",
1214
1605
  tagNames: [
1215
1606
  ...defaultSchema.tagNames ?? [],
1607
+ "select",
1608
+ "option",
1609
+ "input",
1610
+ "label",
1611
+ "fieldset",
1612
+ "legend",
1613
+ "datalist",
1614
+ "noscript",
1216
1615
  "figure",
1217
1616
  "figcaption",
1218
1617
  "button",
@@ -1234,6 +1633,11 @@ var serializer = unified2().use(rehypeSanitize, {
1234
1633
  "ariaLabelledBy",
1235
1634
  "ariaHidden",
1236
1635
  "ariaSort",
1636
+ "hidden",
1637
+ "ariaLive",
1638
+ "dataPlotleafExplore",
1639
+ "dataPlotleafView",
1640
+ "dataPlotleafStatus",
1237
1641
  "dataPlotleafChart",
1238
1642
  "dataPlotleafTable",
1239
1643
  "dataColumn",
@@ -1244,8 +1648,36 @@ var serializer = unified2().use(rehypeSanitize, {
1244
1648
  "dataStatus",
1245
1649
  "dataSlice"
1246
1650
  ],
1247
- button: ["type", "disabled", "ariaLabel", "dataColumn"],
1248
- section: ["className", "ariaLabel"],
1651
+ input: [
1652
+ "id",
1653
+ "type",
1654
+ "name",
1655
+ "value",
1656
+ "checked",
1657
+ "disabled",
1658
+ "step",
1659
+ "list",
1660
+ "dataPlotleafInput",
1661
+ "dataRangePart",
1662
+ "ariaDescribedBy"
1663
+ ],
1664
+ select: ["id", "disabled", "dataPlotleafInput"],
1665
+ option: ["value", "selected"],
1666
+ label: ["htmlFor", "className"],
1667
+ button: [
1668
+ "type",
1669
+ "disabled",
1670
+ "ariaLabel",
1671
+ "dataColumn",
1672
+ "dataPlotleafReset"
1673
+ ],
1674
+ section: [
1675
+ "className",
1676
+ "ariaLabel",
1677
+ "dataPlotleafExplore",
1678
+ "dataPlotleafView",
1679
+ "hidden"
1680
+ ],
1249
1681
  meter: ["min", "max", "value", "className", "ariaLabel"],
1250
1682
  svg: ["className", "viewBox", "width", "height", "role", "ariaLabel"],
1251
1683
  use: ["href"],
@@ -1258,7 +1690,7 @@ var serializer = unified2().use(rehypeSanitize, {
1258
1690
  td: ["className", "dataRaw"]
1259
1691
  }
1260
1692
  }).use(rehypeStringify);
1261
- function label(field) {
1693
+ function label2(field) {
1262
1694
  const value = field.replaceAll("_", " ");
1263
1695
  return value.charAt(0).toUpperCase() + value.slice(1);
1264
1696
  }
@@ -1274,7 +1706,7 @@ function table(rows, columns, title, id, sortable = true) {
1274
1706
  const numeric = columns.map((column) => column.kind === "sparkline" || rows.some((row) => typeof row[column.field] === "number") || Boolean(column.format));
1275
1707
  const caption = h("caption", {}, [t(title)]);
1276
1708
  const headers = columns.map((column, index) => {
1277
- const name = column.label ?? label(column.field);
1709
+ const name = column.label ?? label2(column.field);
1278
1710
  const content = canSort[index] ? h("button", {
1279
1711
  type: "button",
1280
1712
  disabled: true,
@@ -1335,13 +1767,13 @@ function renderContent(report) {
1335
1767
  headings.set(slug, count + 1);
1336
1768
  return `plotleaf-heading-${slug}${count ? `-${count + 1}` : ""}`;
1337
1769
  };
1338
- function decorate(node) {
1770
+ function decorate(node, namespace = "") {
1339
1771
  if (node.type !== "element")
1340
1772
  return;
1341
1773
  if (/^h[1-6]$/.test(node.tagName)) {
1342
1774
  const text = (child) => child.type === "text" ? child.value : child.children.map((n) => text(n)).join("");
1343
- node.properties.id = headingId(text(node));
1344
- if (node.tagName === "h1")
1775
+ node.properties.id = headingId(namespace + text(node));
1776
+ if (node.tagName === "h1" && !namespace)
1345
1777
  hasH1 = true;
1346
1778
  }
1347
1779
  if (node.tagName === "a" && typeof node.properties.href === "string") {
@@ -1350,22 +1782,24 @@ function renderContent(report) {
1350
1782
  try {
1351
1783
  fragment = decodeURIComponent(fragment);
1352
1784
  } catch {}
1353
- node.properties.href = `#plotleaf-heading-${slugOf(fragment)}`;
1785
+ node.properties.href = `#plotleaf-heading-${slugOf(namespace + fragment)}`;
1354
1786
  }
1355
1787
  if (/^https?:/.test(node.properties.href))
1356
1788
  node.properties.rel = ["noopener", "noreferrer"];
1357
1789
  }
1358
- node.children.forEach((child) => decorate(child));
1790
+ node.children.forEach((child) => decorate(child, namespace));
1359
1791
  }
1360
- function render(blocks) {
1792
+ function render(blocks, namespace = "") {
1361
1793
  return blocks.flatMap((block) => {
1362
1794
  if (block.type === "markdown") {
1363
1795
  const root = markdown.runSync(block.tree);
1364
- root.children.forEach((child) => decorate(child));
1796
+ root.children.forEach((child) => decorate(child, namespace));
1365
1797
  return root.children;
1366
1798
  }
1367
1799
  const id = `plotleaf-component-${String(++sequence).padStart(4, "0")}`;
1368
1800
  switch (block.name) {
1801
+ case "explore":
1802
+ return exploration(block, id, render, payload.explorations ??= []);
1369
1803
  case "grid":
1370
1804
  return [
1371
1805
  h("div", {
@@ -1374,7 +1808,7 @@ function renderContent(report) {
1374
1808
  ...block.children.length > 0 && block.children.every((child) => child.type === "component" && child.name === "metric") ? ["metric-grid"] : []
1375
1809
  ],
1376
1810
  dataColumns: block.props.columns
1377
- }, render(block.children))
1811
+ }, render(block.children, namespace))
1378
1812
  ];
1379
1813
  case "metric": {
1380
1814
  const props = block.props;
@@ -1411,7 +1845,7 @@ function renderContent(report) {
1411
1845
  t(block.props.title)
1412
1846
  ])
1413
1847
  ] : [],
1414
- ...render(block.children)
1848
+ ...render(block.children, namespace)
1415
1849
  ])
1416
1850
  ];
1417
1851
  case "table":
@@ -1440,17 +1874,17 @@ function renderContent(report) {
1440
1874
  const { stage, value, total, share, status, title, format } = block.props;
1441
1875
  const rows = block.rows ?? [];
1442
1876
  const columns = [
1443
- { field: stage, label: label(stage), sign: "auto", semantics: "neutral" },
1877
+ { field: stage, label: label2(stage), sign: "auto", semantics: "neutral" },
1444
1878
  {
1445
1879
  field: value,
1446
- label: label(value),
1880
+ label: label2(value),
1447
1881
  format,
1448
1882
  sign: "auto",
1449
1883
  semantics: "neutral"
1450
1884
  },
1451
1885
  {
1452
1886
  field: total,
1453
- label: label(total),
1887
+ label: label2(total),
1454
1888
  format,
1455
1889
  sign: "auto",
1456
1890
  semantics: "neutral"
@@ -1570,13 +2004,13 @@ function renderContent(report) {
1570
2004
  dataDisclosure(rows, [
1571
2005
  {
1572
2006
  field: category,
1573
- label: label(category),
2007
+ label: label2(category),
1574
2008
  sign: "auto",
1575
2009
  semantics: "neutral"
1576
2010
  },
1577
2011
  {
1578
2012
  field: value,
1579
- label: label(value),
2013
+ label: label2(value),
1580
2014
  format,
1581
2015
  sign: "auto",
1582
2016
  semantics: "neutral"
@@ -1611,8 +2045,8 @@ function renderContent(report) {
1611
2045
  payload.charts.push(chart);
1612
2046
  }
1613
2047
  const columns = [
1614
- { field: x, label: label(x), sign: "auto", semantics: "neutral" },
1615
- { field: y, label: label(y), format, sign: "auto", semantics: "neutral" }
2048
+ { field: x, label: label2(x), sign: "auto", semantics: "neutral" },
2049
+ { field: y, label: label2(y), format, sign: "auto", semantics: "neutral" }
1616
2050
  ];
1617
2051
  return [
1618
2052
  h("figure", { className: ["chart"] }, [
@@ -1645,26 +2079,35 @@ function renderContent(report) {
1645
2079
  };
1646
2080
  }
1647
2081
 
1648
- // src/render/html.ts
2082
+ // src/render/document.ts
1649
2083
  var escapeHtml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
1650
2084
  var inlineScript = (value) => value.replace(/<\/script/gi, "<\\/script").replaceAll("<!--", "\\x3c!--");
1651
2085
  var reload = (revision) => `(()=>{const source=new EventSource("/__plotleaf/events");source.addEventListener("ready",event=>{if(Number(event.data)!==${revision})location.reload()});source.addEventListener("reload",()=>location.reload());addEventListener("pagehide",()=>source.close(),{once:true});})();`;
1652
- function documentHtml(title, description, body, styles, scripts, preview, payload, notices = "", revision = 0) {
2086
+ function documentHtml(title, description, body, styles, scripts, preview, hash, payload, notices = "", revision = 0) {
1653
2087
  const code = [...scripts, ...preview ? [reload(revision)] : []].map(inlineScript);
1654
- const hashes = code.map((script) => `'sha256-${createHash2("sha256").update(script).digest("base64")}'`).join(" ");
2088
+ const hashes = code.map((script) => `'sha256-${hash(script)}'`).join(" ");
1655
2089
  const policy = `default-src 'none'; script-src ${hashes || "'none'"}; style-src 'unsafe-inline'; img-src data:; font-src data:; connect-src ${preview ? "'self'" : "'none'"}; base-uri 'none'; object-src 'none'; form-action 'none'`;
1656
2090
  return `<!doctype html>
1657
2091
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="${escapeHtml(policy)}"><meta name="color-scheme" content="light dark"><title>${escapeHtml(title)}</title>${description ? `<meta name="description" content="${escapeHtml(description)}">` : ""}<style>${styles}</style></head><body><a class="skip-link" href="#plotleaf-report">Skip to report</a><main class="report" id="plotleaf-report">${body}</main>${payload ? `<script type="application/json" id="plotleaf-data">${payload}</script>` : ""}${notices ? `<template id="plotleaf-notices"><pre>${escapeHtml(notices)}</pre></template>` : ""}${code.map((script) => `<script>${script}</script>`).join("")}</body></html>
1658
2092
  `;
1659
2093
  }
1660
- function renderHtml(report, assets, preview = false, revision = 0) {
2094
+ function renderHtml(report, assets, hash, preview = false, revision = 0) {
1661
2095
  const content = renderContent(report);
1662
2096
  const images = report.images?.length ? `<svg class="image-definitions" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><defs>${report.images.map((image) => `<symbol id="${escapeHtml(image.id)}" viewBox="0 0 64 48"><image href="data:${image.mime};base64,${image.base64}" width="64" height="48" preserveAspectRatio="xMidYMid meet"/></symbol>`).join("")}</defs></svg>` : "";
1663
- return documentHtml(report.metadata.title, report.metadata.description, images + content.html, assets.styles, [content.hasCharts ? assets.charts : assets.base], preview, content.hasCharts ? serializeData(content.payload) : undefined, content.hasCharts ? assets.notices : "", revision);
2097
+ return documentHtml(report.metadata.title, report.metadata.description, images + content.html, assets.styles, [content.hasCharts ? assets.charts : assets.base], preview, hash, content.hasCharts || content.payload.explorations?.length ? serializeData(content.payload) : undefined, content.hasCharts ? assets.notices : "", revision);
1664
2098
  }
1665
- function renderDiagnostics(diagnostics, assets, preview = true, revision = 0) {
2099
+ function renderDiagnostics(diagnostics, assets, hash, preview = true, revision = 0) {
1666
2100
  const body = `<h1>Report needs attention</h1><p class="diagnostic-intro">Update the source files to refresh this preview.</p>${diagnostics.map((item) => `<section class="diagnostic"><p class="diagnostic-location">${escapeHtml(item.file)}:${item.line}:${item.column} · ${escapeHtml(item.code)}</p><p>${escapeHtml(item.message)}</p>${item.hint ? `<p class="diagnostic-hint">${escapeHtml(item.hint)}</p>` : ""}</section>`).join("")}`;
1667
- return documentHtml("Report needs attention", undefined, body, assets.styles, [], preview, undefined, "", revision);
2101
+ return documentHtml("Report needs attention", undefined, body, assets.styles, [], preview, hash, undefined, "", revision);
2102
+ }
2103
+
2104
+ // src/render/html.ts
2105
+ var hash = (script) => createHash2("sha256").update(script).digest("base64");
2106
+ function renderHtml2(report, assets, preview = false, revision = 0) {
2107
+ return renderHtml(report, assets, hash, preview, revision);
2108
+ }
2109
+ function renderDiagnostics2(diagnostics, assets, preview = true, revision = 0) {
2110
+ return renderDiagnostics(diagnostics, assets, hash, preview, revision);
1668
2111
  }
1669
2112
 
1670
2113
  // src/export.ts
@@ -1749,7 +2192,7 @@ async function startPreview(filename, assets, options = {}) {
1749
2192
  const log = options.log ?? console.log;
1750
2193
  let compilation = options.initial ?? await compileReport(filename);
1751
2194
  let revision = 0;
1752
- const render = () => compilation.report ? renderHtml(compilation.report, assets, true, revision) : renderDiagnostics(compilation.diagnostics, assets, true, revision);
2195
+ const render = () => compilation.report ? renderHtml2(compilation.report, assets, true, revision) : renderDiagnostics2(compilation.diagnostics, assets, true, revision);
1753
2196
  let html = render();
1754
2197
  let closed = false;
1755
2198
  let running = false;
@@ -1892,7 +2335,7 @@ data: ${revision}
1892
2335
  // package.json
1893
2336
  var package_default = {
1894
2337
  name: "plotleaf",
1895
- version: "0.3.1",
2338
+ version: "0.4.0",
1896
2339
  description: "Beautiful, interactive reports from Markdown and JSON. Built for agents.",
1897
2340
  type: "module",
1898
2341
  license: "MIT",
@@ -1900,7 +2343,7 @@ var package_default = {
1900
2343
  type: "git",
1901
2344
  url: "git+https://github.com/LoueeD/plotleaf.git"
1902
2345
  },
1903
- homepage: "https://github.com/LoueeD/plotleaf#readme",
2346
+ homepage: "https://plotleaf-site.loueed.workers.dev",
1904
2347
  bugs: {
1905
2348
  url: "https://github.com/LoueeD/plotleaf/issues"
1906
2349
  },
@@ -1919,6 +2362,8 @@ var package_default = {
1919
2362
  "dist/assets/notices.txt",
1920
2363
  "schema/components.json",
1921
2364
  "examples/report.md",
2365
+ "examples/interactions/*.md",
2366
+ "examples/interactions/*.json",
1922
2367
  "examples/storefront/*.md",
1923
2368
  "examples/storefront/*.json",
1924
2369
  "examples/adoption/*.md",
@@ -2077,7 +2522,7 @@ async function main() {
2077
2522
  process.exitCode = 1;
2078
2523
  return;
2079
2524
  }
2080
- const html = renderHtml(result.report, await assets());
2525
+ const html = renderHtml2(result.report, await assets());
2081
2526
  const defaultOutput = result.filename.slice(0, -path6.extname(result.filename).length) + ".html";
2082
2527
  const destination = await writeReport(values.out ?? defaultOutput, html, result.dependencies);
2083
2528
  console.log(`Built ${displayPath(destination)} (${(Buffer.byteLength(html) / 1024).toFixed(1)} KiB)`);