@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.
Files changed (103) hide show
  1. package/README.md +27 -14
  2. package/dist/cli.js +15 -2
  3. package/dist/dsl/chartTypes.d.ts +16 -0
  4. package/dist/dsl/chartTypes.js +37 -0
  5. package/dist/dsl/chartTypes.test.d.ts +1 -0
  6. package/dist/dsl/chartTypes.test.js +32 -0
  7. package/dist/dsl/dataKey.d.ts +25 -0
  8. package/dist/dsl/dataKey.js +42 -0
  9. package/dist/dsl/dataKey.test.d.ts +1 -0
  10. package/dist/dsl/dataKey.test.js +35 -0
  11. package/dist/dsl/suggest.d.ts +1 -0
  12. package/dist/dsl/suggest.js +47 -0
  13. package/dist/dsl/suggest.test.d.ts +1 -0
  14. package/dist/dsl/suggest.test.js +20 -0
  15. package/dist/dsl/universalProperties.d.ts +30 -0
  16. package/dist/dsl/universalProperties.js +52 -0
  17. package/dist/dsl/universalProperties.test.d.ts +1 -0
  18. package/dist/dsl/universalProperties.test.js +26 -0
  19. package/dist/dsl/validate.d.ts +10 -0
  20. package/dist/dsl/validate.js +68 -0
  21. package/dist/dsl/validate.test.d.ts +1 -0
  22. package/dist/dsl/validate.test.js +73 -0
  23. package/dist/errors.d.ts +20 -1
  24. package/dist/errors.js +1 -0
  25. package/dist/errors.test.js +21 -0
  26. package/dist/lib/zodToJsonSchema.d.ts +10 -5
  27. package/dist/lib/zodToJsonSchema.js +14 -6
  28. package/dist/links/buildUrls.d.ts +14 -0
  29. package/dist/links/buildUrls.js +20 -0
  30. package/dist/links/buildUrls.test.d.ts +1 -0
  31. package/dist/links/buildUrls.test.js +28 -0
  32. package/dist/links/editorConfig.d.ts +4 -0
  33. package/dist/links/editorConfig.js +15 -0
  34. package/dist/links/editorConfig.test.d.ts +1 -0
  35. package/dist/links/editorConfig.test.js +28 -0
  36. package/dist/links/encode.d.ts +11 -0
  37. package/dist/links/encode.js +19 -0
  38. package/dist/links/encode.test.d.ts +1 -0
  39. package/dist/links/encode.test.js +37 -0
  40. package/dist/prompts/authorChart.js +23 -18
  41. package/dist/prompts/authorChart.test.js +6 -0
  42. package/dist/render/diagnose.d.ts +19 -0
  43. package/dist/render/diagnose.js +100 -0
  44. package/dist/render/diagnose.test.d.ts +1 -0
  45. package/dist/render/diagnose.test.js +53 -0
  46. package/dist/render/frame.d.ts +10 -0
  47. package/dist/render/frame.js +10 -0
  48. package/dist/render/frame.test.d.ts +1 -0
  49. package/dist/render/frame.test.js +12 -0
  50. package/dist/render/jsdomEnv.d.ts +2 -1
  51. package/dist/render/jsdomEnv.js +14 -1
  52. package/dist/render/jsdomEnv.test.js +36 -2
  53. package/dist/render/renderSceneState.d.ts +5 -1
  54. package/dist/render/renderSceneState.js +4 -3
  55. package/dist/render/renderSceneState.test.js +13 -7
  56. package/dist/render/validatePipeline.d.ts +23 -0
  57. package/dist/render/validatePipeline.js +41 -0
  58. package/dist/render/validatePipeline.test.d.ts +1 -0
  59. package/dist/render/validatePipeline.test.js +34 -0
  60. package/dist/resources/docsReader.d.ts +4 -1
  61. package/dist/resources/docsReader.js +23 -6
  62. package/dist/resources/docsReader.test.js +27 -2
  63. package/dist/resources/index.d.ts +1 -1
  64. package/dist/resources/samples.d.ts +1 -2
  65. package/dist/server.d.ts +9 -0
  66. package/dist/server.js +63 -5
  67. package/dist/server.test.js +101 -4
  68. package/dist/tools/describeChartType.d.ts +33 -0
  69. package/dist/tools/describeChartType.js +119 -0
  70. package/dist/tools/describeChartType.test.d.ts +1 -0
  71. package/dist/tools/describeChartType.test.js +58 -0
  72. package/dist/tools/exportChart.d.ts +17 -0
  73. package/dist/tools/exportChart.js +31 -0
  74. package/dist/tools/exportChart.test.d.ts +1 -0
  75. package/dist/tools/exportChart.test.js +43 -0
  76. package/dist/tools/getExample.d.ts +20 -0
  77. package/dist/tools/getExample.js +55 -0
  78. package/dist/tools/getExample.test.d.ts +1 -0
  79. package/dist/tools/getExample.test.js +40 -0
  80. package/dist/tools/getGrammar.d.ts +17 -0
  81. package/dist/tools/getGrammar.js +38 -0
  82. package/dist/tools/getGrammar.test.d.ts +1 -0
  83. package/dist/tools/getGrammar.test.js +24 -0
  84. package/dist/tools/inspect.d.ts +8 -1
  85. package/dist/tools/inspect.js +31 -7
  86. package/dist/tools/inspect.test.js +35 -13
  87. package/dist/tools/listChartTypes.d.ts +14 -0
  88. package/dist/tools/listChartTypes.js +42 -0
  89. package/dist/tools/listChartTypes.test.d.ts +1 -0
  90. package/dist/tools/listChartTypes.test.js +42 -0
  91. package/dist/tools/render.d.ts +14 -12
  92. package/dist/tools/render.js +96 -28
  93. package/dist/tools/render.test.js +137 -1
  94. package/dist/tools/validate.d.ts +11 -3
  95. package/dist/tools/validate.js +9 -1
  96. package/dist/tools/validate.test.js +17 -12
  97. package/dist/transports/http.d.ts +4 -2
  98. package/dist/transports/http.js +232 -23
  99. package/dist/transports/http.test.js +158 -22
  100. package/package.json +4 -2
  101. package/public/apple-touch-icon.png +0 -0
  102. package/public/favicon.png +0 -0
  103. 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 four deterministic tools: `validate_dsl`, `inspect_dsl`, `recommend_chart_type`, and `render`. Your LLM writes the `.bpc`; the MCP grounds it in real dataviz pedagogy and gives it a tight feedback loop.
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 -- npx -y @blueprint-chart/mcp
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`; precise errors with line/column |
55
- | `inspect_dsl` | Parse and summarize: chart type, scenes, series, annotations |
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:** *(reads `bpc://grammar`, `bpc://handbook/choosing`, `bpc://samples/letter-frequency`, writes the `.bpc`, calls `validate_dsl` to confirm it parses, calls `render` with `format: 'png'` and shows you the image and the source)*
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 precise errors
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 (note the line + column):
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
- "ok": false,
138
- "code": "E_PARSE",
146
+ "valid": false,
139
147
  "errors": [
140
- { "line": 2, "column": 19, "message": "Expected \"\\\"\" but end of input found." }
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), the response is `{ ok: false, code: "E_RENDER", }` **and still includes** the SVG that was successfully produced partial success is preserved.
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}/mcp\n`);
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 {};