mviz 1.6.6 → 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.
@@ -6,6 +6,7 @@
6
6
  * input), the per-cell `data-sort` attribute for numeric sorting, and one
7
7
  * vanilla-JS init function that wires up click/input handlers.
8
8
  */
9
+ import type { FormatType } from '../types.js';
9
10
  export interface TableInteractivity {
10
11
  sortable: boolean;
11
12
  filter: boolean;
@@ -22,8 +23,23 @@ export declare function sortableHeaderAttrs(colIdx: number, sortable: boolean):
22
23
  /**
23
24
  * Generate `data-sort="..."` for a cell so numeric sort doesn't depend on the
24
25
  * rendered text (which may be formatted, contain SVG sparklines, etc.).
26
+ *
27
+ * When the column declares a `format`, the sort key is derived from that
28
+ * declaration — `date` → epoch ms, `duration` → seconds, numeric formats →
29
+ * raw number. This is the preferred path because format removes ambiguity
30
+ * (e.g. is `"5m"` 5 minutes or 5 million dollars?).
31
+ *
32
+ * For untyped columns, two unambiguous string shapes still get a numeric
33
+ * key as a courtesy: ISO-8601 dates (`YYYY-MM-DD`) and duration strings
34
+ * that include a seconds segment (`10s`, `1m7s`, `1h2m3s`). Anything else —
35
+ * including bare `5m` or `1h`, which is ambiguous (5 minutes or 5 million?) —
36
+ * falls back to locale string sort. Columns that explicitly declare
37
+ * `fmt: "duration"` still get lenient parsing of `5m`/`1h`; the strict form
38
+ * only applies to the untyped courtesy path. Pre-formatted currency strings
39
+ * are intentionally not parsed — pass numeric values and declare
40
+ * `format: "currency_auto"` (etc.) instead.
25
41
  */
26
- export declare function cellSortAttr(value: unknown): string;
42
+ export declare function cellSortAttr(value: unknown, format?: FormatType): string;
27
43
  /**
28
44
  * Build the filter toolbar (global search input when `filter: true`).
29
45
  */
@@ -6,6 +6,7 @@
6
6
  * input), the per-cell `data-sort` attribute for numeric sorting, and one
7
7
  * vanilla-JS init function that wires up click/input handlers.
8
8
  */
9
+ import { formatSortKey, parseDurationString, parseIsoDateString, } from '../core/formatting.js';
9
10
  /**
10
11
  * Read sortable/filter options from a spec. Sort defaults ON, filter defaults OFF.
11
12
  */
@@ -27,10 +28,33 @@ export function sortableHeaderAttrs(colIdx, sortable) {
27
28
  /**
28
29
  * Generate `data-sort="..."` for a cell so numeric sort doesn't depend on the
29
30
  * rendered text (which may be formatted, contain SVG sparklines, etc.).
31
+ *
32
+ * When the column declares a `format`, the sort key is derived from that
33
+ * declaration — `date` → epoch ms, `duration` → seconds, numeric formats →
34
+ * raw number. This is the preferred path because format removes ambiguity
35
+ * (e.g. is `"5m"` 5 minutes or 5 million dollars?).
36
+ *
37
+ * For untyped columns, two unambiguous string shapes still get a numeric
38
+ * key as a courtesy: ISO-8601 dates (`YYYY-MM-DD`) and duration strings
39
+ * that include a seconds segment (`10s`, `1m7s`, `1h2m3s`). Anything else —
40
+ * including bare `5m` or `1h`, which is ambiguous (5 minutes or 5 million?) —
41
+ * falls back to locale string sort. Columns that explicitly declare
42
+ * `fmt: "duration"` still get lenient parsing of `5m`/`1h`; the strict form
43
+ * only applies to the untyped courtesy path. Pre-formatted currency strings
44
+ * are intentionally not parsed — pass numeric values and declare
45
+ * `format: "currency_auto"` (etc.) instead.
30
46
  */
31
- export function cellSortAttr(value) {
47
+ export function cellSortAttr(value, format) {
32
48
  if (value === null || value === undefined)
33
49
  return '';
50
+ // Format-driven sort (preferred when declared).
51
+ if (format) {
52
+ const key = formatSortKey(value, format);
53
+ if (key !== null)
54
+ return ` data-sort="${key}"`;
55
+ // Format declared but value can't be coerced — fall through to the
56
+ // untyped heuristics so sort still does *something* sensible.
57
+ }
34
58
  if (typeof value === 'number' && !isNaN(value)) {
35
59
  return ` data-sort="${value}"`;
36
60
  }
@@ -39,6 +63,15 @@ export function cellSortAttr(value) {
39
63
  if (!isNaN(n) && String(n) === value.trim()) {
40
64
  return ` data-sort="${n}"`;
41
65
  }
66
+ const dateKey = parseIsoDateString(value);
67
+ if (dateKey !== null)
68
+ return ` data-sort="${dateKey}"`;
69
+ // Strict: require a seconds segment so bare "5m" / "1h" don't get parsed
70
+ // as durations. Those shapes are ambiguous in an untyped column; declare
71
+ // `fmt: "duration"` to opt into lenient parsing.
72
+ const durKey = parseDurationString(value, { requireSeconds: true });
73
+ if (durKey !== null)
74
+ return ` data-sort="${durKey}"`;
42
75
  return ` data-sort="${escapeAttr(value)}"`;
43
76
  }
44
77
  // Objects/arrays (e.g., sparkline data) are not sortable; omit the attribute.
@@ -498,7 +498,7 @@ function buildDataRows(cfg, inlineStyles = false) {
498
498
  const cellResult = formatCell(value, col, cfg.colors, cfg.heatmapRanges, cfg.dumbbellRanges, cfg.defaultHeatmapColors, undefined, cfg.currency, cfg.pctMultiplyByColumn);
499
499
  // Unwrap `{value, bold, italic}` overrides so numeric sort sees the real value.
500
500
  const sortRaw = extractCellValue(value, col).value;
501
- const sortAttr = cfg.interactivity.sortable ? cellSortAttr(sortRaw) : '';
501
+ const sortAttr = cfg.interactivity.sortable ? cellSortAttr(sortRaw, col.fmt) : '';
502
502
  if (inlineStyles) {
503
503
  const border = isLast ? `border-bottom: 1px solid ${cfg.colors.text};` : '';
504
504
  const cellBg = cellResult.bgColor ?? rowBg;
@@ -1,10 +1,14 @@
1
1
  /**
2
- * Shared helpers for scatter-family charts (scatter, bubble).
2
+ * Shared helpers for chart builders.
3
3
  *
4
- * Consolidates series-grouping, label config, and legend patterns
5
- * that were duplicated across scatter.ts and bubble.ts.
4
+ * Consolidates patterns that were duplicated across multiple chart builders:
5
+ * - scatter-family series grouping/labels (scatter, bubble)
6
+ * - value-axis format context (bar, line, area, combo)
7
+ * - x-axis label config (line, area, combo, bar)
8
+ * - yMin/yMax application (bar, line, area, combo)
9
+ * - multi-series legend (bar, area, combo)
6
10
  */
7
- import type { DataPoint, ThemeColors } from '../types.js';
11
+ import type { ChartSpec, CurrencyConfig, DataPoint, FormatType, ThemeColors, AxisType } from '../types.js';
8
12
  /**
9
13
  * Build ECharts label config for persistent point labels.
10
14
  * @param labelIndex - Index of the label value in the point array
@@ -15,7 +19,57 @@ export declare function buildPointLabelConfig(labelIndex: number, colors: ThemeC
15
19
  */
16
20
  export declare function groupDataBySeries<T>(data: DataPoint[], seriesField: string, buildPoint: (d: DataPoint) => T): Map<string, T[]>;
17
21
  /**
18
- * Build ECharts legend config for multi-series charts.
22
+ * Build ECharts legend config for scatter-style multi-series charts.
23
+ * Uses small text and no item-width override.
19
24
  */
20
25
  export declare function buildSeriesLegend(legendData: string[], colors: ThemeColors): Record<string, unknown>;
26
+ /**
27
+ * Build ECharts legend config for bar/area/combo style charts.
28
+ * Uses narrow line-style swatches matching the rest of the chart palette.
29
+ */
30
+ export declare function buildMultiSeriesLegend(legendData: string[], colors: ThemeColors): Record<string, unknown>;
31
+ /**
32
+ * Resolved format context for a chart's value axis.
33
+ *
34
+ * Wraps the recurring (format, currency, pctMultiply, formatter) tuple
35
+ * so chart builders don't have to re-derive each piece individually.
36
+ */
37
+ export interface ValueAxisFormatContext {
38
+ valueFormat: FormatType;
39
+ currency: CurrencyConfig;
40
+ pctMultiply: boolean;
41
+ axisFormatter: {
42
+ _js_: string;
43
+ } | null;
44
+ labelFormatter?: {
45
+ _js_: string;
46
+ } | null;
47
+ }
48
+ export interface BuildValueAxisFormatContextOptions {
49
+ /** Override the inferred format (e.g. combo's secondaryFormat). */
50
+ formatOverride?: FormatType;
51
+ /** Also build a label formatter (used by bar). */
52
+ withLabelFormatter?: boolean;
53
+ }
54
+ /**
55
+ * Resolve format/currency/pctMultiply and produce axis (and optionally label)
56
+ * formatters for a chart's value axis.
57
+ *
58
+ * Used by bar, line, area, and combo. Combo calls this twice (once per axis).
59
+ */
60
+ export declare function buildValueAxisFormatContext(spec: ChartSpec, data: DataPoint[], yKeys: string[], opts?: BuildValueAxisFormatContextOptions): ValueAxisFormatContext;
61
+ /**
62
+ * Build an ECharts xAxis label config based on the resolved axis type.
63
+ *
64
+ * - `category`: 45° rotation with truncation for long labels
65
+ * - `time`: month-day formatter ("Jan 5")
66
+ * - `value`: just hideOverlap
67
+ */
68
+ export declare function buildXAxisLabelConfig(xAxisType: AxisType): Record<string, unknown>;
69
+ /**
70
+ * Apply yMin/yMax from a spec onto a value-axis object in place.
71
+ * The caller passes the right axis object (xAxis for horizontal bars,
72
+ * yAxis or yAxis[0] for everything else).
73
+ */
74
+ export declare function applyValueAxisRange(axisObj: Record<string, unknown>, spec: ChartSpec): void;
21
75
  //# sourceMappingURL=chart-helpers.d.ts.map
@@ -1,10 +1,15 @@
1
1
  /**
2
- * Shared helpers for scatter-family charts (scatter, bubble).
2
+ * Shared helpers for chart builders.
3
3
  *
4
- * Consolidates series-grouping, label config, and legend patterns
5
- * that were duplicated across scatter.ts and bubble.ts.
4
+ * Consolidates patterns that were duplicated across multiple chart builders:
5
+ * - scatter-family series grouping/labels (scatter, bubble)
6
+ * - value-axis format context (bar, line, area, combo)
7
+ * - x-axis label config (line, area, combo, bar)
8
+ * - yMin/yMax application (bar, line, area, combo)
9
+ * - multi-series legend (bar, area, combo)
6
10
  */
7
- import { FONT_SIZE_TINY } from './themes.js';
11
+ import { FONT_SIZE_TINY, FONT_SIZE_XXS, LEGEND_ITEM_WIDTH, LEGEND_ITEM_HEIGHT, LEGEND_ITEM_GAP, } from './themes.js';
12
+ import { inferFormat, resolveCurrency, shouldPctMultiply, collectNumericFieldValues, getAxisFormatterJs, getLabelFormatterJs, } from './formatting.js';
8
13
  /**
9
14
  * Build ECharts label config for persistent point labels.
10
15
  * @param labelIndex - Index of the label value in the point array
@@ -33,7 +38,8 @@ export function groupDataBySeries(data, seriesField, buildPoint) {
33
38
  return groups;
34
39
  }
35
40
  /**
36
- * Build ECharts legend config for multi-series charts.
41
+ * Build ECharts legend config for scatter-style multi-series charts.
42
+ * Uses small text and no item-width override.
37
43
  */
38
44
  export function buildSeriesLegend(legendData, colors) {
39
45
  return {
@@ -43,4 +49,77 @@ export function buildSeriesLegend(legendData, colors) {
43
49
  textStyle: { color: colors.textSecondary, fontSize: FONT_SIZE_TINY },
44
50
  };
45
51
  }
52
+ /**
53
+ * Build ECharts legend config for bar/area/combo style charts.
54
+ * Uses narrow line-style swatches matching the rest of the chart palette.
55
+ */
56
+ export function buildMultiSeriesLegend(legendData, colors) {
57
+ return {
58
+ data: legendData,
59
+ top: 0,
60
+ textStyle: { color: colors.textSecondary, fontSize: FONT_SIZE_XXS },
61
+ itemWidth: LEGEND_ITEM_WIDTH,
62
+ itemHeight: LEGEND_ITEM_HEIGHT,
63
+ itemGap: LEGEND_ITEM_GAP,
64
+ };
65
+ }
66
+ /**
67
+ * Resolve format/currency/pctMultiply and produce axis (and optionally label)
68
+ * formatters for a chart's value axis.
69
+ *
70
+ * Used by bar, line, area, and combo. Combo calls this twice (once per axis).
71
+ */
72
+ export function buildValueAxisFormatContext(spec, data, yKeys, opts = {}) {
73
+ const firstYKey = yKeys[0] ?? 'value';
74
+ const sampleValue = data.length > 0 && data[0] ? data[0][firstYKey] ?? 0 : 0;
75
+ const valueFormat = opts.formatOverride ?? spec.format ?? inferFormat(firstYKey, sampleValue);
76
+ const currency = resolveCurrency(spec.currency);
77
+ const pctMultiply = shouldPctMultiply(valueFormat, collectNumericFieldValues(data, yKeys));
78
+ const axisFormatter = getAxisFormatterJs(valueFormat, currency.symbol, currency.locale, pctMultiply);
79
+ const ctx = { valueFormat, currency, pctMultiply, axisFormatter };
80
+ if (opts.withLabelFormatter) {
81
+ ctx.labelFormatter = getLabelFormatterJs(valueFormat, currency.symbol, currency.locale, pctMultiply);
82
+ }
83
+ return ctx;
84
+ }
85
+ /**
86
+ * Month-day formatter used for time-axis labels (e.g. "Jan 5").
87
+ * Extracted to a constant so all chart builders share the same JS source.
88
+ */
89
+ const TIME_AXIS_MONTH_DAY_FORMATTER = {
90
+ _js_: `function(value) {
91
+ var d = new Date(value);
92
+ var months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
93
+ return months[d.getMonth()] + ' ' + d.getDate();
94
+ }`,
95
+ };
96
+ /**
97
+ * Build an ECharts xAxis label config based on the resolved axis type.
98
+ *
99
+ * - `category`: 45° rotation with truncation for long labels
100
+ * - `time`: month-day formatter ("Jan 5")
101
+ * - `value`: just hideOverlap
102
+ */
103
+ export function buildXAxisLabelConfig(xAxisType) {
104
+ if (xAxisType === 'category') {
105
+ return { interval: 0, rotate: 45, overflow: 'truncate', ellipsis: '...', width: 100 };
106
+ }
107
+ if (xAxisType === 'time') {
108
+ return { hideOverlap: true, formatter: TIME_AXIS_MONTH_DAY_FORMATTER };
109
+ }
110
+ return { hideOverlap: true };
111
+ }
112
+ /**
113
+ * Apply yMin/yMax from a spec onto a value-axis object in place.
114
+ * The caller passes the right axis object (xAxis for horizontal bars,
115
+ * yAxis or yAxis[0] for everything else).
116
+ */
117
+ export function applyValueAxisRange(axisObj, spec) {
118
+ if (spec.yMin !== undefined) {
119
+ axisObj.min = spec.yMin;
120
+ }
121
+ if (spec.yMax !== undefined) {
122
+ axisObj.max = spec.yMax;
123
+ }
124
+ }
46
125
  //# sourceMappingURL=chart-helpers.js.map
@@ -26,6 +26,39 @@ export declare function localeFixed(value: number, decimals: number, locale: str
26
26
  * values are in percentage units (via detectPctMultiply).
27
27
  */
28
28
  export declare function formatNumber(value: number | null | undefined, format?: FormatType, showSign?: boolean, currency?: CurrencyConfig, pctMultiply?: boolean): string;
29
+ /**
30
+ * Convert a cell value to a numeric (or string) sort key based on the
31
+ * declared column format. The result is what the table emits as
32
+ * `data-sort` so the client-side compare always sees comparable values.
33
+ *
34
+ * - `date` → epoch ms (accepts Date, ISO string, or numeric ms)
35
+ * - `duration` → total seconds (accepts number-of-seconds or `1m7s` style)
36
+ * - currency / pct / num / auto → underlying numeric value
37
+ *
38
+ * Returns null when the value can't be coerced for the declared format —
39
+ * the caller falls back to its untyped heuristic chain.
40
+ */
41
+ export declare function formatSortKey(value: unknown, format?: FormatType): number | string | null;
42
+ /**
43
+ * Parse a duration string like `"10s"`, `"1m7s"`, `"25m39s"`, `"1h2m3s"`
44
+ * into total seconds. Returns null when the input doesn't match.
45
+ * Exported for the untyped fallback path in `cellSortAttr`.
46
+ *
47
+ * When `requireSeconds` is true, only strings that contain an `s` segment
48
+ * match — bare `5m` or `1h` are rejected. The untyped fallback uses this
49
+ * stricter form because a bare magnitude like `5m` is ambiguous (5 minutes
50
+ * or 5 million?); the lenient form is for columns that explicitly declare
51
+ * `fmt: "duration"`.
52
+ */
53
+ export declare function parseDurationString(s: string, options?: {
54
+ requireSeconds?: boolean;
55
+ }): number | null;
56
+ /**
57
+ * Parse an ISO-8601 date string (`YYYY-MM-DD` with optional time/timezone)
58
+ * into epoch ms. Returns null for non-ISO strings so they fall back to
59
+ * locale sort instead of mis-parsing through `Date.parse`.
60
+ */
61
+ export declare function parseIsoDateString(s: string): number | null;
29
62
  /**
30
63
  * Detect whether a series of values needs to be multiplied by 100 for percent formatting.
31
64
  * Returns true when the series looks fractional (max |v| <= 1). Returns false when any
@@ -92,6 +92,12 @@ export function formatNumber(value, format, showSign = false, currency, pctMulti
92
92
  case 'num0b':
93
93
  result = formatNum0b(value);
94
94
  break;
95
+ case 'duration':
96
+ result = formatDuration(value);
97
+ break;
98
+ case 'date':
99
+ result = formatDateValue(value);
100
+ break;
95
101
  default:
96
102
  result = smartFormatNumber(value, false, cur);
97
103
  }
@@ -198,6 +204,135 @@ function formatCurrency0b(value, cur) {
198
204
  const formatted = cur.symbol + localeFixed(absValue / 1_000_000_000, 1, cur.locale) + 'b';
199
205
  return isNegative ? `(${formatted})` : formatted;
200
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
+ }
201
336
  /**
202
337
  * Detect whether a series of values needs to be multiplied by 100 for percent formatting.
203
338
  * Returns true when the series looks fractional (max |v| <= 1). Returns false when any
@@ -16,6 +16,9 @@ export declare class SpecValidationError extends Error {
16
16
  * Lint a single spec - validates against schema and performs data validation.
17
17
  * Throws SpecValidationError on failure. Logs warnings to stderr.
18
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
+ *
19
22
  * @param mode - 'generate' (default) skips lint-only rules so HTML rendering
20
23
  * stays quiet. 'lint' (used by `mviz --lint`) runs every rule, including
21
24
  * advisory ones scoped to the lint command.
@@ -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,20 +211,38 @@ 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.
191
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
+ *
192
235
  * @param mode - 'generate' (default) skips lint-only rules so HTML rendering
193
236
  * stays quiet. 'lint' (used by `mviz --lint`) runs every rule, including
194
237
  * advisory ones scoped to the lint command.
195
238
  */
196
239
  export function lintSpec(spec, mode = 'generate') {
240
+ const normalized = normalizeForLint(spec);
197
241
  // Schema validation
198
- validateAgainstSchema(spec);
242
+ validateAgainstSchema(normalized);
199
243
  // Modular lint rules validation
200
- if (spec && typeof spec === 'object' && 'type' in spec) {
201
- const context = buildLintContext(spec);
244
+ if (normalized && typeof normalized === 'object' && 'type' in normalized) {
245
+ const context = buildLintContext(normalized);
202
246
  const result = executeLintRules(context, mode);
203
247
  // Log warnings to stderr
204
248
  if (result.warnings.length > 0) {
@@ -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