@vis-pilot/engine 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026-present vis-pilot contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # @vis-pilot/engine
2
+
3
+ Merged chart engine package for vis-pilot.
4
+
5
+ Includes:
6
+
7
+ - text DSL parser (`parseText`, `parseTextOrThrow`, `normalizeSchema`)
8
+ - schema-to-option builders (`buildOption`, `registerChart`)
9
+ - option/schema safeguards (`sanitizeOption`, `validateSchema`, `validateOption`)
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ import {
15
+ parseText,
16
+ parseTextOrThrow,
17
+ normalizeSchema,
18
+ validateSchema,
19
+ buildOption,
20
+ sanitizeOption
21
+ } from '@vis-pilot/engine'
22
+
23
+ const schema = normalizeSchema(
24
+ parseTextOrThrow(`
25
+ chart: line
26
+ title: Engine Demo
27
+ x: month
28
+ y: value
29
+ data:
30
+ - month: Jan
31
+ value: 120
32
+ - month: Feb
33
+ value: 150
34
+ `)
35
+ )
36
+
37
+ const result = parseText('chart: gauge')
38
+ if (!result.ok) {
39
+ console.log(result.diagnostics)
40
+ }
41
+
42
+ validateSchema(schema)
43
+ const option = sanitizeOption(buildOption(schema))
44
+ ```
@@ -0,0 +1,2 @@
1
+ import type { ChartSchema, EChartsOption } from '@vis-pilot/core';
2
+ export declare function buildBar(schema: ChartSchema): EChartsOption;
@@ -0,0 +1,44 @@
1
+ import { alignSeriesValues, groupRowsByField, resolveCategoryValues } from './series.js';
2
+ export function buildBar(schema) {
3
+ const seriesField = schema.encode?.series;
4
+ const stack = schema.options?.stacked ? 'total' : undefined;
5
+ if (seriesField && schema.encode?.x && schema.encode?.y) {
6
+ const xField = schema.encode.x;
7
+ const yField = schema.encode.y;
8
+ const xValues = resolveCategoryValues(schema.dataset.source, xField);
9
+ const groups = groupRowsByField(schema.dataset.source, seriesField);
10
+ return {
11
+ title: { text: schema.title },
12
+ tooltip: { trigger: 'axis' },
13
+ legend: { type: 'scroll', bottom: 0 },
14
+ xAxis: { type: 'category', data: xValues },
15
+ yAxis: { type: 'value' },
16
+ series: groups.map(({ name, rows }) => ({
17
+ name,
18
+ type: 'bar',
19
+ stack,
20
+ data: alignSeriesValues(rows, xField, yField, xValues)
21
+ }))
22
+ };
23
+ }
24
+ return {
25
+ title: { text: schema.title },
26
+ tooltip: { trigger: 'axis' },
27
+ dataset: {
28
+ dimensions: schema.dataset.dimensions,
29
+ source: schema.dataset.source
30
+ },
31
+ xAxis: { type: 'category' },
32
+ yAxis: { type: 'value' },
33
+ series: [
34
+ {
35
+ type: 'bar',
36
+ stack,
37
+ encode: {
38
+ x: schema.encode?.x,
39
+ y: schema.encode?.y
40
+ }
41
+ }
42
+ ]
43
+ };
44
+ }
@@ -0,0 +1,2 @@
1
+ import type { ChartSchema, EChartsOption } from '@vis-pilot/core';
2
+ export declare function buildHeatmap(schema: ChartSchema): EChartsOption;
@@ -0,0 +1,31 @@
1
+ export function buildHeatmap(schema) {
2
+ return {
3
+ title: { text: schema.title },
4
+ tooltip: { position: 'top' },
5
+ dataset: {
6
+ dimensions: schema.dataset.dimensions,
7
+ source: schema.dataset.source
8
+ },
9
+ xAxis: { type: 'category' },
10
+ yAxis: { type: 'category' },
11
+ grid: { bottom: 92 },
12
+ visualMap: {
13
+ min: 0,
14
+ max: 100,
15
+ calculable: true,
16
+ orient: 'horizontal',
17
+ left: 'center',
18
+ bottom: 10
19
+ },
20
+ series: [
21
+ {
22
+ type: 'heatmap',
23
+ encode: {
24
+ x: schema.encode?.x,
25
+ y: schema.encode?.y,
26
+ value: schema.encode?.value
27
+ }
28
+ }
29
+ ]
30
+ };
31
+ }
@@ -0,0 +1,6 @@
1
+ import type { ChartSchema, EChartsOption } from '@vis-pilot/core';
2
+ import type { ChartBuilderFn } from './types.js';
3
+ export declare function registerChart(name: string, builder: ChartBuilderFn): void;
4
+ export declare function getChartBuilder(name: string): ChartBuilderFn | undefined;
5
+ export declare function buildOption(schema: ChartSchema): EChartsOption;
6
+ export * from './types.js';
@@ -0,0 +1,28 @@
1
+ import { buildLine } from './line.js';
2
+ import { buildBar } from './bar.js';
3
+ import { buildPie } from './pie.js';
4
+ import { buildScatter } from './scatter.js';
5
+ import { buildRadar } from './radar.js';
6
+ import { buildHeatmap } from './heatmap.js';
7
+ const registry = new Map();
8
+ registry.set('line', buildLine);
9
+ registry.set('area', buildLine);
10
+ registry.set('bar', buildBar);
11
+ registry.set('pie', buildPie);
12
+ registry.set('scatter', buildScatter);
13
+ registry.set('radar', buildRadar);
14
+ registry.set('heatmap', buildHeatmap);
15
+ export function registerChart(name, builder) {
16
+ registry.set(name, builder);
17
+ }
18
+ export function getChartBuilder(name) {
19
+ return registry.get(name);
20
+ }
21
+ export function buildOption(schema) {
22
+ const builder = registry.get(schema.type);
23
+ if (!builder) {
24
+ throw new Error(`No builder registered for chart type: ${schema.type}`);
25
+ }
26
+ return builder(schema);
27
+ }
28
+ export * from './types.js';
@@ -0,0 +1,2 @@
1
+ import type { ChartSchema, EChartsOption } from '@vis-pilot/core';
2
+ export declare function buildLine(schema: ChartSchema): EChartsOption;
@@ -0,0 +1,50 @@
1
+ import { alignSeriesValues, groupRowsByField, resolveCategoryValues } from './series.js';
2
+ export function buildLine(schema) {
3
+ const seriesField = schema.encode?.series;
4
+ const isArea = schema.options?.area || schema.type === 'area';
5
+ const smooth = schema.options?.smooth;
6
+ const stack = schema.options?.stacked ? 'total' : undefined;
7
+ if (seriesField && schema.encode?.x && schema.encode?.y) {
8
+ const xField = schema.encode.x;
9
+ const yField = schema.encode.y;
10
+ const xValues = resolveCategoryValues(schema.dataset.source, xField);
11
+ const groups = groupRowsByField(schema.dataset.source, seriesField);
12
+ return {
13
+ title: { text: schema.title },
14
+ tooltip: { trigger: 'axis' },
15
+ legend: { type: 'scroll', bottom: 0 },
16
+ xAxis: { type: 'category', data: xValues },
17
+ yAxis: { type: 'value' },
18
+ series: groups.map(({ name, rows }) => ({
19
+ name,
20
+ type: 'line',
21
+ smooth,
22
+ areaStyle: isArea ? {} : undefined,
23
+ stack,
24
+ data: alignSeriesValues(rows, xField, yField, xValues)
25
+ }))
26
+ };
27
+ }
28
+ return {
29
+ title: { text: schema.title },
30
+ tooltip: { trigger: 'axis' },
31
+ dataset: {
32
+ dimensions: schema.dataset.dimensions,
33
+ source: schema.dataset.source
34
+ },
35
+ xAxis: { type: 'category' },
36
+ yAxis: { type: 'value' },
37
+ series: [
38
+ {
39
+ type: 'line',
40
+ smooth,
41
+ areaStyle: isArea ? {} : undefined,
42
+ stack,
43
+ encode: {
44
+ x: schema.encode?.x,
45
+ y: schema.encode?.y
46
+ }
47
+ }
48
+ ]
49
+ };
50
+ }
@@ -0,0 +1,2 @@
1
+ import type { ChartSchema, EChartsOption } from '@vis-pilot/core';
2
+ export declare function buildPie(schema: ChartSchema): EChartsOption;
@@ -0,0 +1,20 @@
1
+ export function buildPie(schema) {
2
+ return {
3
+ title: { text: schema.title },
4
+ dataset: {
5
+ dimensions: schema.dataset.dimensions,
6
+ source: schema.dataset.source
7
+ },
8
+ tooltip: { trigger: 'item' },
9
+ series: [
10
+ {
11
+ type: 'pie',
12
+ radius: '60%',
13
+ encode: {
14
+ itemName: schema.encode?.x,
15
+ value: schema.encode?.y ?? schema.encode?.value
16
+ }
17
+ }
18
+ ]
19
+ };
20
+ }
@@ -0,0 +1,2 @@
1
+ import type { ChartSchema, EChartsOption } from '@vis-pilot/core';
2
+ export declare function buildRadar(schema: ChartSchema): EChartsOption;
@@ -0,0 +1,46 @@
1
+ export function buildRadar(schema) {
2
+ const { dimensions, source } = schema.dataset;
3
+ const labelDim = dimensions.find((d) => d !== schema.encode?.value && d !== schema.encode?.series);
4
+ const valueDim = schema.encode?.value ?? dimensions.find((d) => d !== labelDim);
5
+ if (labelDim &&
6
+ valueDim &&
7
+ dimensions.length === 2 &&
8
+ source.length > 0 &&
9
+ typeof source[0][labelDim] === 'string') {
10
+ const indicators = source.map((row) => ({
11
+ name: String(row[labelDim])
12
+ }));
13
+ const values = source.map((row) => row[valueDim]);
14
+ return {
15
+ title: { text: schema.title },
16
+ tooltip: {},
17
+ legend: { show: false },
18
+ radar: { indicator: indicators },
19
+ series: [
20
+ {
21
+ type: 'radar',
22
+ data: [{ value: values }]
23
+ }
24
+ ]
25
+ };
26
+ }
27
+ const seriesDim = schema.encode?.series;
28
+ const valueDims = dimensions.filter((d) => d !== seriesDim);
29
+ const indicators = valueDims.map((name) => ({ name }));
30
+ const data = source.map((row) => ({
31
+ name: seriesDim ? String(row[seriesDim]) : undefined,
32
+ value: valueDims.map((d) => row[d])
33
+ }));
34
+ return {
35
+ title: { text: schema.title },
36
+ tooltip: {},
37
+ legend: { type: 'scroll', bottom: 0 },
38
+ radar: { indicator: indicators },
39
+ series: [
40
+ {
41
+ type: 'radar',
42
+ data
43
+ }
44
+ ]
45
+ };
46
+ }
@@ -0,0 +1,2 @@
1
+ import type { ChartSchema, EChartsOption } from '@vis-pilot/core';
2
+ export declare function buildScatter(schema: ChartSchema): EChartsOption;
@@ -0,0 +1,44 @@
1
+ export function buildScatter(schema) {
2
+ const seriesField = schema.encode?.series;
3
+ if (seriesField) {
4
+ const groups = new Map();
5
+ for (const row of schema.dataset.source) {
6
+ const key = String(row[seriesField]);
7
+ if (!groups.has(key))
8
+ groups.set(key, []);
9
+ groups.get(key).push(row);
10
+ }
11
+ const dims = schema.dataset.dimensions.filter((d) => d !== seriesField);
12
+ return {
13
+ title: { text: schema.title },
14
+ tooltip: { trigger: 'item' },
15
+ legend: { type: 'scroll', bottom: 0 },
16
+ xAxis: { type: 'value', scale: true },
17
+ yAxis: { type: 'value', scale: true },
18
+ series: Array.from(groups, ([name, data]) => ({
19
+ name,
20
+ type: 'scatter',
21
+ data: data.map((row) => dims.map((d) => row[d]))
22
+ }))
23
+ };
24
+ }
25
+ return {
26
+ title: { text: schema.title },
27
+ tooltip: { trigger: 'item' },
28
+ dataset: {
29
+ dimensions: schema.dataset.dimensions,
30
+ source: schema.dataset.source
31
+ },
32
+ xAxis: { type: 'value', scale: true },
33
+ yAxis: { type: 'value', scale: true },
34
+ series: [
35
+ {
36
+ type: 'scatter',
37
+ encode: {
38
+ x: schema.encode?.x,
39
+ y: schema.encode?.y
40
+ }
41
+ }
42
+ ]
43
+ };
44
+ }
@@ -0,0 +1,9 @@
1
+ type Row = Record<string, unknown>;
2
+ export interface GroupedSeriesData {
3
+ name: string;
4
+ rows: Row[];
5
+ }
6
+ export declare function groupRowsByField(rows: Row[], field: string): GroupedSeriesData[];
7
+ export declare function resolveCategoryValues(rows: Row[], field: string): unknown[];
8
+ export declare function alignSeriesValues(rows: Row[], categoryField: string, valueField: string, categoryValues: unknown[]): unknown[];
9
+ export {};
@@ -0,0 +1,26 @@
1
+ export function groupRowsByField(rows, field) {
2
+ const groups = new Map();
3
+ for (const row of rows) {
4
+ const key = String(row[field]);
5
+ const group = groups.get(key);
6
+ if (group) {
7
+ group.push(row);
8
+ }
9
+ else {
10
+ groups.set(key, [row]);
11
+ }
12
+ }
13
+ return Array.from(groups, ([name, groupedRows]) => ({
14
+ name,
15
+ rows: groupedRows
16
+ }));
17
+ }
18
+ export function resolveCategoryValues(rows, field) {
19
+ return [...new Set(rows.map((row) => row[field]))];
20
+ }
21
+ export function alignSeriesValues(rows, categoryField, valueField, categoryValues) {
22
+ return categoryValues.map((categoryValue) => {
23
+ const row = rows.find((item) => item[categoryField] === categoryValue);
24
+ return row ? row[valueField] : null;
25
+ });
26
+ }
@@ -0,0 +1,5 @@
1
+ import type { ChartSchema, EChartsOption } from '@vis-pilot/core';
2
+ export interface ChartBuilder {
3
+ build(schema: ChartSchema): EChartsOption;
4
+ }
5
+ export type ChartBuilderFn = (schema: ChartSchema) => EChartsOption;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ export * from './parser/index.js';
2
+ export * from './builder/index.js';
3
+ export * from './validator/index.js';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export * from './parser/index.js';
2
+ export * from './builder/index.js';
3
+ export * from './validator/index.js';
@@ -0,0 +1,2 @@
1
+ export * from './parseText.js';
2
+ export * from './normalize.js';
@@ -0,0 +1,2 @@
1
+ export * from './parseText.js';
2
+ export * from './normalize.js';
@@ -0,0 +1,2 @@
1
+ import type { ChartSchema } from '@vis-pilot/core';
2
+ export declare function normalizeSchema(input: Partial<ChartSchema>): ChartSchema;
@@ -0,0 +1,33 @@
1
+ import { inferChartType } from '@vis-pilot/core';
2
+ export function normalizeSchema(input) {
3
+ const dataset = input.dataset ?? { dimensions: [], source: [] };
4
+ let dimensions;
5
+ if (dataset.dimensions.length) {
6
+ dimensions = dataset.dimensions;
7
+ }
8
+ else if (dataset.source.length) {
9
+ dimensions = Object.keys(dataset.source[0]);
10
+ }
11
+ else {
12
+ dimensions = [];
13
+ }
14
+ if (!dimensions.length) {
15
+ throw new Error('Cannot normalize schema: dataset.dimensions is empty');
16
+ }
17
+ const schema = {
18
+ type: input.type ?? 'bar',
19
+ title: input.title,
20
+ description: input.description,
21
+ dataset: {
22
+ dimensions,
23
+ source: dataset.source
24
+ },
25
+ encode: input.encode,
26
+ options: input.options,
27
+ theme: input.theme
28
+ };
29
+ if (!input.type) {
30
+ schema.type = inferChartType(schema);
31
+ }
32
+ return schema;
33
+ }
@@ -0,0 +1,21 @@
1
+ import type { ChartSchema } from '@vis-pilot/core';
2
+ export type ParseDiagnosticCode = 'invalid_indentation' | 'invalid_syntax' | 'unsupported_field' | 'unsupported_option' | 'invalid_option_value' | 'invalid_data' | 'invalid_schema' | 'unsupported_chart_type';
3
+ export interface ParseDiagnostic {
4
+ code: ParseDiagnosticCode;
5
+ message: string;
6
+ line?: number;
7
+ }
8
+ export type ParseTextResult = {
9
+ ok: true;
10
+ schema: ChartSchema;
11
+ diagnostics: [];
12
+ } | {
13
+ ok: false;
14
+ diagnostics: ParseDiagnostic[];
15
+ };
16
+ export declare class ParseTextError extends Error {
17
+ readonly diagnostics: ParseDiagnostic[];
18
+ constructor(diagnostic: ParseDiagnostic);
19
+ }
20
+ export declare function parseTextOrThrow(input: string): ChartSchema;
21
+ export declare function parseText(input: string): ParseTextResult;
@@ -0,0 +1,241 @@
1
+ import { inferChartType } from '@vis-pilot/core';
2
+ export class ParseTextError extends Error {
3
+ constructor(diagnostic) {
4
+ super(diagnostic.message);
5
+ this.name = 'ParseTextError';
6
+ this.diagnostics = [diagnostic];
7
+ }
8
+ }
9
+ const ROOT_FIELDS = new Set(['chart', 'title', 'x', 'y', 'series', 'data', 'options']);
10
+ const OPTION_FIELDS = new Set(['stacked', 'smooth', 'area', 'sort']);
11
+ const SUPPORTED_CHART_TYPES = new Set(['line', 'area', 'bar', 'pie', 'scatter', 'radar', 'heatmap']);
12
+ const LEADING_WHITESPACE_REGEX = /^\s*/;
13
+ const ROOT_FIELD_REGEX = /^([a-zA-Z]+):\s*(.*)$/;
14
+ const KEY_VALUE_REGEX = /^([a-zA-Z_][\w-]*):\s*(.+)$/;
15
+ const FENCED_BLOCK_REGEX = /^```(?:vispilot|text|yaml|yml)?\s*\n([\s\S]*?)\n```$/i;
16
+ function unwrapFencedBlock(input) {
17
+ const trimmed = input.trim();
18
+ const match = FENCED_BLOCK_REGEX.exec(trimmed);
19
+ return match?.[1] ?? input;
20
+ }
21
+ function stripInlineComment(line) {
22
+ let quote = null;
23
+ for (let index = 0; index < line.length; index += 1) {
24
+ const character = line[index];
25
+ if ((character === '"' || character === "'") && line[index - 1] !== '\\') {
26
+ quote = quote === character ? null : (quote ?? character);
27
+ continue;
28
+ }
29
+ if (character === '#' && !quote) {
30
+ return line.slice(0, index);
31
+ }
32
+ }
33
+ return line;
34
+ }
35
+ function normalizeIdentifier(value) {
36
+ return value.trim().toLowerCase();
37
+ }
38
+ function parseError(code, message, line) {
39
+ return new ParseTextError({
40
+ code,
41
+ message,
42
+ line
43
+ });
44
+ }
45
+ function parseScalar(value) {
46
+ const trimmed = value.trim();
47
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
48
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
49
+ return trimmed.slice(1, -1);
50
+ }
51
+ const normalized = normalizeIdentifier(trimmed);
52
+ if (normalized === 'true')
53
+ return true;
54
+ if (normalized === 'false')
55
+ return false;
56
+ if (trimmed !== '' && !Number.isNaN(Number(trimmed)))
57
+ return Number(trimmed);
58
+ return trimmed;
59
+ }
60
+ function assertIndent(line, expected, message, lineNumber) {
61
+ const indentMatch = LEADING_WHITESPACE_REGEX.exec(line);
62
+ const indent = indentMatch?.[0].length ?? 0;
63
+ if (indent !== expected) {
64
+ throw parseError('invalid_indentation', message, lineNumber);
65
+ }
66
+ }
67
+ function parseBooleanOption(rawValue, optionKey, lineNumber) {
68
+ const normalized = rawValue.trim().toLowerCase();
69
+ if (normalized === 'true' || normalized === '1') {
70
+ return true;
71
+ }
72
+ if (normalized === 'false' || normalized === '0') {
73
+ return false;
74
+ }
75
+ throw parseError('invalid_option_value', `Invalid boolean value for options.${optionKey} at line ${lineNumber}, expected true/false`, lineNumber);
76
+ }
77
+ function isTrueOptionValue(value) {
78
+ return value === true;
79
+ }
80
+ function parseDataBlock(lines, startIndex) {
81
+ const list = [];
82
+ let blockIndex = startIndex;
83
+ while (blockIndex < lines.length && lines[blockIndex].startsWith(' - ')) {
84
+ const firstLine = lines[blockIndex];
85
+ assertIndent(firstLine, 2, `Invalid data item indentation at line ${blockIndex + 1}, expected 2 spaces`, blockIndex + 1);
86
+ const firstPair = firstLine.slice(4);
87
+ const match = KEY_VALUE_REGEX.exec(firstPair);
88
+ if (!match) {
89
+ throw parseError('invalid_data', `Invalid data item at line ${blockIndex + 1}`, blockIndex + 1);
90
+ }
91
+ const item = {
92
+ [match[1]]: parseScalar(match[2])
93
+ };
94
+ blockIndex += 1;
95
+ while (blockIndex < lines.length && lines[blockIndex].startsWith(' ')) {
96
+ const nestedLine = lines[blockIndex];
97
+ assertIndent(nestedLine, 4, `Invalid data key indentation at line ${blockIndex + 1}, expected 4 spaces`, blockIndex + 1);
98
+ const nestedMatch = KEY_VALUE_REGEX.exec(nestedLine.trim());
99
+ if (!nestedMatch) {
100
+ throw parseError('invalid_data', `Invalid data key-value at line ${blockIndex + 1}`, blockIndex + 1);
101
+ }
102
+ item[nestedMatch[1]] = parseScalar(nestedMatch[2]);
103
+ blockIndex += 1;
104
+ }
105
+ list.push(item);
106
+ }
107
+ return {
108
+ data: list,
109
+ nextIndex: blockIndex
110
+ };
111
+ }
112
+ function parseOptionsBlock(lines, startIndex) {
113
+ const options = {};
114
+ let blockIndex = startIndex;
115
+ while (blockIndex < lines.length && lines[blockIndex].startsWith(' ')) {
116
+ const optionLine = lines[blockIndex];
117
+ assertIndent(optionLine, 2, `Invalid options indentation at line ${blockIndex + 1}, expected 2 spaces`, blockIndex + 1);
118
+ if (optionLine.startsWith(' - ')) {
119
+ break;
120
+ }
121
+ const optionMatch = KEY_VALUE_REGEX.exec(optionLine.trim());
122
+ if (!optionMatch) {
123
+ throw parseError('invalid_syntax', `Invalid option item at line ${blockIndex + 1}`, blockIndex + 1);
124
+ }
125
+ const optionKey = normalizeIdentifier(optionMatch[1]);
126
+ if (!OPTION_FIELDS.has(optionKey)) {
127
+ throw parseError('unsupported_option', `Unsupported option "${optionKey}" at line ${blockIndex + 1}`, blockIndex + 1);
128
+ }
129
+ options[optionKey] = parseBooleanOption(optionMatch[2], optionKey, blockIndex + 1);
130
+ blockIndex += 1;
131
+ }
132
+ return {
133
+ options,
134
+ nextIndex: blockIndex
135
+ };
136
+ }
137
+ export function parseTextOrThrow(input) {
138
+ const lines = unwrapFencedBlock(input)
139
+ .split(/\r?\n/)
140
+ .map(stripInlineComment)
141
+ .map((line) => line.replace(/\s+$/, ''))
142
+ .filter((line) => line.trim().length > 0);
143
+ const root = {};
144
+ let index = 0;
145
+ while (index < lines.length) {
146
+ const line = lines[index];
147
+ assertIndent(line, 0, `Invalid root indentation at line ${index + 1}, expected 0 spaces`, index + 1);
148
+ const rootMatch = ROOT_FIELD_REGEX.exec(line);
149
+ if (!rootMatch) {
150
+ throw parseError('invalid_syntax', `Invalid DSL syntax at line ${index + 1}`, index + 1);
151
+ }
152
+ const key = normalizeIdentifier(rootMatch[1]);
153
+ const rawValue = rootMatch[2];
154
+ if (!ROOT_FIELDS.has(key)) {
155
+ throw parseError('unsupported_field', `Unsupported field "${key}" at line ${index + 1}`, index + 1);
156
+ }
157
+ if (key === 'data') {
158
+ if (rawValue.length > 0) {
159
+ throw parseError('invalid_syntax', 'data field must not have inline value', index + 1);
160
+ }
161
+ const parsedData = parseDataBlock(lines, index + 1);
162
+ root.data = parsedData.data;
163
+ index = parsedData.nextIndex;
164
+ continue;
165
+ }
166
+ if (key === 'options') {
167
+ if (rawValue.length > 0) {
168
+ throw parseError('invalid_syntax', 'options field must not have inline value', index + 1);
169
+ }
170
+ const parsedOptions = parseOptionsBlock(lines, index + 1);
171
+ root.options = parsedOptions.options;
172
+ index = parsedOptions.nextIndex;
173
+ continue;
174
+ }
175
+ root[key] = parseScalar(rawValue);
176
+ index += 1;
177
+ }
178
+ const source = root.data ?? [];
179
+ const dimensions = source.length > 0 ? Object.keys(source[0]) : [root.x, root.y].filter(Boolean);
180
+ if (!dimensions.length) {
181
+ throw parseError('invalid_schema', 'dataset dimensions cannot be empty');
182
+ }
183
+ if (root.chart !== undefined) {
184
+ if (typeof root.chart !== 'string') {
185
+ throw parseError('invalid_schema', 'chart must be a string');
186
+ }
187
+ root.chart = normalizeIdentifier(root.chart);
188
+ if (!SUPPORTED_CHART_TYPES.has(root.chart)) {
189
+ throw parseError('unsupported_chart_type', `Unsupported chart type "${root.chart}"`);
190
+ }
191
+ }
192
+ const schema = {
193
+ type: root.chart ?? 'bar',
194
+ title: root.title,
195
+ dataset: {
196
+ dimensions,
197
+ source
198
+ },
199
+ encode: {
200
+ x: root.x,
201
+ y: root.y,
202
+ series: root.series
203
+ },
204
+ options: {
205
+ stacked: isTrueOptionValue(root.options?.stacked),
206
+ smooth: isTrueOptionValue(root.options?.smooth),
207
+ area: isTrueOptionValue(root.options?.area),
208
+ sort: isTrueOptionValue(root.options?.sort)
209
+ }
210
+ };
211
+ if (!root.chart) {
212
+ schema.type = inferChartType(schema);
213
+ }
214
+ return schema;
215
+ }
216
+ export function parseText(input) {
217
+ try {
218
+ return {
219
+ ok: true,
220
+ schema: parseTextOrThrow(input),
221
+ diagnostics: []
222
+ };
223
+ }
224
+ catch (error) {
225
+ if (error instanceof ParseTextError) {
226
+ return {
227
+ ok: false,
228
+ diagnostics: error.diagnostics
229
+ };
230
+ }
231
+ return {
232
+ ok: false,
233
+ diagnostics: [
234
+ {
235
+ code: 'invalid_syntax',
236
+ message: error instanceof Error ? error.message : 'Unknown parse error'
237
+ }
238
+ ]
239
+ };
240
+ }
241
+ }
@@ -0,0 +1,2 @@
1
+ export * from './sanitize.js';
2
+ export * from './validate.js';
@@ -0,0 +1,2 @@
1
+ export * from './sanitize.js';
2
+ export * from './validate.js';
@@ -0,0 +1,2 @@
1
+ import type { EChartsOption } from '@vis-pilot/core';
2
+ export declare function sanitizeOption(option: unknown): EChartsOption;
@@ -0,0 +1,36 @@
1
+ const BLOCKED_KEYS = new Set([
2
+ 'toolbox',
3
+ 'graphic',
4
+ 'animationDelay',
5
+ 'animationDelayUpdate',
6
+ 'renderItem'
7
+ ]);
8
+ function deepSanitize(value) {
9
+ if (typeof value === 'function') {
10
+ return undefined;
11
+ }
12
+ if (Array.isArray(value)) {
13
+ return value.map((item) => deepSanitize(item)).filter((item) => item !== undefined);
14
+ }
15
+ if (value && typeof value === 'object') {
16
+ const result = {};
17
+ for (const [key, nested] of Object.entries(value)) {
18
+ if (BLOCKED_KEYS.has(key)) {
19
+ continue;
20
+ }
21
+ const sanitized = deepSanitize(nested);
22
+ if (sanitized !== undefined) {
23
+ result[key] = sanitized;
24
+ }
25
+ }
26
+ return result;
27
+ }
28
+ return value;
29
+ }
30
+ export function sanitizeOption(option) {
31
+ const sanitized = deepSanitize(option);
32
+ if (!sanitized || typeof sanitized !== 'object' || Array.isArray(sanitized)) {
33
+ throw new Error('Invalid ECharts option after sanitization');
34
+ }
35
+ return sanitized;
36
+ }
@@ -0,0 +1,4 @@
1
+ import type { ChartSchema, EChartsOption } from '@vis-pilot/core';
2
+ export declare function isVisSyntax(input: string): boolean;
3
+ export declare function validateSchema(schema: ChartSchema): void;
4
+ export declare function validateOption(option: EChartsOption): void;
@@ -0,0 +1,70 @@
1
+ const VIS_ROOT_FIELDS = new Set(['chart', 'title', 'x', 'y', 'series', 'data', 'options']);
2
+ function unwrapVisPilotFence(input) {
3
+ const fencedMatch = /```vispilot\s*([\s\S]*?)```/i.exec(input);
4
+ if (fencedMatch?.[1]) {
5
+ return fencedMatch[1].trim();
6
+ }
7
+ return input;
8
+ }
9
+ export function isVisSyntax(input) {
10
+ if (typeof input !== 'string') {
11
+ return false;
12
+ }
13
+ const text = input.trim();
14
+ if (!text) {
15
+ return false;
16
+ }
17
+ const candidate = unwrapVisPilotFence(text);
18
+ if (!candidate) {
19
+ return false;
20
+ }
21
+ const lines = candidate.split(/\r?\n/);
22
+ let rootFieldHits = 0;
23
+ let hasDataOrOptionsRoot = false;
24
+ const lineLimit = Math.min(lines.length, 80);
25
+ for (let index = 0; index < lineLimit; index += 1) {
26
+ const line = lines[index];
27
+ const trimmed = line.trim();
28
+ if (!trimmed || trimmed.startsWith('- ')) {
29
+ continue;
30
+ }
31
+ if (line[0] === ' ' || line[0] === '\t') {
32
+ continue;
33
+ }
34
+ const separatorIndex = trimmed.indexOf(':');
35
+ if (separatorIndex <= 0) {
36
+ continue;
37
+ }
38
+ const key = trimmed.slice(0, separatorIndex).trim().toLowerCase();
39
+ if (!VIS_ROOT_FIELDS.has(key)) {
40
+ continue;
41
+ }
42
+ rootFieldHits += 1;
43
+ if (key === 'data' || key === 'options') {
44
+ hasDataOrOptionsRoot = true;
45
+ }
46
+ if (rootFieldHits >= 2) {
47
+ return true;
48
+ }
49
+ }
50
+ return rootFieldHits === 1 && hasDataOrOptionsRoot;
51
+ }
52
+ export function validateSchema(schema) {
53
+ if (!schema.type) {
54
+ throw new Error('schema.type is required');
55
+ }
56
+ if (!schema.dataset) {
57
+ throw new Error('schema.dataset is required');
58
+ }
59
+ if (!Array.isArray(schema.dataset.dimensions) || schema.dataset.dimensions.length === 0) {
60
+ throw new TypeError('schema.dataset.dimensions must be a non-empty array');
61
+ }
62
+ if (!Array.isArray(schema.dataset.source)) {
63
+ throw new TypeError('schema.dataset.source must be an array');
64
+ }
65
+ }
66
+ export function validateOption(option) {
67
+ if (!option || typeof option !== 'object') {
68
+ throw new TypeError('option must be an object');
69
+ }
70
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@vis-pilot/engine",
3
+ "version": "0.1.0",
4
+ "description": "Text parser, option builder, and validation engine for vis-pilot.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ }
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "README.md"
21
+ ],
22
+ "sideEffects": false,
23
+ "dependencies": {
24
+ "@vis-pilot/core": "0.1.0"
25
+ },
26
+ "scripts": {
27
+ "build": "tsc -b",
28
+ "typecheck": "tsc -b",
29
+ "test": "vitest run",
30
+ "test:watch": "vitest",
31
+ "clean": "rm -rf dist *.tsbuildinfo"
32
+ }
33
+ }