@plainviz/core 0.1.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/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/ir.d.ts +30 -0
- package/dist/ir.d.ts.map +1 -0
- package/dist/ir.js +6 -0
- package/dist/parse.d.ts +7 -0
- package/dist/parse.d.ts.map +1 -0
- package/dist/parse.js +126 -0
- package/dist/parse.test.d.ts +2 -0
- package/dist/parse.test.d.ts.map +1 -0
- package/dist/parse.test.js +154 -0
- package/package.json +34 -0
- package/src/index.ts +7 -0
- package/src/ir.ts +29 -0
- package/src/parse.test.ts +167 -0
- package/src/parse.ts +142 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAChC,YAAY,EACV,UAAU,EACV,WAAW,EACX,UAAU,EACV,SAAS,GACV,MAAM,MAAM,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { parse } from './parse';
|
package/dist/ir.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlainViz Intermediate Representation (IR)
|
|
3
|
+
* This is the canonical output of the parser.
|
|
4
|
+
* All renderers consume this format.
|
|
5
|
+
*/
|
|
6
|
+
export type ChartType = 'bar' | 'line' | 'pie' | 'area';
|
|
7
|
+
export interface PlainVizIR {
|
|
8
|
+
type: ChartType;
|
|
9
|
+
title?: string;
|
|
10
|
+
subtitle?: string;
|
|
11
|
+
labels: string[];
|
|
12
|
+
values: number[];
|
|
13
|
+
meta?: {
|
|
14
|
+
xAxis?: string;
|
|
15
|
+
yAxis?: string;
|
|
16
|
+
theme?: string;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export interface ParseError {
|
|
20
|
+
line: number;
|
|
21
|
+
message: string;
|
|
22
|
+
}
|
|
23
|
+
export type ParseResult = {
|
|
24
|
+
ok: true;
|
|
25
|
+
ir: PlainVizIR;
|
|
26
|
+
} | {
|
|
27
|
+
ok: false;
|
|
28
|
+
errors: ParseError[];
|
|
29
|
+
};
|
|
30
|
+
//# sourceMappingURL=ir.d.ts.map
|
package/dist/ir.d.ts.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ir.d.ts","sourceRoot":"","sources":["../src/ir.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,MAAM,SAAS,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,MAAM,CAAC;AAExD,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,SAAS,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,IAAI,CAAC,EAAE;QACL,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,KAAK,CAAC,EAAE,MAAM,CAAC;KAChB,CAAC;CACH;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,WAAW,GACnB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,EAAE,EAAE,UAAU,CAAA;CAAE,GAC5B;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,UAAU,EAAE,CAAA;CAAE,CAAC"}
|
package/dist/ir.js
ADDED
package/dist/parse.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../src/parse.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAc,WAAW,EAAyB,MAAM,MAAM,CAAC;AAiB3E,wBAAgB,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,CAuHhD"}
|
package/dist/parse.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlainViz Parser
|
|
3
|
+
* Parses PlainViz syntax into IR (Intermediate Representation)
|
|
4
|
+
*/
|
|
5
|
+
const HEADER_KEYS = new Set([
|
|
6
|
+
'type', 'title', 'subtitle', 'theme',
|
|
7
|
+
'x-axis', 'y-axis', 'x', 'y'
|
|
8
|
+
]);
|
|
9
|
+
function isHeaderKey(key) {
|
|
10
|
+
return HEADER_KEYS.has(key.toLowerCase());
|
|
11
|
+
}
|
|
12
|
+
function cleanNumber(value) {
|
|
13
|
+
// Remove common formatting: $, %, commas, spaces
|
|
14
|
+
const cleaned = value.replace(/[$%,\s]/g, '');
|
|
15
|
+
return parseFloat(cleaned);
|
|
16
|
+
}
|
|
17
|
+
export function parse(input) {
|
|
18
|
+
const lines = input.split('\n');
|
|
19
|
+
const errors = [];
|
|
20
|
+
let type = 'bar';
|
|
21
|
+
let title;
|
|
22
|
+
let subtitle;
|
|
23
|
+
let xAxis;
|
|
24
|
+
let yAxis;
|
|
25
|
+
let theme;
|
|
26
|
+
const labels = [];
|
|
27
|
+
const values = [];
|
|
28
|
+
let inDataSection = false;
|
|
29
|
+
for (let i = 0; i < lines.length; i++) {
|
|
30
|
+
const lineNum = i + 1;
|
|
31
|
+
const line = lines[i];
|
|
32
|
+
const trimmed = line.trim();
|
|
33
|
+
// Skip empty lines and comments
|
|
34
|
+
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
// Find colon separator
|
|
38
|
+
const colonIndex = trimmed.indexOf(':');
|
|
39
|
+
if (colonIndex === -1) {
|
|
40
|
+
errors.push({ line: lineNum, message: `Missing ':' separator` });
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const key = trimmed.slice(0, colonIndex).trim();
|
|
44
|
+
const value = trimmed.slice(colonIndex + 1).trim();
|
|
45
|
+
if (!key) {
|
|
46
|
+
errors.push({ line: lineNum, message: `Empty key before ':'` });
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
// Parse header fields
|
|
50
|
+
if (!inDataSection && isHeaderKey(key)) {
|
|
51
|
+
const keyLower = key.toLowerCase();
|
|
52
|
+
switch (keyLower) {
|
|
53
|
+
case 'type':
|
|
54
|
+
const validTypes = ['bar', 'line', 'pie', 'area'];
|
|
55
|
+
const typeLower = value.toLowerCase();
|
|
56
|
+
if (validTypes.includes(typeLower)) {
|
|
57
|
+
type = typeLower;
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
errors.push({
|
|
61
|
+
line: lineNum,
|
|
62
|
+
message: `Invalid chart type '${value}'. Valid types: ${validTypes.join(', ')}`
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
case 'title':
|
|
67
|
+
title = value.replace(/^["']|["']$/g, '');
|
|
68
|
+
break;
|
|
69
|
+
case 'subtitle':
|
|
70
|
+
subtitle = value.replace(/^["']|["']$/g, '');
|
|
71
|
+
break;
|
|
72
|
+
case 'x-axis':
|
|
73
|
+
case 'x':
|
|
74
|
+
xAxis = value;
|
|
75
|
+
break;
|
|
76
|
+
case 'y-axis':
|
|
77
|
+
case 'y':
|
|
78
|
+
yAxis = value;
|
|
79
|
+
break;
|
|
80
|
+
case 'theme':
|
|
81
|
+
theme = value;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
// Data section
|
|
87
|
+
inDataSection = true;
|
|
88
|
+
const numValue = cleanNumber(value);
|
|
89
|
+
if (isNaN(numValue)) {
|
|
90
|
+
errors.push({
|
|
91
|
+
line: lineNum,
|
|
92
|
+
message: `Invalid number '${value}' for label '${key}'`
|
|
93
|
+
});
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
labels.push(key);
|
|
97
|
+
values.push(numValue);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Validation
|
|
101
|
+
if (labels.length === 0 && errors.length === 0) {
|
|
102
|
+
errors.push({ line: 0, message: 'No data points found' });
|
|
103
|
+
}
|
|
104
|
+
if (errors.length > 0) {
|
|
105
|
+
return { ok: false, errors };
|
|
106
|
+
}
|
|
107
|
+
const ir = {
|
|
108
|
+
type,
|
|
109
|
+
labels,
|
|
110
|
+
values,
|
|
111
|
+
};
|
|
112
|
+
if (title)
|
|
113
|
+
ir.title = title;
|
|
114
|
+
if (subtitle)
|
|
115
|
+
ir.subtitle = subtitle;
|
|
116
|
+
if (xAxis || yAxis || theme) {
|
|
117
|
+
ir.meta = {};
|
|
118
|
+
if (xAxis)
|
|
119
|
+
ir.meta.xAxis = xAxis;
|
|
120
|
+
if (yAxis)
|
|
121
|
+
ir.meta.yAxis = yAxis;
|
|
122
|
+
if (theme)
|
|
123
|
+
ir.meta.theme = theme;
|
|
124
|
+
}
|
|
125
|
+
return { ok: true, ir };
|
|
126
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"parse.test.d.ts","sourceRoot":"","sources":["../src/parse.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parse } from './parse';
|
|
3
|
+
describe('parse', () => {
|
|
4
|
+
describe('basic parsing', () => {
|
|
5
|
+
it('parses simple bar chart', () => {
|
|
6
|
+
const result = parse(`
|
|
7
|
+
Type: Bar
|
|
8
|
+
Title: Test
|
|
9
|
+
|
|
10
|
+
A: 10
|
|
11
|
+
B: 20
|
|
12
|
+
`);
|
|
13
|
+
expect(result.ok).toBe(true);
|
|
14
|
+
if (result.ok) {
|
|
15
|
+
expect(result.ir.type).toBe('bar');
|
|
16
|
+
expect(result.ir.title).toBe('Test');
|
|
17
|
+
expect(result.ir.labels).toEqual(['A', 'B']);
|
|
18
|
+
expect(result.ir.values).toEqual([10, 20]);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
it('defaults to bar chart when type not specified', () => {
|
|
22
|
+
const result = parse(`
|
|
23
|
+
A: 10
|
|
24
|
+
B: 20
|
|
25
|
+
`);
|
|
26
|
+
expect(result.ok).toBe(true);
|
|
27
|
+
if (result.ok) {
|
|
28
|
+
expect(result.ir.type).toBe('bar');
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
describe('number cleaning', () => {
|
|
33
|
+
it('handles currency format ($1,200)', () => {
|
|
34
|
+
const result = parse(`
|
|
35
|
+
Price: $1,200
|
|
36
|
+
`);
|
|
37
|
+
expect(result.ok).toBe(true);
|
|
38
|
+
if (result.ok) {
|
|
39
|
+
expect(result.ir.values).toEqual([1200]);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
it('handles percentage format (50%)', () => {
|
|
43
|
+
const result = parse(`
|
|
44
|
+
Rate: 50%
|
|
45
|
+
`);
|
|
46
|
+
expect(result.ok).toBe(true);
|
|
47
|
+
if (result.ok) {
|
|
48
|
+
expect(result.ir.values).toEqual([50]);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
it('handles numbers with commas (1,000,000)', () => {
|
|
52
|
+
const result = parse(`
|
|
53
|
+
Big: 1,000,000
|
|
54
|
+
`);
|
|
55
|
+
expect(result.ok).toBe(true);
|
|
56
|
+
if (result.ok) {
|
|
57
|
+
expect(result.ir.values).toEqual([1000000]);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe('whitespace handling', () => {
|
|
62
|
+
it('ignores empty lines', () => {
|
|
63
|
+
const result = parse(`
|
|
64
|
+
Type: Bar
|
|
65
|
+
|
|
66
|
+
A: 10
|
|
67
|
+
|
|
68
|
+
B: 20
|
|
69
|
+
|
|
70
|
+
`);
|
|
71
|
+
expect(result.ok).toBe(true);
|
|
72
|
+
if (result.ok) {
|
|
73
|
+
expect(result.ir.labels).toEqual(['A', 'B']);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
it('ignores comment lines (// and #)', () => {
|
|
77
|
+
const result = parse(`
|
|
78
|
+
// This is a comment
|
|
79
|
+
Type: Bar
|
|
80
|
+
# Another comment
|
|
81
|
+
A: 10
|
|
82
|
+
`);
|
|
83
|
+
expect(result.ok).toBe(true);
|
|
84
|
+
if (result.ok) {
|
|
85
|
+
expect(result.ir.labels).toEqual(['A']);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
describe('error handling', () => {
|
|
90
|
+
it('reports error for missing colon', () => {
|
|
91
|
+
const result = parse(`
|
|
92
|
+
Type: Bar
|
|
93
|
+
A 10
|
|
94
|
+
`);
|
|
95
|
+
expect(result.ok).toBe(false);
|
|
96
|
+
if (!result.ok) {
|
|
97
|
+
expect(result.errors[0].message).toContain("Missing ':'");
|
|
98
|
+
expect(result.errors[0].line).toBe(3);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
it('reports error for invalid number', () => {
|
|
102
|
+
const result = parse(`
|
|
103
|
+
Type: Bar
|
|
104
|
+
A: abc
|
|
105
|
+
`);
|
|
106
|
+
expect(result.ok).toBe(false);
|
|
107
|
+
if (!result.ok) {
|
|
108
|
+
expect(result.errors[0].message).toContain("Invalid number");
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
it('reports error for invalid chart type', () => {
|
|
112
|
+
const result = parse(`
|
|
113
|
+
Type: InvalidType
|
|
114
|
+
A: 10
|
|
115
|
+
`);
|
|
116
|
+
expect(result.ok).toBe(false);
|
|
117
|
+
if (!result.ok) {
|
|
118
|
+
expect(result.errors[0].message).toContain("Invalid chart type");
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
it('reports error when no data points', () => {
|
|
122
|
+
const result = parse(`
|
|
123
|
+
Type: Bar
|
|
124
|
+
Title: Empty
|
|
125
|
+
`);
|
|
126
|
+
expect(result.ok).toBe(false);
|
|
127
|
+
if (!result.ok) {
|
|
128
|
+
expect(result.errors[0].message).toContain("No data points");
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
describe('title parsing', () => {
|
|
133
|
+
it('strips quotes from title', () => {
|
|
134
|
+
const result = parse(`
|
|
135
|
+
Title: "Quoted Title"
|
|
136
|
+
A: 10
|
|
137
|
+
`);
|
|
138
|
+
expect(result.ok).toBe(true);
|
|
139
|
+
if (result.ok) {
|
|
140
|
+
expect(result.ir.title).toBe('Quoted Title');
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
it('handles title without quotes', () => {
|
|
144
|
+
const result = parse(`
|
|
145
|
+
Title: Unquoted Title
|
|
146
|
+
A: 10
|
|
147
|
+
`);
|
|
148
|
+
expect(result.ok).toBe(true);
|
|
149
|
+
if (result.ok) {
|
|
150
|
+
expect(result.ir.title).toBe('Unquoted Title');
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@plainviz/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "PlainViz core parser and IR",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"src"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"dev": "tsc --watch",
|
|
21
|
+
"test": "vitest"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"plainviz",
|
|
25
|
+
"parser",
|
|
26
|
+
"chart",
|
|
27
|
+
"visualization"
|
|
28
|
+
],
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"typescript": "^5.7.2",
|
|
32
|
+
"vitest": "^3.0.0"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
ADDED
package/src/ir.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlainViz Intermediate Representation (IR)
|
|
3
|
+
* This is the canonical output of the parser.
|
|
4
|
+
* All renderers consume this format.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type ChartType = 'bar' | 'line' | 'pie' | 'area';
|
|
8
|
+
|
|
9
|
+
export interface PlainVizIR {
|
|
10
|
+
type: ChartType;
|
|
11
|
+
title?: string;
|
|
12
|
+
subtitle?: string;
|
|
13
|
+
labels: string[];
|
|
14
|
+
values: number[];
|
|
15
|
+
meta?: {
|
|
16
|
+
xAxis?: string;
|
|
17
|
+
yAxis?: string;
|
|
18
|
+
theme?: string;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ParseError {
|
|
23
|
+
line: number;
|
|
24
|
+
message: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type ParseResult =
|
|
28
|
+
| { ok: true; ir: PlainVizIR }
|
|
29
|
+
| { ok: false; errors: ParseError[] };
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parse } from './parse';
|
|
3
|
+
|
|
4
|
+
describe('parse', () => {
|
|
5
|
+
describe('basic parsing', () => {
|
|
6
|
+
it('parses simple bar chart', () => {
|
|
7
|
+
const result = parse(`
|
|
8
|
+
Type: Bar
|
|
9
|
+
Title: Test
|
|
10
|
+
|
|
11
|
+
A: 10
|
|
12
|
+
B: 20
|
|
13
|
+
`);
|
|
14
|
+
expect(result.ok).toBe(true);
|
|
15
|
+
if (result.ok) {
|
|
16
|
+
expect(result.ir.type).toBe('bar');
|
|
17
|
+
expect(result.ir.title).toBe('Test');
|
|
18
|
+
expect(result.ir.labels).toEqual(['A', 'B']);
|
|
19
|
+
expect(result.ir.values).toEqual([10, 20]);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('defaults to bar chart when type not specified', () => {
|
|
24
|
+
const result = parse(`
|
|
25
|
+
A: 10
|
|
26
|
+
B: 20
|
|
27
|
+
`);
|
|
28
|
+
expect(result.ok).toBe(true);
|
|
29
|
+
if (result.ok) {
|
|
30
|
+
expect(result.ir.type).toBe('bar');
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('number cleaning', () => {
|
|
36
|
+
it('handles currency format ($1,200)', () => {
|
|
37
|
+
const result = parse(`
|
|
38
|
+
Price: $1,200
|
|
39
|
+
`);
|
|
40
|
+
expect(result.ok).toBe(true);
|
|
41
|
+
if (result.ok) {
|
|
42
|
+
expect(result.ir.values).toEqual([1200]);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('handles percentage format (50%)', () => {
|
|
47
|
+
const result = parse(`
|
|
48
|
+
Rate: 50%
|
|
49
|
+
`);
|
|
50
|
+
expect(result.ok).toBe(true);
|
|
51
|
+
if (result.ok) {
|
|
52
|
+
expect(result.ir.values).toEqual([50]);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('handles numbers with commas (1,000,000)', () => {
|
|
57
|
+
const result = parse(`
|
|
58
|
+
Big: 1,000,000
|
|
59
|
+
`);
|
|
60
|
+
expect(result.ok).toBe(true);
|
|
61
|
+
if (result.ok) {
|
|
62
|
+
expect(result.ir.values).toEqual([1000000]);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('whitespace handling', () => {
|
|
68
|
+
it('ignores empty lines', () => {
|
|
69
|
+
const result = parse(`
|
|
70
|
+
Type: Bar
|
|
71
|
+
|
|
72
|
+
A: 10
|
|
73
|
+
|
|
74
|
+
B: 20
|
|
75
|
+
|
|
76
|
+
`);
|
|
77
|
+
expect(result.ok).toBe(true);
|
|
78
|
+
if (result.ok) {
|
|
79
|
+
expect(result.ir.labels).toEqual(['A', 'B']);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('ignores comment lines (// and #)', () => {
|
|
84
|
+
const result = parse(`
|
|
85
|
+
// This is a comment
|
|
86
|
+
Type: Bar
|
|
87
|
+
# Another comment
|
|
88
|
+
A: 10
|
|
89
|
+
`);
|
|
90
|
+
expect(result.ok).toBe(true);
|
|
91
|
+
if (result.ok) {
|
|
92
|
+
expect(result.ir.labels).toEqual(['A']);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('error handling', () => {
|
|
98
|
+
it('reports error for missing colon', () => {
|
|
99
|
+
const result = parse(`
|
|
100
|
+
Type: Bar
|
|
101
|
+
A 10
|
|
102
|
+
`);
|
|
103
|
+
expect(result.ok).toBe(false);
|
|
104
|
+
if (!result.ok) {
|
|
105
|
+
expect(result.errors[0].message).toContain("Missing ':'");
|
|
106
|
+
expect(result.errors[0].line).toBe(3);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('reports error for invalid number', () => {
|
|
111
|
+
const result = parse(`
|
|
112
|
+
Type: Bar
|
|
113
|
+
A: abc
|
|
114
|
+
`);
|
|
115
|
+
expect(result.ok).toBe(false);
|
|
116
|
+
if (!result.ok) {
|
|
117
|
+
expect(result.errors[0].message).toContain("Invalid number");
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('reports error for invalid chart type', () => {
|
|
122
|
+
const result = parse(`
|
|
123
|
+
Type: InvalidType
|
|
124
|
+
A: 10
|
|
125
|
+
`);
|
|
126
|
+
expect(result.ok).toBe(false);
|
|
127
|
+
if (!result.ok) {
|
|
128
|
+
expect(result.errors[0].message).toContain("Invalid chart type");
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('reports error when no data points', () => {
|
|
133
|
+
const result = parse(`
|
|
134
|
+
Type: Bar
|
|
135
|
+
Title: Empty
|
|
136
|
+
`);
|
|
137
|
+
expect(result.ok).toBe(false);
|
|
138
|
+
if (!result.ok) {
|
|
139
|
+
expect(result.errors[0].message).toContain("No data points");
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('title parsing', () => {
|
|
145
|
+
it('strips quotes from title', () => {
|
|
146
|
+
const result = parse(`
|
|
147
|
+
Title: "Quoted Title"
|
|
148
|
+
A: 10
|
|
149
|
+
`);
|
|
150
|
+
expect(result.ok).toBe(true);
|
|
151
|
+
if (result.ok) {
|
|
152
|
+
expect(result.ir.title).toBe('Quoted Title');
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('handles title without quotes', () => {
|
|
157
|
+
const result = parse(`
|
|
158
|
+
Title: Unquoted Title
|
|
159
|
+
A: 10
|
|
160
|
+
`);
|
|
161
|
+
expect(result.ok).toBe(true);
|
|
162
|
+
if (result.ok) {
|
|
163
|
+
expect(result.ir.title).toBe('Unquoted Title');
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
package/src/parse.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PlainViz Parser
|
|
3
|
+
* Parses PlainViz syntax into IR (Intermediate Representation)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { PlainVizIR, ParseResult, ParseError, ChartType } from './ir';
|
|
7
|
+
|
|
8
|
+
const HEADER_KEYS = new Set([
|
|
9
|
+
'type', 'title', 'subtitle', 'theme',
|
|
10
|
+
'x-axis', 'y-axis', 'x', 'y'
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
function isHeaderKey(key: string): boolean {
|
|
14
|
+
return HEADER_KEYS.has(key.toLowerCase());
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function cleanNumber(value: string): number {
|
|
18
|
+
// Remove common formatting: $, %, commas, spaces
|
|
19
|
+
const cleaned = value.replace(/[$%,\s]/g, '');
|
|
20
|
+
return parseFloat(cleaned);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parse(input: string): ParseResult {
|
|
24
|
+
const lines = input.split('\n');
|
|
25
|
+
const errors: ParseError[] = [];
|
|
26
|
+
|
|
27
|
+
let type: ChartType = 'bar';
|
|
28
|
+
let title: string | undefined;
|
|
29
|
+
let subtitle: string | undefined;
|
|
30
|
+
let xAxis: string | undefined;
|
|
31
|
+
let yAxis: string | undefined;
|
|
32
|
+
let theme: string | undefined;
|
|
33
|
+
|
|
34
|
+
const labels: string[] = [];
|
|
35
|
+
const values: number[] = [];
|
|
36
|
+
|
|
37
|
+
let inDataSection = false;
|
|
38
|
+
|
|
39
|
+
for (let i = 0; i < lines.length; i++) {
|
|
40
|
+
const lineNum = i + 1;
|
|
41
|
+
const line = lines[i];
|
|
42
|
+
const trimmed = line.trim();
|
|
43
|
+
|
|
44
|
+
// Skip empty lines and comments
|
|
45
|
+
if (!trimmed || trimmed.startsWith('//') || trimmed.startsWith('#')) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Find colon separator
|
|
50
|
+
const colonIndex = trimmed.indexOf(':');
|
|
51
|
+
if (colonIndex === -1) {
|
|
52
|
+
errors.push({ line: lineNum, message: `Missing ':' separator` });
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const key = trimmed.slice(0, colonIndex).trim();
|
|
57
|
+
const value = trimmed.slice(colonIndex + 1).trim();
|
|
58
|
+
|
|
59
|
+
if (!key) {
|
|
60
|
+
errors.push({ line: lineNum, message: `Empty key before ':'` });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Parse header fields
|
|
65
|
+
if (!inDataSection && isHeaderKey(key)) {
|
|
66
|
+
const keyLower = key.toLowerCase();
|
|
67
|
+
|
|
68
|
+
switch (keyLower) {
|
|
69
|
+
case 'type':
|
|
70
|
+
const validTypes = ['bar', 'line', 'pie', 'area'];
|
|
71
|
+
const typeLower = value.toLowerCase();
|
|
72
|
+
if (validTypes.includes(typeLower)) {
|
|
73
|
+
type = typeLower as ChartType;
|
|
74
|
+
} else {
|
|
75
|
+
errors.push({
|
|
76
|
+
line: lineNum,
|
|
77
|
+
message: `Invalid chart type '${value}'. Valid types: ${validTypes.join(', ')}`
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
break;
|
|
81
|
+
case 'title':
|
|
82
|
+
title = value.replace(/^["']|["']$/g, '');
|
|
83
|
+
break;
|
|
84
|
+
case 'subtitle':
|
|
85
|
+
subtitle = value.replace(/^["']|["']$/g, '');
|
|
86
|
+
break;
|
|
87
|
+
case 'x-axis':
|
|
88
|
+
case 'x':
|
|
89
|
+
xAxis = value;
|
|
90
|
+
break;
|
|
91
|
+
case 'y-axis':
|
|
92
|
+
case 'y':
|
|
93
|
+
yAxis = value;
|
|
94
|
+
break;
|
|
95
|
+
case 'theme':
|
|
96
|
+
theme = value;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
// Data section
|
|
101
|
+
inDataSection = true;
|
|
102
|
+
|
|
103
|
+
const numValue = cleanNumber(value);
|
|
104
|
+
if (isNaN(numValue)) {
|
|
105
|
+
errors.push({
|
|
106
|
+
line: lineNum,
|
|
107
|
+
message: `Invalid number '${value}' for label '${key}'`
|
|
108
|
+
});
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
labels.push(key);
|
|
113
|
+
values.push(numValue);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Validation
|
|
118
|
+
if (labels.length === 0 && errors.length === 0) {
|
|
119
|
+
errors.push({ line: 0, message: 'No data points found' });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (errors.length > 0) {
|
|
123
|
+
return { ok: false, errors };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const ir: PlainVizIR = {
|
|
127
|
+
type,
|
|
128
|
+
labels,
|
|
129
|
+
values,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
if (title) ir.title = title;
|
|
133
|
+
if (subtitle) ir.subtitle = subtitle;
|
|
134
|
+
if (xAxis || yAxis || theme) {
|
|
135
|
+
ir.meta = {};
|
|
136
|
+
if (xAxis) ir.meta.xAxis = xAxis;
|
|
137
|
+
if (yAxis) ir.meta.yAxis = yAxis;
|
|
138
|
+
if (theme) ir.meta.theme = theme;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { ok: true, ir };
|
|
142
|
+
}
|