mviz 1.6.4 → 1.6.7

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.
Files changed (47) hide show
  1. package/README.md +8 -8
  2. package/dist/charts/area.js +8 -36
  3. package/dist/charts/bar.js +8 -26
  4. package/dist/charts/bubble.js +9 -7
  5. package/dist/charts/combo.js +17 -39
  6. package/dist/charts/dumbbell.js +15 -14
  7. package/dist/charts/funnel.js +12 -7
  8. package/dist/charts/heatmap.js +8 -6
  9. package/dist/charts/line.js +6 -27
  10. package/dist/charts/scatter.js +7 -5
  11. package/dist/cli.js +4 -3
  12. package/dist/components/big_value.d.ts +23 -1
  13. package/dist/components/big_value.js +84 -25
  14. package/dist/components/delta.d.ts +24 -1
  15. package/dist/components/delta.js +63 -17
  16. package/dist/components/table-interactivity.d.ts +69 -0
  17. package/dist/components/table-interactivity.js +216 -0
  18. package/dist/components/table.d.ts +6 -1
  19. package/dist/components/table.js +53 -12
  20. package/dist/core/chart-helpers.d.ts +59 -5
  21. package/dist/core/chart-helpers.js +84 -5
  22. package/dist/core/formatting.d.ts +61 -4
  23. package/dist/core/formatting.js +216 -17
  24. package/dist/core/lint-rules/registry.d.ts +4 -2
  25. package/dist/core/lint-rules/registry.js +6 -1
  26. package/dist/core/lint-rules/rules/index.d.ts +1 -0
  27. package/dist/core/lint-rules/rules/index.js +1 -0
  28. package/dist/core/lint-rules/rules/pct-scalar-gt-one.d.ts +13 -0
  29. package/dist/core/lint-rules/rules/pct-scalar-gt-one.js +46 -0
  30. package/dist/core/lint-rules/types.d.ts +12 -0
  31. package/dist/core/linter.d.ts +10 -2
  32. package/dist/core/linter.js +60 -12
  33. package/dist/layout/block-loader.d.ts +31 -0
  34. package/dist/layout/block-loader.js +143 -0
  35. package/dist/layout/layout-resolver.d.ts +33 -0
  36. package/dist/layout/layout-resolver.js +73 -0
  37. package/dist/layout/markdown-parser.d.ts +34 -0
  38. package/dist/layout/markdown-parser.js +395 -0
  39. package/dist/layout/parser-types.d.ts +116 -0
  40. package/dist/layout/parser-types.js +11 -0
  41. package/dist/layout/parser.d.ts +31 -22
  42. package/dist/layout/parser.js +118 -1006
  43. package/dist/layout/renderer.d.ts +33 -0
  44. package/dist/layout/renderer.js +450 -0
  45. package/dist/types.d.ts +1 -1
  46. package/package.json +6 -6
  47. package/schema/mviz.v1.schema.json +402 -33
@@ -40,8 +40,11 @@ export function localeFixed(value, decimals, locale) {
40
40
  * Format a number according to the specified format type
41
41
  * @param showSign - If true, prefix positive numbers with +
42
42
  * @param currency - Optional currency config for currency formats (defaults to USD)
43
+ * @param pctMultiply - For pct formats: multiply the value by 100 before formatting.
44
+ * Default true. Set false when the caller has already determined that the series
45
+ * values are in percentage units (via detectPctMultiply).
43
46
  */
44
- export function formatNumber(value, format, showSign = false, currency) {
47
+ export function formatNumber(value, format, showSign = false, currency, pctMultiply = true) {
45
48
  if (value === null || value === undefined || isNaN(value)) {
46
49
  return '';
47
50
  }
@@ -69,10 +72,10 @@ export function formatNumber(value, format, showSign = false, currency) {
69
72
  break;
70
73
  case 'pct':
71
74
  case 'pct1':
72
- result = formatPct1(value);
75
+ result = formatPct1(value, pctMultiply);
73
76
  break;
74
77
  case 'pct0':
75
- result = formatPct0(value);
78
+ result = formatPct0(value, pctMultiply);
76
79
  break;
77
80
  case 'num0':
78
81
  result = formatNum0(value);
@@ -89,6 +92,12 @@ export function formatNumber(value, format, showSign = false, currency) {
89
92
  case 'num0b':
90
93
  result = formatNum0b(value);
91
94
  break;
95
+ case 'duration':
96
+ result = formatDuration(value);
97
+ break;
98
+ case 'date':
99
+ result = formatDateValue(value);
100
+ break;
92
101
  default:
93
102
  result = smartFormatNumber(value, false, cur);
94
103
  }
@@ -195,17 +204,199 @@ function formatCurrency0b(value, cur) {
195
204
  const formatted = cur.symbol + localeFixed(absValue / 1_000_000_000, 1, cur.locale) + 'b';
196
205
  return isNegative ? `(${formatted})` : formatted;
197
206
  }
207
+ /**
208
+ * Render a number of seconds as a compact duration string: `"1m7s"`,
209
+ * `"25m39s"`, `"1h2m3s"`. Sub-second values render as `"0.5s"`. Negatives
210
+ * are wrapped in parens like the rest of the formatters.
211
+ */
212
+ function formatDuration(seconds) {
213
+ if (seconds === 0)
214
+ return '0s';
215
+ const neg = seconds < 0;
216
+ let s = Math.abs(seconds);
217
+ const h = Math.floor(s / 3600);
218
+ s -= h * 3600;
219
+ const m = Math.floor(s / 60);
220
+ s -= m * 60;
221
+ let body = '';
222
+ if (h)
223
+ body += `${h}h`;
224
+ if (m)
225
+ body += `${m}m`;
226
+ if (s || (!h && !m)) {
227
+ const secStr = Number.isInteger(s) ? `${s}` : s.toFixed(2).replace(/\.?0+$/, '');
228
+ body += `${secStr}s`;
229
+ }
230
+ return neg ? `(${body})` : body;
231
+ }
232
+ /**
233
+ * Render an epoch-ms number as an ISO `YYYY-MM-DD` date string.
234
+ * String inputs are handled separately by the renderer (passed through),
235
+ * so this only fires when the value is numeric.
236
+ */
237
+ function formatDateValue(value) {
238
+ const d = new Date(value);
239
+ if (isNaN(d.getTime()))
240
+ return String(value);
241
+ const yyyy = d.getUTCFullYear();
242
+ const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
243
+ const dd = String(d.getUTCDate()).padStart(2, '0');
244
+ return `${yyyy}-${mm}-${dd}`;
245
+ }
246
+ /**
247
+ * Convert a cell value to a numeric (or string) sort key based on the
248
+ * declared column format. The result is what the table emits as
249
+ * `data-sort` so the client-side compare always sees comparable values.
250
+ *
251
+ * - `date` → epoch ms (accepts Date, ISO string, or numeric ms)
252
+ * - `duration` → total seconds (accepts number-of-seconds or `1m7s` style)
253
+ * - currency / pct / num / auto → underlying numeric value
254
+ *
255
+ * Returns null when the value can't be coerced for the declared format —
256
+ * the caller falls back to its untyped heuristic chain.
257
+ */
258
+ export function formatSortKey(value, format) {
259
+ if (value === null || value === undefined)
260
+ return null;
261
+ if (!format)
262
+ return null;
263
+ if (format === 'date') {
264
+ if (value instanceof Date) {
265
+ const t = value.getTime();
266
+ return isNaN(t) ? null : t;
267
+ }
268
+ if (typeof value === 'number' && !isNaN(value))
269
+ return value;
270
+ if (typeof value === 'string') {
271
+ const t = Date.parse(value);
272
+ return isNaN(t) ? null : t;
273
+ }
274
+ return null;
275
+ }
276
+ if (format === 'duration') {
277
+ if (typeof value === 'number' && !isNaN(value))
278
+ return value;
279
+ if (typeof value === 'string') {
280
+ return parseDurationString(value);
281
+ }
282
+ return null;
283
+ }
284
+ // Numeric-typed formats (currency*, pct*, num*, auto): pass numeric values
285
+ // through, parse simple numeric strings. Pre-formatted strings are *not*
286
+ // parsed — pass numeric values for typed columns.
287
+ if (typeof value === 'number' && !isNaN(value))
288
+ return value;
289
+ if (typeof value === 'string') {
290
+ const trimmed = value.trim();
291
+ const n = parseFloat(trimmed);
292
+ if (!isNaN(n) && String(n) === trimmed)
293
+ return n;
294
+ }
295
+ return null;
296
+ }
297
+ /**
298
+ * Parse a duration string like `"10s"`, `"1m7s"`, `"25m39s"`, `"1h2m3s"`
299
+ * into total seconds. Returns null when the input doesn't match.
300
+ * Exported for the untyped fallback path in `cellSortAttr`.
301
+ *
302
+ * When `requireSeconds` is true, only strings that contain an `s` segment
303
+ * match — bare `5m` or `1h` are rejected. The untyped fallback uses this
304
+ * stricter form because a bare magnitude like `5m` is ambiguous (5 minutes
305
+ * or 5 million?); the lenient form is for columns that explicitly declare
306
+ * `fmt: "duration"`.
307
+ */
308
+ export function parseDurationString(s, options = {}) {
309
+ const trimmed = s.trim();
310
+ const m = trimmed.match(/^(?:(\d+)h)?(?:(\d+)m)?(?:(\d+(?:\.\d+)?)s)?$/);
311
+ if (!m)
312
+ return null;
313
+ const [, h, mm, ss] = m;
314
+ if (h === undefined && mm === undefined && ss === undefined)
315
+ return null;
316
+ if (options.requireSeconds && ss === undefined)
317
+ return null;
318
+ const hours = h ? parseInt(h, 10) : 0;
319
+ const mins = mm ? parseInt(mm, 10) : 0;
320
+ const secs = ss ? parseFloat(ss) : 0;
321
+ return hours * 3600 + mins * 60 + secs;
322
+ }
323
+ /**
324
+ * Parse an ISO-8601 date string (`YYYY-MM-DD` with optional time/timezone)
325
+ * into epoch ms. Returns null for non-ISO strings so they fall back to
326
+ * locale sort instead of mis-parsing through `Date.parse`.
327
+ */
328
+ export function parseIsoDateString(s) {
329
+ const trimmed = s.trim();
330
+ const iso = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
331
+ if (!iso.test(trimmed))
332
+ return null;
333
+ const t = Date.parse(trimmed);
334
+ return isNaN(t) ? null : t;
335
+ }
336
+ /**
337
+ * Detect whether a series of values needs to be multiplied by 100 for percent formatting.
338
+ * Returns true when the series looks fractional (max |v| <= 1). Returns false when any
339
+ * value exceeds 1, indicating the series is already in percentage units (e.g., [15, 22, 38]).
340
+ *
341
+ * Empty / all-null series default to true (fractional), matching the legacy behavior.
342
+ */
343
+ export function detectPctMultiply(values) {
344
+ let max = 0;
345
+ let sawAny = false;
346
+ for (const v of values) {
347
+ if (v === null || v === undefined || isNaN(v))
348
+ continue;
349
+ sawAny = true;
350
+ const abs = Math.abs(v);
351
+ if (abs > max)
352
+ max = abs;
353
+ }
354
+ if (!sawAny)
355
+ return true;
356
+ return max <= 1;
357
+ }
358
+ /**
359
+ * Series-aware pctMultiply for a chart/table column.
360
+ *
361
+ * For non-pct formats, always returns true (no effect — the flag is unused for those).
362
+ * For pct formats, returns false when the series contains a value > 1, so callers can
363
+ * format already-percent data like [15, 22, 38] as 15%, 22%, 38% instead of 1500% etc.
364
+ */
365
+ export function shouldPctMultiply(format, values) {
366
+ if (format !== 'pct' && format !== 'pct0' && format !== 'pct1')
367
+ return true;
368
+ return detectPctMultiply(values);
369
+ }
370
+ /**
371
+ * Pull numeric values out of a data array for one or more field names.
372
+ * Used by charts to sample a y-series for pct auto-detection.
373
+ */
374
+ export function collectNumericFieldValues(data, fields) {
375
+ const out = [];
376
+ if (!data)
377
+ return out;
378
+ for (const row of data) {
379
+ for (const f of fields) {
380
+ const v = row[f];
381
+ if (typeof v === 'number' && !isNaN(v))
382
+ out.push(v);
383
+ }
384
+ }
385
+ return out;
386
+ }
198
387
  /**
199
388
  * Format as percentage with 1 decimal: 15.0%
200
389
  */
201
- function formatPct1(value) {
202
- return (value * 100).toFixed(1) + '%';
390
+ function formatPct1(value, pctMultiply = true) {
391
+ const scaled = pctMultiply ? value * 100 : value;
392
+ return scaled.toFixed(1) + '%';
203
393
  }
204
394
  /**
205
395
  * Format as percentage with 0 decimals: 15%
206
396
  */
207
- function formatPct0(value) {
208
- return Math.round(value * 100) + '%';
397
+ function formatPct0(value, pctMultiply = true) {
398
+ const scaled = pctMultiply ? value * 100 : value;
399
+ return Math.round(scaled) + '%';
209
400
  }
210
401
  /**
211
402
  * Format as number with 0 decimals: 1,250
@@ -394,8 +585,9 @@ export function parseNumericString(value) {
394
585
  * @param currencySymbol - Symbol to use for currency formats (defaults to '$')
395
586
  * @param locale - Locale for number formatting (defaults to 'en-US')
396
587
  */
397
- export function formatValue(fmt, currencySymbol = '$', locale = 'en-US') {
588
+ export function formatValue(fmt, currencySymbol = '$', locale = 'en-US', pctMultiply = true) {
398
589
  const locStr = JSON.stringify(locale);
590
+ const pctExpr = pctMultiply ? '(value * 100)' : 'value';
399
591
  switch (fmt) {
400
592
  case 'currency':
401
593
  return `'${currencySymbol}' + value.toLocaleString(${locStr})`;
@@ -407,9 +599,9 @@ export function formatValue(fmt, currencySymbol = '$', locale = 'en-US') {
407
599
  return `'${currencySymbol}' + (value/1000000000).toLocaleString(${locStr}, {minimumFractionDigits:1, maximumFractionDigits:1}) + 'b'`;
408
600
  case 'pct':
409
601
  case 'pct1':
410
- return "(value * 100).toFixed(1) + '%'";
602
+ return `${pctExpr}.toFixed(1) + '%'`;
411
603
  case 'pct0':
412
- return "(value * 100).toFixed(0) + '%'";
604
+ return `${pctExpr}.toFixed(0) + '%'`;
413
605
  case 'num0':
414
606
  return "value.toLocaleString()";
415
607
  case 'num1':
@@ -430,7 +622,7 @@ export function formatValue(fmt, currencySymbol = '$', locale = 'en-US') {
430
622
  * @param currencySymbol - Symbol to use for currency formats (defaults to '$')
431
623
  * @param locale - Locale for number formatting (defaults to 'en-US')
432
624
  */
433
- export function getLabelFormatterJs(fmt, currencySymbol = '$', locale = 'en-US') {
625
+ export function getLabelFormatterJs(fmt, currencySymbol = '$', locale = 'en-US', pctMultiply = true) {
434
626
  if (fmt === null || fmt === undefined) {
435
627
  return null;
436
628
  }
@@ -473,7 +665,7 @@ export function getLabelFormatterJs(fmt, currencySymbol = '$', locale = 'en-US')
473
665
  };
474
666
  }
475
667
  // Get the formatting expression for specific formats
476
- const expr = formatValue(fmt, currencySymbol, locale);
668
+ const expr = formatValue(fmt, currencySymbol, locale, pctMultiply);
477
669
  if (expr === 'value') {
478
670
  return null;
479
671
  }
@@ -488,7 +680,7 @@ export function getLabelFormatterJs(fmt, currencySymbol = '$', locale = 'en-US')
488
680
  * @param currencySymbol - Symbol to use for currency formats (defaults to '$')
489
681
  * @param locale - Locale for number formatting (defaults to 'en-US')
490
682
  */
491
- export function getAxisFormatterJs(fmt, currencySymbol = '$', locale = 'en-US') {
683
+ export function getAxisFormatterJs(fmt, currencySymbol = '$', locale = 'en-US', pctMultiply = true) {
492
684
  if (fmt === null || fmt === undefined) {
493
685
  return null;
494
686
  }
@@ -526,19 +718,26 @@ export function getAxisFormatterJs(fmt, currencySymbol = '$', locale = 'en-US')
526
718
  }`,
527
719
  };
528
720
  }
529
- // For percentage, multiply by 100
721
+ // For percentage, multiply by 100 unless the caller signals the series is already in
722
+ // percent units (pctMultiply === false).
530
723
  if (fmt === 'pct' || fmt === 'pct1') {
724
+ const body = pctMultiply
725
+ ? `return (value * 100).toFixed(1) + '%';`
726
+ : `return value.toFixed(1) + '%';`;
531
727
  return {
532
- _js_: `function(value) { return (value * 100).toFixed(1) + '%'; }`,
728
+ _js_: `function(value) { ${body} }`,
533
729
  };
534
730
  }
535
731
  if (fmt === 'pct0') {
732
+ const body = pctMultiply
733
+ ? `return (value * 100).toFixed(0) + '%';`
734
+ : `return value.toFixed(0) + '%';`;
536
735
  return {
537
- _js_: `function(value) { return (value * 100).toFixed(0) + '%'; }`,
736
+ _js_: `function(value) { ${body} }`,
538
737
  };
539
738
  }
540
739
  // Get the formatting expression for specific formats
541
- const expr = formatValue(fmt, currencySymbol, locale);
740
+ const expr = formatValue(fmt, currencySymbol, locale, pctMultiply);
542
741
  if (expr === 'value') {
543
742
  return null;
544
743
  }
@@ -4,7 +4,7 @@
4
4
  * Rules register themselves at module load time. The linter
5
5
  * calls executeLintRules() to run all applicable rules.
6
6
  */
7
- import type { LintContext, LintExecutionResult, LintRuleDefinition } from './types.js';
7
+ import type { LintContext, LintExecutionMode, LintExecutionResult, LintRuleDefinition } from './types.js';
8
8
  /**
9
9
  * Register a lint rule in the global registry.
10
10
  * Called at module load time by rule files.
@@ -14,9 +14,11 @@ export declare function registerLintRule(definition: LintRuleDefinition): void;
14
14
  * Execute all applicable lint rules for a given context.
15
15
  *
16
16
  * @param context - The lint context with spec, type, data, etc.
17
+ * @param mode - 'generate' (default) skips rules flagged `lintOnly: true`;
18
+ * 'lint' runs every applicable rule.
17
19
  * @returns Execution result with errors, warnings, and validity
18
20
  */
19
- export declare function executeLintRules(context: LintContext): LintExecutionResult;
21
+ export declare function executeLintRules(context: LintContext, mode?: LintExecutionMode): LintExecutionResult;
20
22
  /**
21
23
  * Get all registered rules (for testing/debugging)
22
24
  */
@@ -31,15 +31,20 @@ function ruleApplies(rule, type) {
31
31
  * Execute all applicable lint rules for a given context.
32
32
  *
33
33
  * @param context - The lint context with spec, type, data, etc.
34
+ * @param mode - 'generate' (default) skips rules flagged `lintOnly: true`;
35
+ * 'lint' runs every applicable rule.
34
36
  * @returns Execution result with errors, warnings, and validity
35
37
  */
36
- export function executeLintRules(context) {
38
+ export function executeLintRules(context, mode = 'lint') {
37
39
  const errors = [];
38
40
  const warnings = [];
39
41
  for (const ruleDef of ruleRegistry) {
40
42
  if (!ruleApplies(ruleDef, context.type)) {
41
43
  continue;
42
44
  }
45
+ if (ruleDef.lintOnly && mode !== 'lint') {
46
+ continue;
47
+ }
43
48
  try {
44
49
  const results = ruleDef.rule(context);
45
50
  for (const result of results) {
@@ -13,4 +13,5 @@ import './required-fields.js';
13
13
  import './waterfall-type-values.js';
14
14
  import './data-field-exists.js';
15
15
  import './mermaid-syntax.js';
16
+ import './pct-scalar-gt-one.js';
16
17
  //# sourceMappingURL=index.d.ts.map
@@ -13,4 +13,5 @@ import './required-fields.js';
13
13
  import './waterfall-type-values.js';
14
14
  import './data-field-exists.js';
15
15
  import './mermaid-syntax.js';
16
+ import './pct-scalar-gt-one.js';
16
17
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Lint rule: pct-scalar-gt-one
3
+ *
4
+ * Warns when a scalar component (big_value / delta) uses a pct* format on a value
5
+ * whose absolute value is > 1. These formats always multiply by 100, so `value: 15.5`
6
+ * with `format: "pct"` renders as 1550.0%, which is almost never what was intended.
7
+ * The likely fix is to pass `value / 100` or to pass an already-fractional input.
8
+ *
9
+ * Scoped to scalars only. Charts and tables are series-aware and handle the
10
+ * already-percent case automatically (see shouldPctMultiply).
11
+ */
12
+ export {};
13
+ //# sourceMappingURL=pct-scalar-gt-one.d.ts.map
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Lint rule: pct-scalar-gt-one
3
+ *
4
+ * Warns when a scalar component (big_value / delta) uses a pct* format on a value
5
+ * whose absolute value is > 1. These formats always multiply by 100, so `value: 15.5`
6
+ * with `format: "pct"` renders as 1550.0%, which is almost never what was intended.
7
+ * The likely fix is to pass `value / 100` or to pass an already-fractional input.
8
+ *
9
+ * Scoped to scalars only. Charts and tables are series-aware and handle the
10
+ * already-percent case automatically (see shouldPctMultiply).
11
+ */
12
+ import { registerLintRule } from '../registry.js';
13
+ const PCT_FORMATS = new Set(['pct', 'pct0', 'pct1']);
14
+ registerLintRule({
15
+ id: 'pct-scalar-gt-one',
16
+ description: 'Warns when big_value/delta uses pct format with |value| > 1 (will render as value × 100)',
17
+ appliesTo: ['big_value', 'delta'],
18
+ defaultSeverity: 'warning',
19
+ // Advisory rule: only fire when the CLI is explicitly linting. Normal HTML
20
+ // rendering stays quiet so the dashboard output isn't noisy for end users.
21
+ lintOnly: true,
22
+ rule: checkPctScalar,
23
+ });
24
+ function checkPctScalar(context) {
25
+ const { spec } = context;
26
+ const format = spec.format;
27
+ if (typeof format !== 'string' || !PCT_FORMATS.has(format))
28
+ return [];
29
+ const value = spec.value;
30
+ if (typeof value !== 'number' || isNaN(value))
31
+ return [];
32
+ if (Math.abs(value) <= 1)
33
+ return [];
34
+ const scaled = (value * 100).toFixed(1).replace(/\.0$/, '');
35
+ const fractional = value / 100;
36
+ return [
37
+ {
38
+ ruleId: 'pct-scalar-gt-one',
39
+ message: `"value": ${value} with "format": "${format}" will render as ${scaled}% (value × 100).`,
40
+ severity: 'warning',
41
+ path: '/value',
42
+ suggestion: `If ${value} is already a percentage, change it to ${fractional} (or pass the raw fraction). The pct* formats always multiply scalar values by 100.`,
43
+ },
44
+ ];
45
+ }
46
+ //# sourceMappingURL=pct-scalar-gt-one.js.map
@@ -56,9 +56,21 @@ export interface LintRuleDefinition {
56
56
  appliesTo: string[];
57
57
  /** Default severity when rule triggers */
58
58
  defaultSeverity: LintSeverity;
59
+ /**
60
+ * If true, this rule only fires when the CLI is explicitly linting
61
+ * (`mviz --lint …`). Normal HTML generation silently skips it.
62
+ * Use for checks that are advisory for authors but should not noise up
63
+ * the output when the user is just rendering a dashboard.
64
+ */
65
+ lintOnly?: boolean;
59
66
  /** The rule function */
60
67
  rule: LintRule;
61
68
  }
69
+ /**
70
+ * Execution mode passed to the registry. `lint` runs every registered rule.
71
+ * `generate` skips rules flagged `lintOnly: true`.
72
+ */
73
+ export type LintExecutionMode = 'lint' | 'generate';
62
74
  /**
63
75
  * Result of running all applicable lint rules
64
76
  */
@@ -4,6 +4,7 @@
4
4
  * Validates specs against JSON Schema and performs data validation
5
5
  * using modular lint rules. Throws errors on validation failure.
6
6
  */
7
+ import type { LintExecutionMode } from './lint-rules/types.js';
7
8
  /**
8
9
  * Error thrown when spec validation fails
9
10
  */
@@ -14,11 +15,18 @@ export declare class SpecValidationError extends Error {
14
15
  /**
15
16
  * Lint a single spec - validates against schema and performs data validation.
16
17
  * Throws SpecValidationError on failure. Logs warnings to stderr.
18
+ *
19
+ * Columnar specs (`{columns, rows}`) are normalized to the standard
20
+ * `{data}` shape before validation, so callers do not need to convert first.
21
+ *
22
+ * @param mode - 'generate' (default) skips lint-only rules so HTML rendering
23
+ * stays quiet. 'lint' (used by `mviz --lint`) runs every rule, including
24
+ * advisory ones scoped to the lint command.
17
25
  */
18
- export declare function lintSpec(spec: unknown): void;
26
+ export declare function lintSpec(spec: unknown, mode?: LintExecutionMode): void;
19
27
  /**
20
28
  * Lint multiple specs (e.g., from a dashboard)
21
29
  * Throws SpecValidationError on first failure.
22
30
  */
23
- export declare function lintSpecs(specs: unknown[]): void;
31
+ export declare function lintSpecs(specs: unknown[], mode?: LintExecutionMode): void;
24
32
  //# sourceMappingURL=linter.d.ts.map
@@ -10,7 +10,8 @@ import { readFileSync } from 'node:fs';
10
10
  import { join, dirname } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
12
  import { executeLintRules, buildLintContext, findSchemaDir } from './lint-rules/index.js';
13
- import { TYPE_TO_SCHEMA_DEF, VALID_TYPES } from './lint-rules/schema-fields.js';
13
+ import { TYPE_TO_SCHEMA_DEF, VALID_TYPES, VALID_FIELDS } from './lint-rules/schema-fields.js';
14
+ import { convertColumnarFormat } from '../layout/converter.js';
14
15
  // Handle default exports for CommonJS/ESM interop
15
16
  const Ajv2020 = Ajv2020Module;
16
17
  const addFormats = addFormatsModule;
@@ -93,12 +94,37 @@ function filterErrorsNoType(errors) {
93
94
  /**
94
95
  * Filter errors to only those relevant to a specific schema definition
95
96
  */
96
- function filterErrorsForDef(errors, defName) {
97
+ function filterErrorsForDef(errors, defName, specType) {
97
98
  const relevantErrors = errors.filter((e) => e.schemaPath.includes(`/$defs/${defName}/`));
99
+ // Also keep enum errors from shared $defs (like FormatOption) when the
100
+ // failing property belongs to this spec's root — otherwise invalid enum
101
+ // values silently pass because the error lives under `/$defs/FormatOption/enum`
102
+ // rather than the type's own def.
103
+ const sharedEnumErrors = findSharedEnumErrors(errors, specType);
98
104
  if (relevantErrors.length === 0) {
99
- return errors.filter((e) => e.keyword === 'additionalProperties' && e.schemaPath.includes(`/$defs/${defName}`));
105
+ const additionalPropErrors = errors.filter((e) => e.keyword === 'additionalProperties' && e.schemaPath.includes(`/$defs/${defName}`));
106
+ return [...additionalPropErrors, ...sharedEnumErrors];
100
107
  }
101
- return relevantErrors;
108
+ return [...relevantErrors, ...sharedEnumErrors];
109
+ }
110
+ /**
111
+ * Find enum errors whose instancePath points to a top-level field of the
112
+ * spec. These come from shared $defs (e.g. FormatOption) and would otherwise
113
+ * be dropped by the schemaPath-based filter.
114
+ */
115
+ function findSharedEnumErrors(errors, specType) {
116
+ const validFields = VALID_FIELDS[specType];
117
+ if (!validFields)
118
+ return [];
119
+ return errors.filter((e) => {
120
+ if (e.keyword !== 'enum')
121
+ return false;
122
+ // instancePath like "/format" — first segment is the field name.
123
+ const m = e.instancePath.match(/^\/([^/]+)(?:\/|$)/);
124
+ if (!m || !m[1])
125
+ return false;
126
+ return validFields.has(m[1]);
127
+ });
102
128
  }
103
129
  /**
104
130
  * Filter schema errors to only show relevant errors for the detected type.
@@ -113,7 +139,7 @@ function filterSchemaErrors(errors, specType) {
113
139
  if (!defName) {
114
140
  return [createUnknownTypeError(specType)];
115
141
  }
116
- return filterErrorsForDef(errors, defName);
142
+ return filterErrorsForDef(errors, defName, specType);
117
143
  }
118
144
  /**
119
145
  * Format a schema error into a readable message
@@ -185,17 +211,39 @@ function logWarnings(warnings) {
185
211
  }
186
212
  console.error('');
187
213
  }
214
+ /**
215
+ * Normalize a spec for validation.
216
+ *
217
+ * Currently this just folds the columnar `{columns, rows}` shape into the
218
+ * standard `{data}` shape. Linting always runs against the normalized spec so
219
+ * every entry point (CLI, markdown parser, programmatic) sees the same
220
+ * contract.
221
+ */
222
+ function normalizeForLint(spec) {
223
+ if (spec && typeof spec === 'object' && 'type' in spec) {
224
+ return convertColumnarFormat(spec);
225
+ }
226
+ return spec;
227
+ }
188
228
  /**
189
229
  * Lint a single spec - validates against schema and performs data validation.
190
230
  * Throws SpecValidationError on failure. Logs warnings to stderr.
231
+ *
232
+ * Columnar specs (`{columns, rows}`) are normalized to the standard
233
+ * `{data}` shape before validation, so callers do not need to convert first.
234
+ *
235
+ * @param mode - 'generate' (default) skips lint-only rules so HTML rendering
236
+ * stays quiet. 'lint' (used by `mviz --lint`) runs every rule, including
237
+ * advisory ones scoped to the lint command.
191
238
  */
192
- export function lintSpec(spec) {
239
+ export function lintSpec(spec, mode = 'generate') {
240
+ const normalized = normalizeForLint(spec);
193
241
  // Schema validation
194
- validateAgainstSchema(spec);
242
+ validateAgainstSchema(normalized);
195
243
  // Modular lint rules validation
196
- if (spec && typeof spec === 'object' && 'type' in spec) {
197
- const context = buildLintContext(spec);
198
- const result = executeLintRules(context);
244
+ if (normalized && typeof normalized === 'object' && 'type' in normalized) {
245
+ const context = buildLintContext(normalized);
246
+ const result = executeLintRules(context, mode);
199
247
  // Log warnings to stderr
200
248
  if (result.warnings.length > 0) {
201
249
  logWarnings(result.warnings);
@@ -217,9 +265,9 @@ export function lintSpec(spec) {
217
265
  * Lint multiple specs (e.g., from a dashboard)
218
266
  * Throws SpecValidationError on first failure.
219
267
  */
220
- export function lintSpecs(specs) {
268
+ export function lintSpecs(specs, mode = 'generate') {
221
269
  for (const spec of specs) {
222
- lintSpec(spec);
270
+ lintSpec(spec, mode);
223
271
  }
224
272
  }
225
273
  //# sourceMappingURL=linter.js.map
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Phase 2: Load + convert + validate.
3
+ *
4
+ * Walks a raw IR (from markdown-parser) and resolves every `RawCodeBlockItem`
5
+ * into a `ParsedItem` by:
6
+ * - reading any `file=` reference (CSV or JSON)
7
+ * - parsing inline JSON
8
+ * - merging file data with inline options
9
+ * - applying columnar -> standard format conversion
10
+ * - injecting frontmatter currency / theme defaults
11
+ * - linting the resulting spec
12
+ *
13
+ * Errors collected during this phase are returned as a string list so the
14
+ * caller can report them in non-strict mode or short-circuit in strict mode.
15
+ */
16
+ import type { ParsedFrontmatter, RawSection, Section } from './parser-types.js';
17
+ export interface LoadOptions {
18
+ baseDir: string | undefined;
19
+ strict: boolean;
20
+ lintMode: 'generate' | 'lint';
21
+ frontmatter: ParsedFrontmatter;
22
+ }
23
+ export interface LoadResult {
24
+ sections: Section[];
25
+ errors: string[];
26
+ }
27
+ /**
28
+ * Resolve all raw items into parsed items. Walks every section/row/item.
29
+ */
30
+ export declare function loadSections(rawSections: RawSection[], options: LoadOptions): LoadResult;
31
+ //# sourceMappingURL=block-loader.d.ts.map