plotleaf 0.3.2 → 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
@@ -65,39 +65,85 @@ function displayPath(filename) {
65
65
  return path.relative(process.cwd(), filename) || path.basename(filename);
66
66
  }
67
67
 
68
- // src/compiler/sources.ts
69
- class LocalFileError extends Error {
70
- kind;
71
- constructor(kind, message) {
72
- super(message);
73
- this.kind = kind;
74
- }
75
- }
76
-
77
- // src/shared/date.ts
78
- function parseDate(value) {
79
- const date = value.slice(0, 10);
80
- if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
81
- return;
82
- const day = Date.parse(`${date}T00:00:00.000Z`);
83
- if (!Number.isFinite(day) || new Date(day).toISOString().slice(0, 10) !== date)
84
- return;
85
- if (value.length === 10)
86
- return day;
87
- 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))
88
- return;
89
- const timestamp = Date.parse(value);
90
- return Number.isFinite(timestamp) ? timestamp : undefined;
91
- }
68
+ // src/compiler/exploration-schema.ts
69
+ import * as z from "zod";
92
70
 
93
- // src/shared/payload.ts
94
- var funnelStatuses = {
95
- "on-target": "On target",
96
- "below-target": "Below target",
97
- "at-risk": "At risk",
98
- neutral: "Neutral"
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
99
84
  };
100
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";
146
+
101
147
  // src/shared/limits.ts
102
148
  var limits = {
103
149
  markdownBytes: 1024 ** 2,
@@ -117,927 +163,1150 @@ var limits = {
117
163
  imagePixels: 4000000
118
164
  };
119
165
 
120
- // src/compiler/resolve-data.ts
121
- var reserved = new Set(["__proto__", "constructor", "prototype"]);
166
+ // src/shared/payload.ts
167
+ var funnelStatuses = {
168
+ "on-target": "On target",
169
+ "below-target": "Below target",
170
+ "at-risk": "At risk",
171
+ neutral: "Neutral"
172
+ };
122
173
 
123
- class DataResolver {
124
- diagnostics;
125
- files;
126
- images;
127
- cache = new Map;
128
- constructor(diagnostics, files, images) {
129
- this.diagnostics = diagnostics;
130
- this.files = files;
131
- this.images = images;
132
- }
133
- async load(reference, block) {
134
- const fail = (code, message, hint) => {
135
- this.diagnostics.add(code, `${reference}: ${message}`, block.position, hint);
136
- return;
137
- };
138
- let canonical;
139
- try {
140
- canonical = await this.files.resolve(reference, [".json"]);
141
- } catch (error) {
142
- 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.");
143
- }
144
- let dataset = this.cache.get(canonical);
145
- if (!dataset) {
146
- if (this.cache.size >= limits.datasets)
147
- return fail("E_DATA_LIMIT", `At most ${limits.datasets} datasets are supported.`);
148
- let text;
149
- try {
150
- text = new TextDecoder("utf-8", { fatal: true }).decode(await this.files.read(canonical, limits.jsonBytes));
151
- } catch (error) {
152
- return fail("E_DATA_READ", error instanceof Error ? error.message : "Could not read data.");
153
- }
154
- let parsed;
155
- try {
156
- parsed = JSON.parse(text);
157
- } catch {
158
- return fail("E_DATA_JSON", "Invalid JSON.", "Use a JSON array of records, without comments or trailing commas.");
159
- }
160
- if (!Array.isArray(parsed))
161
- return fail("E_DATA_SHAPE", "Expected an array of flat JSON records.");
162
- if (parsed.length > limits.rows)
163
- return fail("E_DATA_LIMIT", `Dataset exceeds ${limits.rows.toLocaleString("en-GB")} rows.`, "Prepare a smaller dataset before rendering.");
164
- const arrays = new Map;
165
- for (const [index, row] of parsed.entries()) {
166
- if (!row || typeof row !== "object" || Array.isArray(row))
167
- return fail("E_DATA_SHAPE", `Row ${index + 1} is not a record.`);
168
- for (const [key, value] of Object.entries(row)) {
169
- if (reserved.has(key))
170
- return fail("E_DATA_KEY", `Row ${index + 1} contains the reserved key ${JSON.stringify(key)}.`);
171
- if (Array.isArray(value)) {
172
- if (value.length > limits.sparklinePoints)
173
- return fail("E_DATA_LIMIT", `Row ${index + 1}, field ${JSON.stringify(key)} exceeds ${limits.sparklinePoints} sparkline points.`, "Prepare a bounded sequence; values are never truncated.");
174
- if (value.some((item) => item !== null && !(typeof item === "number" && Number.isFinite(item))))
175
- return fail("E_DATA_SHAPE", `Row ${index + 1}, field ${JSON.stringify(key)} must contain only finite numbers or null gaps.`, "Only declared sparkline columns support sequences; nested objects and sequences are invalid.");
176
- if (!arrays.has(key))
177
- arrays.set(key, index);
178
- } else if (!(value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)))
179
- return fail("E_DATA_SHAPE", `Row ${index + 1}, field ${JSON.stringify(key)} must contain a finite number, string, boolean or null.`, "Prepare flat scalar records; use an explicit sparkline column for bounded numeric/null sequences.");
180
- }
181
- }
182
- dataset = { rows: parsed, arrays };
183
- this.cache.set(canonical, dataset);
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."
184
330
  }
185
- const declared = new Set(block.name === "table" ? block.children.flatMap((child) => child.type === "component" && child.name === "column" && child.props.kind === "sparkline" ? [child.props.field] : []) : []);
186
- for (const [field, index] of dataset.arrays)
187
- if (!declared.has(field))
188
- return fail("E_DATA_SHAPE", `Row ${index + 1}, field ${JSON.stringify(field)} is a sequence without an explicit sparkline column.`, 'Use a table column with kind="sparkline" for that field. Charts and scalar tables require flat scalar datasets.');
189
- return dataset.rows;
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
+ :::`
190
343
  }
191
- async resolve(blocks) {
192
- for (const block of blocks) {
193
- if (block.type !== "component")
194
- continue;
195
- if (block.name === "line" || block.name === "area" || block.name === "bar" || block.name === "donut" || block.name === "funnel" || block.name === "table") {
196
- const rows = await this.load(block.props.data, block);
197
- if (rows) {
198
- if (!rows.length)
199
- this.diagnostics.add("W_DATA_EMPTY", `${block.props.data} is empty; a no-data state will be shown.`, block.position, "Field validation requires at least one record.", "warning");
200
- if (block.name === "table")
201
- await this.resolveTable(block, rows);
202
- else if (block.name === "donut" || block.name === "funnel")
203
- this.resolveParts(block, rows);
204
- else
205
- this.resolveChart(block, rows);
206
- }
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 } : {}
207
369
  }
208
- await this.resolve(block.children);
209
- }
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;
210
398
  }
211
- field(rows, name, block) {
212
- const index = rows.findIndex((row) => !Object.hasOwn(row, name));
213
- if (index < 0)
214
- return true;
215
- const available = Object.keys(rows[index] ?? {});
216
- this.diagnostics.add("E_DATA_FIELD", `${block.name} references missing field ${JSON.stringify(name)} in ${block.props && "data" in block.props ? block.props.data : "dataset"}, row ${index + 1}.`, block.position, `Available fields: ${available.slice(0, 12).join(", ") || "none"}${available.length > 12 ? ` … (${available.length - 12} more)` : ""}.`);
217
- return false;
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.");
218
421
  }
219
- resolveChart(block, rows) {
220
- const { x, y } = block.props;
221
- if (rows.length > limits.chartRows) {
222
- this.diagnostics.add("E_DATA_LIMIT", `Chart exceeds ${limits.chartRows} points/categories.`, block.position, "Prepare a smaller chart aggregate.");
223
- return;
224
- }
225
- if (!this.field(rows, x, block) || !this.field(rows, y, block))
226
- return;
227
- const kind = block.name === "bar" ? "category" : block.props.xType;
228
- const seen = new Set;
229
- let previous = -Infinity;
230
- for (const [index, row] of rows.entries()) {
231
- const xv = row[x];
232
- const yv = row[y];
233
- const validY = typeof yv === "number" && Number.isFinite(yv) || block.name !== "bar" && yv === null;
234
- let normalized;
235
- if (kind === "category" && (typeof xv === "string" || typeof xv === "number"))
236
- normalized = String(xv);
237
- if (kind === "number" && typeof xv === "number")
238
- normalized = xv;
239
- if (kind === "date" && typeof xv === "string")
240
- normalized = parseDate(xv);
241
- if (normalized === undefined || !validY) {
242
- this.diagnostics.add("E_DATA_TYPE", `${block.props.data}, row ${index + 1}: ${JSON.stringify(x)} must be ${kind === "date" ? "an ISO date or zoned timestamp" : `a ${kind === "number" ? "number" : "string or number category"}`} and ${JSON.stringify(y)} must be ${block.name === "bar" ? "a number" : "a number or null"}.`, block.position);
243
- return;
244
- }
245
- if (seen.has(normalized) || typeof normalized === "number" && kind !== "category" && normalized <= previous) {
246
- this.diagnostics.add("E_DATA_ORDER", `${block.props.data}, row ${index + 1}: x values must be unique${kind !== "category" ? " and increasing" : ""}.`, block.position, "Sort or aggregate the prepared dataset; plotleaf does not silently change chart data.");
247
- return;
248
- }
249
- seen.add(normalized);
250
- if (typeof normalized === "number")
251
- previous = normalized;
252
- }
253
- block.rows = rows.map((row) => Object.fromEntries([...new Set([x, y])].map((key) => [key, row[key]])));
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.`);
254
426
  }
255
- resolveParts(block, rows) {
256
- const props = block.props;
257
- const maximum = block.name === "donut" ? limits.donutSlices : limits.funnelStages;
258
- const fields = block.name === "donut" ? [block.props.category, props.value] : [
259
- block.props.stage,
260
- props.value,
261
- block.props.total,
262
- block.props.share,
263
- ...block.props.status ? [block.props.status] : []
264
- ];
265
- const fail = (code, message, hint) => {
266
- this.diagnostics.add(code, `${props.data}: ${message}`, block.position, hint);
267
- };
268
- if (rows.length > maximum) {
269
- fail("E_DATA_LIMIT", `${block.name} supports at most ${maximum} rows.`, "Prepare a smaller summary; plotleaf does not group or truncate data.");
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);
270
458
  return;
271
459
  }
272
- if (fields.some((field) => !this.field(rows, field, block)))
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);
273
463
  return;
274
- const seen = new Set;
275
- let sum = 0;
276
- for (const [index, row] of rows.entries()) {
277
- const name = row[fields[0]];
278
- const value = row[props.value];
279
- if (typeof name !== "string" || !name.trim() || name.length > 1000) {
280
- fail("E_DATA_TYPE", `Row ${index + 1}: ${JSON.stringify(fields[0])} must be a nonblank string of at most 1000 characters.`, "Supply a short, explicit label for every category or stage.");
281
- return;
282
- }
283
- if (seen.has(name)) {
284
- fail("E_DATA_ORDER", `Row ${index + 1}: category/stage labels must be unique.`, "Prepare distinct labels; source order is preserved.");
285
- return;
286
- }
287
- seen.add(name);
288
- if (typeof value !== "number" || value < 0) {
289
- fail("E_DATA_TYPE", `Row ${index + 1}: ${JSON.stringify(props.value)} must be a nonnegative number.`, "Use finite JSON numbers, not nulls or numeric strings.");
290
- return;
291
- }
292
- if (block.name === "donut") {
293
- sum += value;
294
- if (!Number.isFinite(sum)) {
295
- fail("E_DATA_VALUE", "The donut total exceeds the finite numeric range.", "Prepare values in smaller units before rendering.");
296
- return;
297
- }
298
- } else {
299
- const total = row[block.props.total];
300
- const share = row[block.props.share];
301
- if (typeof total !== "number" || total <= 0 || value > total || typeof share !== "number" || share < 0 || share > 1) {
302
- fail("E_DATA_VALUE", `Row ${index + 1}: total must be positive, value must not exceed total, and share must be a number from 0 to 1.`, "Supply each stage's denominator and prepared ratio explicitly; use an empty array when no baseline exists.");
303
- return;
304
- }
305
- if (Math.abs(share - value / total) > 0.0005 + Number.EPSILON) {
306
- fail("E_DATA_VALUE", `Row ${index + 1}: share does not match value / total.`, "Prepare a ratio consistent with the displayed values; rounding to 0.1 percentage point is supported.");
307
- return;
308
- }
309
- if (block.props.status) {
310
- const status = row[block.props.status];
311
- if (typeof status !== "string" || !Object.hasOwn(funnelStatuses, status)) {
312
- fail("E_DATA_VALUE", `Row ${index + 1}: status must be ${Object.keys(funnelStatuses).join(", ")}.`, "Supply business status explicitly, or omit the status property for neutral styling.");
313
- return;
314
- }
315
- }
316
- }
317
464
  }
318
- block.rows = rows.map((row) => Object.fromEntries(fields.map((field) => [field, row[field]])));
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));
319
481
  }
320
- async resolveTable(block, rows) {
321
- if (rows.length > limits.tableRows) {
322
- this.diagnostics.add("E_DATA_LIMIT", `Table exceeds ${limits.tableRows} rows.`, block.position, "Provide a prepared summary table; rows are never silently truncated.");
323
- return;
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 [];
324
487
  }
325
- const inferred = Object.keys(rows[0] ?? {});
326
- const explicit = block.children.filter((child) => child.type === "component" && child.name === "column");
327
- const columns = explicit.length ? explicit.map((column) => column.props) : inferred.map((field) => ({
328
- field,
329
- sign: "auto",
330
- semantics: "neutral"
331
- }));
332
- if (columns.length > limits.tableColumns) {
333
- this.diagnostics.add("E_DATA_LIMIT", `Table exceeds ${limits.tableColumns} columns.`, block.position, "Declare a smaller selection of column fields.");
334
- return;
335
- }
336
- if (new Set(columns.map((column) => column.field)).size !== columns.length) {
337
- this.diagnostics.add("E_TABLE_COLUMN", "A table cannot declare the same column field twice.", block.position);
338
- return;
339
- }
340
- for (const [index, row] of rows.entries())
341
- if (Object.keys(row).length !== inferred.length || inferred.some((field) => !Object.hasOwn(row, field))) {
342
- this.diagnostics.add("E_DATA_SHAPE", `${block.props.data}, row ${index + 1}: table records must have consistent keys.`, block.position);
343
- return;
344
- }
345
- const prepared = rows.map(() => ({}));
346
- for (const column of columns) {
347
- const origin = explicit.find((child) => child.props.field === column.field) ?? block;
348
- const fields = [
349
- column.field,
350
- ...column.alt ? [column.alt] : [],
351
- ...column.sortBy ? [column.sortBy] : []
352
- ];
353
- if (fields.some((field) => !this.field(rows, field, origin)))
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
+ }
354
510
  continue;
355
- const fail = (code, row, message, hint) => this.diagnostics.add(code, `${block.props.data}, row ${row + 1}, field ${JSON.stringify(column.field)}: ${message}`, origin.position, hint);
356
- const kind = column.kind ?? "scalar";
357
- if (kind === "scalar") {
358
- const types = new Set(rows.map((row) => row[column.field]).filter((value) => value !== null).map((value) => typeof value));
359
- if (types.size > 1)
360
- this.diagnostics.add("E_DATA_TYPE", `Table field ${JSON.stringify(column.field)} has mixed non-null types.`, origin.position);
361
- if ((column.format || column.sign !== "auto" || column.semantics !== "neutral") && types.size && !types.has("number"))
362
- this.diagnostics.add("E_TABLE_FORMAT", `Numeric formatting/sign/semantics cannot be applied to ${JSON.stringify(column.field)}.`, origin.position);
363
511
  }
364
- let sortType;
365
- for (const [index, row] of rows.entries()) {
366
- const value = row[column.field];
367
- const sort = column.sortBy ? row[column.sortBy] : undefined;
368
- if (column.sortBy && (Array.isArray(sort) || sort === undefined)) {
369
- fail("E_TABLE_SORT", index, "sortBy must name a scalar field.");
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.`);
370
525
  continue;
371
526
  }
372
- if (sort !== undefined && sort !== null) {
373
- if (sortType && sortType !== typeof sort) {
374
- fail("E_TABLE_SORT", index, "sortBy values must have consistent non-null types.");
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])
375
539
  continue;
376
- }
377
- sortType = typeof sort;
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]);
378
543
  }
379
- if (kind === "scalar") {
380
- prepared[index][column.field] = value;
381
- continue;
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;
382
654
  }
383
- let rich;
384
- if (kind === "sparkline") {
385
- if (value !== null && !Array.isArray(value)) {
386
- fail("E_DATA_TYPE", index, "Sparkline values must be a bounded numeric/null array or null.");
387
- continue;
388
- }
389
- rich = {
390
- kind: "sparkline",
391
- values: value ?? [],
392
- ...column.sortBy ? { sort } : {}
393
- };
394
- } else {
395
- const alt = row[column.alt];
396
- if (value !== null && (typeof alt !== "string" || !alt.trim() || alt.length > 1000) || value === null && alt !== null && typeof alt !== "string") {
397
- fail("E_TABLE_IMAGE", index, "alt must name a nonblank string of at most 1000 characters for each image.");
398
- continue;
399
- }
400
- if (value === null) {
401
- prepared[index][column.field] = {
402
- kind: "image",
403
- imageId: null,
404
- alt: "",
405
- ...column.sortBy ? { sort } : {}
406
- };
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")
407
715
  continue;
716
+ if (child.name === "explore") {
717
+ fail(`View ${view.id}: nested explorations are unsupported.`);
718
+ return false;
408
719
  }
409
- if (typeof value !== "string" || !value.trim()) {
410
- fail("E_TABLE_IMAGE", index, "Image values must be a plain local PNG/JPEG path or null.");
411
- continue;
720
+ if (++budget.components > explorationLimits.components) {
721
+ fail("Expanded components exceed 512.", "E_EXPLORE_LIMIT");
722
+ return false;
412
723
  }
413
- try {
414
- const image = await this.images.load(value);
415
- rich = {
416
- kind: "image",
417
- imageId: image.id,
418
- alt,
419
- ...column.sortBy ? { sort } : {}
420
- };
421
- } catch (error) {
422
- 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.");
423
- continue;
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);
424
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;
425
762
  }
426
- prepared[index][column.field] = rich;
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;
427
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
+ });
428
782
  }
429
- block.columns = columns;
430
- block.rows = prepared;
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");
431
790
  }
432
791
  }
433
792
 
434
- // src/compiler/files.ts
435
- import path2 from "node:path";
436
- import { open, realpath, stat } from "node:fs/promises";
437
- function contained(root, filename) {
438
- const relative = path2.relative(root, filename);
439
- return relative === "" || !path2.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path2.sep}`);
440
- }
441
- async function readBytesBounded(filename, maximum) {
442
- if (!(await stat(filename)).isFile())
443
- throw new Error("Expected a regular file.");
444
- const file = await open(filename, "r");
445
- try {
446
- const info = await file.stat();
447
- if (!info.isFile())
448
- throw new Error("Expected a regular file.");
449
- if (info.size > maximum)
450
- throw new Error(`File exceeds the ${Math.floor(maximum / 1024)} KiB size limit.`);
451
- const chunks = [];
452
- let bytes = 0;
453
- while (bytes <= maximum) {
454
- const buffer = Buffer.alloc(Math.min(64 * 1024, maximum + 1 - bytes));
455
- const result = await file.read(buffer, 0, buffer.length, null);
456
- if (!result.bytesRead)
457
- break;
458
- chunks.push(buffer.subarray(0, result.bytesRead));
459
- bytes += result.bytesRead;
460
- }
461
- if (bytes > maximum)
462
- throw new Error("File grew beyond the size limit while reading.");
463
- return Buffer.concat(chunks, bytes);
464
- } finally {
465
- await file.close();
466
- }
467
- }
468
- async function readBounded(filename, maximum) {
469
- const bytes = await readBytesBounded(filename, maximum);
470
- return {
471
- text: new TextDecoder("utf-8", { fatal: true }).decode(bytes),
472
- bytes: bytes.length
473
- };
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;
474
807
  }
475
808
 
476
- class ReportFiles {
477
- root;
478
- dependencies;
479
- usedBytes;
480
- constructor(root, dependencies, usedBytes) {
481
- this.root = root;
482
- this.dependencies = dependencies;
483
- this.usedBytes = usedBytes;
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;
484
824
  }
485
- async resolve(reference, extensions) {
486
- const fail = (message) => {
487
- throw new LocalFileError("path", message);
825
+ async load(reference, block) {
826
+ const fail = (code, message, hint) => {
827
+ this.diagnostics.add(code, `${reference}: ${message}`, block.position, hint);
828
+ return;
488
829
  };
489
- if (path2.isAbsolute(reference) || /^[A-Za-z][A-Za-z\d+.-]*:/.test(reference) || reference.includes("\\") || /%[\da-f]{2}/i.test(reference) || reference.includes("\x00"))
490
- fail(`Use a plain relative local ${extensions.join("/")} path.`);
491
- const candidate = path2.resolve(this.root, reference);
492
- const hidden = (filename) => path2.relative(this.root, filename).split(path2.sep).some((part) => part.startsWith("."));
493
- if (!contained(this.root, candidate))
494
- fail("Reference escapes the report directory.");
495
- if (hidden(candidate))
496
- fail("Hidden files and directories are not allowed.");
497
- if (!extensions.includes(path2.extname(candidate).toLowerCase()))
498
- fail(`Use a ${extensions.join("/")} file.`);
499
- this.dependencies.add(candidate);
500
830
  let canonical;
501
831
  try {
502
- canonical = await realpath(candidate);
503
- } catch {
504
- throw new LocalFileError("read", "Cannot read the referenced file. Create it or correct the reference.");
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.");
505
835
  }
506
- if (!contained(this.root, canonical) || hidden(canonical))
507
- fail("The resolved file or symlink escapes the permitted report files.");
508
- if (!extensions.includes(path2.extname(canonical).toLowerCase()))
509
- fail(`The resolved file must also have a ${extensions.join("/")} extension.`);
510
- this.dependencies.add(canonical);
511
- return canonical;
512
- }
513
- async read(filename, maximum) {
514
- const bytes = await readBytesBounded(filename, Math.min(maximum, limits.totalBytes - this.usedBytes));
515
- this.usedBytes += bytes.length;
516
- return bytes;
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 {
850
+ return fail("E_DATA_JSON", "Invalid JSON.", "Use a JSON array of records, without comments or trailing commas.");
851
+ }
852
+ if (!Array.isArray(parsed))
853
+ return fail("E_DATA_SHAPE", "Expected an array of flat JSON records.");
854
+ if (parsed.length > limits.rows)
855
+ return fail("E_DATA_LIMIT", `Dataset exceeds ${limits.rows.toLocaleString("en-GB")} rows.`, "Prepare a smaller dataset before rendering.");
856
+ const arrays = new Map;
857
+ for (const [index, row] of parsed.entries()) {
858
+ if (!row || typeof row !== "object" || Array.isArray(row))
859
+ return fail("E_DATA_SHAPE", `Row ${index + 1} is not a record.`);
860
+ for (const [key, value] of Object.entries(row)) {
861
+ if (reserved.has(key))
862
+ return fail("E_DATA_KEY", `Row ${index + 1} contains the reserved key ${JSON.stringify(key)}.`);
863
+ if (Array.isArray(value)) {
864
+ if (value.length > limits.sparklinePoints)
865
+ return fail("E_DATA_LIMIT", `Row ${index + 1}, field ${JSON.stringify(key)} exceeds ${limits.sparklinePoints} sparkline points.`, "Prepare a bounded sequence; values are never truncated.");
866
+ if (value.some((item) => item !== null && !(typeof item === "number" && Number.isFinite(item))))
867
+ return fail("E_DATA_SHAPE", `Row ${index + 1}, field ${JSON.stringify(key)} must contain only finite numbers or null gaps.`, "Only declared sparkline columns support sequences; nested objects and sequences are invalid.");
868
+ if (!arrays.has(key))
869
+ arrays.set(key, index);
870
+ } else if (!(value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)))
871
+ return fail("E_DATA_SHAPE", `Row ${index + 1}, field ${JSON.stringify(key)} must contain a finite number, string, boolean or null.`, "Prepare flat scalar records; use an explicit sparkline column for bounded numeric/null sequences.");
872
+ }
873
+ }
874
+ dataset = { rows: parsed, arrays };
875
+ this.cache.set(canonical, dataset);
876
+ }
877
+ const declared = new Set(block.name === "table" ? block.children.flatMap((child) => child.type === "component" && child.name === "column" && child.props.kind === "sparkline" ? [child.props.field] : []) : []);
878
+ for (const [field, index] of dataset.arrays)
879
+ if (!declared.has(field))
880
+ return fail("E_DATA_SHAPE", `Row ${index + 1}, field ${JSON.stringify(field)} is a sequence without an explicit sparkline column.`, 'Use a table column with kind="sparkline" for that field. Charts and scalar tables require flat scalar datasets.');
881
+ return dataset.rows;
517
882
  }
518
- }
519
-
520
- // src/compiler/images.ts
521
- import { createHash } from "node:crypto";
522
- import path3 from "node:path";
523
- import { imageSize } from "image-size";
524
- function formatOf(bytes) {
525
- if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
526
- let offset = 8;
527
- let data = false;
528
- while (offset + 12 <= bytes.length) {
529
- const size = bytes.readUInt32BE(offset);
530
- const type = bytes.toString("ascii", offset + 4, offset + 8);
531
- if (offset + size + 12 > bytes.length)
532
- break;
533
- if (offset === 8 && (type !== "IHDR" || size !== 13))
534
- break;
535
- if (type === "acTL")
536
- throw new Error("Animated PNG is not supported; provide a static image.");
537
- if (type === "IDAT" && size > 0)
538
- data = true;
539
- offset += size + 12;
540
- if (type === "IEND") {
541
- if (data && size === 0 && offset === bytes.length)
542
- return "png";
543
- break;
883
+ async resolve(blocks) {
884
+ for (const block of blocks) {
885
+ if (block.type !== "component")
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
+ }
891
+ if (block.name === "line" || block.name === "area" || block.name === "bar" || block.name === "donut" || block.name === "funnel" || block.name === "table") {
892
+ const rows = await this.load(block.props.data, block);
893
+ if (rows) {
894
+ if (!rows.length)
895
+ this.diagnostics.add("W_DATA_EMPTY", `${block.props.data} is empty; a no-data state will be shown.`, block.position, "Field validation requires at least one record.", "warning");
896
+ if (block.name === "table")
897
+ await this.resolveTable(block, rows);
898
+ else if (block.name === "donut" || block.name === "funnel")
899
+ this.resolveParts(block, rows);
900
+ else
901
+ this.resolveChart(block, rows);
902
+ }
544
903
  }
904
+ await this.resolve(block.children);
545
905
  }
546
- throw new Error("Incomplete or invalid PNG container.");
547
906
  }
548
- if (bytes.length >= 4 && bytes.readUInt16BE(0) === 65496 && bytes.readUInt16BE(bytes.length - 2) === 65497)
549
- return "jpg";
550
- throw new Error("Expected a static PNG or JPEG image with a matching file extension.");
551
- }
552
-
553
- class ImageResolver {
554
- files;
555
- attempted = new Set;
556
- filesRead = new Map;
557
- embedded = new Map;
558
- constructor(files) {
559
- this.files = files;
907
+ field(rows, name, block) {
908
+ const index = rows.findIndex((row) => !Object.hasOwn(row, name));
909
+ if (index < 0)
910
+ return true;
911
+ const available = Object.keys(rows[index] ?? {});
912
+ this.diagnostics.add("E_DATA_FIELD", `${block.name} references missing field ${JSON.stringify(name)} in ${block.props && "data" in block.props ? block.props.data : "dataset"}, row ${index + 1}.`, block.position, `Available fields: ${available.slice(0, 12).join(", ") || "none"}${available.length > 12 ? ` … (${available.length - 12} more)` : ""}.`);
913
+ return false;
560
914
  }
561
- async load(reference) {
562
- const requested = path3.resolve(this.files.root, reference);
563
- if (!this.attempted.has(requested) && this.attempted.size >= limits.imageFiles)
564
- throw new Error(`At most ${limits.imageFiles} local image references are supported.`);
565
- this.attempted.add(requested);
566
- const canonical = await this.files.resolve(reference, [
567
- ".png",
568
- ".jpg",
569
- ".jpeg"
570
- ]);
571
- const cached = this.filesRead.get(canonical);
572
- if (cached) {
573
- if (path3.extname(reference).toLowerCase() === ".png" !== (cached.mime === "image/png"))
574
- throw new Error("Image signature does not match its file extension.");
575
- return cached;
576
- }
577
- if (this.filesRead.size >= limits.imageFiles)
578
- throw new Error(`At most ${limits.imageFiles} local image files are supported.`);
579
- const bytes = await this.files.read(canonical, limits.imageBytes);
580
- const format = formatOf(bytes);
581
- const expected = (name) => path3.extname(name).toLowerCase() === ".png" ? "png" : "jpg";
582
- if (format !== expected(reference) || format !== expected(canonical))
583
- throw new Error("Image signature does not match its file extension.");
584
- let size;
585
- try {
586
- size = imageSize(bytes);
587
- } catch {
588
- throw new Error("Unsupported or damaged PNG/JPEG header.");
915
+ resolveChart(block, rows) {
916
+ const { x, y } = block.props;
917
+ if (rows.length > limits.chartRows) {
918
+ this.diagnostics.add("E_DATA_LIMIT", `Chart exceeds ${limits.chartRows} points/categories.`, block.position, "Prepare a smaller chart aggregate.");
919
+ return;
589
920
  }
590
- if (size.type !== format || !Number.isSafeInteger(size.width) || !Number.isSafeInteger(size.height) || size.width < 1 || size.height < 1)
591
- throw new Error("Image must have valid positive raster dimensions.");
592
- if (size.width > limits.imageDimension || size.height > limits.imageDimension || size.width * size.height > limits.imagePixels)
593
- throw new Error(`Image exceeds ${limits.imageDimension} pixels per side or ${limits.imagePixels.toLocaleString("en-GB")} pixels in total. Resize the prepared asset.`);
594
- const id = `plotleaf-image-${createHash("sha256").update(bytes).digest("hex")}`;
595
- const image = this.embedded.get(id) ?? {
596
- id,
597
- mime: format === "png" ? "image/png" : "image/jpeg",
598
- base64: bytes.toString("base64")
599
- };
600
- this.filesRead.set(canonical, image);
601
- this.embedded.set(id, image);
602
- return image;
921
+ if (!this.field(rows, x, block) || !this.field(rows, y, block))
922
+ return;
923
+ const kind = block.name === "bar" ? "category" : block.props.xType;
924
+ const seen = new Set;
925
+ let previous = -Infinity;
926
+ for (const [index, row] of rows.entries()) {
927
+ const xv = row[x];
928
+ const yv = row[y];
929
+ const validY = typeof yv === "number" && Number.isFinite(yv) || block.name !== "bar" && yv === null;
930
+ let normalized;
931
+ if (kind === "category" && (typeof xv === "string" || typeof xv === "number"))
932
+ normalized = String(xv);
933
+ if (kind === "number" && typeof xv === "number")
934
+ normalized = xv;
935
+ if (kind === "date" && typeof xv === "string")
936
+ normalized = parseDate(xv);
937
+ if (normalized === undefined || !validY) {
938
+ this.diagnostics.add("E_DATA_TYPE", `${block.props.data}, row ${index + 1}: ${JSON.stringify(x)} must be ${kind === "date" ? "an ISO date or zoned timestamp" : `a ${kind === "number" ? "number" : "string or number category"}`} and ${JSON.stringify(y)} must be ${block.name === "bar" ? "a number" : "a number or null"}.`, block.position);
939
+ return;
940
+ }
941
+ if (seen.has(normalized) || typeof normalized === "number" && kind !== "category" && normalized <= previous) {
942
+ this.diagnostics.add("E_DATA_ORDER", `${block.props.data}, row ${index + 1}: x values must be unique${kind !== "category" ? " and increasing" : ""}.`, block.position, "Sort or aggregate the prepared dataset; plotleaf does not silently change chart data.");
943
+ return;
944
+ }
945
+ seen.add(normalized);
946
+ if (typeof normalized === "number")
947
+ previous = normalized;
948
+ }
949
+ block.rows = rows.map((row) => Object.fromEntries([...new Set([x, y])].map((key) => [key, row[key]])));
603
950
  }
604
- }
605
-
606
- // src/compiler/data.ts
607
- class DataResolver2 extends DataResolver {
608
- root;
609
- constructor(root, diagnostics, dependencies, totalBytes = 0) {
610
- const files = new ReportFiles(root, dependencies, totalBytes);
611
- super(diagnostics, files, new ImageResolver(files));
612
- this.root = root;
951
+ resolveParts(block, rows) {
952
+ const props = block.props;
953
+ const maximum = block.name === "donut" ? limits.donutSlices : limits.funnelStages;
954
+ const fields = block.name === "donut" ? [block.props.category, props.value] : [
955
+ block.props.stage,
956
+ props.value,
957
+ block.props.total,
958
+ block.props.share,
959
+ ...block.props.status ? [block.props.status] : []
960
+ ];
961
+ const fail = (code, message, hint) => {
962
+ this.diagnostics.add(code, `${props.data}: ${message}`, block.position, hint);
963
+ };
964
+ if (rows.length > maximum) {
965
+ fail("E_DATA_LIMIT", `${block.name} supports at most ${maximum} rows.`, "Prepare a smaller summary; plotleaf does not group or truncate data.");
966
+ return;
967
+ }
968
+ if (fields.some((field) => !this.field(rows, field, block)))
969
+ return;
970
+ const seen = new Set;
971
+ let sum = 0;
972
+ for (const [index, row] of rows.entries()) {
973
+ const name = row[fields[0]];
974
+ const value = row[props.value];
975
+ if (typeof name !== "string" || !name.trim() || name.length > 1000) {
976
+ fail("E_DATA_TYPE", `Row ${index + 1}: ${JSON.stringify(fields[0])} must be a nonblank string of at most 1000 characters.`, "Supply a short, explicit label for every category or stage.");
977
+ return;
978
+ }
979
+ if (seen.has(name)) {
980
+ fail("E_DATA_ORDER", `Row ${index + 1}: category/stage labels must be unique.`, "Prepare distinct labels; source order is preserved.");
981
+ return;
982
+ }
983
+ seen.add(name);
984
+ if (typeof value !== "number" || value < 0) {
985
+ fail("E_DATA_TYPE", `Row ${index + 1}: ${JSON.stringify(props.value)} must be a nonnegative number.`, "Use finite JSON numbers, not nulls or numeric strings.");
986
+ return;
987
+ }
988
+ if (block.name === "donut") {
989
+ sum += value;
990
+ if (!Number.isFinite(sum)) {
991
+ fail("E_DATA_VALUE", "The donut total exceeds the finite numeric range.", "Prepare values in smaller units before rendering.");
992
+ return;
993
+ }
994
+ } else {
995
+ const total = row[block.props.total];
996
+ const share = row[block.props.share];
997
+ if (typeof total !== "number" || total <= 0 || value > total || typeof share !== "number" || share < 0 || share > 1) {
998
+ fail("E_DATA_VALUE", `Row ${index + 1}: total must be positive, value must not exceed total, and share must be a number from 0 to 1.`, "Supply each stage's denominator and prepared ratio explicitly; use an empty array when no baseline exists.");
999
+ return;
1000
+ }
1001
+ if (Math.abs(share - value / total) > 0.0005 + Number.EPSILON) {
1002
+ fail("E_DATA_VALUE", `Row ${index + 1}: share does not match value / total.`, "Prepare a ratio consistent with the displayed values; rounding to 0.1 percentage point is supported.");
1003
+ return;
1004
+ }
1005
+ if (block.props.status) {
1006
+ const status = row[block.props.status];
1007
+ if (typeof status !== "string" || !Object.hasOwn(funnelStatuses, status)) {
1008
+ fail("E_DATA_VALUE", `Row ${index + 1}: status must be ${Object.keys(funnelStatuses).join(", ")}.`, "Supply business status explicitly, or omit the status property for neutral styling.");
1009
+ return;
1010
+ }
1011
+ }
1012
+ }
1013
+ }
1014
+ block.rows = rows.map((row) => Object.fromEntries(fields.map((field) => [field, row[field]])));
613
1015
  }
614
- }
615
-
616
- // src/compiler/parse.ts
617
- import { unified } from "unified";
618
- import remarkParse from "remark-parse";
619
- import remarkDirective from "remark-directive";
620
- import remarkFrontmatter from "remark-frontmatter";
621
- import { parseDocument, visit } from "yaml";
622
- import * as z2 from "zod";
623
-
624
- // src/compiler/registry.ts
625
- import * as z from "zod";
626
- var text = z.string().min(1).max(1000);
627
- 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).");
628
- var chart = {
629
- data: text,
630
- x: text,
631
- y: text,
632
- title: text,
633
- format: format.default("number")
634
- };
635
- var line = z.strictObject({
636
- ...chart,
637
- xType: z.enum(["category", "date", "number"]).default("category"),
638
- target: z.number().finite().optional(),
639
- targetLabel: text.optional()
640
- });
641
- var registry = {
642
- grid: {
643
- forms: ["container"],
644
- children: [
645
- "metric",
646
- "line",
647
- "area",
648
- "bar",
649
- "donut",
650
- "funnel",
651
- "table",
652
- "callout"
653
- ],
654
- description: "A responsive row of one to four components.",
655
- schema: z.strictObject({
656
- columns: z.number().int().min(1).max(4).default(2)
657
- }),
658
- example: `:::grid{columns="4"}
659
- ::metric{label="Teams" value="1492"}
660
- :::`
661
- },
662
- metric: {
663
- forms: ["leaf"],
664
- children: [],
665
- description: "A prepared numeric value with optional comparison, direction and sentiment.",
666
- schema: z.strictObject({
667
- label: text,
668
- value: z.number().finite(),
669
- format: format.default("number"),
670
- comparison: text.optional(),
671
- direction: z.enum(["up", "down", "flat"]).optional(),
672
- sentiment: z.enum(["positive", "negative", "neutral"]).default("neutral")
673
- }),
674
- example: '::metric{label="Conversion" value="0.103" format="percent"}'
675
- },
676
- line: {
677
- forms: ["leaf"],
678
- children: [],
679
- description: "An interactive single-series line chart from local JSON.",
680
- schema: line,
681
- example: '::line{data="./usage.json" x="date" y="teams" xType="date" title="Active teams"}'
682
- },
683
- area: {
684
- forms: ["leaf"],
685
- children: [],
686
- description: "A line chart with a restrained area fill.",
687
- schema: line,
688
- example: '::area{data="./usage.json" x="date" y="teams" xType="date" title="Active teams" target="1600"}'
689
- },
690
- bar: {
691
- forms: ["leaf"],
692
- children: [],
693
- description: "An interactive category bar chart with a zero baseline.",
694
- schema: z.strictObject(chart),
695
- example: '::bar{data="./errors.json" x="category" y="count" title="Errors by category"}'
696
- },
697
- donut: {
698
- forms: ["leaf"],
699
- children: [],
700
- description: "A part-to-whole donut with a readable legend and native data table.",
701
- schema: z.strictObject({
702
- data: text,
703
- category: text,
704
- value: text,
705
- title: text,
706
- format: format.default("number")
707
- }),
708
- dataRules: {
709
- category: "Unique nonblank strings of at most 1000 characters; source order is preserved.",
710
- value: "Finite nonnegative numbers with a finite sum. Nulls and numeric strings are invalid. All-zero values show an empty state.",
711
- maximumRows: limits.donutSlices
712
- },
713
- example: '::donut{data="./plans.json" category="plan" value="teams" title="Teams by plan"}'
714
- },
715
- funnel: {
716
- forms: ["leaf"],
717
- children: [],
718
- description: "Ordered stage/progress rows with prepared values, denominators, shares and optional explicit status. Works without JavaScript.",
719
- schema: z.strictObject({
720
- data: text,
721
- stage: text,
722
- value: text,
723
- total: text,
724
- share: text,
725
- status: text.optional(),
726
- title: text,
727
- format: format.default("number")
728
- }),
729
- dataRules: {
730
- stage: "Unique nonblank strings of at most 1000 characters; source order is preserved.",
731
- value: "Finite nonnegative number no greater than its row's total.",
732
- total: "Finite positive denominator supplied per row. No denominator is inferred from adjacent stages.",
733
- share: "Prepared ratio from 0 to 1, matching value / total to within 0.0005 (rounding to 0.1 percentage point).",
734
- status: Object.keys(funnelStatuses),
735
- maximumRows: limits.funnelStages
736
- },
737
- example: '::funnel{data="./funnel.json" stage="stage" value="teams" total="baseline" share="share" status="status" title="Funnel by stage"}'
738
- },
739
- table: {
740
- forms: ["leaf", "container"],
741
- children: ["column"],
742
- description: "A semantic sortable table, with inferred or explicitly formatted columns.",
743
- schema: z.strictObject({ data: text, title: text.optional() }),
744
- example: `:::table{data="./plans.json" title="By plan"}
745
- ::column{field="plan" label="Plan"}
746
- ::column{field="change" label="Δ QoQ" format="percent" sign="always"}
747
- :::`
748
- },
749
- column: {
750
- forms: ["leaf"],
751
- children: [],
752
- description: "A scalar, local image or bounded sparkline column. Omitted kind means scalar.",
753
- schema: z.strictObject({
754
- field: text,
755
- label: text.optional(),
756
- kind: z.enum(["scalar", "image", "sparkline"]).optional(),
757
- alt: text.optional(),
758
- sortBy: text.optional(),
759
- format: format.optional(),
760
- sign: z.enum(["auto", "always"]).default("auto"),
761
- semantics: z.enum(["neutral", "higher-is-better", "lower-is-better"]).default("neutral")
762
- }),
763
- example: '::column{field="change" label="Δ QoQ" format="percent" sign="always" semantics="higher-is-better"}',
764
- dataRules: {
765
- 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.",
766
- 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.",
767
- 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."
1016
+ async resolveTable(block, rows) {
1017
+ if (rows.length > limits.tableRows) {
1018
+ this.diagnostics.add("E_DATA_LIMIT", `Table exceeds ${limits.tableRows} rows.`, block.position, "Provide a prepared summary table; rows are never silently truncated.");
1019
+ return;
1020
+ }
1021
+ const inferred = Object.keys(rows[0] ?? {});
1022
+ const explicit = block.children.filter((child) => child.type === "component" && child.name === "column");
1023
+ const columns = explicit.length ? explicit.map((column) => column.props) : inferred.map((field) => ({
1024
+ field,
1025
+ sign: "auto",
1026
+ semantics: "neutral"
1027
+ }));
1028
+ if (columns.length > limits.tableColumns) {
1029
+ this.diagnostics.add("E_DATA_LIMIT", `Table exceeds ${limits.tableColumns} columns.`, block.position, "Declare a smaller selection of column fields.");
1030
+ return;
1031
+ }
1032
+ if (new Set(columns.map((column) => column.field)).size !== columns.length) {
1033
+ this.diagnostics.add("E_TABLE_COLUMN", "A table cannot declare the same column field twice.", block.position);
1034
+ return;
1035
+ }
1036
+ for (const [index, row] of rows.entries())
1037
+ if (Object.keys(row).length !== inferred.length || inferred.some((field) => !Object.hasOwn(row, field))) {
1038
+ this.diagnostics.add("E_DATA_SHAPE", `${block.props.data}, row ${index + 1}: table records must have consistent keys.`, block.position);
1039
+ return;
1040
+ }
1041
+ const prepared = rows.map(() => ({}));
1042
+ for (const column of columns) {
1043
+ const origin = explicit.find((child) => child.props.field === column.field) ?? block;
1044
+ const fields = [
1045
+ column.field,
1046
+ ...column.alt ? [column.alt] : [],
1047
+ ...column.sortBy ? [column.sortBy] : []
1048
+ ];
1049
+ if (fields.some((field) => !this.field(rows, field, origin)))
1050
+ continue;
1051
+ const fail = (code, row, message, hint) => this.diagnostics.add(code, `${block.props.data}, row ${row + 1}, field ${JSON.stringify(column.field)}: ${message}`, origin.position, hint);
1052
+ const kind = column.kind ?? "scalar";
1053
+ if (kind === "scalar") {
1054
+ const types = new Set(rows.map((row) => row[column.field]).filter((value) => value !== null).map((value) => typeof value));
1055
+ if (types.size > 1)
1056
+ this.diagnostics.add("E_DATA_TYPE", `Table field ${JSON.stringify(column.field)} has mixed non-null types.`, origin.position);
1057
+ if ((column.format || column.sign !== "auto" || column.semantics !== "neutral") && types.size && !types.has("number"))
1058
+ this.diagnostics.add("E_TABLE_FORMAT", `Numeric formatting/sign/semantics cannot be applied to ${JSON.stringify(column.field)}.`, origin.position);
1059
+ }
1060
+ let sortType;
1061
+ for (const [index, row] of rows.entries()) {
1062
+ const value = row[column.field];
1063
+ const sort = column.sortBy ? row[column.sortBy] : undefined;
1064
+ if (column.sortBy && (Array.isArray(sort) || sort === undefined)) {
1065
+ fail("E_TABLE_SORT", index, "sortBy must name a scalar field.");
1066
+ continue;
1067
+ }
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
+ }
768
1124
  }
769
- },
770
- callout: {
771
- forms: ["container"],
772
- children: ["markdown"],
773
- description: "A contextual note or warning containing ordinary Markdown.",
774
- schema: z.strictObject({
775
- tone: z.enum(["note", "warning"]).default("note"),
776
- title: text.optional()
777
- }),
778
- example: `:::callout{title="Coverage"}
779
- Seven complete days of prepared data.
780
- :::`
1125
+ block.columns = columns;
1126
+ block.rows = prepared;
781
1127
  }
782
- };
783
- 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);
784
1166
  return {
785
- version: 1,
786
- syntax: "markdown-directives",
787
- attributes: "Literal strings decoded according to each property schema. No JavaScript expressions.",
788
- limits,
789
- data: {
790
- shape: "array of flat JSON records; explicit table sparkline columns permit bounded numeric/null arrays only in their declared fields",
791
- resolution: "relative to the report directory; local .json files only",
792
- percent: "ratios: 0.12 displays as 12%",
793
- percentagePoints: "point values: 0.8 displays as 0.8 pt",
794
- currency: "major units; omit .00 on whole amounts, preserve nonzero cents",
795
- locale: "en-GB",
796
- timeZone: "UTC"
797
- },
798
- components: Object.fromEntries(Object.entries(registry).map(([name, entry]) => [
799
- name,
800
- {
801
- description: entry.description,
802
- forms: entry.forms,
803
- children: entry.children,
804
- props: z.toJSONSchema(entry.schema),
805
- example: entry.example,
806
- ..."dataRules" in entry ? { dataRules: entry.dataRules } : {}
807
- }
808
- ])),
809
- rules: [
810
- "Use explicit closing fences for containers.",
811
- "Components are standalone blocks, not inline/list/blockquote content.",
812
- "column is only allowed inside table; grids cannot nest.",
813
- "direction and explicitly supplied sentiment require metric comparison text.",
814
- "targetLabel requires target.",
815
- "Dataset fields are literal own-property keys, not expressions.",
816
- "Charts preserve source order; date/number x values must increase.",
817
- "Line/area null y values create gaps; bar null y values are invalid.",
818
- "Donut category/value fields must differ; category count is bounded, and all-zero values do not create equal slices.",
819
- "Funnel fields must differ; shares and denominators are supplied, validated and displayed per row, with no sorting or status inference.",
820
- "Only explicit image columns can load local static PNG/JPEG; every resource stays within the report root and shared byte limits.",
821
- "Filters, maps, arbitrary nested data and executable components are not supported in this release."
822
- ]
1167
+ text: new TextDecoder("utf-8", { fatal: true }).decode(bytes),
1168
+ bytes: bytes.length
823
1169
  };
824
1170
  }
825
- var specs = componentSpec().components;
826
- var numericLiteral = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/;
827
- function decodeProps(name, attributes) {
828
- const decoded = Object.create(null);
829
- const properties = specs[name].props.properties ?? {};
830
- for (const [key, value] of Object.entries(attributes)) {
831
- const property = properties[key];
832
- if (property?.type === "number" || property?.type === "integer")
833
- decoded[key] = typeof value === "string" && numericLiteral.test(value) ? Number(value) : value;
834
- else
835
- 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;
836
1180
  }
837
- const result = registry[name].schema.safeParse(decoded);
838
- if (!result.success)
839
- return {
840
- 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);
841
1184
  };
842
- const props = result.data;
843
- const errors = [];
844
- if (name === "column") {
845
- const column = props;
846
- const kind = column.kind ?? "scalar";
847
- if (kind === "image" && !column.alt)
848
- errors.push("image columns require an alt field.");
849
- if (kind !== "image" && column.alt)
850
- errors.push("alt is only supported on image columns.");
851
- if (kind === "image" && column.format)
852
- errors.push("Image columns cannot use numeric formatting.");
853
- if (kind !== "scalar" && (column.sign !== "auto" || column.semantics !== "neutral"))
854
- errors.push("sign and semantics apply only to scalar columns.");
855
- if (kind === "scalar" && column.sortBy)
856
- errors.push("Scalar columns sort their own field; sortBy is for image/sparkline columns.");
857
- if (column.alt === column.field || column.sortBy === column.field)
858
- errors.push("alt and sortBy must reference fields different from the rich cell field.");
859
- }
860
- if (name === "donut" || name === "funnel") {
861
- const fields = Object.entries(props).filter(([key, value]) => ["category", "stage", "value", "total", "share", "status"].includes(key) && value !== undefined).map(([, value]) => value);
862
- if (new Set(fields).size !== fields.length)
863
- errors.push(`${name} field properties must reference different dataset fields.`);
864
- }
865
- if ("format" in props && props.format?.startsWith("currency:") && !Intl.supportedValuesOf("currency").includes(props.format.slice(9)))
866
- errors.push(`Unknown ISO currency code ${props.format.slice(9)}.`);
867
- if (name === "metric" && !props.comparison && (attributes.direction !== undefined || attributes.sentiment !== undefined))
868
- errors.push("direction and sentiment require comparison text.");
869
- if ((name === "line" || name === "area") && "targetLabel" in props && props.targetLabel !== undefined && (!("target" in props) || props.target === undefined))
870
- errors.push("targetLabel requires a numeric target.");
871
- return { props, errors };
872
- }
873
-
874
- // src/compiler/parse.ts
875
- var parser = unified().use(remarkParse).use(remarkFrontmatter).use(remarkDirective);
876
- var metadataSchema = z2.strictObject({
877
- title: z2.string().min(1).max(500).optional(),
878
- description: z2.string().max(2000).optional()
879
- });
880
- function textOf(node) {
881
- return node.value ?? node.children?.map(textOf).join("") ?? "";
882
- }
883
- function parseReport(source, fallbackTitle, diagnostics) {
884
- const tree = parser.parse(source);
885
- const lines = source.split(/\r?\n/);
886
- const recognized = new Set;
887
- const inert = new Set;
888
- const definitions = new Map;
889
- let metadata = { title: fallbackTitle };
890
- const firstHeading = tree.children.find((node) => node.type === "heading" && node.depth === 1);
891
- if (firstHeading)
892
- metadata.title = textOf(firstHeading);
893
- function inspect(node, depth = 0) {
894
- if (depth > 64) {
895
- diagnostics.add("E_REPORT_DEPTH", "Markdown nesting exceeds 64 levels.", node.position);
896
- return;
897
- }
898
- if (node.type === "code" || node.type === "yaml") {
899
- for (let line = node.position?.start.line ?? 0;line <= (node.position?.end.line ?? 0); line++)
900
- inert.add(line);
901
- return;
902
- }
903
- if (node.type === "html")
904
- 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.");
905
- if (node.type === "image" || node.type === "imageReference")
906
- 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.');
907
- if (node.type === "link" || node.type === "definition") {
908
- const url = node.url ?? "";
909
- let allowed = url.startsWith("#");
910
- try {
911
- allowed ||= ["https:", "http:", "mailto:"].includes(new URL(url).protocol);
912
- } catch {}
913
- if (!allowed)
914
- diagnostics.add("E_LINK_URL", `Unsupported link destination ${JSON.stringify(url)}.`, node.position, "Use an https:, http:, mailto: link or a same-document #anchor.");
915
- if (node.type === "definition")
916
- 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.");
917
1201
  }
918
- 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;
919
1208
  }
920
- inspect(tree);
921
- function blocks(nodes, parent = "root", depth = 0) {
922
- if (depth > 32) {
923
- diagnostics.add("E_REPORT_DEPTH", "Component nesting exceeds 32 levels.");
924
- return [];
925
- }
926
- const output = [];
927
- for (const node of nodes) {
928
- if (node.type === "yaml") {
929
- try {
930
- const document = parseDocument(node.value ?? "", {
931
- uniqueKeys: true,
932
- strict: true
933
- });
934
- if (document.errors.length || document.warnings.length)
935
- throw new Error([...document.errors, ...document.warnings].map((error) => error.message).join("; "));
936
- visit(document, {
937
- Alias() {
938
- throw new Error("YAML aliases are not supported.");
939
- }
940
- });
941
- const result = metadataSchema.safeParse(document.toJS({ maxAliasCount: 0 }) ?? {});
942
- if (!result.success)
943
- throw new Error(result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; "));
944
- metadata = { ...metadata, ...result.data };
945
- } catch (error) {
946
- diagnostics.add("E_FRONTMATTER", error instanceof Error ? error.message : "Invalid frontmatter.", node.position);
947
- }
948
- continue;
949
- }
950
- if (["containerDirective", "leafDirective", "textDirective"].includes(node.type)) {
951
- const name = node.name ?? "";
952
- recognized.add(node.position?.start.line ?? 0);
953
- if (node.type === "containerDirective") {
954
- const closingLine = node.position?.end.line ?? 0;
955
- const closing = lines[closingLine - 1]?.trim() ?? "";
956
- if (!/^:{3,}$/.test(closing))
957
- 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.");
958
- else
959
- recognized.add(closingLine);
960
- }
961
- if (!Object.hasOwn(registry, name)) {
962
- diagnostics.add("E_COMPONENT_UNKNOWN", `Unknown component ${JSON.stringify(name)}.`, node.position, `Supported components: ${Object.keys(registry).join(", ")}. Run plotleaf schema for their properties.`);
963
- continue;
964
- }
965
- const componentName = name;
966
- const entry = registry[componentName];
967
- const form = node.type === "containerDirective" ? "container" : node.type === "leafDirective" ? "leaf" : "text";
968
- if (!entry.forms.includes(form))
969
- diagnostics.add("E_COMPONENT_FORM", `${name} does not support the ${form} directive form.`, node.position, `Use ${entry.forms.includes("container") ? `:::${name} … :::` : `::${name}{…}`}.`);
970
- const allowed = parent === "root" ? name !== "column" : registry[parent]?.children?.includes(name);
971
- if (!allowed)
972
- 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.");
973
- const opening = lines[(node.position?.start.line ?? 1) - 1] ?? "";
974
- const names = new Set;
975
- for (const match of opening.matchAll(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|([A-Za-z][\w-]*)\s*=/g)) {
976
- if (!match[1])
977
- continue;
978
- if (names.has(match[1]))
979
- diagnostics.add("E_PROP_DUPLICATE", `Duplicate property ${JSON.stringify(match[1])} on ${name}.`, node.position, "Specify each property once.");
980
- names.add(match[1]);
981
- }
982
- if (node.type === "leafDirective" && node.children?.length)
983
- diagnostics.add("E_COMPONENT_LABEL", `${name} does not accept a directive [label].`, node.position, "Use the label or title property instead.");
984
- const decoded = decodeProps(componentName, node.attributes ?? {});
985
- for (const message of decoded.errors)
986
- diagnostics.add("E_PROP_VALUE", message, node.position, `Run plotleaf schema for the ${name} contract.`);
987
- const children = blocks(node.children ?? [], name, depth + 1);
988
- if (decoded.props)
989
- output.push({
990
- type: "component",
991
- name: componentName,
992
- props: decoded.props,
993
- children,
994
- position: node.position
995
- });
996
- 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;
997
1240
  }
998
- if (parent !== "root" && !registry[parent]?.children?.includes("markdown"))
999
- diagnostics.add("E_COMPONENT_CHILD", `${parent} only accepts its declared component children.`, node.position);
1000
- const nestedDirectives = (candidate, nesting = 0) => {
1001
- if (nesting > 64)
1002
- return;
1003
- if (candidate.type.endsWith("Directive"))
1004
- diagnostics.add("E_COMPONENT_CONTEXT", "Components must be standalone blocks, not inline/list/blockquote content.", candidate.position);
1005
- candidate.children?.forEach((child) => nestedDirectives(child, nesting + 1));
1006
- };
1007
- nestedDirectives(node);
1008
- const last = output.at(-1);
1009
- if (last?.type === "markdown")
1010
- last.tree.children.push(node);
1011
- else
1012
- output.push({
1013
- type: "markdown",
1014
- tree: { type: "root", children: [node] }
1015
- });
1016
1241
  }
1017
- return output;
1242
+ throw new Error("Incomplete or invalid PNG container.");
1018
1243
  }
1019
- const reportBlocks = blocks(tree.children);
1020
- function attachDefinitions(items) {
1021
- for (const block of items) {
1022
- if (block.type === "markdown") {
1023
- for (const [identifier, url] of definitions)
1024
- if (!block.tree.children.some((node) => node.type === "definition" && node.identifier === identifier))
1025
- block.tree.children.push({ type: "definition", identifier, url });
1026
- } else
1027
- attachDefinitions(block.children);
1028
- }
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;
1029
1256
  }
1030
- attachDefinitions(reportBlocks);
1031
- for (let index = 0;index < lines.length; index++) {
1032
- if (/^\s*::/.test(lines[index] ?? "") && !recognized.has(index + 1) && !inert.has(index + 1)) {
1033
- const position = {
1034
- start: { line: index + 1, column: 1 },
1035
- end: { line: index + 1, column: 1 }
1036
- };
1037
- 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.");
1038
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;
1039
1309
  }
1040
- return { metadata, blocks: reportBlocks };
1041
1310
  }
1042
1311
 
1043
1312
  // src/compiler/compile.ts
@@ -1073,12 +1342,6 @@ async function compileReport(input) {
1073
1342
  // src/render/html.ts
1074
1343
  import { createHash as createHash2 } from "node:crypto";
1075
1344
 
1076
- // src/render/content.ts
1077
- import { unified as unified2 } from "unified";
1078
- import remarkRehype from "remark-rehype";
1079
- import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
1080
- import rehypeStringify from "rehype-stringify";
1081
-
1082
1345
  // src/render/tree.ts
1083
1346
  var t = (value) => ({
1084
1347
  type: "text",
@@ -1086,6 +1349,122 @@ var t = (value) => ({
1086
1349
  });
1087
1350
  var h = (tagName, properties = {}, children = []) => ({ type: "element", tagName, properties, children });
1088
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
+
1089
1468
  // src/shared/format.ts
1090
1469
  var numbers = new Map;
1091
1470
  function formatValue(value, format = "number", sign = "auto", compact = false) {
@@ -1225,6 +1604,14 @@ var serializer = unified2().use(rehypeSanitize, {
1225
1604
  clobberPrefix: "",
1226
1605
  tagNames: [
1227
1606
  ...defaultSchema.tagNames ?? [],
1607
+ "select",
1608
+ "option",
1609
+ "input",
1610
+ "label",
1611
+ "fieldset",
1612
+ "legend",
1613
+ "datalist",
1614
+ "noscript",
1228
1615
  "figure",
1229
1616
  "figcaption",
1230
1617
  "button",
@@ -1246,6 +1633,11 @@ var serializer = unified2().use(rehypeSanitize, {
1246
1633
  "ariaLabelledBy",
1247
1634
  "ariaHidden",
1248
1635
  "ariaSort",
1636
+ "hidden",
1637
+ "ariaLive",
1638
+ "dataPlotleafExplore",
1639
+ "dataPlotleafView",
1640
+ "dataPlotleafStatus",
1249
1641
  "dataPlotleafChart",
1250
1642
  "dataPlotleafTable",
1251
1643
  "dataColumn",
@@ -1256,8 +1648,36 @@ var serializer = unified2().use(rehypeSanitize, {
1256
1648
  "dataStatus",
1257
1649
  "dataSlice"
1258
1650
  ],
1259
- button: ["type", "disabled", "ariaLabel", "dataColumn"],
1260
- 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
+ ],
1261
1681
  meter: ["min", "max", "value", "className", "ariaLabel"],
1262
1682
  svg: ["className", "viewBox", "width", "height", "role", "ariaLabel"],
1263
1683
  use: ["href"],
@@ -1270,7 +1690,7 @@ var serializer = unified2().use(rehypeSanitize, {
1270
1690
  td: ["className", "dataRaw"]
1271
1691
  }
1272
1692
  }).use(rehypeStringify);
1273
- function label(field) {
1693
+ function label2(field) {
1274
1694
  const value = field.replaceAll("_", " ");
1275
1695
  return value.charAt(0).toUpperCase() + value.slice(1);
1276
1696
  }
@@ -1286,7 +1706,7 @@ function table(rows, columns, title, id, sortable = true) {
1286
1706
  const numeric = columns.map((column) => column.kind === "sparkline" || rows.some((row) => typeof row[column.field] === "number") || Boolean(column.format));
1287
1707
  const caption = h("caption", {}, [t(title)]);
1288
1708
  const headers = columns.map((column, index) => {
1289
- const name = column.label ?? label(column.field);
1709
+ const name = column.label ?? label2(column.field);
1290
1710
  const content = canSort[index] ? h("button", {
1291
1711
  type: "button",
1292
1712
  disabled: true,
@@ -1347,13 +1767,13 @@ function renderContent(report) {
1347
1767
  headings.set(slug, count + 1);
1348
1768
  return `plotleaf-heading-${slug}${count ? `-${count + 1}` : ""}`;
1349
1769
  };
1350
- function decorate(node) {
1770
+ function decorate(node, namespace = "") {
1351
1771
  if (node.type !== "element")
1352
1772
  return;
1353
1773
  if (/^h[1-6]$/.test(node.tagName)) {
1354
1774
  const text = (child) => child.type === "text" ? child.value : child.children.map((n) => text(n)).join("");
1355
- node.properties.id = headingId(text(node));
1356
- if (node.tagName === "h1")
1775
+ node.properties.id = headingId(namespace + text(node));
1776
+ if (node.tagName === "h1" && !namespace)
1357
1777
  hasH1 = true;
1358
1778
  }
1359
1779
  if (node.tagName === "a" && typeof node.properties.href === "string") {
@@ -1362,22 +1782,24 @@ function renderContent(report) {
1362
1782
  try {
1363
1783
  fragment = decodeURIComponent(fragment);
1364
1784
  } catch {}
1365
- node.properties.href = `#plotleaf-heading-${slugOf(fragment)}`;
1785
+ node.properties.href = `#plotleaf-heading-${slugOf(namespace + fragment)}`;
1366
1786
  }
1367
1787
  if (/^https?:/.test(node.properties.href))
1368
1788
  node.properties.rel = ["noopener", "noreferrer"];
1369
1789
  }
1370
- node.children.forEach((child) => decorate(child));
1790
+ node.children.forEach((child) => decorate(child, namespace));
1371
1791
  }
1372
- function render(blocks) {
1792
+ function render(blocks, namespace = "") {
1373
1793
  return blocks.flatMap((block) => {
1374
1794
  if (block.type === "markdown") {
1375
1795
  const root = markdown.runSync(block.tree);
1376
- root.children.forEach((child) => decorate(child));
1796
+ root.children.forEach((child) => decorate(child, namespace));
1377
1797
  return root.children;
1378
1798
  }
1379
1799
  const id = `plotleaf-component-${String(++sequence).padStart(4, "0")}`;
1380
1800
  switch (block.name) {
1801
+ case "explore":
1802
+ return exploration(block, id, render, payload.explorations ??= []);
1381
1803
  case "grid":
1382
1804
  return [
1383
1805
  h("div", {
@@ -1386,7 +1808,7 @@ function renderContent(report) {
1386
1808
  ...block.children.length > 0 && block.children.every((child) => child.type === "component" && child.name === "metric") ? ["metric-grid"] : []
1387
1809
  ],
1388
1810
  dataColumns: block.props.columns
1389
- }, render(block.children))
1811
+ }, render(block.children, namespace))
1390
1812
  ];
1391
1813
  case "metric": {
1392
1814
  const props = block.props;
@@ -1423,7 +1845,7 @@ function renderContent(report) {
1423
1845
  t(block.props.title)
1424
1846
  ])
1425
1847
  ] : [],
1426
- ...render(block.children)
1848
+ ...render(block.children, namespace)
1427
1849
  ])
1428
1850
  ];
1429
1851
  case "table":
@@ -1452,17 +1874,17 @@ function renderContent(report) {
1452
1874
  const { stage, value, total, share, status, title, format } = block.props;
1453
1875
  const rows = block.rows ?? [];
1454
1876
  const columns = [
1455
- { field: stage, label: label(stage), sign: "auto", semantics: "neutral" },
1877
+ { field: stage, label: label2(stage), sign: "auto", semantics: "neutral" },
1456
1878
  {
1457
1879
  field: value,
1458
- label: label(value),
1880
+ label: label2(value),
1459
1881
  format,
1460
1882
  sign: "auto",
1461
1883
  semantics: "neutral"
1462
1884
  },
1463
1885
  {
1464
1886
  field: total,
1465
- label: label(total),
1887
+ label: label2(total),
1466
1888
  format,
1467
1889
  sign: "auto",
1468
1890
  semantics: "neutral"
@@ -1582,13 +2004,13 @@ function renderContent(report) {
1582
2004
  dataDisclosure(rows, [
1583
2005
  {
1584
2006
  field: category,
1585
- label: label(category),
2007
+ label: label2(category),
1586
2008
  sign: "auto",
1587
2009
  semantics: "neutral"
1588
2010
  },
1589
2011
  {
1590
2012
  field: value,
1591
- label: label(value),
2013
+ label: label2(value),
1592
2014
  format,
1593
2015
  sign: "auto",
1594
2016
  semantics: "neutral"
@@ -1623,8 +2045,8 @@ function renderContent(report) {
1623
2045
  payload.charts.push(chart);
1624
2046
  }
1625
2047
  const columns = [
1626
- { field: x, label: label(x), sign: "auto", semantics: "neutral" },
1627
- { 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" }
1628
2050
  ];
1629
2051
  return [
1630
2052
  h("figure", { className: ["chart"] }, [
@@ -1672,7 +2094,7 @@ function documentHtml(title, description, body, styles, scripts, preview, hash,
1672
2094
  function renderHtml(report, assets, hash, preview = false, revision = 0) {
1673
2095
  const content = renderContent(report);
1674
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>` : "";
1675
- return documentHtml(report.metadata.title, report.metadata.description, images + content.html, assets.styles, [content.hasCharts ? assets.charts : assets.base], preview, hash, 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);
1676
2098
  }
1677
2099
  function renderDiagnostics(diagnostics, assets, hash, preview = true, revision = 0) {
1678
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("")}`;
@@ -1913,7 +2335,7 @@ data: ${revision}
1913
2335
  // package.json
1914
2336
  var package_default = {
1915
2337
  name: "plotleaf",
1916
- version: "0.3.2",
2338
+ version: "0.4.0",
1917
2339
  description: "Beautiful, interactive reports from Markdown and JSON. Built for agents.",
1918
2340
  type: "module",
1919
2341
  license: "MIT",
@@ -1940,6 +2362,8 @@ var package_default = {
1940
2362
  "dist/assets/notices.txt",
1941
2363
  "schema/components.json",
1942
2364
  "examples/report.md",
2365
+ "examples/interactions/*.md",
2366
+ "examples/interactions/*.json",
1943
2367
  "examples/storefront/*.md",
1944
2368
  "examples/storefront/*.json",
1945
2369
  "examples/adoption/*.md",