plotleaf 0.4.1 → 0.5.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,9 +65,138 @@ function displayPath(filename) {
65
65
  return path.relative(process.cwd(), filename) || path.basename(filename);
66
66
  }
67
67
 
68
- // src/compiler/exploration-schema.ts
68
+ // src/compiler/subtotal-schema.ts
69
69
  import * as z from "zod";
70
70
 
71
+ // src/shared/limits.ts
72
+ var limits = {
73
+ markdownBytes: 1024 ** 2,
74
+ jsonBytes: 10 * 1024 ** 2,
75
+ totalBytes: 25 * 1024 ** 2,
76
+ datasets: 64,
77
+ rows: 1e5,
78
+ chartRows: 1e4,
79
+ donutSlices: 8,
80
+ funnelStages: 20,
81
+ tableRows: 1000,
82
+ tableColumns: 40,
83
+ sparklinePoints: 64,
84
+ imageFiles: 64,
85
+ imageBytes: 1024 ** 2,
86
+ imageDimension: 4096,
87
+ imagePixels: 4000000
88
+ };
89
+
90
+ // src/compiler/subtotal-schema.ts
91
+ var row = z.record(z.string(), z.unknown());
92
+ var subtotalSchema = z.strictObject({
93
+ version: z.literal(1),
94
+ scope: z.string().trim().min(1).max(1000),
95
+ groups: z.array(z.strictObject({
96
+ id: z.string().trim().min(1).max(1000),
97
+ label: z.string().trim().min(1).max(1000),
98
+ rows: z.array(row).min(1).max(limits.tableRows),
99
+ subtotal: row
100
+ })).max(limits.tableRows)
101
+ }).superRefine((bundle, context) => {
102
+ const ids = new Set;
103
+ let count = 0;
104
+ for (const [index, group] of bundle.groups.entries()) {
105
+ if (ids.has(group.id))
106
+ context.addIssue({
107
+ code: "custom",
108
+ path: ["groups", index, "id"],
109
+ message: "Group IDs must be unique."
110
+ });
111
+ ids.add(group.id);
112
+ count += group.rows.length + 1;
113
+ }
114
+ if (count > limits.tableRows)
115
+ context.addIssue({
116
+ code: "custom",
117
+ path: ["groups"],
118
+ message: `At most ${limits.tableRows} detail and subtotal rows are supported.`
119
+ });
120
+ });
121
+
122
+ // src/compiler/composition-schema.ts
123
+ import * as z2 from "zod";
124
+ var compositionLimits = {
125
+ depth: 16,
126
+ definitions: 64,
127
+ nodes: 512,
128
+ rows: 20000,
129
+ dataBytes: 8 * 1024 * 1024,
130
+ parameters: 32,
131
+ jsonDepth: 32,
132
+ jsonContainers: 1e4
133
+ };
134
+ var identifier = z2.string().regex(/^[A-Za-z][A-Za-z0-9_-]{0,63}$/);
135
+ var literal3 = z2.union([
136
+ z2.string().max(1000),
137
+ z2.number().finite(),
138
+ z2.boolean()
139
+ ]);
140
+ var parameter = z2.discriminatedUnion("type", [
141
+ z2.strictObject({
142
+ type: z2.literal("string"),
143
+ default: z2.string().max(1000).optional()
144
+ }),
145
+ z2.strictObject({
146
+ type: z2.literal("number"),
147
+ default: z2.number().finite().optional()
148
+ }),
149
+ z2.strictObject({
150
+ type: z2.literal("boolean"),
151
+ default: z2.boolean().optional()
152
+ })
153
+ ]);
154
+ var value = z2.union([literal3, z2.strictObject({ prop: identifier })]);
155
+ var node = z2.lazy(() => z2.strictObject({
156
+ name: identifier,
157
+ props: z2.record(identifier, value),
158
+ children: z2.array(node).max(compositionLimits.nodes).optional()
159
+ }));
160
+ var compositionSchema = z2.strictObject({
161
+ version: z2.literal(1),
162
+ props: z2.record(identifier, parameter),
163
+ components: z2.array(node).min(1).max(compositionLimits.nodes)
164
+ }).superRefine((definition, context) => {
165
+ if (Object.hasOwn(definition.props, "src"))
166
+ context.addIssue({
167
+ code: "custom",
168
+ path: ["props", "src"],
169
+ message: "src is reserved for the composition definition path."
170
+ });
171
+ if (Object.keys(definition.props).length > compositionLimits.parameters)
172
+ context.addIssue({
173
+ code: "custom",
174
+ path: ["props"],
175
+ message: `At most ${compositionLimits.parameters} parameters are supported.`
176
+ });
177
+ });
178
+ function checkCompositionStructure(value) {
179
+ const pending = [{ value, depth: 0 }];
180
+ let containers = 0;
181
+ while (pending.length) {
182
+ const current = pending.pop();
183
+ if (current.value === null || typeof current.value !== "object")
184
+ continue;
185
+ if (current.depth > compositionLimits.jsonDepth || ++containers > compositionLimits.jsonContainers)
186
+ return "Composition JSON exceeds the nesting or container limit.";
187
+ for (const [key, child] of Object.entries(current.value)) {
188
+ if (["__proto__", "constructor", "prototype"].includes(key))
189
+ return `Reserved object key ${JSON.stringify(key)}.`;
190
+ if (child !== null && typeof child === "object")
191
+ pending.push({ value: child, depth: current.depth + 1 });
192
+ }
193
+ }
194
+ return;
195
+ }
196
+
197
+ // src/compiler/registry.ts
198
+ import * as z4 from "zod";
199
+
71
200
  // src/shared/exploration.ts
72
201
  var valueKey = (value) => typeof value === "object" ? `${value.start}/${value.end}` : `${typeof value}:${String(value)}`;
73
202
  var explorationLimits = {
@@ -84,85 +213,56 @@ var explorationLimits = {
84
213
  };
85
214
 
86
215
  // 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) => {
216
+ import * as z3 from "zod";
217
+ var id = z3.string().regex(/^[a-z][a-z0-9_-]{0,47}$/).refine((value) => !["constructor", "prototype"].includes(value), "Reserved identifier.");
218
+ var label = z3.string().min(1).max(160);
219
+ var date = z3.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine((value) => {
90
220
  const timestamp = Date.parse(`${value}T00:00:00Z`);
91
221
  return Number.isFinite(timestamp) && new Date(timestamp).toISOString().slice(0, 10) === value;
92
222
  }, "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.");
223
+ var range = z3.strictObject({ start: date, end: date }).refine((value) => value.start <= value.end, "Range start must not follow its end.");
94
224
  var common = { id, label };
95
- var explorationInputSchema = z.discriminatedUnion("type", [
96
- z.strictObject({
225
+ var explorationInputSchema = z3.discriminatedUnion("type", [
226
+ z3.strictObject({
97
227
  ...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)
228
+ type: z3.enum(["select", "segmented", "text"]),
229
+ default: z3.string().max(120),
230
+ options: z3.array(z3.strictObject({ value: z3.string().max(120), label })).min(1).max(explorationLimits.options)
101
231
  }),
102
- z.strictObject({
232
+ z3.strictObject({
103
233
  ...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)
234
+ type: z3.literal("number"),
235
+ default: z3.number().finite(),
236
+ options: z3.array(z3.strictObject({ value: z3.number().finite(), label })).min(1).max(explorationLimits.options)
107
237
  }),
108
- z.strictObject({
238
+ z3.strictObject({
109
239
  ...common,
110
- type: z.literal("toggle"),
111
- default: z.boolean()
240
+ type: z3.literal("toggle"),
241
+ default: z3.boolean()
112
242
  }),
113
- z.strictObject({
243
+ z3.strictObject({
114
244
  ...common,
115
- type: z.literal("date-range"),
245
+ type: z3.literal("date-range"),
116
246
  default: range,
117
- options: z.array(z.strictObject({ value: range, label })).min(1).max(explorationLimits.options)
247
+ options: z3.array(z3.strictObject({ value: range, label })).min(1).max(explorationLimits.options)
118
248
  })
119
249
  ]);
120
- var explorationSchema = z.strictObject({
121
- inputs: z.array(explorationInputSchema).min(1).max(explorationLimits.inputs),
122
- views: z.array(z.strictObject({
250
+ var explorationSchema = z3.strictObject({
251
+ inputs: z3.array(explorationInputSchema).min(1).max(explorationLimits.inputs),
252
+ views: z3.array(z3.strictObject({
123
253
  id,
124
254
  label,
125
- when: z.record(id, z.union([
126
- z.string().max(120),
127
- z.number().finite(),
128
- z.boolean(),
255
+ when: z3.record(id, z3.union([
256
+ z3.string().max(120),
257
+ z3.number().finite(),
258
+ z3.boolean(),
129
259
  range
130
260
  ])),
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({})
261
+ markdown: z3.string().min(1).max(1024 ** 2),
262
+ datasets: z3.record(z3.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]*\.json$/), z3.array(z3.record(z3.string(), z3.unknown())).max(1e4)).default({})
133
263
  })).min(1).max(explorationLimits.views)
134
264
  });
135
265
 
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
-
147
- // src/shared/limits.ts
148
- var limits = {
149
- markdownBytes: 1024 ** 2,
150
- jsonBytes: 10 * 1024 ** 2,
151
- totalBytes: 25 * 1024 ** 2,
152
- datasets: 64,
153
- rows: 1e5,
154
- chartRows: 1e4,
155
- donutSlices: 8,
156
- funnelStages: 20,
157
- tableRows: 1000,
158
- tableColumns: 40,
159
- sparklinePoints: 64,
160
- imageFiles: 64,
161
- imageBytes: 1024 ** 2,
162
- imageDimension: 4096,
163
- imagePixels: 4000000
164
- };
165
-
166
266
  // src/shared/payload.ts
167
267
  var funnelStatuses = {
168
268
  "on-target": "On target",
@@ -172,8 +272,8 @@ var funnelStatuses = {
172
272
  };
173
273
 
174
274
  // 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).");
275
+ var text = z4.string().min(1).max(1000);
276
+ var format = z4.string().regex(/^(number|percent|percentage-points|currency:[A-Z]{3})$/, "Use number, percent, percentage-points or currency:GBP (an ISO currency code).");
177
277
  var chart = {
178
278
  data: text,
179
279
  x: text,
@@ -181,20 +281,31 @@ var chart = {
181
281
  title: text,
182
282
  format: format.default("number")
183
283
  };
184
- var line = z2.strictObject({
284
+ var line = z4.strictObject({
185
285
  ...chart,
186
- xType: z2.enum(["category", "date", "number"]).default("category"),
187
- target: z2.number().finite().optional(),
286
+ xType: z4.enum(["category", "date", "number"]).default("category"),
287
+ target: z4.number().finite().optional(),
188
288
  targetLabel: text.optional()
189
289
  });
190
290
  var registry = {
291
+ component: {
292
+ forms: ["leaf"],
293
+ children: [],
294
+ description: "A declarative composition from a local JSON definition.",
295
+ dataRules: {
296
+ schema: z4.toJSONSchema(compositionSchema),
297
+ limits: compositionLimits
298
+ },
299
+ schema: z4.object({ src: text }).catchall(z4.string().max(1000)),
300
+ example: '::component{src="./components/summary.json" label="Orders" value="1284"}'
301
+ },
191
302
  explore: {
192
303
  forms: ["leaf"],
193
304
  children: [],
194
305
  description: "Scoped native controls select complete agent-prepared views. No analytics or report JavaScript.",
195
- schema: z2.strictObject({ data: text, title: text }),
306
+ schema: z4.strictObject({ data: text, title: text }),
196
307
  dataRules: {
197
- schema: z2.toJSONSchema(explorationSchema),
308
+ schema: z4.toJSONSchema(explorationSchema),
198
309
  resolution: "Relative JSON bundle; inline view datasets override the same plain filename only within that view.",
199
310
  limits: explorationLimits,
200
311
  binding: "Every input affects exactly this exploration. Every view declares all input values. No matching combination hides the previous view."
@@ -205,6 +316,7 @@ var registry = {
205
316
  forms: ["container"],
206
317
  children: [
207
318
  "metric",
319
+ "component",
208
320
  "line",
209
321
  "area",
210
322
  "bar",
@@ -214,8 +326,8 @@ var registry = {
214
326
  "callout"
215
327
  ],
216
328
  description: "A responsive row of one to four components.",
217
- schema: z2.strictObject({
218
- columns: z2.number().int().min(1).max(4).default(2)
329
+ schema: z4.strictObject({
330
+ columns: z4.number().int().min(1).max(4).default(2)
219
331
  }),
220
332
  example: `:::grid{columns="4"}
221
333
  ::metric{label="Teams" value="1492"}
@@ -225,13 +337,13 @@ var registry = {
225
337
  forms: ["leaf"],
226
338
  children: [],
227
339
  description: "A prepared numeric value with optional comparison, direction and sentiment.",
228
- schema: z2.strictObject({
340
+ schema: z4.strictObject({
229
341
  label: text,
230
- value: z2.number().finite(),
342
+ value: z4.number().finite(),
231
343
  format: format.default("number"),
232
344
  comparison: text.optional(),
233
- direction: z2.enum(["up", "down", "flat"]).optional(),
234
- sentiment: z2.enum(["positive", "negative", "neutral"]).default("neutral")
345
+ direction: z4.enum(["up", "down", "flat"]).optional(),
346
+ sentiment: z4.enum(["positive", "negative", "neutral"]).default("neutral")
235
347
  }),
236
348
  example: '::metric{label="Conversion" value="0.103" format="percent"}'
237
349
  },
@@ -253,14 +365,14 @@ var registry = {
253
365
  forms: ["leaf"],
254
366
  children: [],
255
367
  description: "An interactive category bar chart with a zero baseline.",
256
- schema: z2.strictObject(chart),
368
+ schema: z4.strictObject(chart),
257
369
  example: '::bar{data="./errors.json" x="category" y="count" title="Errors by category"}'
258
370
  },
259
371
  donut: {
260
372
  forms: ["leaf"],
261
373
  children: [],
262
374
  description: "A part-to-whole donut with a readable legend and native data table.",
263
- schema: z2.strictObject({
375
+ schema: z4.strictObject({
264
376
  data: text,
265
377
  category: text,
266
378
  value: text,
@@ -278,7 +390,7 @@ var registry = {
278
390
  forms: ["leaf"],
279
391
  children: [],
280
392
  description: "Ordered stage/progress rows with prepared values, denominators, shares and optional explicit status. Works without JavaScript.",
281
- schema: z2.strictObject({
393
+ schema: z4.strictObject({
282
394
  data: text,
283
395
  stage: text,
284
396
  value: text,
@@ -302,7 +414,15 @@ var registry = {
302
414
  forms: ["leaf", "container"],
303
415
  children: ["column"],
304
416
  description: "A semantic sortable table, with inferred or explicitly formatted columns.",
305
- schema: z2.strictObject({ data: text, title: text.optional() }),
417
+ dataRules: {
418
+ grouped: z4.toJSONSchema(subtotalSchema),
419
+ sorting: "Details sort within groups; prepared subtotals stay at the end of their group. Scope is supplied explicitly."
420
+ },
421
+ schema: z4.strictObject({
422
+ data: text,
423
+ title: text.optional(),
424
+ prepared: z4.enum(["grouped"]).optional()
425
+ }),
306
426
  example: `:::table{data="./plans.json" title="By plan"}
307
427
  ::column{field="plan" label="Plan"}
308
428
  ::column{field="change" label="Δ QoQ" format="percent" sign="always"}
@@ -312,15 +432,15 @@ var registry = {
312
432
  forms: ["leaf"],
313
433
  children: [],
314
434
  description: "A scalar, local image or bounded sparkline column. Omitted kind means scalar.",
315
- schema: z2.strictObject({
435
+ schema: z4.strictObject({
316
436
  field: text,
317
437
  label: text.optional(),
318
- kind: z2.enum(["scalar", "image", "sparkline"]).optional(),
438
+ kind: z4.enum(["scalar", "image", "sparkline"]).optional(),
319
439
  alt: text.optional(),
320
440
  sortBy: text.optional(),
321
441
  format: format.optional(),
322
- sign: z2.enum(["auto", "always"]).default("auto"),
323
- semantics: z2.enum(["neutral", "higher-is-better", "lower-is-better"]).default("neutral")
442
+ sign: z4.enum(["auto", "always"]).default("auto"),
443
+ semantics: z4.enum(["neutral", "higher-is-better", "lower-is-better"]).default("neutral")
324
444
  }),
325
445
  example: '::column{field="change" label="Δ QoQ" format="percent" sign="always" semantics="higher-is-better"}',
326
446
  dataRules: {
@@ -333,8 +453,8 @@ var registry = {
333
453
  forms: ["container"],
334
454
  children: ["markdown"],
335
455
  description: "A contextual note or warning containing ordinary Markdown.",
336
- schema: z2.strictObject({
337
- tone: z2.enum(["note", "warning"]).default("note"),
456
+ schema: z4.strictObject({
457
+ tone: z4.enum(["note", "warning"]).default("note"),
338
458
  title: text.optional()
339
459
  }),
340
460
  example: `:::callout{title="Coverage"}
@@ -363,7 +483,7 @@ function componentSpec() {
363
483
  description: entry.description,
364
484
  forms: entry.forms,
365
485
  children: entry.children,
366
- props: z2.toJSONSchema(entry.schema),
486
+ props: z4.toJSONSchema(entry.schema),
367
487
  example: entry.example,
368
488
  ..."dataRules" in entry ? { dataRules: entry.dataRules } : {}
369
489
  }
@@ -433,11 +553,187 @@ function decodeProps(name, attributes) {
433
553
  return { props, errors };
434
554
  }
435
555
 
556
+ // src/compiler/sources.ts
557
+ class LocalFileError extends Error {
558
+ kind;
559
+ constructor(kind, message) {
560
+ super(message);
561
+ this.kind = kind;
562
+ }
563
+ }
564
+
565
+ // src/compiler/composition.ts
566
+ var componentProperties = componentSpec().components;
567
+ var compositionState = () => ({
568
+ definitions: new Map,
569
+ nodes: 0,
570
+ rows: 0,
571
+ dataBytes: 0
572
+ });
573
+ function relativeTo(reference, base) {
574
+ if (!base)
575
+ return reference;
576
+ if (/^(?:\/|[A-Za-z][A-Za-z\d+.-]*:)/.test(reference) || reference.includes("\\"))
577
+ return reference;
578
+ return `${base}/${reference}`;
579
+ }
580
+ function parentOf(reference) {
581
+ const index = reference.lastIndexOf("/");
582
+ return index < 0 ? "" : reference.slice(0, index);
583
+ }
584
+ async function expandComposition(block, files, diagnostics, state, parent, stack = [], origins = {}) {
585
+ const fail = (code, message) => {
586
+ diagnostics.add(code, `${block.props.src}: ${message}`, block.position, "Correct the composition definition and its declared literal parameters.");
587
+ return [];
588
+ };
589
+ let filename;
590
+ try {
591
+ filename = await files.resolve(block.props.src, [".json"]);
592
+ } catch (error) {
593
+ return fail(error instanceof LocalFileError && error.kind === "path" ? "E_COMPONENT_PATH" : "E_COMPONENT_READ", error instanceof Error ? error.message : "Cannot resolve definition.");
594
+ }
595
+ if (stack.includes(filename))
596
+ return fail("E_COMPONENT_CYCLE", "Composition definitions cannot contain cycles.");
597
+ if (stack.length >= compositionLimits.depth)
598
+ return fail("E_COMPONENT_LIMIT", `Composition nesting exceeds ${compositionLimits.depth}.`);
599
+ let definition = state.definitions.get(filename);
600
+ if (!definition) {
601
+ if (state.definitions.size >= compositionLimits.definitions)
602
+ return fail("E_COMPONENT_LIMIT", `At most ${compositionLimits.definitions} definitions are supported.`);
603
+ let raw;
604
+ try {
605
+ raw = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(await files.read(filename, limits.jsonBytes)));
606
+ } catch (error) {
607
+ return fail("E_COMPONENT_READ", error instanceof Error ? error.message : "Cannot read definition JSON.");
608
+ }
609
+ const structural = checkCompositionStructure(raw);
610
+ if (structural)
611
+ return fail("E_COMPONENT_SCHEMA", structural);
612
+ const parsed = compositionSchema.safeParse(raw);
613
+ if (!parsed.success)
614
+ return fail("E_COMPONENT_SCHEMA", parsed.error.issues.slice(0, 8).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; "));
615
+ definition = parsed.data;
616
+ state.definitions.set(filename, definition);
617
+ }
618
+ const supplied = Object.entries(block.props).filter(([name]) => name !== "src");
619
+ for (const [name] of supplied)
620
+ if (!Object.hasOwn(definition.props, name))
621
+ return fail("E_COMPONENT_PROP", `Unknown parameter ${JSON.stringify(name)}.`);
622
+ const base = parentOf(block.props.src);
623
+ const values = Object.create(null);
624
+ const valueOrigins = Object.create(null);
625
+ for (const [name, spec] of Object.entries(definition.props)) {
626
+ const input = block.props[name];
627
+ valueOrigins[name] = input === undefined ? base : origins[name] ?? "";
628
+ if (input === undefined) {
629
+ if (spec.default === undefined)
630
+ return fail("E_COMPONENT_PROP", `Missing required parameter ${JSON.stringify(name)}.`);
631
+ values[name] = spec.default;
632
+ } else if (spec.type === "number") {
633
+ if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(input) || !Number.isFinite(Number(input)))
634
+ return fail("E_COMPONENT_PROP", `${name} must be a finite number.`);
635
+ values[name] = Number(input);
636
+ } else if (spec.type === "boolean") {
637
+ if (input !== "true" && input !== "false")
638
+ return fail("E_COMPONENT_PROP", `${name} must be true or false.`);
639
+ values[name] = input === "true";
640
+ } else
641
+ values[name] = input;
642
+ }
643
+ const build = async (nodes, context, depth) => {
644
+ const out = [];
645
+ if (depth > compositionLimits.depth)
646
+ return fail("E_COMPONENT_LIMIT", "Component nesting exceeds the composition limit.");
647
+ for (const node of nodes) {
648
+ if (++state.nodes > compositionLimits.nodes)
649
+ return fail("E_COMPONENT_LIMIT", `Expanded components exceed ${compositionLimits.nodes}.`);
650
+ if (!Object.hasOwn(registry, node.name)) {
651
+ fail("E_COMPONENT_UNKNOWN", `Unknown built-in ${JSON.stringify(node.name)}.`);
652
+ continue;
653
+ }
654
+ const name = node.name;
655
+ const entry = registry[name];
656
+ const allowed = context === "root" ? name !== "column" : registry[context]?.children?.includes(name);
657
+ if (!allowed) {
658
+ fail("E_COMPONENT_CONTEXT", `${name} is not allowed inside ${context}.`);
659
+ continue;
660
+ }
661
+ const attrs = Object.create(null);
662
+ const childOrigins = Object.create(null);
663
+ const properties = componentProperties[name].props.properties ?? {};
664
+ let bad = false;
665
+ for (const [key, value] of Object.entries(node.props)) {
666
+ const dynamic = typeof value === "object";
667
+ if (dynamic && !Object.hasOwn(values, value.prop)) {
668
+ fail("E_COMPONENT_PROP", `Unknown parameter reference ${JSON.stringify(value.prop)}.`);
669
+ bad = true;
670
+ continue;
671
+ }
672
+ let resolved = dynamic ? values[value.prop] : value;
673
+ const property = properties[key];
674
+ const expected = property?.type === "integer" ? "number" : property?.type;
675
+ if (name !== "component" && expected && expected !== typeof resolved) {
676
+ fail("E_COMPONENT_PROP", `${name}.${key} requires ${expected}; received ${typeof resolved}.`);
677
+ bad = true;
678
+ continue;
679
+ }
680
+ if (typeof resolved === "string" && (key === "data" && [
681
+ "line",
682
+ "area",
683
+ "bar",
684
+ "donut",
685
+ "funnel",
686
+ "table",
687
+ "explore"
688
+ ].includes(name) || key === "src" && name === "component"))
689
+ resolved = relativeTo(resolved, dynamic ? valueOrigins[value.prop] : base);
690
+ attrs[key] = String(resolved);
691
+ if (name === "component" && key !== "src")
692
+ childOrigins[key] = dynamic ? valueOrigins[value.prop] : base;
693
+ }
694
+ if (bad)
695
+ continue;
696
+ const decoded = decodeProps(name, attrs);
697
+ if (decoded.errors.length) {
698
+ fail("E_COMPONENT_PROP", `${name}: ${decoded.errors.join("; ")}`);
699
+ continue;
700
+ }
701
+ const children = node.children ?? [];
702
+ if (children.length && !entry.forms.includes("container")) {
703
+ fail("E_COMPONENT_CONTEXT", `${name} cannot contain children.`);
704
+ continue;
705
+ }
706
+ const built = {
707
+ type: "component",
708
+ name,
709
+ props: decoded.props,
710
+ children: [],
711
+ position: block.position,
712
+ compositionOrigin: true
713
+ };
714
+ if (name === "component") {
715
+ out.push(...await expandComposition(built, files, diagnostics, state, context, [...stack, filename], childOrigins));
716
+ } else {
717
+ built.children = await build(children, name, depth + 1);
718
+ out.push(built);
719
+ }
720
+ }
721
+ return out;
722
+ };
723
+ return build(definition.components, parent, 0);
724
+ }
725
+
436
726
  // src/compiler/parse.ts
727
+ import { unified } from "unified";
728
+ import remarkParse from "remark-parse";
729
+ import remarkDirective from "remark-directive";
730
+ import remarkFrontmatter from "remark-frontmatter";
731
+ import { parseDocument, visit } from "yaml";
732
+ import * as z5 from "zod";
437
733
  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()
734
+ var metadataSchema = z5.strictObject({
735
+ title: z5.string().min(1).max(500).optional(),
736
+ description: z5.string().max(2000).optional()
441
737
  });
442
738
  function textOf(node) {
443
739
  return node.value ?? node.children?.map(textOf).join("") ?? "";
@@ -602,15 +898,6 @@ function parseReport(source, fallbackTitle, diagnostics) {
602
898
  return { metadata, blocks: reportBlocks };
603
899
  }
604
900
 
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
901
  // src/compiler/exploration.ts
615
902
  var expansionBudget = () => ({
616
903
  explorations: 0,
@@ -814,12 +1101,16 @@ class DataResolver {
814
1101
  files;
815
1102
  images;
816
1103
  expansion;
1104
+ compositions;
1105
+ inExploration;
817
1106
  cache;
818
- constructor(diagnostics, files, images, expansion = expansionBudget(), cache = new Map) {
1107
+ constructor(diagnostics, files, images, expansion = expansionBudget(), compositions = compositionState(), inExploration = false, cache = new Map) {
819
1108
  this.diagnostics = diagnostics;
820
1109
  this.files = files;
821
1110
  this.images = images;
822
1111
  this.expansion = expansion;
1112
+ this.compositions = compositions;
1113
+ this.inExploration = inExploration;
823
1114
  this.cache = cache;
824
1115
  }
825
1116
  async load(reference, block) {
@@ -849,8 +1140,29 @@ class DataResolver {
849
1140
  } catch {
850
1141
  return fail("E_DATA_JSON", "Invalid JSON.", "Use a JSON array of records, without comments or trailing commas.");
851
1142
  }
1143
+ let grouped;
1144
+ if (block.name === "table" && block.props.prepared === "grouped") {
1145
+ const structural = checkCompositionStructure(parsed);
1146
+ if (structural)
1147
+ return fail("E_TABLE_GROUPS", structural);
1148
+ const result = subtotalSchema.safeParse(parsed);
1149
+ if (!result.success)
1150
+ return fail("E_TABLE_GROUPS", result.error.issues.slice(0, 8).map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; "));
1151
+ grouped = {
1152
+ scope: result.data.scope,
1153
+ groups: result.data.groups.map((group) => ({
1154
+ id: group.id,
1155
+ label: group.label,
1156
+ size: group.rows.length
1157
+ }))
1158
+ };
1159
+ parsed = result.data.groups.flatMap((group) => [
1160
+ ...group.rows,
1161
+ group.subtotal
1162
+ ]);
1163
+ }
852
1164
  if (!Array.isArray(parsed))
853
- return fail("E_DATA_SHAPE", "Expected an array of flat JSON records.");
1165
+ return fail("E_DATA_SHAPE", "Expected an array of flat JSON records, or declare prepared=grouped for a subtotal bundle.");
854
1166
  if (parsed.length > limits.rows)
855
1167
  return fail("E_DATA_LIMIT", `Dataset exceeds ${limits.rows.toLocaleString("en-GB")} rows.`, "Prepare a smaller dataset before rendering.");
856
1168
  const arrays = new Map;
@@ -871,21 +1183,44 @@ class DataResolver {
871
1183
  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
1184
  }
873
1185
  }
874
- dataset = { rows: parsed, arrays };
1186
+ dataset = {
1187
+ rows: parsed,
1188
+ arrays,
1189
+ ...grouped ? { grouped } : {}
1190
+ };
875
1191
  this.cache.set(canonical, dataset);
876
1192
  }
1193
+ if (Boolean(dataset.grouped) !== (block.name === "table" && block.props.prepared === "grouped"))
1194
+ return fail("E_DATA_SHAPE", "Grouped subtotal bundles require prepared=grouped and cannot be used as ordinary datasets.");
1195
+ if (block.name === "table" && dataset.grouped)
1196
+ block.tableGroups = dataset.grouped;
877
1197
  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
1198
  for (const [field, index] of dataset.arrays)
879
1199
  if (!declared.has(field))
880
1200
  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
1201
  return dataset.rows;
882
1202
  }
883
- async resolve(blocks) {
884
- for (const block of blocks) {
1203
+ async resolve(blocks, parent = "root") {
1204
+ for (let index = 0;index < blocks.length; index++) {
1205
+ let block = blocks[index];
1206
+ if (block.type === "component" && block.name === "component") {
1207
+ const expanded = await expandComposition(block, this.files, this.diagnostics, this.compositions, parent);
1208
+ blocks.splice(index, 1, ...expanded);
1209
+ if (!expanded.length) {
1210
+ index--;
1211
+ continue;
1212
+ }
1213
+ index--;
1214
+ continue;
1215
+ }
885
1216
  if (block.type !== "component")
886
1217
  continue;
887
1218
  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);
1219
+ if (this.inExploration) {
1220
+ this.diagnostics.add("E_EXPLORE_SCHEMA", "Nested explorations are unsupported, including inside compositions.", block.position);
1221
+ continue;
1222
+ }
1223
+ await resolveExploration(block, this.files, this.diagnostics, (children, files, diagnostics) => new DataResolver(diagnostics, files, this.images, this.expansion, this.compositions, true, this.cache).resolve(children), this.expansion);
889
1224
  continue;
890
1225
  }
891
1226
  if (block.name === "line" || block.name === "area" || block.name === "bar" || block.name === "donut" || block.name === "funnel" || block.name === "table") {
@@ -901,7 +1236,15 @@ class DataResolver {
901
1236
  this.resolveChart(block, rows);
902
1237
  }
903
1238
  }
904
- await this.resolve(block.children);
1239
+ if (block.compositionOrigin && block.rows) {
1240
+ this.compositions.rows += block.rows.length;
1241
+ this.compositions.dataBytes += new TextEncoder().encode(JSON.stringify(block.rows)).length;
1242
+ if (this.compositions.rows > compositionLimits.rows || this.compositions.dataBytes > compositionLimits.dataBytes) {
1243
+ this.diagnostics.add("E_COMPONENT_LIMIT", "Expanded composition data exceeds its row or byte budget.", block.position, "Prepare a smaller dataset or reuse fewer components.");
1244
+ return;
1245
+ }
1246
+ }
1247
+ await this.resolve(block.children, block.name);
905
1248
  }
906
1249
  }
907
1250
  field(rows, name, block) {
@@ -1686,7 +2029,8 @@ var serializer = unified2().use(rehypeSanitize, {
1686
2029
  ul: ["className", "role"],
1687
2030
  ol: ["start", "className", "role"],
1688
2031
  li: ["className", "dataStatus"],
1689
- th: ["scope", "ariaSort", "className"],
2032
+ th: ["scope", "ariaSort", "className", "colSpan"],
2033
+ tr: ["className", "dataPlotleafGroupHeader", "dataPlotleafSubtotal"],
1690
2034
  td: ["className", "dataRaw"]
1691
2035
  }
1692
2036
  }).use(rehypeStringify);
@@ -1700,11 +2044,14 @@ function slugOf(text) {
1700
2044
  function cellText(value, column) {
1701
2045
  return value === null ? "—" : typeof value === "number" ? formatValue(value, column.format, column.sign) : String(value);
1702
2046
  }
1703
- function table(rows, columns, title, id, sortable = true) {
2047
+ function table(rows, columns, title, id, sortable = true, groups) {
1704
2048
  const scales = columns.map((column) => column.kind === "sparkline" ? columnScale(rows, column.field) : undefined);
1705
2049
  const canSort = columns.map((column) => sortable && (!column.kind || column.kind === "scalar" || Boolean(column.sortBy)));
1706
2050
  const numeric = columns.map((column) => column.kind === "sparkline" || rows.some((row) => typeof row[column.field] === "number") || Boolean(column.format));
1707
- const caption = h("caption", {}, [t(title)]);
2051
+ const caption = h("caption", {}, [
2052
+ t(title),
2053
+ ...groups ? [h("span", { className: ["table-scope"] }, [t(groups.scope)])] : []
2054
+ ]);
1708
2055
  const headers = columns.map((column, index) => {
1709
2056
  const name = column.label ?? label2(column.field);
1710
2057
  const content = canSort[index] ? h("button", {
@@ -1738,12 +2085,27 @@ function table(rows, columns, title, id, sortable = true) {
1738
2085
  });
1739
2086
  return h("tr", {}, cells);
1740
2087
  });
1741
- const body = h("tbody", {}, tableRows);
2088
+ let offset = 0;
2089
+ const bodies = groups ? groups.groups.map((group) => {
2090
+ const entries = tableRows.slice(offset, offset + group.size + 1);
2091
+ offset += group.size + 1;
2092
+ const subtotal = entries.at(-1);
2093
+ subtotal.properties.dataPlotleafSubtotal = "";
2094
+ subtotal.properties.className = ["table-subtotal"];
2095
+ return h("tbody", {}, [
2096
+ h("tr", { dataPlotleafGroupHeader: "", className: ["table-group"] }, [
2097
+ h("th", { scope: "rowgroup", colSpan: columns.length }, [
2098
+ t(group.label)
2099
+ ])
2100
+ ]),
2101
+ ...entries
2102
+ ]);
2103
+ }) : [h("tbody", {}, tableRows)];
1742
2104
  return h("div", { className: ["table-scroll"] }, [
1743
2105
  h("table", { id, ...sortable ? { dataPlotleafTable: "" } : {} }, [
1744
2106
  caption,
1745
2107
  head,
1746
- body
2108
+ ...bodies
1747
2109
  ])
1748
2110
  ]);
1749
2111
  }
@@ -1798,6 +2160,8 @@ function renderContent(report) {
1798
2160
  }
1799
2161
  const id = `plotleaf-component-${String(++sequence).padStart(4, "0")}`;
1800
2162
  switch (block.name) {
2163
+ case "component":
2164
+ return [];
1801
2165
  case "explore":
1802
2166
  return exploration(block, id, render, payload.explorations ??= []);
1803
2167
  case "grid":
@@ -1850,7 +2214,7 @@ function renderContent(report) {
1850
2214
  ];
1851
2215
  case "table":
1852
2216
  return [
1853
- table(block.rows ?? [], block.columns ?? [], block.props.title ?? "Data", id),
2217
+ table(block.rows ?? [], block.columns ?? [], block.props.title ?? "Data", id, true, block.tableGroups),
1854
2218
  ...!block.rows?.length ? [
1855
2219
  h("p", { className: ["empty-state"] }, [
1856
2220
  t("No data to display.")
@@ -2335,7 +2699,7 @@ data: ${revision}
2335
2699
  // package.json
2336
2700
  var package_default = {
2337
2701
  name: "plotleaf",
2338
- version: "0.4.1",
2702
+ version: "0.5.0",
2339
2703
  description: "Beautiful, interactive reports from Markdown and JSON. Built for agents.",
2340
2704
  type: "module",
2341
2705
  license: "MIT",
@@ -2362,6 +2726,8 @@ var package_default = {
2362
2726
  "dist/assets/notices.txt",
2363
2727
  "schema/components.json",
2364
2728
  "examples/report.md",
2729
+ "examples/m6/*.md",
2730
+ "examples/m6/*.json",
2365
2731
  "examples/interactions/*.md",
2366
2732
  "examples/interactions/*.json",
2367
2733
  "examples/storefront/*.md",