@blueprint-chart/mcp 0.1.1 → 0.1.4

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 (131) hide show
  1. package/README.md +31 -15
  2. package/dist/cli.js +15 -2
  3. package/dist/dsl/capabilityMatrix.d.ts +22 -0
  4. package/dist/dsl/capabilityMatrix.js +37 -0
  5. package/dist/dsl/capabilityMatrix.test.d.ts +1 -0
  6. package/dist/dsl/capabilityMatrix.test.js +49 -0
  7. package/dist/dsl/chartTypes.d.ts +16 -0
  8. package/dist/dsl/chartTypes.js +37 -0
  9. package/dist/dsl/chartTypes.test.d.ts +1 -0
  10. package/dist/dsl/chartTypes.test.js +32 -0
  11. package/dist/dsl/dataKey.d.ts +25 -0
  12. package/dist/dsl/dataKey.js +42 -0
  13. package/dist/dsl/dataKey.test.d.ts +1 -0
  14. package/dist/dsl/dataKey.test.js +35 -0
  15. package/dist/dsl/goalRanking.d.ts +7 -0
  16. package/dist/dsl/goalRanking.js +76 -0
  17. package/dist/dsl/goalRanking.test.d.ts +1 -0
  18. package/dist/dsl/goalRanking.test.js +83 -0
  19. package/dist/dsl/parseErrorHints.d.ts +12 -0
  20. package/dist/dsl/parseErrorHints.js +32 -0
  21. package/dist/dsl/parseErrorHints.test.d.ts +1 -0
  22. package/dist/dsl/parseErrorHints.test.js +26 -0
  23. package/dist/dsl/semanticWarnings.d.ts +7 -0
  24. package/dist/dsl/semanticWarnings.js +66 -0
  25. package/dist/dsl/semanticWarnings.test.d.ts +1 -0
  26. package/dist/dsl/semanticWarnings.test.js +32 -0
  27. package/dist/dsl/suggest.d.ts +1 -0
  28. package/dist/dsl/suggest.js +66 -0
  29. package/dist/dsl/suggest.test.d.ts +1 -0
  30. package/dist/dsl/suggest.test.js +34 -0
  31. package/dist/dsl/universalProperties.d.ts +30 -0
  32. package/dist/dsl/universalProperties.js +52 -0
  33. package/dist/dsl/universalProperties.test.d.ts +1 -0
  34. package/dist/dsl/universalProperties.test.js +26 -0
  35. package/dist/dsl/validate.d.ts +10 -0
  36. package/dist/dsl/validate.js +68 -0
  37. package/dist/dsl/validate.test.d.ts +1 -0
  38. package/dist/dsl/validate.test.js +73 -0
  39. package/dist/errors.d.ts +20 -1
  40. package/dist/errors.js +1 -0
  41. package/dist/errors.test.js +21 -0
  42. package/dist/lib/zodToJsonSchema.d.ts +10 -5
  43. package/dist/lib/zodToJsonSchema.js +14 -6
  44. package/dist/links/buildUrls.d.ts +14 -0
  45. package/dist/links/buildUrls.js +20 -0
  46. package/dist/links/buildUrls.test.d.ts +1 -0
  47. package/dist/links/buildUrls.test.js +28 -0
  48. package/dist/links/editorConfig.d.ts +4 -0
  49. package/dist/links/editorConfig.js +15 -0
  50. package/dist/links/editorConfig.test.d.ts +1 -0
  51. package/dist/links/editorConfig.test.js +28 -0
  52. package/dist/links/encode.d.ts +11 -0
  53. package/dist/links/encode.js +19 -0
  54. package/dist/links/encode.test.d.ts +1 -0
  55. package/dist/links/encode.test.js +37 -0
  56. package/dist/parse.js +14 -6
  57. package/dist/parse.test.js +8 -0
  58. package/dist/prompts/authorChart.js +23 -18
  59. package/dist/prompts/authorChart.test.js +6 -0
  60. package/dist/render/diagnose.d.ts +19 -0
  61. package/dist/render/diagnose.js +100 -0
  62. package/dist/render/diagnose.test.d.ts +1 -0
  63. package/dist/render/diagnose.test.js +53 -0
  64. package/dist/render/frame.d.ts +10 -0
  65. package/dist/render/frame.js +10 -0
  66. package/dist/render/frame.test.d.ts +1 -0
  67. package/dist/render/frame.test.js +12 -0
  68. package/dist/render/jsdomEnv.d.ts +2 -1
  69. package/dist/render/jsdomEnv.js +14 -1
  70. package/dist/render/jsdomEnv.test.js +36 -2
  71. package/dist/render/renderSceneState.d.ts +5 -1
  72. package/dist/render/renderSceneState.js +4 -3
  73. package/dist/render/renderSceneState.test.js +13 -7
  74. package/dist/render/validatePipeline.d.ts +23 -0
  75. package/dist/render/validatePipeline.js +41 -0
  76. package/dist/render/validatePipeline.test.d.ts +1 -0
  77. package/dist/render/validatePipeline.test.js +34 -0
  78. package/dist/resources/docsReader.d.ts +4 -1
  79. package/dist/resources/docsReader.js +23 -6
  80. package/dist/resources/docsReader.test.js +27 -2
  81. package/dist/resources/index.d.ts +1 -1
  82. package/dist/resources/samples.d.ts +1 -2
  83. package/dist/server.d.ts +9 -0
  84. package/dist/server.js +75 -5
  85. package/dist/server.test.js +105 -4
  86. package/dist/tools/describeChartType.d.ts +41 -0
  87. package/dist/tools/describeChartType.js +143 -0
  88. package/dist/tools/describeChartType.test.d.ts +1 -0
  89. package/dist/tools/describeChartType.test.js +78 -0
  90. package/dist/tools/exportChart.d.ts +17 -0
  91. package/dist/tools/exportChart.js +31 -0
  92. package/dist/tools/exportChart.test.d.ts +1 -0
  93. package/dist/tools/exportChart.test.js +43 -0
  94. package/dist/tools/getExample.d.ts +20 -0
  95. package/dist/tools/getExample.js +55 -0
  96. package/dist/tools/getExample.test.d.ts +1 -0
  97. package/dist/tools/getExample.test.js +40 -0
  98. package/dist/tools/getGrammar.d.ts +17 -0
  99. package/dist/tools/getGrammar.js +38 -0
  100. package/dist/tools/getGrammar.test.d.ts +1 -0
  101. package/dist/tools/getGrammar.test.js +35 -0
  102. package/dist/tools/inspect.d.ts +8 -1
  103. package/dist/tools/inspect.js +40 -7
  104. package/dist/tools/inspect.test.js +62 -13
  105. package/dist/tools/listChartTypes.d.ts +14 -0
  106. package/dist/tools/listChartTypes.js +42 -0
  107. package/dist/tools/listChartTypes.test.d.ts +1 -0
  108. package/dist/tools/listChartTypes.test.js +42 -0
  109. package/dist/tools/listPalettes.d.ts +13 -0
  110. package/dist/tools/listPalettes.js +12 -0
  111. package/dist/tools/listPalettes.test.d.ts +1 -0
  112. package/dist/tools/listPalettes.test.js +15 -0
  113. package/dist/tools/recommend.js +3 -1
  114. package/dist/tools/recommend.test.js +40 -0
  115. package/dist/tools/render.d.ts +14 -12
  116. package/dist/tools/render.js +96 -28
  117. package/dist/tools/render.test.js +137 -1
  118. package/dist/tools/searchExamples.d.ts +28 -0
  119. package/dist/tools/searchExamples.js +54 -0
  120. package/dist/tools/searchExamples.test.d.ts +1 -0
  121. package/dist/tools/searchExamples.test.js +32 -0
  122. package/dist/tools/validate.d.ts +9 -3
  123. package/dist/tools/validate.js +11 -1
  124. package/dist/tools/validate.test.js +33 -11
  125. package/dist/transports/http.d.ts +4 -2
  126. package/dist/transports/http.js +232 -23
  127. package/dist/transports/http.test.js +158 -22
  128. package/package.json +5 -3
  129. package/public/apple-touch-icon.png +0 -0
  130. package/public/favicon.png +0 -0
  131. 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 eleven deterministic tools: `validate_dsl`, `inspect_dsl`, `recommend_chart_type`, `render`, `list_chart_types`, `describe_chart_type`, `get_example`, `get_grammar`, `export_chart`, `search_examples`, and `list_palettes`. 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,29 @@ 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), PNG, or HTML; always returns structured frame metadata, with structured `errors[]` (each with `code` + `suggestion`) on failure. Pass `save: <path>` to write the output to disk (requires `MCP_ALLOW_FS_WRITE=1`) |
61
+ | `list_chart_types` | List all renderable chart types (tool equivalent of `bpc://handbook/choosing`) |
62
+ | `describe_chart_type` | Properties, when-to-use, when-NOT-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
+ | `search_examples` | Find canonical examples by topic keywords and/or chart type (returns pointers; fetch full DSL with `get_example`) |
65
+ | `get_grammar` | Full DSL syntax reference (tool equivalent of `bpc://grammar`) |
66
+ | `list_palettes` | List named colour palettes with hex colours for `colorPalette` |
67
+ | `export_chart` | Validate a `.bpc` and return shareable editor URLs — an editable `copyUrl` and a read-only `embedUrl` for iframes (requires `BLUEPRINT_CHART_EDITOR_URL`) |
68
+
69
+ The discovery tools (`list_chart_types`, `describe_chart_type`, `get_example`, `search_examples`, `get_grammar`, `list_palettes`) let clients without MCP resource support access the same reference material that the `bpc://` URIs expose.
58
70
 
59
71
  ## Resources
60
72
 
@@ -77,7 +89,7 @@ Once the MCP is connected, ask Claude to make a chart:
77
89
 
78
90
  > **You:** Make a horizontal bar chart of English letter frequencies — top 10, highlight E.
79
91
  >
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)*
92
+ > **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
93
  >
82
94
  > Here's the chart:
83
95
  >
@@ -119,7 +131,7 @@ chart bar-vertical {
119
131
 
120
132
  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
133
 
122
- ### `validate_dsl` — parse with precise errors
134
+ ### `validate_dsl` — parse with structured diagnostics
123
135
 
124
136
  Request:
125
137
 
@@ -130,15 +142,19 @@ Request:
130
142
  }
131
143
  ```
132
144
 
133
- Response (note the line + column):
145
+ Response `valid` is false; each entry in `errors[]` carries a `code`, human-readable `message`, and an actionable `suggestion`:
134
146
 
135
147
  ```json
136
148
  {
137
- "ok": false,
138
- "code": "E_PARSE",
149
+ "valid": false,
139
150
  "errors": [
140
- { "line": 2, "column": 19, "message": "Expected \"\\\"\" but end of input found." }
141
- ]
151
+ {
152
+ "code": "E_PARSE",
153
+ "message": "Expected \"\\\"\" but end of input found.",
154
+ "suggestion": "Close the string literal on line 2."
155
+ }
156
+ ],
157
+ "warnings": []
142
158
  }
143
159
  ```
144
160
 
@@ -195,7 +211,7 @@ Response:
195
211
  }
196
212
  ```
197
213
 
198
- ### `render` — SVG (default) or PNG
214
+ ### `render` — SVG (default), PNG, or HTML
199
215
 
200
216
  Request:
201
217
 
@@ -219,7 +235,7 @@ Response:
219
235
  }
220
236
  ```
221
237
 
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.
238
+ 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
239
 
224
240
  ### Reading a resource
225
241
 
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,22 @@
1
+ /**
2
+ * Per-(chartType × key) capability metadata. `key` is a chart property OR a
3
+ * directive name (`highlight`, `colorize`, `annotation`, `transform`, `scene`).
4
+ *
5
+ * Phase 1: this matrix is MCP-local, seeded from the authoring usability test
6
+ * (test-results/mcp-authoring-report.md). Phase 2 promotes it into
7
+ * @blueprint-chart/lib as renderer-verified metadata; the MCP then consumes
8
+ * lib's matrix and deletes this file.
9
+ *
10
+ * Design: DEFAULT every cell to { applicable: true, implemented: true }
11
+ * ("supported") and list ONLY non-default cells in OVERRIDES. This keeps
12
+ * warnings high-precision (no false positives on the 34 bundled samples) and
13
+ * means a new chart type needs zero matrix edits to avoid spurious warnings.
14
+ */
15
+ export interface CapabilityCell {
16
+ applicable: boolean;
17
+ implemented: boolean;
18
+ note?: string;
19
+ }
20
+ export type CapabilityStatus = 'supported' | 'not-implemented' | 'inapplicable';
21
+ export declare function statusOf(cell: CapabilityCell): CapabilityStatus;
22
+ export declare function lookupCapability(chartType: string, key: string): CapabilityCell;
@@ -0,0 +1,37 @@
1
+ const SUPPORTED = Object.freeze({ applicable: true, implemented: true });
2
+ export function statusOf(cell) {
3
+ if (!cell.applicable) {
4
+ return 'inapplicable';
5
+ }
6
+ if (!cell.implemented) {
7
+ return 'not-implemented';
8
+ }
9
+ return 'supported';
10
+ }
11
+ const OVERRIDES = {
12
+ donut: {
13
+ sort: { applicable: false, implemented: false, note: 'Slice order on a donut follows data order; use a `transform sort` on the data instead.' },
14
+ sortMode: { applicable: false, implemented: false, note: 'sortMode applies to grouped/stacked charts, not donut.' },
15
+ valueLabels: { applicable: false, implemented: false, note: 'Donut shows slice labels; use `displayAsPercentage` / `tooltips` instead of valueLabels.' },
16
+ },
17
+ pie: {
18
+ sort: { applicable: false, implemented: false, note: 'Slice order on a pie follows data order; use a `transform sort` on the data instead.' },
19
+ sortMode: { applicable: false, implemented: false, note: 'sortMode applies to grouped/stacked charts, not pie.' },
20
+ valueLabels: { applicable: false, implemented: false, note: 'Pie shows slice labels; use `tooltips` / `displayAsPercentage` instead of valueLabels.' },
21
+ },
22
+ // Single-series line/area are one path with no per-series/per-category marks.
23
+ // `highlight` (dim others) and `colorize` (recolour a target) both need a
24
+ // target to act on, so they are inapplicable here — they work on the
25
+ // multi-series variants (line-multi / area-stacked), which default supported.
26
+ line: {
27
+ highlight: { applicable: false, implemented: false, note: 'highlight dims other series/categories; a single-series line has nothing to dim. Use an `annotation` to call out a point, or `line-multi` for multiple series.' },
28
+ colorize: { applicable: false, implemented: false, note: 'colorize recolours a named series/category; a single-series line has no target. Use `colorPalette` / `colors`, or `line-multi` for multiple series.' },
29
+ },
30
+ area: {
31
+ highlight: { applicable: false, implemented: false, note: 'highlight dims other series/categories; a single-series area has nothing to dim. Use an `annotation` to call out a point, or `area-stacked` for multiple series.' },
32
+ colorize: { applicable: false, implemented: false, note: 'colorize recolours a named series/category; a single-series area has no target. Use `colorPalette` / `colors`, or `area-stacked` for multiple series.' },
33
+ },
34
+ };
35
+ export function lookupCapability(chartType, key) {
36
+ return OVERRIDES[chartType]?.[key] ?? SUPPORTED;
37
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { samples } from '@blueprint-chart/lib';
3
+ import { canonicalChartType } from './chartTypes';
4
+ import { lookupCapability, statusOf } from './capabilityMatrix';
5
+ describe('capabilityMatrix', () => {
6
+ it('defaults unknown cells to supported', () => {
7
+ expect(statusOf(lookupCapability('bar-vertical', 'title'))).toBe('supported');
8
+ expect(statusOf(lookupCapability('bar-vertical', 'some-unknown-key'))).toBe('supported');
9
+ });
10
+ it('marks sort on donut as inapplicable with a note', () => {
11
+ const cell = lookupCapability('donut', 'sort');
12
+ expect(statusOf(cell)).toBe('inapplicable');
13
+ expect(cell.note).toBeTruthy();
14
+ });
15
+ it('marks colorize supported on donut/pie (W1c shipped per-slice colorize)', () => {
16
+ expect(statusOf(lookupCapability('donut', 'colorize'))).toBe('supported');
17
+ expect(statusOf(lookupCapability('pie', 'colorize'))).toBe('supported');
18
+ });
19
+ it('marks colorize inapplicable on single-series line/area, supported on multi-series (W1c)', () => {
20
+ expect(statusOf(lookupCapability('line', 'colorize'))).toBe('inapplicable');
21
+ expect(lookupCapability('line', 'colorize').note).toBeTruthy();
22
+ expect(statusOf(lookupCapability('area', 'colorize'))).toBe('inapplicable');
23
+ expect(statusOf(lookupCapability('line-multi', 'colorize'))).toBe('supported');
24
+ expect(statusOf(lookupCapability('bar-multi', 'colorize'))).toBe('supported');
25
+ });
26
+ it('marks highlight inapplicable on single-series line/area, supported on multi-series (W1b)', () => {
27
+ expect(statusOf(lookupCapability('line', 'highlight'))).toBe('inapplicable');
28
+ expect(lookupCapability('line', 'highlight').note).toBeTruthy();
29
+ expect(statusOf(lookupCapability('area', 'highlight'))).toBe('inapplicable');
30
+ // multi-series variants got highlight dimming in W1b → default supported
31
+ expect(statusOf(lookupCapability('line-multi', 'highlight'))).toBe('supported');
32
+ expect(statusOf(lookupCapability('area-stacked', 'highlight'))).toBe('supported');
33
+ expect(statusOf(lookupCapability('donut', 'highlight'))).toBe('supported');
34
+ });
35
+ it('does not flag any bundled sample property as inapplicable', () => {
36
+ const offenders = [];
37
+ for (const s of samples) {
38
+ const type = canonicalChartType(s.chartType) ?? s.chartType;
39
+ // matches bare-word "key =" property assignments (the only form bundled samples use)
40
+ const keys = Array.from(s.dsl.matchAll(/^\s*([A-Za-z][A-Za-z0-9]*)\s*=/gm)).map(m => m[1]);
41
+ for (const key of keys) {
42
+ if (statusOf(lookupCapability(type, key)) === 'inapplicable') {
43
+ offenders.push(`${s.id} (${type}): ${key}`);
44
+ }
45
+ }
46
+ }
47
+ expect(offenders).toEqual([]);
48
+ });
49
+ });
@@ -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,7 @@
1
+ import type { ChartRecommendation } from '@blueprint-chart/lib';
2
+ /**
3
+ * Re-rank lib's recommendations using the goal string. Returns a new array;
4
+ * never mutates the input. `rowCount` (when provided) enables a pie-before-donut
5
+ * tiebreak for very small slice counts.
6
+ */
7
+ export declare function applyGoalReranking(recs: ChartRecommendation[], goal: string | undefined, rowCount?: number): ChartRecommendation[];
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Goal keyword → chart types that goal favours, classified `strong` or `weak`.
3
+ *
4
+ * STRONG rules name a goal that implies a different chart *family* (part-to-whole,
5
+ * composition-over-time, range) and so may override lib's column-type "best".
6
+ * WEAK rules are narrative framings (trend, crossover, rank) that only break ties
7
+ * among non-best candidates — they must NOT demote lib's structurally-best pick.
8
+ * (Without this distinction the "overtakes" keyword wrongly boosted line-multi
9
+ * above the correct bar-multi for quarterly-revenue.)
10
+ *
11
+ * Used as a re-ranking layer ON TOP of lib's column-type/row-count ranking;
12
+ * lib's `recommendCharts` ignores the goal string entirely.
13
+ */
14
+ const GOAL_RULES = [
15
+ { pattern: /\b(share|part[- ]to[- ]whole|proportion|percentage of (the )?total|breakdown|make up)\b/i, types: ['pie', 'donut', 'bar-stacked', 'column-stacked'], strong: true },
16
+ { pattern: /(?:\b(?:composition|mix|stacked).*(?:over time|by year|trend)|\b(?:over time|by year).*(?:composition|mix))\b/i, types: ['area-stacked', 'column-stacked'], strong: true },
17
+ { pattern: /\b(over time|trend|grew|rose|fell|climbed|change over|year[- ]over[- ]year)\b/i, types: ['line', 'line-multi', 'area'], strong: false },
18
+ { pattern: /\b(overtak\w*|overtook|crossover|surpass|cross over|catch[- ]up|diverg\w*)/i, types: ['line-multi', 'line'], strong: false },
19
+ { pattern: /\b(rank|ranked|more than|compared|top \d+|most|largest|biggest)\b/i, types: ['bar-vertical', 'bar-horizontal'], strong: false },
20
+ { pattern: /\b(range|high and low|high\/low|margin of error|confidence|interval|plus or minus)\b/i, types: ['bar-split'], strong: true },
21
+ ];
22
+ /**
23
+ * Re-rank lib's recommendations using the goal string. Returns a new array;
24
+ * never mutates the input. `rowCount` (when provided) enables a pie-before-donut
25
+ * tiebreak for very small slice counts.
26
+ */
27
+ export function applyGoalReranking(recs, goal, rowCount) {
28
+ if (!goal || goal.trim() === '') {
29
+ return recs;
30
+ }
31
+ const strongBoost = new Set();
32
+ const weakBoost = new Set();
33
+ for (const rule of GOAL_RULES) {
34
+ if (rule.pattern.test(goal)) {
35
+ const target = rule.strong ? strongBoost : weakBoost;
36
+ for (const t of rule.types) {
37
+ target.add(t);
38
+ }
39
+ }
40
+ }
41
+ // No-match contract: if nothing matched, return unchanged WITHOUT running the
42
+ // tier sort — otherwise tier 1 (lib "best") would float ahead of a lib-higher
43
+ // "good" even when the author gave no usable goal.
44
+ if (strongBoost.size === 0 && weakBoost.size === 0) {
45
+ return recs;
46
+ }
47
+ // Tier: strong-boosted (0) → lib "best" (1) → weak-boosted (2) → rest (3).
48
+ const tierOf = (rec) => {
49
+ if (strongBoost.has(rec.chartType)) {
50
+ return 0;
51
+ }
52
+ if (rec.fitness === 'best') {
53
+ return 1;
54
+ }
55
+ if (weakBoost.has(rec.chartType)) {
56
+ return 2;
57
+ }
58
+ return 3;
59
+ };
60
+ const sorted = recs
61
+ .map((rec, index) => ({ rec, index, tier: tierOf(rec) }))
62
+ // tier ascending; within a tier preserve lib's original order (stable)
63
+ .sort((a, b) => (a.tier === b.tier ? a.index - b.index : a.tier - b.tier))
64
+ .map(x => x.rec);
65
+ // Pie suits very small N; for ≤5 slices prefer pie over donut when both are
66
+ // present (and pie currently trails donut). Larger N keeps lib's donut order.
67
+ if (rowCount !== undefined && rowCount <= 5) {
68
+ const donutIdx = sorted.findIndex(r => r.chartType === 'donut');
69
+ const pieIdx = sorted.findIndex(r => r.chartType === 'pie');
70
+ if (donutIdx !== -1 && pieIdx !== -1 && pieIdx > donutIdx) {
71
+ const [pieRec] = sorted.splice(pieIdx, 1);
72
+ sorted.splice(donutIdx, 0, pieRec);
73
+ }
74
+ }
75
+ return sorted;
76
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { applyGoalReranking } from './goalRanking';
3
+ const recs = [
4
+ { chartType: 'bar-vertical', label: 'Bar', fitness: 'best', reason: 'r' },
5
+ { chartType: 'donut', label: 'Donut', fitness: 'good', reason: 'r' },
6
+ { chartType: 'pie', label: 'Pie', fitness: 'alternative', reason: 'r' },
7
+ { chartType: 'area-stacked', label: 'Area stacked', fitness: 'alternative', reason: 'r' },
8
+ ];
9
+ describe('applyGoalReranking', () => {
10
+ it('returns input unchanged when goal is undefined', () => {
11
+ expect(applyGoalReranking(recs, undefined)).toEqual(recs);
12
+ });
13
+ it('promotes part-to-whole types for a "share of total" goal', () => {
14
+ const out = applyGoalReranking(recs, 'show each region as a share of the total population');
15
+ expect(['pie', 'donut']).toContain(out[0].chartType);
16
+ });
17
+ it('promotes area-stacked for a "composition over time" goal', () => {
18
+ const out = applyGoalReranking(recs, 'composition of energy sources over time');
19
+ expect(out[0].chartType).toBe('area-stacked');
20
+ });
21
+ it('keeps original order when no keyword matches', () => {
22
+ const out = applyGoalReranking(recs, 'just some unrelated text');
23
+ expect(out.map(r => r.chartType)).toEqual(recs.map(r => r.chartType));
24
+ });
25
+ it('keeps lib "best" bar-multi ahead of a narrative-boosted line-multi (quarterly-revenue)', () => {
26
+ const r = [
27
+ { chartType: 'line-multi', label: 'Multi-Line', fitness: 'good', reason: 'r' },
28
+ { chartType: 'bar-multi', label: 'Grouped Bar', fitness: 'best', reason: 'r' },
29
+ ];
30
+ const out = applyGoalReranking(r, 'software overtakes hardware as the top revenue driver', 6);
31
+ expect(out[0].chartType).toBe('bar-multi');
32
+ expect(out[1].chartType).toBe('line-multi');
33
+ });
34
+ it('promotes pie ahead of donut for a part-to-whole goal at very small N (world-population)', () => {
35
+ const r = [
36
+ { chartType: 'donut', label: 'Donut', fitness: 'good', reason: 'r' },
37
+ { chartType: 'pie', label: 'Pie', fitness: 'alternative', reason: 'r' },
38
+ { chartType: 'bar-vertical', label: 'Bar', fitness: 'best', reason: 'r' },
39
+ { chartType: 'bar-horizontal', label: 'HBar', fitness: 'good', reason: 'r' },
40
+ ];
41
+ const out = applyGoalReranking(r, 'Asia is nearly 60% of the world population; share of total', 5);
42
+ expect(out[0].chartType).toBe('pie');
43
+ expect(out.map(x => x.chartType)).toEqual(['pie', 'donut', 'bar-vertical', 'bar-horizontal']);
44
+ });
45
+ it('leaves lib "best" bar-vertical #1 for a ranked-comparison goal (co2-emissions)', () => {
46
+ const r = [
47
+ { chartType: 'bar-vertical', label: 'Bar', fitness: 'best', reason: 'r' },
48
+ { chartType: 'bar-horizontal', label: 'HBar', fitness: 'good', reason: 'r' },
49
+ { chartType: 'donut', label: 'Donut', fitness: 'good', reason: 'r' },
50
+ { chartType: 'pie', label: 'Pie', fitness: 'alternative', reason: 'r' },
51
+ ];
52
+ const out = applyGoalReranking(r, 'China emits more than the US and India combined; ranked', 6);
53
+ expect(out.map(x => x.chartType)).toEqual(['bar-vertical', 'bar-horizontal', 'donut', 'pie']);
54
+ });
55
+ it('promotes bar-split to #1 for a range / high-low goal (election-polls)', () => {
56
+ const r = [
57
+ { chartType: 'bar-multi', label: 'Grouped Bar', fitness: 'best', reason: 'r' },
58
+ { chartType: 'bar-split', label: 'Split Bar', fitness: 'good', reason: 'r' },
59
+ { chartType: 'line-multi', label: 'Multi-Line', fitness: 'alternative', reason: 'r' },
60
+ ];
61
+ const out = applyGoalReranking(r, 'each party has a high and low estimate; a polling range', 6);
62
+ expect(out[0].chartType).toBe('bar-split');
63
+ });
64
+ it('does NOT apply the pie tiebreak for larger N', () => {
65
+ const r = [
66
+ { chartType: 'donut', label: 'Donut', fitness: 'good', reason: 'r' },
67
+ { chartType: 'pie', label: 'Pie', fitness: 'alternative', reason: 'r' },
68
+ { chartType: 'bar-vertical', label: 'Bar', fitness: 'best', reason: 'r' },
69
+ ];
70
+ const out = applyGoalReranking(r, 'share of total', 12);
71
+ const di = out.findIndex(x => x.chartType === 'donut');
72
+ const pi = out.findIndex(x => x.chartType === 'pie');
73
+ expect(di).toBeLessThan(pi);
74
+ });
75
+ it('returns input unchanged on no-match WITHOUT floating best ahead of a lib-higher good', () => {
76
+ const r = [
77
+ { chartType: 'line-multi', label: 'Multi-Line', fitness: 'good', reason: 'r' },
78
+ { chartType: 'bar-multi', label: 'Grouped Bar', fitness: 'best', reason: 'r' },
79
+ ];
80
+ const out = applyGoalReranking(r, 'just some unrelated text', 6);
81
+ expect(out.map(x => x.chartType)).toEqual(['line-multi', 'bar-multi']);
82
+ });
83
+ });