@blueprint-chart/mcp 0.1.1 → 0.1.3
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 +27 -14
- package/dist/cli.js +15 -2
- package/dist/dsl/chartTypes.d.ts +16 -0
- package/dist/dsl/chartTypes.js +37 -0
- package/dist/dsl/chartTypes.test.d.ts +1 -0
- package/dist/dsl/chartTypes.test.js +32 -0
- package/dist/dsl/dataKey.d.ts +25 -0
- package/dist/dsl/dataKey.js +42 -0
- package/dist/dsl/dataKey.test.d.ts +1 -0
- package/dist/dsl/dataKey.test.js +35 -0
- package/dist/dsl/suggest.d.ts +1 -0
- package/dist/dsl/suggest.js +47 -0
- package/dist/dsl/suggest.test.d.ts +1 -0
- package/dist/dsl/suggest.test.js +20 -0
- package/dist/dsl/universalProperties.d.ts +30 -0
- package/dist/dsl/universalProperties.js +52 -0
- package/dist/dsl/universalProperties.test.d.ts +1 -0
- package/dist/dsl/universalProperties.test.js +26 -0
- package/dist/dsl/validate.d.ts +10 -0
- package/dist/dsl/validate.js +68 -0
- package/dist/dsl/validate.test.d.ts +1 -0
- package/dist/dsl/validate.test.js +73 -0
- package/dist/errors.d.ts +20 -1
- package/dist/errors.js +1 -0
- package/dist/errors.test.js +21 -0
- package/dist/lib/zodToJsonSchema.d.ts +10 -5
- package/dist/lib/zodToJsonSchema.js +14 -6
- package/dist/links/buildUrls.d.ts +14 -0
- package/dist/links/buildUrls.js +20 -0
- package/dist/links/buildUrls.test.d.ts +1 -0
- package/dist/links/buildUrls.test.js +28 -0
- package/dist/links/editorConfig.d.ts +4 -0
- package/dist/links/editorConfig.js +15 -0
- package/dist/links/editorConfig.test.d.ts +1 -0
- package/dist/links/editorConfig.test.js +28 -0
- package/dist/links/encode.d.ts +11 -0
- package/dist/links/encode.js +19 -0
- package/dist/links/encode.test.d.ts +1 -0
- package/dist/links/encode.test.js +37 -0
- package/dist/prompts/authorChart.js +23 -18
- package/dist/prompts/authorChart.test.js +6 -0
- package/dist/render/diagnose.d.ts +19 -0
- package/dist/render/diagnose.js +100 -0
- package/dist/render/diagnose.test.d.ts +1 -0
- package/dist/render/diagnose.test.js +53 -0
- package/dist/render/frame.d.ts +10 -0
- package/dist/render/frame.js +10 -0
- package/dist/render/frame.test.d.ts +1 -0
- package/dist/render/frame.test.js +12 -0
- package/dist/render/jsdomEnv.d.ts +2 -1
- package/dist/render/jsdomEnv.js +14 -1
- package/dist/render/jsdomEnv.test.js +36 -2
- package/dist/render/renderSceneState.d.ts +5 -1
- package/dist/render/renderSceneState.js +4 -3
- package/dist/render/renderSceneState.test.js +13 -7
- package/dist/render/validatePipeline.d.ts +23 -0
- package/dist/render/validatePipeline.js +41 -0
- package/dist/render/validatePipeline.test.d.ts +1 -0
- package/dist/render/validatePipeline.test.js +34 -0
- package/dist/resources/docsReader.d.ts +4 -1
- package/dist/resources/docsReader.js +23 -6
- package/dist/resources/docsReader.test.js +27 -2
- package/dist/resources/index.d.ts +1 -1
- package/dist/resources/samples.d.ts +1 -2
- package/dist/server.d.ts +9 -0
- package/dist/server.js +63 -5
- package/dist/server.test.js +101 -4
- package/dist/tools/describeChartType.d.ts +33 -0
- package/dist/tools/describeChartType.js +119 -0
- package/dist/tools/describeChartType.test.d.ts +1 -0
- package/dist/tools/describeChartType.test.js +58 -0
- package/dist/tools/exportChart.d.ts +17 -0
- package/dist/tools/exportChart.js +31 -0
- package/dist/tools/exportChart.test.d.ts +1 -0
- package/dist/tools/exportChart.test.js +43 -0
- package/dist/tools/getExample.d.ts +20 -0
- package/dist/tools/getExample.js +55 -0
- package/dist/tools/getExample.test.d.ts +1 -0
- package/dist/tools/getExample.test.js +40 -0
- package/dist/tools/getGrammar.d.ts +17 -0
- package/dist/tools/getGrammar.js +38 -0
- package/dist/tools/getGrammar.test.d.ts +1 -0
- package/dist/tools/getGrammar.test.js +24 -0
- package/dist/tools/inspect.d.ts +8 -1
- package/dist/tools/inspect.js +31 -7
- package/dist/tools/inspect.test.js +35 -13
- package/dist/tools/listChartTypes.d.ts +14 -0
- package/dist/tools/listChartTypes.js +42 -0
- package/dist/tools/listChartTypes.test.d.ts +1 -0
- package/dist/tools/listChartTypes.test.js +42 -0
- package/dist/tools/render.d.ts +14 -12
- package/dist/tools/render.js +96 -28
- package/dist/tools/render.test.js +137 -1
- package/dist/tools/validate.d.ts +11 -3
- package/dist/tools/validate.js +9 -1
- package/dist/tools/validate.test.js +17 -12
- package/dist/transports/http.d.ts +4 -2
- package/dist/transports/http.js +232 -23
- package/dist/transports/http.test.js +158 -22
- package/package.json +4 -2
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +9 -0
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
</div>
|
|
19
19
|
|
|
20
|
-
The MCP exposes Blueprint Chart's dataviz handbook, DSL grammar reference, chart-type docs, and canonical samples as MCP resources, plus
|
|
20
|
+
The MCP exposes Blueprint Chart's dataviz handbook, DSL grammar reference, chart-type docs, and canonical samples as MCP resources, plus eight deterministic tools: `validate_dsl`, `inspect_dsl`, `recommend_chart_type`, `render`, `list_chart_types`, `describe_chart_type`, `get_example`, and `get_grammar`. Your LLM writes the `.bpc`; the MCP grounds it in real dataviz pedagogy and gives it a tight feedback loop.
|
|
21
21
|
|
|
22
22
|
## Install
|
|
23
23
|
|
|
@@ -44,17 +44,26 @@ Add to `claude_desktop_config.json`:
|
|
|
44
44
|
## Use with Claude Code
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
|
-
claude mcp add blueprint-chart
|
|
47
|
+
claude mcp add blueprint-chart \
|
|
48
|
+
-e BLUEPRINT_CHART_EDITOR_URL=https://blueprintchart.com \
|
|
49
|
+
-e BLUEPRINT_CHART_DOCS_URL=https://docs.blueprintchart.com \
|
|
50
|
+
-- npx -y @blueprint-chart/mcp
|
|
48
51
|
```
|
|
49
52
|
|
|
50
53
|
## Tools
|
|
51
54
|
|
|
52
55
|
| Tool | Purpose |
|
|
53
56
|
| --- | --- |
|
|
54
|
-
| `validate_dsl` | Parse `.bpc`;
|
|
55
|
-
| `inspect_dsl` | Parse and summarize:
|
|
56
|
-
| `recommend_chart_type` | Rank chart types for a given column shape |
|
|
57
|
-
| `render` | Render to SVG (default) or PNG |
|
|
57
|
+
| `validate_dsl` | Parse `.bpc`; returns `{ valid, errors[], warnings[] }` — each error has `code`, `message`, `suggestion` |
|
|
58
|
+
| `inspect_dsl` | Parse and summarize: `chartType`, `scenes`, `seriesCount`, `rowCount`, `hasHighlights`, `hasColorizes`, etc. |
|
|
59
|
+
| `recommend_chart_type` | Rank chart types for a given column shape and row count |
|
|
60
|
+
| `render` | Render to SVG (default) or PNG; structured `errors[]` (each with `code` + `suggestion`) on failure |
|
|
61
|
+
| `list_chart_types` | List all renderable chart types (tool equivalent of `bpc://handbook/choosing`) |
|
|
62
|
+
| `describe_chart_type` | Properties, when-to-use, and data-shape for one chart type (tool equivalent of `bpc://chart-types/{slug}`) |
|
|
63
|
+
| `get_example` | Fetch a canonical `.bpc` sample by chart type or sample name (tool equivalent of `bpc://samples/{id}`) |
|
|
64
|
+
| `get_grammar` | Full DSL syntax reference (tool equivalent of `bpc://grammar`) |
|
|
65
|
+
|
|
66
|
+
The four discovery tools (`list_chart_types`, `describe_chart_type`, `get_example`, `get_grammar`) let clients without MCP resource support access the same reference material that the `bpc://` URIs expose.
|
|
58
67
|
|
|
59
68
|
## Resources
|
|
60
69
|
|
|
@@ -77,7 +86,7 @@ Once the MCP is connected, ask Claude to make a chart:
|
|
|
77
86
|
|
|
78
87
|
> **You:** Make a horizontal bar chart of English letter frequencies — top 10, highlight E.
|
|
79
88
|
>
|
|
80
|
-
> **Claude:** *(
|
|
89
|
+
> **Claude:** *(calls `list_chart_types`, `get_example({ chartType: "bar-horizontal" })`, writes the `.bpc`, calls `validate_dsl` to confirm it parses, calls `render` with `format: 'png'` and shows you the image and the source)*
|
|
81
90
|
>
|
|
82
91
|
> Here's the chart:
|
|
83
92
|
>
|
|
@@ -119,7 +128,7 @@ chart bar-vertical {
|
|
|
119
128
|
|
|
120
129
|
Full grammar at `bpc://grammar`; 17 canonical samples at `bpc://samples/<id>` (`letter-frequency`, `co2-emissions`, `quarterly-revenue`, `browser-market`, `temperature-anomaly`, `population-stacked-bar`, ...).
|
|
121
130
|
|
|
122
|
-
### `validate_dsl` — parse with
|
|
131
|
+
### `validate_dsl` — parse with structured diagnostics
|
|
123
132
|
|
|
124
133
|
Request:
|
|
125
134
|
|
|
@@ -130,15 +139,19 @@ Request:
|
|
|
130
139
|
}
|
|
131
140
|
```
|
|
132
141
|
|
|
133
|
-
Response
|
|
142
|
+
Response — `valid` is false; each entry in `errors[]` carries a `code`, human-readable `message`, and an actionable `suggestion`:
|
|
134
143
|
|
|
135
144
|
```json
|
|
136
145
|
{
|
|
137
|
-
"
|
|
138
|
-
"code": "E_PARSE",
|
|
146
|
+
"valid": false,
|
|
139
147
|
"errors": [
|
|
140
|
-
{
|
|
141
|
-
|
|
148
|
+
{
|
|
149
|
+
"code": "E_PARSE",
|
|
150
|
+
"message": "Expected \"\\\"\" but end of input found.",
|
|
151
|
+
"suggestion": "Close the string literal on line 2."
|
|
152
|
+
}
|
|
153
|
+
],
|
|
154
|
+
"warnings": []
|
|
142
155
|
}
|
|
143
156
|
```
|
|
144
157
|
|
|
@@ -219,7 +232,7 @@ Response:
|
|
|
219
232
|
}
|
|
220
233
|
```
|
|
221
234
|
|
|
222
|
-
If rasterization fails (rare),
|
|
235
|
+
If rasterization fails (rare), `errors[]` is non-empty — each entry has a `code` (`"E_RENDER"`) and a `suggestion` — **and the response still includes** the SVG that was successfully produced, so partial success is preserved.
|
|
223
236
|
|
|
224
237
|
### Reading a resource
|
|
225
238
|
|
package/dist/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ function parseList(value) {
|
|
|
15
15
|
function parseConfig(argv) {
|
|
16
16
|
const env = process.env;
|
|
17
17
|
const port = Number(env.PORT) || 4321;
|
|
18
|
-
const host = env.MCP_HOST || '127.0.0.1';
|
|
18
|
+
const host = env.MCP_HOST || (env.PORT ? '0.0.0.0' : '127.0.0.1');
|
|
19
19
|
const allowedOriginsList = parseList(env.MCP_ALLOWED_ORIGINS);
|
|
20
20
|
const allowedOrigins = allowedOriginsList
|
|
21
21
|
&& allowedOriginsList.length === 1 && allowedOriginsList[0] === '*'
|
|
@@ -38,6 +38,7 @@ function parseConfig(argv) {
|
|
|
38
38
|
? Number(env.MCP_RATE_LIMIT_PER_MINUTE)
|
|
39
39
|
: undefined,
|
|
40
40
|
silent: parseBool(env.MCP_SILENT),
|
|
41
|
+
rootRedirectUrl: env.MCP_ROOT_REDIRECT_URL || undefined,
|
|
41
42
|
},
|
|
42
43
|
};
|
|
43
44
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -76,6 +77,18 @@ Environment variables (HTTP mode):
|
|
|
76
77
|
MCP_MAX_CONCURRENT_REQUESTS Cap on concurrent POSTs (default 16).
|
|
77
78
|
MCP_RATE_LIMIT_PER_MINUTE Per-IP rate limit (default off; e.g. 60).
|
|
78
79
|
MCP_SILENT=1 Suppress JSON access logs to stderr.
|
|
80
|
+
MCP_ROOT_REDIRECT_URL If set, redirect GET / to this URL.
|
|
81
|
+
MCP_PUBLIC_URL Public base URL (no path, no trailing slash).
|
|
82
|
+
When set, advertised in serverInfo.icons so
|
|
83
|
+
MCP clients (claude.ai, etc.) can render the
|
|
84
|
+
favicon. Example: https://mcp.example.com
|
|
85
|
+
BLUEPRINT_CHART_EDITOR_URL Editor app base URL (no trailing slash), e.g.
|
|
86
|
+
https://blueprintchart.com. Required for the
|
|
87
|
+
export_chart tool to mint copy/embed links;
|
|
88
|
+
unset disables export (returns E_CONFIG).
|
|
89
|
+
BLUEPRINT_CHART_DOCS_URL Docs site base URL, e.g.
|
|
90
|
+
https://docs.blueprintchart.com. When set,
|
|
91
|
+
tools/resources include public docsUrl fields.
|
|
79
92
|
`);
|
|
80
93
|
process.exit(0);
|
|
81
94
|
}
|
|
@@ -105,7 +118,7 @@ const config = parseConfig(process.argv.slice(2));
|
|
|
105
118
|
if (config.http) {
|
|
106
119
|
startHttp(config.httpOpts)
|
|
107
120
|
.then((handle) => {
|
|
108
|
-
process.stderr.write(`MCP HTTP server listening at ${handle.url}
|
|
121
|
+
process.stderr.write(`MCP HTTP server listening at ${handle.url}\n`);
|
|
109
122
|
installSignalHandlers(handle.close);
|
|
110
123
|
})
|
|
111
124
|
.catch((err) => {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps alias chart-type names to canonical names. Lib registers aliases
|
|
3
|
+
* (`horizontal-bar` → `bar-horizontal`, `vertical-bar` → `bar-vertical`) at
|
|
4
|
+
* the registry level so both render identically; we surface that mapping here
|
|
5
|
+
* so validation and discovery tools can normalize input.
|
|
6
|
+
*
|
|
7
|
+
* Static rather than derived from the registry because the registry doesn't
|
|
8
|
+
* track alias→canonical pairs — it just registers each alias as a duplicate
|
|
9
|
+
* entry. Source of truth: `packages/lib/src/charts/registry.ts:445-446` and
|
|
10
|
+
* `packages/lib/src/enums.ts:24-25`.
|
|
11
|
+
*/
|
|
12
|
+
export declare const CHART_TYPE_ALIASES: Record<string, string>;
|
|
13
|
+
export declare function listCanonicalChartTypes(): string[];
|
|
14
|
+
export declare function canonicalChartType(name: string): string | undefined;
|
|
15
|
+
export declare function isKnownChartType(name: string): boolean;
|
|
16
|
+
export declare function aliasesFor(canonical: string): string[];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { listCharts } from '@blueprint-chart/lib';
|
|
2
|
+
/**
|
|
3
|
+
* Maps alias chart-type names to canonical names. Lib registers aliases
|
|
4
|
+
* (`horizontal-bar` → `bar-horizontal`, `vertical-bar` → `bar-vertical`) at
|
|
5
|
+
* the registry level so both render identically; we surface that mapping here
|
|
6
|
+
* so validation and discovery tools can normalize input.
|
|
7
|
+
*
|
|
8
|
+
* Static rather than derived from the registry because the registry doesn't
|
|
9
|
+
* track alias→canonical pairs — it just registers each alias as a duplicate
|
|
10
|
+
* entry. Source of truth: `packages/lib/src/charts/registry.ts:445-446` and
|
|
11
|
+
* `packages/lib/src/enums.ts:24-25`.
|
|
12
|
+
*/
|
|
13
|
+
export const CHART_TYPE_ALIASES = {
|
|
14
|
+
'horizontal-bar': 'bar-horizontal',
|
|
15
|
+
'vertical-bar': 'bar-vertical',
|
|
16
|
+
};
|
|
17
|
+
const ALIAS_NAMES = new Set(Object.keys(CHART_TYPE_ALIASES));
|
|
18
|
+
export function listCanonicalChartTypes() {
|
|
19
|
+
return listCharts().filter(name => !ALIAS_NAMES.has(name));
|
|
20
|
+
}
|
|
21
|
+
export function canonicalChartType(name) {
|
|
22
|
+
if (CHART_TYPE_ALIASES[name]) {
|
|
23
|
+
return CHART_TYPE_ALIASES[name];
|
|
24
|
+
}
|
|
25
|
+
if (listCharts().includes(name)) {
|
|
26
|
+
return name;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
export function isKnownChartType(name) {
|
|
31
|
+
return canonicalChartType(name) !== undefined;
|
|
32
|
+
}
|
|
33
|
+
export function aliasesFor(canonical) {
|
|
34
|
+
return Object.entries(CHART_TYPE_ALIASES)
|
|
35
|
+
.filter(([, target]) => target === canonical)
|
|
36
|
+
.map(([alias]) => alias);
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { CHART_TYPE_ALIASES, aliasesFor, canonicalChartType, isKnownChartType, listCanonicalChartTypes, } from './chartTypes';
|
|
3
|
+
describe('chartTypes', () => {
|
|
4
|
+
it('lists canonical chart types', () => {
|
|
5
|
+
const canon = listCanonicalChartTypes();
|
|
6
|
+
expect(canon).toContain('bar-vertical');
|
|
7
|
+
expect(canon).toContain('bar-horizontal');
|
|
8
|
+
expect(canon).not.toContain('horizontal-bar'); // alias excluded
|
|
9
|
+
});
|
|
10
|
+
it('maps aliases to canonical names', () => {
|
|
11
|
+
expect(CHART_TYPE_ALIASES['horizontal-bar']).toBe('bar-horizontal');
|
|
12
|
+
expect(CHART_TYPE_ALIASES['vertical-bar']).toBe('bar-vertical');
|
|
13
|
+
});
|
|
14
|
+
it('canonicalChartType returns canonical for alias or canonical', () => {
|
|
15
|
+
expect(canonicalChartType('horizontal-bar')).toBe('bar-horizontal');
|
|
16
|
+
expect(canonicalChartType('bar-horizontal')).toBe('bar-horizontal');
|
|
17
|
+
});
|
|
18
|
+
it('canonicalChartType returns undefined for unknown', () => {
|
|
19
|
+
expect(canonicalChartType('bar')).toBeUndefined();
|
|
20
|
+
expect(canonicalChartType('totally-fake')).toBeUndefined();
|
|
21
|
+
});
|
|
22
|
+
it('isKnownChartType accepts canonical and aliases', () => {
|
|
23
|
+
expect(isKnownChartType('bar-vertical')).toBe(true);
|
|
24
|
+
expect(isKnownChartType('horizontal-bar')).toBe(true);
|
|
25
|
+
expect(isKnownChartType('bar')).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
it('aliasesFor returns the registered aliases for a canonical name', () => {
|
|
28
|
+
expect(aliasesFor('bar-horizontal')).toEqual(['horizontal-bar']);
|
|
29
|
+
expect(aliasesFor('bar-vertical')).toEqual(['vertical-bar']);
|
|
30
|
+
expect(aliasesFor('bar-stacked')).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PropertyNode } from '@blueprint-chart/lib';
|
|
2
|
+
/**
|
|
3
|
+
* Heuristic for "data key is unquoted identifier-shaped." The lib's PropertyNode
|
|
4
|
+
* tagged quoted-string keys via an isQuoted boolean in older grammar versions,
|
|
5
|
+
* but the installed lib (v0.1.19) does not consistently expose it. We fall back
|
|
6
|
+
* to a regex match against the Identifier production from grammar.peggy.
|
|
7
|
+
*
|
|
8
|
+
* The Identifier production is: [a-zA-Z_#][a-zA-Z0-9_#-]*
|
|
9
|
+
*
|
|
10
|
+
* However, since parsed PropertyNode has no isQuoted flag, we cannot distinguish
|
|
11
|
+
* `"China" = 5` from `China = 5` purely from the AST. We tighten the heuristic
|
|
12
|
+
* by only flagging keys that start with a lowercase letter — real data labels in
|
|
13
|
+
* shipped samples always start with an uppercase letter, a digit, or `_`. A
|
|
14
|
+
* camelCase-starting key (e.g. `unquotedKey`) is a strong signal that the user
|
|
15
|
+
* typed a property key as if it were a chart-level option.
|
|
16
|
+
*/
|
|
17
|
+
export declare function looksLikeUnquotedKey(entry: PropertyNode): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Inverse of `looksLikeUnquotedKey`, with an additional exclusion of the
|
|
20
|
+
* `_series` pseudo-key (which is a multi-series header, not a row label).
|
|
21
|
+
*
|
|
22
|
+
* Quoted-label-shaped means: proper nouns, digit-leading, hyphen/underscore-
|
|
23
|
+
* leading — anything that does NOT start with a lowercase letter.
|
|
24
|
+
*/
|
|
25
|
+
export declare function looksLikeQuotedLabel(entry: PropertyNode): boolean;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heuristic for "data key is unquoted identifier-shaped." The lib's PropertyNode
|
|
3
|
+
* tagged quoted-string keys via an isQuoted boolean in older grammar versions,
|
|
4
|
+
* but the installed lib (v0.1.19) does not consistently expose it. We fall back
|
|
5
|
+
* to a regex match against the Identifier production from grammar.peggy.
|
|
6
|
+
*
|
|
7
|
+
* The Identifier production is: [a-zA-Z_#][a-zA-Z0-9_#-]*
|
|
8
|
+
*
|
|
9
|
+
* However, since parsed PropertyNode has no isQuoted flag, we cannot distinguish
|
|
10
|
+
* `"China" = 5` from `China = 5` purely from the AST. We tighten the heuristic
|
|
11
|
+
* by only flagging keys that start with a lowercase letter — real data labels in
|
|
12
|
+
* shipped samples always start with an uppercase letter, a digit, or `_`. A
|
|
13
|
+
* camelCase-starting key (e.g. `unquotedKey`) is a strong signal that the user
|
|
14
|
+
* typed a property key as if it were a chart-level option.
|
|
15
|
+
*/
|
|
16
|
+
export function looksLikeUnquotedKey(entry) {
|
|
17
|
+
const k = entry.key;
|
|
18
|
+
const tagged = entry.isQuoted;
|
|
19
|
+
if (typeof tagged === 'boolean') {
|
|
20
|
+
return !tagged;
|
|
21
|
+
}
|
|
22
|
+
// Only flag identifiers that start with a lowercase letter — proper-noun labels
|
|
23
|
+
// and abbreviations used as data row labels always start with uppercase or `_`.
|
|
24
|
+
return /^[a-z][A-Za-z0-9_#-]*$/.test(k);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Inverse of `looksLikeUnquotedKey`, with an additional exclusion of the
|
|
28
|
+
* `_series` pseudo-key (which is a multi-series header, not a row label).
|
|
29
|
+
*
|
|
30
|
+
* Quoted-label-shaped means: proper nouns, digit-leading, hyphen/underscore-
|
|
31
|
+
* leading — anything that does NOT start with a lowercase letter.
|
|
32
|
+
*/
|
|
33
|
+
export function looksLikeQuotedLabel(entry) {
|
|
34
|
+
if (entry.key === '_series') {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
const tagged = entry.isQuoted;
|
|
38
|
+
if (typeof tagged === 'boolean') {
|
|
39
|
+
return tagged;
|
|
40
|
+
}
|
|
41
|
+
return !/^[a-z][A-Za-z0-9_#-]*$/.test(entry.key);
|
|
42
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { looksLikeUnquotedKey, looksLikeQuotedLabel } from './dataKey';
|
|
3
|
+
function node(key) {
|
|
4
|
+
return { key, value: 1 };
|
|
5
|
+
}
|
|
6
|
+
describe('dataKey helpers', () => {
|
|
7
|
+
it('looksLikeUnquotedKey flags camelCase identifiers', () => {
|
|
8
|
+
expect(looksLikeUnquotedKey(node('unquotedKey'))).toBe(true);
|
|
9
|
+
expect(looksLikeUnquotedKey(node('sort'))).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
it('looksLikeUnquotedKey passes proper-noun labels through', () => {
|
|
12
|
+
expect(looksLikeUnquotedKey(node('China'))).toBe(false);
|
|
13
|
+
expect(looksLikeUnquotedKey(node('United States'))).toBe(false);
|
|
14
|
+
expect(looksLikeUnquotedKey(node('_series'))).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
it('looksLikeQuotedLabel excludes _series pseudo-key', () => {
|
|
17
|
+
expect(looksLikeQuotedLabel(node('_series'))).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
it('looksLikeQuotedLabel accepts proper-noun labels', () => {
|
|
20
|
+
expect(looksLikeQuotedLabel(node('China'))).toBe(true);
|
|
21
|
+
expect(looksLikeQuotedLabel(node('New York'))).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
it('looksLikeQuotedLabel rejects camelCase keys', () => {
|
|
24
|
+
expect(looksLikeQuotedLabel(node('sort'))).toBe(false);
|
|
25
|
+
expect(looksLikeQuotedLabel(node('colorPalette'))).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
it('respects tagged isQuoted field when present', () => {
|
|
28
|
+
const tagged = { key: 'someKey', value: 1, isQuoted: true };
|
|
29
|
+
expect(looksLikeUnquotedKey(tagged)).toBe(false);
|
|
30
|
+
expect(looksLikeQuotedLabel(tagged)).toBe(true);
|
|
31
|
+
const untagged = { key: 'SomeKey', value: 1, isQuoted: false };
|
|
32
|
+
expect(looksLikeUnquotedKey(untagged)).toBe(true);
|
|
33
|
+
expect(looksLikeQuotedLabel(untagged)).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function nearestSuggestion(input: string, candidates: readonly string[], maxDistance?: number): string | undefined;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
function distance(a, b) {
|
|
2
|
+
if (a === b) {
|
|
3
|
+
return 0;
|
|
4
|
+
}
|
|
5
|
+
if (a.length === 0) {
|
|
6
|
+
return b.length;
|
|
7
|
+
}
|
|
8
|
+
if (b.length === 0) {
|
|
9
|
+
return a.length;
|
|
10
|
+
}
|
|
11
|
+
const m = a.length;
|
|
12
|
+
const n = b.length;
|
|
13
|
+
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
14
|
+
for (let i = 0; i <= m; i++) {
|
|
15
|
+
dp[i][0] = i;
|
|
16
|
+
}
|
|
17
|
+
for (let j = 0; j <= n; j++) {
|
|
18
|
+
dp[0][j] = j;
|
|
19
|
+
}
|
|
20
|
+
for (let i = 1; i <= m; i++) {
|
|
21
|
+
for (let j = 1; j <= n; j++) {
|
|
22
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
23
|
+
const deletion = dp[i - 1][j] + 1;
|
|
24
|
+
const insertion = dp[i][j - 1] + 1;
|
|
25
|
+
const substitution = dp[i - 1][j - 1] + cost;
|
|
26
|
+
dp[i][j] = Math.min(deletion, insertion, substitution);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return dp[m][n];
|
|
30
|
+
}
|
|
31
|
+
const DEFAULT_MAX_DISTANCE = 10;
|
|
32
|
+
export function nearestSuggestion(input, candidates, maxDistance = DEFAULT_MAX_DISTANCE) {
|
|
33
|
+
let best;
|
|
34
|
+
for (const cand of candidates) {
|
|
35
|
+
const d = distance(input, cand);
|
|
36
|
+
if (d > maxDistance) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
// Prefer candidates that start with the input (prefix match bonus)
|
|
40
|
+
const prefixBonus = cand.startsWith(input) ? -1000 : 0;
|
|
41
|
+
const score = d + prefixBonus;
|
|
42
|
+
if (!best || score < best.score || (score === best.score && cand < best.name)) {
|
|
43
|
+
best = { name: cand, dist: d, score };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return best?.name;
|
|
47
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { nearestSuggestion } from './suggest';
|
|
3
|
+
describe('nearestSuggestion', () => {
|
|
4
|
+
it('returns the closest match within edit distance', () => {
|
|
5
|
+
expect(nearestSuggestion('bar', ['bar-vertical', 'bar-horizontal', 'line']))
|
|
6
|
+
.toBe('bar-vertical');
|
|
7
|
+
});
|
|
8
|
+
it('returns the alphabetically-first on tie', () => {
|
|
9
|
+
expect(nearestSuggestion('xx', ['ab', 'ba'])).toBe('ab');
|
|
10
|
+
});
|
|
11
|
+
it('returns undefined when no candidate is within max distance', () => {
|
|
12
|
+
expect(nearestSuggestion('zz', ['absolutely-unrelated'], 3)).toBeUndefined();
|
|
13
|
+
});
|
|
14
|
+
it('returns undefined when candidates is empty', () => {
|
|
15
|
+
expect(nearestSuggestion('any', [])).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
it('handles exact match', () => {
|
|
18
|
+
expect(nearestSuggestion('line', ['line', 'pie'])).toBe('line');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata for a universal chart property (type, description, and optional
|
|
3
|
+
* choice list). Consumed by `describeChartType` to build the properties list.
|
|
4
|
+
*/
|
|
5
|
+
export interface UniversalPropertyMeta {
|
|
6
|
+
type: string;
|
|
7
|
+
description?: string;
|
|
8
|
+
choices?: string[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Static metadata for universal properties that are consumed by astToDefinition
|
|
12
|
+
* directly rather than registered per chart type via getChartOptions. These
|
|
13
|
+
* appear in chart DSL across all chart types (e.g. sort, title, colors).
|
|
14
|
+
*
|
|
15
|
+
* This is the single source of truth for both the metadata and the membership
|
|
16
|
+
* of the universal-properties set. `UNIVERSAL_PROPERTIES` is derived from the
|
|
17
|
+
* keys of this object so the two cannot drift apart.
|
|
18
|
+
*/
|
|
19
|
+
export declare const UNIVERSAL_PROPERTY_META: Readonly<Record<string, UniversalPropertyMeta>>;
|
|
20
|
+
/**
|
|
21
|
+
* Property keys recognized at the chart top level regardless of chart type.
|
|
22
|
+
*
|
|
23
|
+
* Derived from the keys of UNIVERSAL_PROPERTY_META to keep membership and
|
|
24
|
+
* metadata in lockstep. If a new universal property is added to the lib,
|
|
25
|
+
* add it to UNIVERSAL_PROPERTY_META above. The sample-roundtrip test (added
|
|
26
|
+
* in Task 6) catches drift: if a sample uses a key we don't know about, the
|
|
27
|
+
* validator will error on it and that test will fail.
|
|
28
|
+
*/
|
|
29
|
+
export declare const UNIVERSAL_PROPERTIES: ReadonlySet<string>;
|
|
30
|
+
export declare function isUniversalProperty(key: string): boolean;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static metadata for universal properties that are consumed by astToDefinition
|
|
3
|
+
* directly rather than registered per chart type via getChartOptions. These
|
|
4
|
+
* appear in chart DSL across all chart types (e.g. sort, title, colors).
|
|
5
|
+
*
|
|
6
|
+
* This is the single source of truth for both the metadata and the membership
|
|
7
|
+
* of the universal-properties set. `UNIVERSAL_PROPERTIES` is derived from the
|
|
8
|
+
* keys of this object so the two cannot drift apart.
|
|
9
|
+
*/
|
|
10
|
+
export const UNIVERSAL_PROPERTY_META = {
|
|
11
|
+
// Attribution metadata
|
|
12
|
+
title: { type: 'text', description: 'Chart title' },
|
|
13
|
+
description: { type: 'text', description: 'Chart description / subtitle' },
|
|
14
|
+
byline: { type: 'text', description: 'Byline / author credit' },
|
|
15
|
+
source: { type: 'text', description: 'Data source label' },
|
|
16
|
+
sourceUrl: { type: 'text', description: 'Data source URL' },
|
|
17
|
+
note: { type: 'text', description: 'Footer note' },
|
|
18
|
+
// Sort directives
|
|
19
|
+
sort: { type: 'select', description: 'Sort direction for categories', choices: ['ascending', 'descending', 'none'] },
|
|
20
|
+
sortMode: { type: 'select', description: 'Sort mode for grouped/stacked charts', choices: ['total', 'within-groups', 'none'] },
|
|
21
|
+
// Theme + palette (apply to every chart type)
|
|
22
|
+
theme: { type: 'text', description: 'Visual theme name' },
|
|
23
|
+
colorPalette: { type: 'text', description: 'Named color palette' },
|
|
24
|
+
colors: { type: 'colors', description: 'Custom color list' },
|
|
25
|
+
// Frame / layout
|
|
26
|
+
padding: { type: 'text', description: 'Frame padding (CSS shorthand)' },
|
|
27
|
+
background: { type: 'text', description: 'Background color' },
|
|
28
|
+
frameSizing: { type: 'select', description: 'Frame sizing mode', choices: ['auto', 'standard', 'aspect-ratio'] },
|
|
29
|
+
aspectRatio: { type: 'text', description: 'Aspect ratio (width / height)' },
|
|
30
|
+
// Value-label toggles (shared across bar/column variants but used universally
|
|
31
|
+
// in samples; per-type registry overrides when stricter)
|
|
32
|
+
valueLabels: { type: 'boolean', description: 'Show value labels on bars/segments' },
|
|
33
|
+
verticalLabelPosition: { type: 'select', description: 'Vertical axis label position', choices: ['auto', 'inside', 'outside', 'off'] },
|
|
34
|
+
horizontalLabelPosition: { type: 'select', description: 'Horizontal axis label position', choices: ['auto', 'inside', 'outside', 'off'] },
|
|
35
|
+
verticalGridStyle: { type: 'select', description: 'Vertical grid line style', choices: ['solid', 'dashed', 'dotted', 'none'] },
|
|
36
|
+
horizontalGridStyle: { type: 'select', description: 'Horizontal grid line style', choices: ['solid', 'dashed', 'dotted', 'none'] },
|
|
37
|
+
// TODO(lib): expose heightMode on area-stacked via getChartOptions
|
|
38
|
+
heightMode: { type: 'text', description: 'Height mode for stacked area charts' },
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Property keys recognized at the chart top level regardless of chart type.
|
|
42
|
+
*
|
|
43
|
+
* Derived from the keys of UNIVERSAL_PROPERTY_META to keep membership and
|
|
44
|
+
* metadata in lockstep. If a new universal property is added to the lib,
|
|
45
|
+
* add it to UNIVERSAL_PROPERTY_META above. The sample-roundtrip test (added
|
|
46
|
+
* in Task 6) catches drift: if a sample uses a key we don't know about, the
|
|
47
|
+
* validator will error on it and that test will fail.
|
|
48
|
+
*/
|
|
49
|
+
export const UNIVERSAL_PROPERTIES = new Set(Object.keys(UNIVERSAL_PROPERTY_META));
|
|
50
|
+
export function isUniversalProperty(key) {
|
|
51
|
+
return UNIVERSAL_PROPERTIES.has(key);
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { UNIVERSAL_PROPERTIES, UNIVERSAL_PROPERTY_META, isUniversalProperty } from './universalProperties';
|
|
3
|
+
describe('universalProperties', () => {
|
|
4
|
+
it('includes attribution metadata', () => {
|
|
5
|
+
expect(UNIVERSAL_PROPERTIES.has('title')).toBe(true);
|
|
6
|
+
expect(UNIVERSAL_PROPERTIES.has('description')).toBe(true);
|
|
7
|
+
expect(UNIVERSAL_PROPERTIES.has('byline')).toBe(true);
|
|
8
|
+
expect(UNIVERSAL_PROPERTIES.has('source')).toBe(true);
|
|
9
|
+
expect(UNIVERSAL_PROPERTIES.has('sourceUrl')).toBe(true);
|
|
10
|
+
expect(UNIVERSAL_PROPERTIES.has('note')).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
it('includes universal sort directives', () => {
|
|
13
|
+
expect(UNIVERSAL_PROPERTIES.has('sort')).toBe(true);
|
|
14
|
+
expect(UNIVERSAL_PROPERTIES.has('sortMode')).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
it('isUniversalProperty matches', () => {
|
|
17
|
+
expect(isUniversalProperty('title')).toBe(true);
|
|
18
|
+
expect(isUniversalProperty('madeUpKey')).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
it('UNIVERSAL_PROPERTY_META has schema entries matching the set', () => {
|
|
21
|
+
expect(UNIVERSAL_PROPERTY_META['title']?.type).toBe('text');
|
|
22
|
+
expect(UNIVERSAL_PROPERTY_META['sort']?.choices).toContain('ascending');
|
|
23
|
+
// Membership and metadata are in lockstep
|
|
24
|
+
expect(new Set(Object.keys(UNIVERSAL_PROPERTY_META))).toEqual(UNIVERSAL_PROPERTIES);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ChartNode } from '@blueprint-chart/lib';
|
|
2
|
+
export type ValidationCode = 'E_UNKNOWN_CHART_TYPE' | 'E_UNKNOWN_PROPERTY' | 'E_INVALID_PROPERTY_VALUE' | 'E_DUPLICATE_BLOCK' | 'E_EMPTY_DATA' | 'E_UNKNOWN_DATA_KEY';
|
|
3
|
+
export interface ValidationIssue {
|
|
4
|
+
code: ValidationCode;
|
|
5
|
+
path: string;
|
|
6
|
+
message: string;
|
|
7
|
+
suggestion?: string;
|
|
8
|
+
context?: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
export declare function validateAst(ast: ChartNode): ValidationIssue[];
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { getChartOptions } from '@blueprint-chart/lib';
|
|
2
|
+
import { canonicalChartType, isKnownChartType, listCanonicalChartTypes } from './chartTypes';
|
|
3
|
+
import { UNIVERSAL_PROPERTIES } from './universalProperties';
|
|
4
|
+
import { nearestSuggestion } from './suggest';
|
|
5
|
+
import { looksLikeUnquotedKey } from './dataKey';
|
|
6
|
+
function chartLevelKnownKeys(chartType) {
|
|
7
|
+
const canonical = canonicalChartType(chartType) ?? chartType;
|
|
8
|
+
const perType = getChartOptions(canonical).map(o => o.key);
|
|
9
|
+
return [...UNIVERSAL_PROPERTIES, ...perType];
|
|
10
|
+
}
|
|
11
|
+
export function validateAst(ast) {
|
|
12
|
+
const issues = [];
|
|
13
|
+
const chartType = ast.chartType;
|
|
14
|
+
const chartTypeKnown = isKnownChartType(chartType);
|
|
15
|
+
if (!chartTypeKnown) {
|
|
16
|
+
const known = listCanonicalChartTypes();
|
|
17
|
+
issues.push({
|
|
18
|
+
code: 'E_UNKNOWN_CHART_TYPE',
|
|
19
|
+
path: 'chart',
|
|
20
|
+
message: `Unknown chart type "${chartType}". Known types: ${known.join(', ')}.`,
|
|
21
|
+
suggestion: nearestSuggestion(chartType, known),
|
|
22
|
+
context: { got: chartType, known },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
// Chart-level property keys (only meaningful when the chart type is known)
|
|
26
|
+
if (chartTypeKnown) {
|
|
27
|
+
const known = chartLevelKnownKeys(chartType);
|
|
28
|
+
const knownSet = new Set(known);
|
|
29
|
+
for (const prop of ast.properties ?? []) {
|
|
30
|
+
if (!knownSet.has(prop.key)) {
|
|
31
|
+
issues.push({
|
|
32
|
+
code: 'E_UNKNOWN_PROPERTY',
|
|
33
|
+
path: `chart.${prop.key}`,
|
|
34
|
+
message: `Unknown property "${prop.key}" on chart ${chartType}.`,
|
|
35
|
+
suggestion: nearestSuggestion(prop.key, known),
|
|
36
|
+
context: { got: prop.key, chartType },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Data block
|
|
42
|
+
const dataEntries = ast.data?.entries ?? [];
|
|
43
|
+
if (dataEntries.length === 0) {
|
|
44
|
+
issues.push({
|
|
45
|
+
code: 'E_EMPTY_DATA',
|
|
46
|
+
path: 'data',
|
|
47
|
+
message: 'Chart has no data. Add a `data { … }` block with at least one entry.',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
for (const entry of dataEntries) {
|
|
52
|
+
// The `_series` pseudo-key is a multi-series header, not a row label.
|
|
53
|
+
if (entry.key === '_series') {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (looksLikeUnquotedKey(entry)) {
|
|
57
|
+
issues.push({
|
|
58
|
+
code: 'E_UNKNOWN_DATA_KEY',
|
|
59
|
+
path: `data.${entry.key}`,
|
|
60
|
+
message: `Data key "${entry.key}" looks like an identifier, but data rows expect quoted string labels (e.g. \`"${entry.key}" = …\`).`,
|
|
61
|
+
context: { got: entry.key },
|
|
62
|
+
suggestion: `"${entry.key}"`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return issues;
|
|
68
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|