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.
- package/README.md +8 -8
- package/dist/charts/area.js +8 -36
- package/dist/charts/bar.js +8 -26
- package/dist/charts/bubble.js +9 -7
- package/dist/charts/combo.js +17 -39
- package/dist/charts/dumbbell.js +15 -14
- package/dist/charts/funnel.js +12 -7
- package/dist/charts/heatmap.js +8 -6
- package/dist/charts/line.js +6 -27
- package/dist/charts/scatter.js +7 -5
- package/dist/cli.js +4 -3
- package/dist/components/big_value.d.ts +23 -1
- package/dist/components/big_value.js +84 -25
- package/dist/components/delta.d.ts +24 -1
- package/dist/components/delta.js +63 -17
- package/dist/components/table-interactivity.d.ts +69 -0
- package/dist/components/table-interactivity.js +216 -0
- package/dist/components/table.d.ts +6 -1
- package/dist/components/table.js +53 -12
- package/dist/core/chart-helpers.d.ts +59 -5
- package/dist/core/chart-helpers.js +84 -5
- package/dist/core/formatting.d.ts +61 -4
- package/dist/core/formatting.js +216 -17
- package/dist/core/lint-rules/registry.d.ts +4 -2
- package/dist/core/lint-rules/registry.js +6 -1
- package/dist/core/lint-rules/rules/index.d.ts +1 -0
- package/dist/core/lint-rules/rules/index.js +1 -0
- package/dist/core/lint-rules/rules/pct-scalar-gt-one.d.ts +13 -0
- package/dist/core/lint-rules/rules/pct-scalar-gt-one.js +46 -0
- package/dist/core/lint-rules/types.d.ts +12 -0
- package/dist/core/linter.d.ts +10 -2
- package/dist/core/linter.js +60 -12
- package/dist/layout/block-loader.d.ts +31 -0
- package/dist/layout/block-loader.js +143 -0
- package/dist/layout/layout-resolver.d.ts +33 -0
- package/dist/layout/layout-resolver.js +73 -0
- package/dist/layout/markdown-parser.d.ts +34 -0
- package/dist/layout/markdown-parser.js +395 -0
- package/dist/layout/parser-types.d.ts +116 -0
- package/dist/layout/parser-types.js +11 -0
- package/dist/layout/parser.d.ts +31 -22
- package/dist/layout/parser.js +118 -1006
- package/dist/layout/renderer.d.ts +33 -0
- package/dist/layout/renderer.js +450 -0
- package/dist/types.d.ts +1 -1
- package/package.json +6 -6
- package/schema/mviz.v1.schema.json +402 -33
|
@@ -0,0 +1,143 @@
|
|
|
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 { existsSync, readFileSync } from 'node:fs';
|
|
17
|
+
import { resolve } from 'node:path';
|
|
18
|
+
import { lintSpec } from '../core/linter.js';
|
|
19
|
+
import { convertColumnarFormat } from './converter.js';
|
|
20
|
+
import { parseCsv } from './csv.js';
|
|
21
|
+
/**
|
|
22
|
+
* Resolve all raw items into parsed items. Walks every section/row/item.
|
|
23
|
+
*/
|
|
24
|
+
export function loadSections(rawSections, options) {
|
|
25
|
+
const errors = [];
|
|
26
|
+
const sections = rawSections.map((raw) => loadSection(raw, options, errors));
|
|
27
|
+
return { sections, errors };
|
|
28
|
+
}
|
|
29
|
+
function loadSection(raw, options, errors) {
|
|
30
|
+
const rows = [];
|
|
31
|
+
for (const rawRow of raw.rows) {
|
|
32
|
+
const row = loadRow(rawRow, options, errors);
|
|
33
|
+
if (row.length > 0)
|
|
34
|
+
rows.push(row);
|
|
35
|
+
}
|
|
36
|
+
const section = { title: raw.title, rows };
|
|
37
|
+
if (raw.pageBreak !== undefined)
|
|
38
|
+
section.pageBreak = raw.pageBreak;
|
|
39
|
+
if (raw.hasDivider !== undefined)
|
|
40
|
+
section.hasDivider = raw.hasDivider;
|
|
41
|
+
return section;
|
|
42
|
+
}
|
|
43
|
+
function loadRow(rawRow, options, errors) {
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const raw of rawRow) {
|
|
46
|
+
if (raw.kind === 'resolved') {
|
|
47
|
+
out.push(raw.item);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const item = loadCodeBlock(raw, options, errors);
|
|
51
|
+
if (item)
|
|
52
|
+
out.push(item);
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Resolve a single code block into a ParsedItem (or null on non-fatal failure).
|
|
58
|
+
*/
|
|
59
|
+
function loadCodeBlock(raw, options, errors) {
|
|
60
|
+
try {
|
|
61
|
+
const spec = buildSpec(raw, options);
|
|
62
|
+
if (spec === null)
|
|
63
|
+
return null;
|
|
64
|
+
lintSpec({ ...spec, type: raw.type }, options.lintMode);
|
|
65
|
+
const item = { type: raw.type, spec };
|
|
66
|
+
if (raw.size)
|
|
67
|
+
item.size = raw.size;
|
|
68
|
+
return item;
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
const errorMsg = e instanceof Error ? e.message : 'Unknown error';
|
|
72
|
+
const formatted = `Line ${raw.startLine}, \`\`\`${raw.type}: ${errorMsg}`;
|
|
73
|
+
errors.push(formatted);
|
|
74
|
+
if (options.strict)
|
|
75
|
+
throw new Error(formatted);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Build the final spec for a code block by merging any file data with the
|
|
81
|
+
* inline JSON, applying defaults, and running columnar conversion.
|
|
82
|
+
*
|
|
83
|
+
* Returns null when the block is empty (no inline JSON, no file).
|
|
84
|
+
*/
|
|
85
|
+
function buildSpec(raw, options) {
|
|
86
|
+
const fileResult = loadFileData(raw.file, options);
|
|
87
|
+
let spec = fileResult.spec;
|
|
88
|
+
const fileData = fileResult.data;
|
|
89
|
+
if (raw.inlineLines.length > 0) {
|
|
90
|
+
const inlineSpec = JSON.parse(raw.inlineLines.join('\n'));
|
|
91
|
+
spec = fileData !== null ? { ...inlineSpec, data: fileData } : inlineSpec;
|
|
92
|
+
}
|
|
93
|
+
else if (fileData !== null) {
|
|
94
|
+
spec = autoDetectFromCsv(fileData);
|
|
95
|
+
}
|
|
96
|
+
if (!spec.data && Object.keys(spec).length === 0)
|
|
97
|
+
return null;
|
|
98
|
+
spec = convertColumnarFormat(spec);
|
|
99
|
+
applySpecDefaults(spec, options.frontmatter);
|
|
100
|
+
return spec;
|
|
101
|
+
}
|
|
102
|
+
function loadFileData(file, options) {
|
|
103
|
+
const empty = { data: null, spec: {} };
|
|
104
|
+
if (!file || !options.baseDir)
|
|
105
|
+
return empty;
|
|
106
|
+
const filePath = resolve(options.baseDir, file);
|
|
107
|
+
if (!existsSync(filePath)) {
|
|
108
|
+
if (options.strict)
|
|
109
|
+
throw new Error(`File not found: ${filePath}`);
|
|
110
|
+
return empty;
|
|
111
|
+
}
|
|
112
|
+
if (file.endsWith('.csv')) {
|
|
113
|
+
return { data: parseCsv(filePath), spec: {} };
|
|
114
|
+
}
|
|
115
|
+
if (file.endsWith('.json')) {
|
|
116
|
+
return loadJsonFile(filePath);
|
|
117
|
+
}
|
|
118
|
+
return empty;
|
|
119
|
+
}
|
|
120
|
+
function loadJsonFile(filePath) {
|
|
121
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
122
|
+
const parsed = JSON.parse(content);
|
|
123
|
+
if (Array.isArray(parsed)) {
|
|
124
|
+
return { data: parsed, spec: {} };
|
|
125
|
+
}
|
|
126
|
+
return { data: null, spec: convertColumnarFormat(parsed) };
|
|
127
|
+
}
|
|
128
|
+
function autoDetectFromCsv(fileData) {
|
|
129
|
+
const spec = { data: fileData };
|
|
130
|
+
const columns = fileData.length > 0 ? Object.keys(fileData[0]) : [];
|
|
131
|
+
if (columns.length >= 2) {
|
|
132
|
+
spec.x = columns[0];
|
|
133
|
+
spec.y = columns[1];
|
|
134
|
+
}
|
|
135
|
+
return spec;
|
|
136
|
+
}
|
|
137
|
+
function applySpecDefaults(spec, frontmatter) {
|
|
138
|
+
spec.theme = (spec.theme ?? frontmatter.theme);
|
|
139
|
+
if (!spec.currency && frontmatter.currency) {
|
|
140
|
+
spec.currency = frontmatter.currency;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
//# sourceMappingURL=block-loader.js.map
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 3: Layout resolution.
|
|
3
|
+
*
|
|
4
|
+
* Resolves auto-sized items in each row to concrete `[cols, rows]` sizes and
|
|
5
|
+
* validates print mode (which requires every component to have an explicit
|
|
6
|
+
* size). Operates in-place on the resolved `Section[]` IR.
|
|
7
|
+
*/
|
|
8
|
+
import type { ParsedItem, Section } from './parser-types.js';
|
|
9
|
+
export interface LayoutOptions {
|
|
10
|
+
printMode: boolean;
|
|
11
|
+
strict: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface LayoutResult {
|
|
14
|
+
errors: string[];
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Validate print mode (if enabled) and then resolve auto sizes.
|
|
18
|
+
*
|
|
19
|
+
* IMPORTANT — order matters:
|
|
20
|
+
* 1. Print-mode validation runs FIRST against the original sizing intent so
|
|
21
|
+
* `size=auto` (and missing sizes) are still visible as `'auto' | null`
|
|
22
|
+
* sentinels. The pre-refactor parser ran in this order; flipping it
|
|
23
|
+
* causes `print: true` + `size=auto` specs to silently pass because
|
|
24
|
+
* `auto` markers are replaced with concrete `[cols, rows]` numbers.
|
|
25
|
+
* 2. Auto-size resolution happens AFTER, mutating items in place.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveLayout(sections: Section[], options: LayoutOptions): LayoutResult;
|
|
28
|
+
/**
|
|
29
|
+
* Resolve `auto` sizes in a single row by distributing remaining grid columns
|
|
30
|
+
* across the auto-sized items.
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveAutoSizes(row: ParsedItem[]): void;
|
|
33
|
+
//# sourceMappingURL=layout-resolver.d.ts.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 3: Layout resolution.
|
|
3
|
+
*
|
|
4
|
+
* Resolves auto-sized items in each row to concrete `[cols, rows]` sizes and
|
|
5
|
+
* validates print mode (which requires every component to have an explicit
|
|
6
|
+
* size). Operates in-place on the resolved `Section[]` IR.
|
|
7
|
+
*/
|
|
8
|
+
import { DEFAULT_SIZES, GRID_TOTAL_COLUMNS, autoSizeChart } from '../core/themes.js';
|
|
9
|
+
/** Minimum column span granted to an `auto`-sized item. */
|
|
10
|
+
const MIN_AUTO_COLUMN_SPAN = 4;
|
|
11
|
+
/**
|
|
12
|
+
* Validate print mode (if enabled) and then resolve auto sizes.
|
|
13
|
+
*
|
|
14
|
+
* IMPORTANT — order matters:
|
|
15
|
+
* 1. Print-mode validation runs FIRST against the original sizing intent so
|
|
16
|
+
* `size=auto` (and missing sizes) are still visible as `'auto' | null`
|
|
17
|
+
* sentinels. The pre-refactor parser ran in this order; flipping it
|
|
18
|
+
* causes `print: true` + `size=auto` specs to silently pass because
|
|
19
|
+
* `auto` markers are replaced with concrete `[cols, rows]` numbers.
|
|
20
|
+
* 2. Auto-size resolution happens AFTER, mutating items in place.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveLayout(sections, options) {
|
|
23
|
+
const errors = [];
|
|
24
|
+
if (options.printMode) {
|
|
25
|
+
validatePrintMode(sections, options.strict, errors);
|
|
26
|
+
}
|
|
27
|
+
for (const section of sections) {
|
|
28
|
+
for (const row of section.rows) {
|
|
29
|
+
resolveAutoSizes(row);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return { errors };
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve `auto` sizes in a single row by distributing remaining grid columns
|
|
36
|
+
* across the auto-sized items.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveAutoSizes(row) {
|
|
39
|
+
const autoItems = [];
|
|
40
|
+
let fixedCols = 0;
|
|
41
|
+
for (const item of row) {
|
|
42
|
+
if (item.size === 'auto') {
|
|
43
|
+
autoItems.push(item);
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
const size = item.size ?? DEFAULT_SIZES[item.type] ?? [8, 4];
|
|
47
|
+
fixedCols += size[0];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (autoItems.length === 0)
|
|
51
|
+
return;
|
|
52
|
+
const remainingCols = Math.max(0, GRID_TOTAL_COLUMNS - fixedCols);
|
|
53
|
+
const colsPerAuto = Math.max(MIN_AUTO_COLUMN_SPAN, Math.floor(remainingCols / autoItems.length));
|
|
54
|
+
for (const item of autoItems) {
|
|
55
|
+
const [autoCols, autoRows] = autoSizeChart(item.type, item.spec);
|
|
56
|
+
item.size = autoItems.length === 1 ? [autoCols, autoRows] : [Math.min(autoCols, colsPerAuto), autoRows];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function validatePrintMode(sections, strict, errors) {
|
|
60
|
+
for (const section of sections) {
|
|
61
|
+
for (const row of section.rows) {
|
|
62
|
+
for (const item of row) {
|
|
63
|
+
if (item.size === null || item.size === undefined || item.size === 'auto') {
|
|
64
|
+
const msg = `Print mode requires explicit size for "${item.type}" component. Use size=[cols,rows] syntax.`;
|
|
65
|
+
if (strict)
|
|
66
|
+
throw new Error(msg);
|
|
67
|
+
errors.push(msg);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=layout-resolver.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 1: Markdown -> raw IR.
|
|
3
|
+
*
|
|
4
|
+
* Pure tokenization. Reads a markdown string and produces a `ParsedMarkdown`
|
|
5
|
+
* IR consisting of sections, rows, and items. Code blocks are emitted as
|
|
6
|
+
* `RawCodeBlockItem`s with their inline body still as raw text — no file I/O,
|
|
7
|
+
* no JSON parsing, no validation happens here.
|
|
8
|
+
*
|
|
9
|
+
* Frontmatter parsing also lives in this module (see `parseFrontmatter`).
|
|
10
|
+
*/
|
|
11
|
+
import type { ParsedFrontmatter, ParsedItem, ParsedMarkdown } from './parser-types.js';
|
|
12
|
+
/**
|
|
13
|
+
* Parse YAML-like frontmatter from the head of a markdown document.
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseFrontmatter(lines: string[], baseTheme: string): ParsedFrontmatter;
|
|
16
|
+
/**
|
|
17
|
+
* Parse a code-block opening line (everything after the leading ```), extracting
|
|
18
|
+
* type, optional `size=` directive, and optional `file=` reference.
|
|
19
|
+
*/
|
|
20
|
+
export declare function parseCodeBlockHeader(headerLine: string): {
|
|
21
|
+
type: string;
|
|
22
|
+
size: [number, number] | 'auto' | null;
|
|
23
|
+
file: string | null;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Parse a markdown table block (lines starting with `|`) into a table item.
|
|
27
|
+
* Returns null if the block isn't a valid table.
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseMarkdownTable(tableLines: string[], theme: string): ParsedItem | null;
|
|
30
|
+
/**
|
|
31
|
+
* Parse markdown into a raw IR.
|
|
32
|
+
*/
|
|
33
|
+
export declare function parseMarkdown(markdown: string, baseTheme: string): ParsedMarkdown;
|
|
34
|
+
//# sourceMappingURL=markdown-parser.d.ts.map
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 1: Markdown -> raw IR.
|
|
3
|
+
*
|
|
4
|
+
* Pure tokenization. Reads a markdown string and produces a `ParsedMarkdown`
|
|
5
|
+
* IR consisting of sections, rows, and items. Code blocks are emitted as
|
|
6
|
+
* `RawCodeBlockItem`s with their inline body still as raw text — no file I/O,
|
|
7
|
+
* no JSON parsing, no validation happens here.
|
|
8
|
+
*
|
|
9
|
+
* Frontmatter parsing also lives in this module (see `parseFrontmatter`).
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Parse YAML-like frontmatter from the head of a markdown document.
|
|
13
|
+
*/
|
|
14
|
+
export function parseFrontmatter(lines, baseTheme) {
|
|
15
|
+
const initial = createDefaultFrontmatter(lines, baseTheme);
|
|
16
|
+
if (lines.length === 0 || lines[0] !== '---')
|
|
17
|
+
return initial;
|
|
18
|
+
const frontmatterEnd = findFrontmatterEnd(lines);
|
|
19
|
+
if (frontmatterEnd <= 0)
|
|
20
|
+
return initial;
|
|
21
|
+
const result = { ...initial };
|
|
22
|
+
for (let i = 1; i < frontmatterEnd; i++) {
|
|
23
|
+
applyFrontmatterLine(result, lines[i]);
|
|
24
|
+
}
|
|
25
|
+
result.remainingLines = lines.slice(frontmatterEnd + 1);
|
|
26
|
+
result.frontmatterEndLine = frontmatterEnd + 1;
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
function createDefaultFrontmatter(lines, baseTheme) {
|
|
30
|
+
return {
|
|
31
|
+
theme: baseTheme,
|
|
32
|
+
pageTitle: 'Dashboard',
|
|
33
|
+
pageTitleFromFrontmatter: false,
|
|
34
|
+
continuous: false,
|
|
35
|
+
orientation: 'portrait',
|
|
36
|
+
printMode: false,
|
|
37
|
+
currency: undefined,
|
|
38
|
+
remainingLines: lines,
|
|
39
|
+
frontmatterEndLine: 0,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function findFrontmatterEnd(lines) {
|
|
43
|
+
for (let i = 1; i < lines.length; i++) {
|
|
44
|
+
if (lines[i] === '---')
|
|
45
|
+
return i;
|
|
46
|
+
}
|
|
47
|
+
return -1;
|
|
48
|
+
}
|
|
49
|
+
function applyFrontmatterLine(target, line) {
|
|
50
|
+
const colonIdx = line.indexOf(':');
|
|
51
|
+
if (colonIdx <= 0)
|
|
52
|
+
return;
|
|
53
|
+
const key = line.slice(0, colonIdx).trim();
|
|
54
|
+
let val = line.slice(colonIdx + 1).trim();
|
|
55
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
56
|
+
val = val.slice(1, -1);
|
|
57
|
+
}
|
|
58
|
+
if (key === 'theme')
|
|
59
|
+
target.theme = val;
|
|
60
|
+
else if (key === 'title') {
|
|
61
|
+
target.pageTitle = val;
|
|
62
|
+
target.pageTitleFromFrontmatter = true;
|
|
63
|
+
}
|
|
64
|
+
else if (key === 'continuous')
|
|
65
|
+
target.continuous = isTruthy(val);
|
|
66
|
+
else if (key === 'orientation' && (val === 'landscape' || val === 'portrait')) {
|
|
67
|
+
target.orientation = val;
|
|
68
|
+
}
|
|
69
|
+
else if (key === 'print')
|
|
70
|
+
target.printMode = isTruthy(val);
|
|
71
|
+
else if (key === 'currency')
|
|
72
|
+
target.currency = val.toUpperCase();
|
|
73
|
+
}
|
|
74
|
+
function isTruthy(val) {
|
|
75
|
+
return ['true', 'yes', '1'].includes(val.toLowerCase());
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Parse a code-block opening line (everything after the leading ```), extracting
|
|
79
|
+
* type, optional `size=` directive, and optional `file=` reference.
|
|
80
|
+
*/
|
|
81
|
+
export function parseCodeBlockHeader(headerLine) {
|
|
82
|
+
let header = headerLine.slice(3).trim();
|
|
83
|
+
let size = null;
|
|
84
|
+
if (/size\s*=\s*auto\b/.test(header)) {
|
|
85
|
+
size = 'auto';
|
|
86
|
+
header = header.replace(/\s*size\s*=\s*auto\b/, '');
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const m = header.match(/size\s*=\s*\[\s*(\d+)\s*,\s*(\d+)\s*\]/);
|
|
90
|
+
if (m) {
|
|
91
|
+
size = [parseInt(m[1], 10), parseInt(m[2], 10)];
|
|
92
|
+
header = header.replace(/\s*size\s*=\s*\[\s*\d+\s*,\s*\d+\s*\]/, '');
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
let file = null;
|
|
96
|
+
if (header.includes(' file=')) {
|
|
97
|
+
const parts = header.split(' file=');
|
|
98
|
+
header = parts[0].trim();
|
|
99
|
+
file = parts[1]?.trim() ?? null;
|
|
100
|
+
}
|
|
101
|
+
const type = header.split(' ')[0] ?? '';
|
|
102
|
+
return { type, size, file };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Parse a markdown table block (lines starting with `|`) into a table item.
|
|
106
|
+
* Returns null if the block isn't a valid table.
|
|
107
|
+
*/
|
|
108
|
+
export function parseMarkdownTable(tableLines, theme) {
|
|
109
|
+
if (tableLines.length < 2)
|
|
110
|
+
return null;
|
|
111
|
+
const headers = parseTableHeaders(tableLines[0]);
|
|
112
|
+
if (!headers || headers.length === 0)
|
|
113
|
+
return null;
|
|
114
|
+
const alignments = parseTableAlignments(tableLines[1], headers.length);
|
|
115
|
+
if (!alignments)
|
|
116
|
+
return null;
|
|
117
|
+
const data = parseTableRows(tableLines.slice(2), headers);
|
|
118
|
+
if (data.length === 0)
|
|
119
|
+
return null;
|
|
120
|
+
const columns = headers.map((header, i) => ({
|
|
121
|
+
id: header,
|
|
122
|
+
title: header,
|
|
123
|
+
align: alignments[i] ?? 'left',
|
|
124
|
+
}));
|
|
125
|
+
return { type: 'table', spec: { data, columns, theme } };
|
|
126
|
+
}
|
|
127
|
+
function parseTableHeaders(line) {
|
|
128
|
+
let trimmed = line.trim();
|
|
129
|
+
if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) {
|
|
130
|
+
trimmed = '|' + trimmed + '|';
|
|
131
|
+
}
|
|
132
|
+
return trimmed.split('|').slice(1, -1).map((h) => h.trim());
|
|
133
|
+
}
|
|
134
|
+
function parseTableAlignments(sepLine, headerCount) {
|
|
135
|
+
const trimmed = sepLine.trim();
|
|
136
|
+
if (!trimmed.includes('-'))
|
|
137
|
+
return null;
|
|
138
|
+
const parts = trimmed.split('|').slice(1, -1).map((s) => s.trim());
|
|
139
|
+
const alignments = parts.map((part) => {
|
|
140
|
+
if (part.startsWith(':') && part.endsWith(':'))
|
|
141
|
+
return 'center';
|
|
142
|
+
if (part.endsWith(':'))
|
|
143
|
+
return 'right';
|
|
144
|
+
return 'left';
|
|
145
|
+
});
|
|
146
|
+
while (alignments.length < headerCount)
|
|
147
|
+
alignments.push('left');
|
|
148
|
+
return alignments;
|
|
149
|
+
}
|
|
150
|
+
function parseTableRows(lines, headers) {
|
|
151
|
+
const data = [];
|
|
152
|
+
for (const raw of lines) {
|
|
153
|
+
let line = raw.trim();
|
|
154
|
+
if (!line || line.startsWith('|---') || /^[|\-:\s]+$/.test(line))
|
|
155
|
+
continue;
|
|
156
|
+
if (!line.startsWith('|'))
|
|
157
|
+
line = '|' + line + '|';
|
|
158
|
+
const cells = line.split('|').slice(1, -1).map((c) => c.trim());
|
|
159
|
+
if (cells.length === 0)
|
|
160
|
+
continue;
|
|
161
|
+
const row = {};
|
|
162
|
+
for (let j = 0; j < headers.length; j++) {
|
|
163
|
+
row[headers[j]] = coerceCellValue(cells[j] ?? '');
|
|
164
|
+
}
|
|
165
|
+
data.push(row);
|
|
166
|
+
}
|
|
167
|
+
return data;
|
|
168
|
+
}
|
|
169
|
+
function coerceCellValue(value) {
|
|
170
|
+
if (value === '')
|
|
171
|
+
return value;
|
|
172
|
+
if (value.includes('.')) {
|
|
173
|
+
const num = parseFloat(value);
|
|
174
|
+
if (!isNaN(num))
|
|
175
|
+
return num;
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
const num = parseInt(value, 10);
|
|
179
|
+
if (!isNaN(num))
|
|
180
|
+
return num;
|
|
181
|
+
}
|
|
182
|
+
return value;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Parse markdown into a raw IR.
|
|
186
|
+
*/
|
|
187
|
+
export function parseMarkdown(markdown, baseTheme) {
|
|
188
|
+
const lines = markdown.trim().split('\n');
|
|
189
|
+
const frontmatter = parseFrontmatter(lines, baseTheme);
|
|
190
|
+
const state = createParseState(frontmatter);
|
|
191
|
+
for (let i = 0; i < frontmatter.remainingLines.length; i++) {
|
|
192
|
+
processLine(state, frontmatter.remainingLines[i], frontmatter.frontmatterEndLine + i + 1);
|
|
193
|
+
}
|
|
194
|
+
finalizeParseState(state);
|
|
195
|
+
return { frontmatter, sections: state.sections, pageTitle: state.pageTitle };
|
|
196
|
+
}
|
|
197
|
+
function createParseState(frontmatter) {
|
|
198
|
+
return {
|
|
199
|
+
theme: frontmatter.theme,
|
|
200
|
+
sections: [],
|
|
201
|
+
currentSection: { title: '', rows: [] },
|
|
202
|
+
currentRow: [],
|
|
203
|
+
inCodeBlock: false,
|
|
204
|
+
codeType: '',
|
|
205
|
+
codeContent: [],
|
|
206
|
+
codeSize: null,
|
|
207
|
+
codeFile: null,
|
|
208
|
+
codeStartLine: 0,
|
|
209
|
+
paragraphLines: [],
|
|
210
|
+
inMarkdownTable: false,
|
|
211
|
+
markdownTableLines: [],
|
|
212
|
+
handledFirstH1: false,
|
|
213
|
+
pageTitle: frontmatter.pageTitle,
|
|
214
|
+
pageTitleFromFrontmatter: frontmatter.pageTitleFromFrontmatter,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function flushParagraph(s) {
|
|
218
|
+
if (s.paragraphLines.length === 0)
|
|
219
|
+
return;
|
|
220
|
+
const text = s.paragraphLines.join(' ').trim();
|
|
221
|
+
if (text) {
|
|
222
|
+
pushResolvedItem(s.currentRow, { type: 'text', spec: { content: text, theme: s.theme } });
|
|
223
|
+
}
|
|
224
|
+
s.paragraphLines = [];
|
|
225
|
+
}
|
|
226
|
+
function flushMarkdownTable(s) {
|
|
227
|
+
if (s.markdownTableLines.length > 0) {
|
|
228
|
+
const tableItem = parseMarkdownTable(s.markdownTableLines, s.theme);
|
|
229
|
+
if (tableItem)
|
|
230
|
+
pushResolvedItem(s.currentRow, tableItem);
|
|
231
|
+
s.markdownTableLines = [];
|
|
232
|
+
}
|
|
233
|
+
s.inMarkdownTable = false;
|
|
234
|
+
}
|
|
235
|
+
function flushRow(s) {
|
|
236
|
+
if (s.currentRow.length > 0) {
|
|
237
|
+
s.currentSection.rows.push(s.currentRow);
|
|
238
|
+
s.currentRow = [];
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function pushResolvedItem(row, item) {
|
|
242
|
+
const wrapped = { kind: 'resolved', item };
|
|
243
|
+
row.push(wrapped);
|
|
244
|
+
}
|
|
245
|
+
function startSection(s, next) {
|
|
246
|
+
if (s.currentSection.rows.length > 0 || s.currentSection.title) {
|
|
247
|
+
s.sections.push(s.currentSection);
|
|
248
|
+
}
|
|
249
|
+
s.currentSection = next;
|
|
250
|
+
}
|
|
251
|
+
function processLine(s, line, _absoluteLineNum) {
|
|
252
|
+
if (line.startsWith('```')) {
|
|
253
|
+
handleFence(s, line, _absoluteLineNum);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (s.inCodeBlock) {
|
|
257
|
+
s.codeContent.push(line);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (handleHeading(s, line))
|
|
261
|
+
return;
|
|
262
|
+
if (handlePageOrDivider(s, line))
|
|
263
|
+
return;
|
|
264
|
+
if (!line.trim()) {
|
|
265
|
+
flushParagraph(s);
|
|
266
|
+
if (s.inMarkdownTable)
|
|
267
|
+
flushMarkdownTable(s);
|
|
268
|
+
if (s.currentRow.length > 0)
|
|
269
|
+
flushRow(s);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (line.trim().startsWith('|')) {
|
|
273
|
+
flushParagraph(s);
|
|
274
|
+
s.inMarkdownTable = true;
|
|
275
|
+
s.markdownTableLines.push(line);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (s.inMarkdownTable)
|
|
279
|
+
flushMarkdownTable(s);
|
|
280
|
+
s.paragraphLines.push(line);
|
|
281
|
+
}
|
|
282
|
+
function handleFence(s, line, absoluteLineNum) {
|
|
283
|
+
if (!s.inCodeBlock) {
|
|
284
|
+
flushParagraph(s);
|
|
285
|
+
const { type, size, file } = parseCodeBlockHeader(line);
|
|
286
|
+
s.codeType = type;
|
|
287
|
+
s.codeSize = size;
|
|
288
|
+
s.codeFile = file;
|
|
289
|
+
s.codeContent = [];
|
|
290
|
+
s.codeStartLine = absoluteLineNum;
|
|
291
|
+
s.inCodeBlock = true;
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
s.inCodeBlock = false;
|
|
295
|
+
if (s.codeType) {
|
|
296
|
+
const block = {
|
|
297
|
+
kind: 'code',
|
|
298
|
+
type: s.codeType,
|
|
299
|
+
inlineLines: s.codeContent,
|
|
300
|
+
file: s.codeFile,
|
|
301
|
+
size: s.codeSize,
|
|
302
|
+
startLine: s.codeStartLine,
|
|
303
|
+
};
|
|
304
|
+
s.currentRow.push(block);
|
|
305
|
+
}
|
|
306
|
+
s.codeType = '';
|
|
307
|
+
s.codeContent = [];
|
|
308
|
+
s.codeSize = null;
|
|
309
|
+
s.codeFile = null;
|
|
310
|
+
}
|
|
311
|
+
function handleHeading(s, line) {
|
|
312
|
+
if (line.startsWith('# ') && !line.startsWith('## ') && !line.startsWith('### ')) {
|
|
313
|
+
handleH1(s, line.slice(2).trim());
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
if (line.startsWith('## ') && !line.startsWith('### ')) {
|
|
317
|
+
flushParagraph(s);
|
|
318
|
+
flushRow(s);
|
|
319
|
+
startSection(s, { title: line.slice(3).trim(), rows: [] });
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
if (line.startsWith('### ')) {
|
|
323
|
+
flushParagraph(s);
|
|
324
|
+
if (s.inMarkdownTable)
|
|
325
|
+
flushMarkdownTable(s);
|
|
326
|
+
flushRow(s);
|
|
327
|
+
pushResolvedItem(s.currentRow, {
|
|
328
|
+
type: 'inline_header',
|
|
329
|
+
spec: { text: line.slice(4).trim(), theme: s.theme },
|
|
330
|
+
});
|
|
331
|
+
flushRow(s);
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Handle a leading or non-leading H1.
|
|
338
|
+
*
|
|
339
|
+
* Leading H1 disambiguation (issue #249):
|
|
340
|
+
* 1. No frontmatter title: leading H1 becomes the page title (no section).
|
|
341
|
+
* 2. Frontmatter title matches H1: suppress the H1 (no section).
|
|
342
|
+
* 3. Frontmatter + different H1: render H1 as a section (legacy behavior).
|
|
343
|
+
* Non-leading H1s always render as a section.
|
|
344
|
+
*/
|
|
345
|
+
function handleH1(s, headingText) {
|
|
346
|
+
flushParagraph(s);
|
|
347
|
+
flushRow(s);
|
|
348
|
+
const isLeading = !s.handledFirstH1 &&
|
|
349
|
+
s.sections.length === 0 &&
|
|
350
|
+
s.currentSection.rows.length === 0 &&
|
|
351
|
+
!s.currentSection.title;
|
|
352
|
+
if (isLeading) {
|
|
353
|
+
s.handledFirstH1 = true;
|
|
354
|
+
if (!s.pageTitleFromFrontmatter) {
|
|
355
|
+
// Case 1: promote leading H1 to page title.
|
|
356
|
+
s.pageTitle = headingText;
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
if (s.pageTitle.trim().toLowerCase() === headingText.toLowerCase()) {
|
|
360
|
+
// Case 2: H1 duplicates frontmatter title — suppress.
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
// Case 3: fall through to legacy section behavior.
|
|
364
|
+
}
|
|
365
|
+
startSection(s, { title: headingText, rows: [] });
|
|
366
|
+
}
|
|
367
|
+
function handlePageOrDivider(s, line) {
|
|
368
|
+
if (line.trim() === '===') {
|
|
369
|
+
flushParagraph(s);
|
|
370
|
+
if (s.inMarkdownTable)
|
|
371
|
+
flushMarkdownTable(s);
|
|
372
|
+
flushRow(s);
|
|
373
|
+
startSection(s, { title: '', rows: [], pageBreak: true });
|
|
374
|
+
return true;
|
|
375
|
+
}
|
|
376
|
+
if (['---', '***', '___'].includes(line.trim())) {
|
|
377
|
+
flushParagraph(s);
|
|
378
|
+
if (s.inMarkdownTable)
|
|
379
|
+
flushMarkdownTable(s);
|
|
380
|
+
flushRow(s);
|
|
381
|
+
startSection(s, { title: '', rows: [], hasDivider: true });
|
|
382
|
+
return true;
|
|
383
|
+
}
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
386
|
+
function finalizeParseState(s) {
|
|
387
|
+
flushParagraph(s);
|
|
388
|
+
if (s.inMarkdownTable)
|
|
389
|
+
flushMarkdownTable(s);
|
|
390
|
+
flushRow(s);
|
|
391
|
+
if (s.currentSection.rows.length > 0 || s.currentSection.title) {
|
|
392
|
+
s.sections.push(s.currentSection);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
//# sourceMappingURL=markdown-parser.js.map
|