@blueprint-chart/mcp 0.1.3 → 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 (45) hide show
  1. package/README.md +8 -5
  2. package/dist/dsl/capabilityMatrix.d.ts +22 -0
  3. package/dist/dsl/capabilityMatrix.js +37 -0
  4. package/dist/dsl/capabilityMatrix.test.d.ts +1 -0
  5. package/dist/dsl/capabilityMatrix.test.js +49 -0
  6. package/dist/dsl/goalRanking.d.ts +7 -0
  7. package/dist/dsl/goalRanking.js +76 -0
  8. package/dist/dsl/goalRanking.test.d.ts +1 -0
  9. package/dist/dsl/goalRanking.test.js +83 -0
  10. package/dist/dsl/parseErrorHints.d.ts +12 -0
  11. package/dist/dsl/parseErrorHints.js +32 -0
  12. package/dist/dsl/parseErrorHints.test.d.ts +1 -0
  13. package/dist/dsl/parseErrorHints.test.js +26 -0
  14. package/dist/dsl/semanticWarnings.d.ts +7 -0
  15. package/dist/dsl/semanticWarnings.js +66 -0
  16. package/dist/dsl/semanticWarnings.test.d.ts +1 -0
  17. package/dist/dsl/semanticWarnings.test.js +32 -0
  18. package/dist/dsl/suggest.js +19 -0
  19. package/dist/dsl/suggest.test.js +14 -0
  20. package/dist/parse.js +14 -6
  21. package/dist/parse.test.js +8 -0
  22. package/dist/server.js +12 -0
  23. package/dist/server.test.js +6 -2
  24. package/dist/tools/describeChartType.d.ts +8 -0
  25. package/dist/tools/describeChartType.js +24 -0
  26. package/dist/tools/describeChartType.test.js +20 -0
  27. package/dist/tools/getGrammar.d.ts +2 -2
  28. package/dist/tools/getGrammar.js +1 -1
  29. package/dist/tools/getGrammar.test.js +11 -0
  30. package/dist/tools/inspect.js +16 -7
  31. package/dist/tools/inspect.test.js +27 -0
  32. package/dist/tools/listPalettes.d.ts +13 -0
  33. package/dist/tools/listPalettes.js +12 -0
  34. package/dist/tools/listPalettes.test.d.ts +1 -0
  35. package/dist/tools/listPalettes.test.js +15 -0
  36. package/dist/tools/recommend.js +3 -1
  37. package/dist/tools/recommend.test.js +40 -0
  38. package/dist/tools/searchExamples.d.ts +28 -0
  39. package/dist/tools/searchExamples.js +54 -0
  40. package/dist/tools/searchExamples.test.d.ts +1 -0
  41. package/dist/tools/searchExamples.test.js +32 -0
  42. package/dist/tools/validate.d.ts +3 -5
  43. package/dist/tools/validate.js +8 -6
  44. package/dist/tools/validate.test.js +17 -0
  45. package/package.json +2 -2
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 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.
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
 
@@ -57,13 +57,16 @@ claude mcp add blueprint-chart \
57
57
  | `validate_dsl` | Parse `.bpc`; returns `{ valid, errors[], warnings[] }` — each error has `code`, `message`, `suggestion` |
58
58
  | `inspect_dsl` | Parse and summarize: `chartType`, `scenes`, `seriesCount`, `rowCount`, `hasHighlights`, `hasColorizes`, etc. |
59
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 |
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
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}`) |
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
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`) |
64
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`) |
65
68
 
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.
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.
67
70
 
68
71
  ## Resources
69
72
 
@@ -208,7 +211,7 @@ Response:
208
211
  }
209
212
  ```
210
213
 
211
- ### `render` — SVG (default) or PNG
214
+ ### `render` — SVG (default), PNG, or HTML
212
215
 
213
216
  Request:
214
217
 
@@ -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,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
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Translates raw PEG parser messages from @blueprint-chart/lib into plain,
3
+ * actionable guidance. The raw token-class messages (e.g.
4
+ * `Expected "\t" or [^\t\n\r{}=] but "\n" found`) were the worst-rated error
5
+ * in the authoring usability test — newcomers averaged 6.3 attempts-to-valid,
6
+ * largely stuck on data syntax. Unknown messages pass through unchanged.
7
+ */
8
+ export interface HumanizedError {
9
+ message: string;
10
+ suggestion?: string;
11
+ }
12
+ export declare function humanizeParseError(raw: string): HumanizedError;
@@ -0,0 +1,32 @@
1
+ const RULES = [
2
+ {
3
+ // The PEG message contains the literal two-char sequence backslash-t (e.g.
4
+ // `Expected "\t" or [^\t\n\r{}=] ...`); \\t in this literal matches that
5
+ // backslash-t, not an actual tab.
6
+ match: /Expected .*\\t.* but .* found/,
7
+ message: 'A data row must be written as `"Label" = value` (a quoted label, `=`, then the value). Multiple values per row are comma-separated.',
8
+ suggestion: 'Single series: `"Asia" = 59.4`. Multi-series: add `_series = "Gold","Silver"` then `"USA" = 40,44`.',
9
+ },
10
+ {
11
+ match: /Expected whitespace but ":" found/,
12
+ message: 'The chart declaration uses a block, not a colon. Write `chart <type> { … }`.',
13
+ suggestion: 'chart donut {\n data { "A" = 1 }\n}',
14
+ },
15
+ {
16
+ match: /Expected (?:"chart"|end of input)[^"]*but "d" found/,
17
+ message: 'The `data { … }` block must be nested inside the chart block, not at the top level.',
18
+ suggestion: 'chart bar-vertical {\n data { "A" = 1 }\n}',
19
+ },
20
+ {
21
+ match: /Expected "=" .* but ":" found/,
22
+ message: 'Properties use `=`, not `:`. Write `title = "…"`.',
23
+ },
24
+ ];
25
+ export function humanizeParseError(raw) {
26
+ for (const rule of RULES) {
27
+ if (rule.match.test(raw)) {
28
+ return { message: rule.message, suggestion: rule.suggestion };
29
+ }
30
+ }
31
+ return { message: raw };
32
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { humanizeParseError } from './parseErrorHints';
3
+ describe('humanizeParseError', () => {
4
+ // Real lib message (with trailing period): 'Expected "\t" or [^\t\n\r{}=] but "\n" found.'
5
+ it('explains the tab/delimiter error for data rows', () => {
6
+ const h = humanizeParseError('Expected "\\t" or [^\\t\\n\\r{}=] but "\\n" found.');
7
+ expect(h.message).toMatch(/data row/i);
8
+ expect(h.suggestion).toMatch(/_series|comma/i);
9
+ });
10
+ // Real lib message (with trailing period): 'Expected whitespace but ":" found.'
11
+ it('explains a YAML-style chart declaration', () => {
12
+ const h = humanizeParseError('Expected whitespace but ":" found.');
13
+ expect(h.message).toMatch(/chart <type> \{/i);
14
+ });
15
+ // Real lib message: 'Expected "chart" or optional whitespace but "d" found.'
16
+ // (data block written at the top level, not inside a chart block)
17
+ it('explains data at the top level', () => {
18
+ const h = humanizeParseError('Expected "chart" or optional whitespace but "d" found.');
19
+ expect(h.message).toMatch(/inside the chart block/i);
20
+ });
21
+ it('passes unknown messages through unchanged', () => {
22
+ const h = humanizeParseError('something totally different');
23
+ expect(h.message).toBe('something totally different');
24
+ expect(h.suggestion).toBeUndefined();
25
+ });
26
+ });
@@ -0,0 +1,7 @@
1
+ import type { ChartNode } from '@blueprint-chart/lib';
2
+ import type { ValidationIssue } from './validate';
3
+ /** Like ValidationIssue but with a widened string `code` for the new W_* advisory codes. */
4
+ export type WarningIssue = Omit<ValidationIssue, 'code'> & {
5
+ code: string;
6
+ };
7
+ export declare function collectWarnings(ast: ChartNode): WarningIssue[];
@@ -0,0 +1,66 @@
1
+ import { canonicalChartType } from './chartTypes';
2
+ import { lookupCapability, statusOf } from './capabilityMatrix';
3
+ // Chart types that REQUIRE multiple series to be meaningful.
4
+ const MULTI_SERIES_TYPES = new Set([
5
+ 'bar-multi',
6
+ 'bar-grouped',
7
+ 'bar-stacked',
8
+ 'bar-split',
9
+ 'column-stacked',
10
+ 'line-multi',
11
+ 'area-stacked',
12
+ ]);
13
+ function checkKey(type, key, path) {
14
+ const cell = lookupCapability(type, key);
15
+ const status = statusOf(cell);
16
+ if (status === 'inapplicable') {
17
+ return {
18
+ code: 'W_NO_EFFECT',
19
+ path,
20
+ message: `"${key}" has no effect on a ${type} chart.${cell.note ? ' ' + cell.note : ''}`,
21
+ };
22
+ }
23
+ if (status === 'not-implemented') {
24
+ return {
25
+ code: 'W_NOT_IMPLEMENTED',
26
+ path,
27
+ message: `"${key}" is not yet honored by the ${type} renderer.${cell.note ? ' ' + cell.note : ''}`,
28
+ };
29
+ }
30
+ return undefined;
31
+ }
32
+ export function collectWarnings(ast) {
33
+ const issues = [];
34
+ const type = canonicalChartType(ast.chartType) ?? ast.chartType;
35
+ for (const prop of ast.properties ?? []) {
36
+ const issue = checkKey(type, prop.key, `chart.${prop.key}`);
37
+ if (issue) {
38
+ issues.push(issue);
39
+ }
40
+ }
41
+ if ((ast.colorizes ?? []).some(c => c.fromHighlight !== true)) {
42
+ const issue = checkKey(type, 'colorize', 'chart.colorize');
43
+ if (issue) {
44
+ issues.push(issue);
45
+ }
46
+ }
47
+ if ((ast.highlights?.length ?? 0) > 0 || (ast.colorizes ?? []).some(c => c.fromHighlight === true)) {
48
+ const issue = checkKey(type, 'highlight', 'chart.highlight');
49
+ if (issue) {
50
+ issues.push(issue);
51
+ }
52
+ }
53
+ if (MULTI_SERIES_TYPES.has(type)) {
54
+ const entries = ast.data?.entries ?? [];
55
+ const hasSeriesHeader = entries.some(e => e.key === '_series');
56
+ const hasMultiValueRow = entries.some(e => (e.values?.length ?? 0) > 1);
57
+ if (!hasSeriesHeader && !hasMultiValueRow && entries.length > 0) {
58
+ issues.push({
59
+ code: 'W_MULTISERIES_SHAPE',
60
+ path: 'data',
61
+ message: `A ${type} chart needs multiple series, but the data parsed as single-value rows with no \`_series\` header. Add a \`_series = "A","B",…\` row and comma-separated values per row (e.g. \`"USA" = 40,44,42\`).`,
62
+ });
63
+ }
64
+ }
65
+ return issues;
66
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseDsl } from '../parse';
3
+ import { collectWarnings } from './semanticWarnings';
4
+ function warn(src) {
5
+ const r = parseDsl(src);
6
+ if (!r.ok) {
7
+ throw new Error('parse failed: ' + JSON.stringify(r.errors));
8
+ }
9
+ return collectWarnings(r.data.ast);
10
+ }
11
+ describe('collectWarnings', () => {
12
+ it('warns W_NO_EFFECT for sort on donut', () => {
13
+ const w = warn('chart donut {\n sort = descending\n data {\n "A" = 1\n "B" = 2\n }\n}');
14
+ expect(w.some(i => i.code === 'W_NO_EFFECT' && i.path === 'chart.sort')).toBe(true);
15
+ });
16
+ it('does not warn for colorize on donut (now supported since W1c)', () => {
17
+ const w = warn('chart donut {\n data { "A" = 1 }\n colorize "A" { color = "#f00" }\n}');
18
+ expect(w.some(i => i.path.includes('colorize'))).toBe(false);
19
+ });
20
+ it('warns W_MULTISERIES_SHAPE when a multi-series type parsed zero series', () => {
21
+ const w = warn('chart bar-multi {\n data {\n "A" = 1\n "B" = 2\n }\n}');
22
+ expect(w.some(i => i.code === 'W_MULTISERIES_SHAPE')).toBe(true);
23
+ });
24
+ it('is silent for a well-formed multi-series chart', () => {
25
+ const w = warn('chart bar-multi {\n data {\n _series = "X","Y"\n "A" = 1,2\n "B" = 3,4\n }\n}');
26
+ expect(w.filter(i => i.code === 'W_MULTISERIES_SHAPE')).toEqual([]);
27
+ });
28
+ it('is silent for a clean single-series bar chart', () => {
29
+ const w = warn('chart bar-vertical {\n title = "t"\n data { "A" = 1 }\n}');
30
+ expect(w).toEqual([]);
31
+ });
32
+ });
@@ -29,7 +29,26 @@ function distance(a, b) {
29
29
  return dp[m][n];
30
30
  }
31
31
  const DEFAULT_MAX_DISTANCE = 10;
32
+ /**
33
+ * Hand-curated synonyms for property keys authors commonly guess wrong but that
34
+ * are too edit-distant for the Levenshtein scan to catch. `subtitle` is the
35
+ * canonical example: authors reach for it constantly, but the real key is
36
+ * `description` (distance 7), so the distance scan wrongly suggests `title`.
37
+ */
38
+ const SYNONYMS = {
39
+ subtitle: 'description',
40
+ subhead: 'description',
41
+ subheading: 'description',
42
+ author: 'byline',
43
+ credit: 'byline',
44
+ caption: 'note',
45
+ footnote: 'note',
46
+ };
32
47
  export function nearestSuggestion(input, candidates, maxDistance = DEFAULT_MAX_DISTANCE) {
48
+ const synonym = SYNONYMS[input];
49
+ if (synonym && candidates.includes(synonym)) {
50
+ return synonym;
51
+ }
33
52
  let best;
34
53
  for (const cand of candidates) {
35
54
  const d = distance(input, cand);
@@ -17,4 +17,18 @@ describe('nearestSuggestion', () => {
17
17
  it('handles exact match', () => {
18
18
  expect(nearestSuggestion('line', ['line', 'pie'])).toBe('line');
19
19
  });
20
+ it('suggests description for the common subtitle mistake', () => {
21
+ const keys = ['title', 'description', 'byline', 'source', 'sourceUrl', 'note'];
22
+ expect(nearestSuggestion('subtitle', keys)).toBe('description');
23
+ });
24
+ it('still does edit-distance matching for other typos', () => {
25
+ const keys = ['title', 'description', 'colorPalette'];
26
+ expect(nearestSuggestion('titel', keys)).toBe('title');
27
+ });
28
+ it('falls back to edit-distance when the synonym target is absent from candidates', () => {
29
+ const keys = ['title', 'byline', 'note']; // no 'description'
30
+ // subtitle's synonym (description) isn't a candidate, so the scan runs;
31
+ // nearest by distance among these is 'title'
32
+ expect(nearestSuggestion('subtitle', keys)).toBe('title');
33
+ });
20
34
  });
package/dist/parse.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { parse as libParse } from '@blueprint-chart/lib';
2
2
  import { ErrorCode, toolError, toolOk } from './errors';
3
+ import { humanizeParseError } from './dsl/parseErrorHints';
3
4
  export function parseDsl(source) {
4
5
  if (typeof source !== 'string') {
5
6
  return toolError(ErrorCode.E_INPUT, [{ path: 'source', message: 'expected string' }]);
@@ -10,15 +11,22 @@ export function parseDsl(source) {
10
11
  }
11
12
  catch (err) {
12
13
  if (err instanceof Error) {
13
- // lib's parser wraps SyntaxError with " at L:C" suffix in the message
14
14
  const match = err.message.match(/^(.*) at (\d+):(\d+)$/);
15
15
  if (match) {
16
- const [, message, line, column] = match;
17
- return toolError(ErrorCode.E_PARSE, [
18
- { line: Number(line), column: Number(column), message: message.trim() },
19
- ]);
16
+ const [, rawMessage, line, column] = match;
17
+ const h = humanizeParseError(rawMessage.trim());
18
+ return toolError(ErrorCode.E_PARSE, [{
19
+ line: Number(line),
20
+ column: Number(column),
21
+ message: h.message,
22
+ ...(h.suggestion !== undefined && { suggestion: h.suggestion }),
23
+ }]);
20
24
  }
21
- return toolError(ErrorCode.E_PARSE, [{ message: err.message }]);
25
+ const h = humanizeParseError(err.message);
26
+ return toolError(ErrorCode.E_PARSE, [{
27
+ message: h.message,
28
+ ...(h.suggestion !== undefined && { suggestion: h.suggestion }),
29
+ }]);
22
30
  }
23
31
  return toolError(ErrorCode.E_INTERNAL, [{ message: String(err) }]);
24
32
  }
@@ -19,6 +19,14 @@ describe('parseDsl', () => {
19
19
  expect(typeof r.errors[0].message).toBe('string');
20
20
  }
21
21
  });
22
+ it('humanizes the parser message (YAML-colon case flows through parse.ts)', () => {
23
+ const r = parseDsl('chart: donut { data { "A" = 1 } }');
24
+ expect(r.ok).toBe(false);
25
+ if (!r.ok) {
26
+ expect(r.code).toBe('E_PARSE');
27
+ expect(r.errors[0].message).toContain('chart <type> {');
28
+ }
29
+ });
22
30
  it('returns E_INPUT for non-string input', () => {
23
31
  const r = parseDsl(123);
24
32
  expect(r.ok).toBe(false);
package/dist/server.js CHANGED
@@ -12,6 +12,8 @@ import { describeChartType, DescribeChartTypeInputSchema } from './tools/describ
12
12
  import { getExample, GetExampleInputSchema } from './tools/getExample';
13
13
  import { getGrammar, GetGrammarInputSchema } from './tools/getGrammar';
14
14
  import { exportChart, ExportChartInputSchema } from './tools/exportChart';
15
+ import { searchExamples, SearchExamplesInputSchema } from './tools/searchExamples';
16
+ import { listPalettesTool, ListPalettesInputSchema } from './tools/listPalettes';
15
17
  import { listResources, readResource } from './resources/index';
16
18
  import { authorChartPrompt } from './prompts/authorChart';
17
19
  import { zodToJsonSchema } from './lib/zodToJsonSchema';
@@ -68,6 +70,16 @@ export const TOOLS = {
68
70
  inputSchema: GetExampleInputSchema,
69
71
  handler: args => getExample(args),
70
72
  },
73
+ search_examples: {
74
+ description: 'Find canonical .bpc examples by topic keywords and/or chart type. Returns ranked pointers { id, title, description, chartType } — call get_example with an id to fetch the full DSL.',
75
+ inputSchema: SearchExamplesInputSchema,
76
+ handler: args => searchExamples(args),
77
+ },
78
+ list_palettes: {
79
+ description: 'List every named colour palette with its label and hex colours, for use in `colorPalette = "<name>"`.',
80
+ inputSchema: ListPalettesInputSchema,
81
+ handler: () => listPalettesTool(),
82
+ },
71
83
  get_grammar: {
72
84
  description: 'Return the .bpc DSL grammar as markdown. Pass { section: "chart" | "data" | "properties" | "scenes" | "annotations" } for a focused subset, or no args for the full grammar.',
73
85
  inputSchema: GetGrammarInputSchema,
@@ -12,7 +12,7 @@ async function connectInMemory() {
12
12
  return { client, server };
13
13
  }
14
14
  describe('TOOLS registry', () => {
15
- it('contains the nine expected tool names', () => {
15
+ it('contains the eleven expected tool names', () => {
16
16
  expect(Object.keys(TOOLS).sort()).toEqual([
17
17
  'describe_chart_type',
18
18
  'export_chart',
@@ -20,14 +20,16 @@ describe('TOOLS registry', () => {
20
20
  'get_grammar',
21
21
  'inspect_dsl',
22
22
  'list_chart_types',
23
+ 'list_palettes',
23
24
  'recommend_chart_type',
24
25
  'render',
26
+ 'search_examples',
25
27
  'validate_dsl',
26
28
  ]);
27
29
  });
28
30
  });
29
31
  describe('server', () => {
30
- it('lists 9 tools', async () => {
32
+ it('lists 11 tools', async () => {
31
33
  const { client } = await connectInMemory();
32
34
  const r = await client.listTools();
33
35
  const names = r.tools.map(t => t.name).sort();
@@ -38,8 +40,10 @@ describe('server', () => {
38
40
  'get_grammar',
39
41
  'inspect_dsl',
40
42
  'list_chart_types',
43
+ 'list_palettes',
41
44
  'recommend_chart_type',
42
45
  'render',
46
+ 'search_examples',
43
47
  'validate_dsl',
44
48
  ]);
45
49
  });
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { type ToolResult } from '../errors';
3
+ import { type CapabilityStatus } from '../dsl/capabilityMatrix';
3
4
  export declare const DescribeChartTypeInputSchema: z.ZodObject<{
4
5
  chartType: z.ZodString;
5
6
  }, "strict", z.ZodTypeAny, {
@@ -15,6 +16,12 @@ export interface ChartTypeProperty {
15
16
  choices?: string[];
16
17
  default?: unknown;
17
18
  }
19
+ export interface ChartTypeDirective {
20
+ name: string;
21
+ status: CapabilityStatus;
22
+ description: string;
23
+ note?: string;
24
+ }
18
25
  export interface ChartTypeDataShape {
19
26
  kind: 'single-series' | 'multi-series' | 'unknown';
20
27
  example: string;
@@ -26,6 +33,7 @@ export interface DescribeChartTypeOutput {
26
33
  whenToUse: string[];
27
34
  whenNotToUse: string[];
28
35
  properties: ChartTypeProperty[];
36
+ directives: ChartTypeDirective[];
29
37
  dataShape: ChartTypeDataShape;
30
38
  exampleSlug?: string;
31
39
  docsUrl?: string;
@@ -6,6 +6,7 @@ import { nearestSuggestion } from '../dsl/suggest';
6
6
  import { UNIVERSAL_PROPERTIES, UNIVERSAL_PROPERTY_META } from '../dsl/universalProperties';
7
7
  import { ErrorCode, toolError, toolOk } from '../errors';
8
8
  import { publicDocUrl } from '../resources/docsReader';
9
+ import { lookupCapability, statusOf } from '../dsl/capabilityMatrix';
9
10
  export const DescribeChartTypeInputSchema = z.object({
10
11
  chartType: z.string(),
11
12
  }).strict();
@@ -81,6 +82,27 @@ function inferDataShape(name, example) {
81
82
  }
82
83
  return { kind: 'unknown', example: 'data {\n "Label" = 1.0\n}' };
83
84
  }
85
+ const DIRECTIVE_DOCS = [
86
+ { name: 'highlight', description: 'Emphasise one category/series, e.g. `highlight "China"`.' },
87
+ { name: 'colorize', description: 'Override colour for a category/series, e.g. `colorize "China" { color = "#f00" }`.' },
88
+ { name: 'annotation', description: 'Attach a callout to a data point, e.g. `annotation "2009" { text = "…" }`.' },
89
+ { name: 'transform', description: 'Reshape data, e.g. `transform sort { column = "value" direction = descending }`.' },
90
+ { name: 'scene', description: 'Add a narrative step that overrides data/properties, e.g. `scene "Step 2" { … }`.' },
91
+ ];
92
+ function buildDirectives(canonical) {
93
+ return DIRECTIVE_DOCS.map((d) => {
94
+ const cell = lookupCapability(canonical, d.name);
95
+ const directive = {
96
+ name: d.name,
97
+ status: statusOf(cell),
98
+ description: d.description,
99
+ };
100
+ if (cell.note) {
101
+ directive.note = cell.note;
102
+ }
103
+ return directive;
104
+ });
105
+ }
84
106
  export function describeChartType(input) {
85
107
  const parsed = DescribeChartTypeInputSchema.safeParse(input);
86
108
  if (!parsed.success) {
@@ -99,6 +121,7 @@ export function describeChartType(input) {
99
121
  }
100
122
  const doc = extractDocSections(canonical);
101
123
  const properties = buildProperties(canonical);
124
+ const directives = buildDirectives(canonical);
102
125
  const sample = samples.find(s => s.chartType === canonical);
103
126
  const exampleText = doc.example || sample?.dsl || '';
104
127
  const output = {
@@ -108,6 +131,7 @@ export function describeChartType(input) {
108
131
  whenToUse: doc.whenToUse,
109
132
  whenNotToUse: doc.whenNotToUse,
110
133
  properties,
134
+ directives,
111
135
  dataShape: inferDataShape(canonical, exampleText),
112
136
  exampleSlug: sample?.id,
113
137
  };
@@ -56,3 +56,23 @@ describe('describe_chart_type', () => {
56
56
  }
57
57
  });
58
58
  });
59
+ describe('describe_chart_type directives', () => {
60
+ it('lists highlight/colorize/annotation directives with status', () => {
61
+ const r = describeChartType({ chartType: 'bar-vertical' });
62
+ expect(r.ok).toBe(true);
63
+ if (r.ok) {
64
+ const names = r.data.directives.map(d => d.name);
65
+ expect(names).toEqual(expect.arrayContaining(['highlight', 'colorize', 'annotation']));
66
+ const highlight = r.data.directives.find(d => d.name === 'highlight');
67
+ expect(highlight?.status).toBe('supported');
68
+ }
69
+ });
70
+ it('marks colorize supported on donut (W1c shipped per-slice colorize)', () => {
71
+ const r = describeChartType({ chartType: 'donut' });
72
+ expect(r.ok).toBe(true);
73
+ if (r.ok) {
74
+ const colorize = r.data.directives.find(d => d.name === 'colorize');
75
+ expect(colorize?.status).toBe('supported');
76
+ }
77
+ });
78
+ });
@@ -4,9 +4,9 @@ declare const SectionSchema: z.ZodDefault<z.ZodEnum<["all", "chart", "properties
4
4
  export declare const GetGrammarInputSchema: z.ZodObject<{
5
5
  section: z.ZodOptional<z.ZodDefault<z.ZodEnum<["all", "chart", "properties", "scenes", "annotations"]>>>;
6
6
  }, "strict", z.ZodTypeAny, {
7
- section?: "chart" | "all" | "properties" | "scenes" | "annotations" | undefined;
7
+ section?: "chart" | "properties" | "all" | "scenes" | "annotations" | undefined;
8
8
  }, {
9
- section?: "chart" | "all" | "properties" | "scenes" | "annotations" | undefined;
9
+ section?: "chart" | "properties" | "all" | "scenes" | "annotations" | undefined;
10
10
  }>;
11
11
  export type GetGrammarInput = z.infer<typeof GetGrammarInputSchema>;
12
12
  export interface GetGrammarOutput {
@@ -6,7 +6,7 @@ export const GetGrammarInputSchema = z.object({
6
6
  section: SectionSchema.optional(),
7
7
  }).strict();
8
8
  const SECTION_TO_SLUG = {
9
- chart: 'index',
9
+ chart: 'properties', // reference/dsl has no standalone chart-block doc; properties.md covers the block + properties
10
10
  properties: 'properties',
11
11
  scenes: 'scenes-and-transforms',
12
12
  annotations: 'annotations',
@@ -22,3 +22,14 @@ describe('get_grammar', () => {
22
22
  expect(r.ok).toBe(false);
23
23
  });
24
24
  });
25
+ describe('get_grammar section resolution', () => {
26
+ it('returns non-empty text for every enum section', () => {
27
+ for (const section of ['all', 'chart', 'properties', 'scenes', 'annotations']) {
28
+ const r = getGrammar({ section });
29
+ expect(r.ok, `section ${section} should resolve`).toBe(true);
30
+ if (r.ok) {
31
+ expect(r.data.text.length).toBeGreaterThan(0);
32
+ }
33
+ }
34
+ });
35
+ });
@@ -20,9 +20,10 @@ function summarizeScenes(ast) {
20
20
  function summarizeData(ast) {
21
21
  const entries = ast.data?.entries ?? [];
22
22
  const seriesEntry = entries.find(e => e.key === '_series');
23
- const seriesNames = seriesEntry
24
- ? String(seriesEntry.value).split(',').map(s => s.trim().replace(/^"|"$/g, ''))
23
+ const seriesValues = seriesEntry
24
+ ? (seriesEntry.values ?? [seriesEntry.value])
25
25
  : [];
26
+ const seriesNames = seriesValues.map(v => String(v).trim().replace(/^"|"$/g, ''));
26
27
  const rowEntries = entries.filter(looksLikeQuotedLabel);
27
28
  return {
28
29
  rowCount: rowEntries.length,
@@ -33,13 +34,21 @@ function summarizeData(ast) {
33
34
  };
34
35
  }
35
36
  function countHighlights(ast) {
36
- // Grammar: `highlight "X" { … }` parses as ColorizeNode with fromHighlight:true,
37
- // `highlight "X"` (no braces) parses as HighlightNode. Count both.
38
- const fromHighlightColorizes = (ast.colorizes ?? []).filter((c) => c.fromHighlight === true).length;
39
- return (ast.highlights?.length ?? 0) + fromHighlightColorizes;
37
+ // `highlight "X" { … }` parses as ColorizeNode with fromHighlight:true,
38
+ // `highlight "X"` (no braces) parses as HighlightNode. Count both, at the
39
+ // chart level AND inside every scene (scenes carry their own highlights).
40
+ // unknown[]: only .length is read, so element type is irrelevant here
41
+ const countIn = (node) => {
42
+ const fromHighlightColorizes = (node.colorizes ?? []).filter(c => c.fromHighlight === true).length;
43
+ return (node.highlights?.length ?? 0) + fromHighlightColorizes;
44
+ };
45
+ const sceneTotal = (ast.scenes ?? []).reduce((sum, s) => sum + countIn(s), 0);
46
+ return countIn(ast) + sceneTotal;
40
47
  }
41
48
  function countNonHighlightColorizes(ast) {
42
- return (ast.colorizes ?? []).filter((c) => c.fromHighlight !== true).length;
49
+ const countIn = (cs) => (cs ?? []).filter(c => c.fromHighlight !== true).length;
50
+ const sceneTotal = (ast.scenes ?? []).reduce((sum, s) => sum + countIn(s.colorizes), 0);
51
+ return countIn(ast.colorizes) + sceneTotal;
43
52
  }
44
53
  export function inspectDsl(input) {
45
54
  const parsed = parseDsl(input.source);
@@ -49,3 +49,30 @@ describe('inspect_dsl', () => {
49
49
  expect(r.ok).toBe(false);
50
50
  });
51
51
  });
52
+ describe('inspect_dsl fixes', () => {
53
+ it('lists ALL series names from the _series header', () => {
54
+ const src = 'chart bar-multi {\n data {\n _series = "Hardware","Software","Services"\n "Q1" = 1,2,3\n }\n}';
55
+ const r = inspectDsl({ source: src });
56
+ expect(r.ok).toBe(true);
57
+ if (r.ok) {
58
+ expect(r.data.data.seriesNames).toEqual(['Hardware', 'Software', 'Services']);
59
+ expect(r.data.data.multiSeries).toBe(true);
60
+ }
61
+ });
62
+ it('reports hasHighlights:true when a highlight lives inside a scene', () => {
63
+ const src = 'chart area-stacked {\n data {\n _series = "A","B"\n "2000" = 1,2\n }\n scene "S1" {\n highlight "A"\n }\n}';
64
+ const r = inspectDsl({ source: src });
65
+ expect(r.ok).toBe(true);
66
+ if (r.ok) {
67
+ expect(r.data.hasHighlights).toBe(true);
68
+ }
69
+ });
70
+ it('reports hasColorizes:true when a colorize lives inside a scene', () => {
71
+ const src = 'chart area-stacked {\n data {\n _series = "A","B"\n "2000" = 1,2\n }\n scene "S1" {\n colorize "A" { color = "#f00" }\n }\n}';
72
+ const r = inspectDsl({ source: src });
73
+ expect(r.ok).toBe(true);
74
+ if (r.ok) {
75
+ expect(r.data.hasColorizes).toBe(true);
76
+ }
77
+ });
78
+ });
@@ -0,0 +1,13 @@
1
+ import { z } from 'zod';
2
+ import { type ToolResult } from '../errors';
3
+ export declare const ListPalettesInputSchema: z.ZodObject<{}, "strict", z.ZodTypeAny, {}, {}>;
4
+ export type ListPalettesInput = z.infer<typeof ListPalettesInputSchema>;
5
+ export interface PaletteSummary {
6
+ name: string;
7
+ label: string;
8
+ colors: string[];
9
+ }
10
+ export interface ListPalettesOutput {
11
+ palettes: PaletteSummary[];
12
+ }
13
+ export declare function listPalettesTool(): ToolResult<ListPalettesOutput>;
@@ -0,0 +1,12 @@
1
+ import { z } from 'zod';
2
+ import { listPalettes } from '@blueprint-chart/lib';
3
+ import { toolOk } from '../errors';
4
+ export const ListPalettesInputSchema = z.object({}).strict();
5
+ export function listPalettesTool() {
6
+ const palettes = listPalettes().map(p => ({
7
+ name: p.name,
8
+ label: p.label,
9
+ colors: [...p.colors],
10
+ }));
11
+ return toolOk({ palettes });
12
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { listPalettesTool } from './listPalettes';
3
+ describe('list_palettes', () => {
4
+ it('returns named palettes with their colours', () => {
5
+ const r = listPalettesTool();
6
+ expect(r.ok).toBe(true);
7
+ if (r.ok) {
8
+ expect(r.data.palettes.length).toBeGreaterThan(0);
9
+ const first = r.data.palettes[0];
10
+ expect(first).toHaveProperty('name');
11
+ expect(Array.isArray(first.colors)).toBe(true);
12
+ expect(first.colors.length).toBeGreaterThan(0);
13
+ }
14
+ });
15
+ });
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { recommendCharts } from '@blueprint-chart/lib';
3
+ import { applyGoalReranking } from '../dsl/goalRanking';
3
4
  import { ErrorCode, toolError, toolOk } from '../errors';
4
5
  const ColumnTypeSchema = z.enum(['string', 'number', 'date']);
5
6
  export const RecommendInputSchema = z.object({
@@ -12,6 +13,7 @@ export function recommendChartType(input) {
12
13
  if (!parsed.success) {
13
14
  return toolError(ErrorCode.E_INPUT, parsed.error.issues.map(i => ({ path: i.path.join('.'), message: i.message })));
14
15
  }
15
- const recommendations = recommendCharts(parsed.data.columnTypes, parsed.data.rowCount);
16
+ const base = recommendCharts(parsed.data.columnTypes, parsed.data.rowCount);
17
+ const recommendations = applyGoalReranking(base, parsed.data.goal, parsed.data.rowCount);
16
18
  return toolOk({ recommendations });
17
19
  }
@@ -30,4 +30,44 @@ describe('recommend_chart_type', () => {
30
30
  expect(r.code).toBe('E_INPUT');
31
31
  }
32
32
  });
33
+ it('reorders toward part-to-whole when a goal says "share of total"', () => {
34
+ const r = recommendChartType({ columnTypes: ['string', 'number'], rowCount: 5, goal: 'each region as a share of the total' });
35
+ expect(r.ok).toBe(true);
36
+ if (r.ok) {
37
+ expect(['pie', 'donut', 'bar-stacked', 'column-stacked']).toContain(r.data.recommendations[0]?.chartType);
38
+ }
39
+ });
40
+ it('keeps lib-best bar-multi ahead of a narrative-boosted line-multi', () => {
41
+ const r = recommendChartType({
42
+ columnTypes: ['string', 'number', 'number', 'number'],
43
+ rowCount: 6,
44
+ goal: 'software overtakes hardware as the top revenue driver',
45
+ });
46
+ expect(r.ok).toBe(true);
47
+ if (r.ok) {
48
+ const types = r.data.recommendations.map(x => x.chartType);
49
+ const barMulti = types.indexOf('bar-multi');
50
+ const lineMulti = types.indexOf('line-multi');
51
+ expect(barMulti).toBeGreaterThanOrEqual(0);
52
+ expect(lineMulti).toBeGreaterThanOrEqual(0);
53
+ expect(barMulti).toBeLessThan(lineMulti);
54
+ }
55
+ });
56
+ it('threads rowCount so the pie tiebreak fires for a small-N part-to-whole goal', () => {
57
+ const r = recommendChartType({
58
+ columnTypes: ['string', 'number'],
59
+ rowCount: 5,
60
+ goal: 'Asia is nearly 60% of the world population; share of total across five regions',
61
+ });
62
+ expect(r.ok).toBe(true);
63
+ if (r.ok) {
64
+ const types = r.data.recommendations.map(x => x.chartType);
65
+ const pieIdx = types.indexOf('pie');
66
+ const donutIdx = types.indexOf('donut');
67
+ // both should be present and boosted to the front; pie before donut at N=5
68
+ expect(pieIdx).toBeGreaterThanOrEqual(0);
69
+ expect(donutIdx).toBeGreaterThanOrEqual(0);
70
+ expect(pieIdx).toBeLessThan(donutIdx);
71
+ }
72
+ });
33
73
  });
@@ -0,0 +1,28 @@
1
+ import { z } from 'zod';
2
+ import { type ToolResult } from '../errors';
3
+ export declare const SearchExamplesInputSchema: z.ZodObject<{
4
+ query: z.ZodOptional<z.ZodString>;
5
+ chartType: z.ZodOptional<z.ZodString>;
6
+ limit: z.ZodOptional<z.ZodNumber>;
7
+ }, "strict", z.ZodTypeAny, {
8
+ chartType?: string | undefined;
9
+ query?: string | undefined;
10
+ limit?: number | undefined;
11
+ }, {
12
+ chartType?: string | undefined;
13
+ query?: string | undefined;
14
+ limit?: number | undefined;
15
+ }>;
16
+ export type SearchExamplesInput = z.infer<typeof SearchExamplesInputSchema>;
17
+ export interface SearchExampleHit {
18
+ id: string;
19
+ title: string;
20
+ description: string;
21
+ chartType: string;
22
+ /** Raw count of query terms matched in title+description; used only for ordering. */
23
+ score: number;
24
+ }
25
+ export interface SearchExamplesOutput {
26
+ results: SearchExampleHit[];
27
+ }
28
+ export declare function searchExamples(input: unknown): ToolResult<SearchExamplesOutput>;
@@ -0,0 +1,54 @@
1
+ import { z } from 'zod';
2
+ import { samples } from '@blueprint-chart/lib';
3
+ import { canonicalChartType } from '../dsl/chartTypes';
4
+ import { ErrorCode, toolError, toolOk } from '../errors';
5
+ export const SearchExamplesInputSchema = z.object({
6
+ query: z.string().optional(),
7
+ chartType: z.string().optional(),
8
+ limit: z.number().int().positive().max(20).optional(),
9
+ }).strict();
10
+ function scoreSample(s, terms) {
11
+ if (terms.length === 0) {
12
+ return 1;
13
+ }
14
+ const hay = `${s.title} ${s.description}`.toLowerCase();
15
+ let score = 0;
16
+ for (const t of terms) {
17
+ if (hay.includes(t)) {
18
+ score += 1;
19
+ }
20
+ }
21
+ return score;
22
+ }
23
+ export function searchExamples(input) {
24
+ const parsed = SearchExamplesInputSchema.safeParse(input);
25
+ if (!parsed.success) {
26
+ return toolError(ErrorCode.E_INPUT, parsed.error.issues.map(i => ({ path: i.path.join('.'), message: i.message })));
27
+ }
28
+ const { query, chartType, limit } = parsed.data;
29
+ if ((!query || query.trim() === '') && !chartType) {
30
+ return toolError(ErrorCode.E_INPUT, [{
31
+ path: 'query',
32
+ message: 'Provide a `query` (topic keywords) and/or a `chartType` to search examples.',
33
+ }]);
34
+ }
35
+ let canonical;
36
+ if (chartType) {
37
+ canonical = canonicalChartType(chartType);
38
+ if (!canonical) {
39
+ return toolError(ErrorCode.E_INPUT, [{
40
+ code: 'E_UNKNOWN_CHART_TYPE',
41
+ path: 'chartType',
42
+ message: `Unknown chart type "${chartType}".`,
43
+ }]);
44
+ }
45
+ }
46
+ const terms = (query ?? '').toLowerCase().split(/\s+/).filter(Boolean);
47
+ const results = samples
48
+ .filter(s => (canonical ? s.chartType === canonical : true))
49
+ .map(s => ({ id: s.id, title: s.title, description: s.description, chartType: s.chartType, score: scoreSample(s, terms) }))
50
+ .filter(h => h.score > 0)
51
+ .sort((a, b) => b.score - a.score || a.id.localeCompare(b.id))
52
+ .slice(0, limit ?? 10);
53
+ return toolOk({ results });
54
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { searchExamples } from './searchExamples';
3
+ describe('search_examples', () => {
4
+ it('finds samples by topic keyword in title/description', () => {
5
+ const r = searchExamples({ query: 'population' });
6
+ expect(r.ok).toBe(true);
7
+ if (r.ok) {
8
+ expect(r.data.results.length).toBeGreaterThan(0);
9
+ expect(r.data.results[0]).toHaveProperty('id');
10
+ expect(r.data.results[0]).toHaveProperty('chartType');
11
+ expect(r.data.results[0]).not.toHaveProperty('dsl');
12
+ }
13
+ });
14
+ it('filters by chartType', () => {
15
+ const r = searchExamples({ chartType: 'pie' });
16
+ expect(r.ok).toBe(true);
17
+ if (r.ok) {
18
+ expect(r.data.results.every(x => x.chartType === 'pie')).toBe(true);
19
+ }
20
+ });
21
+ it('rejects when neither query nor chartType is given', () => {
22
+ const r = searchExamples({});
23
+ expect(r.ok).toBe(false);
24
+ });
25
+ it('rejects an unknown chartType', () => {
26
+ const r = searchExamples({ chartType: 'notachart' });
27
+ expect(r.ok).toBe(false);
28
+ if (!r.ok) {
29
+ expect(r.code).toBe('E_INPUT');
30
+ }
31
+ });
32
+ });
@@ -1,5 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { type ValidationIssue } from '../dsl/validate';
3
+ import { type WarningIssue } from '../dsl/semanticWarnings';
3
4
  import { type ToolResult } from '../errors';
4
5
  export declare const ValidateInputSchema: z.ZodObject<{
5
6
  source: z.ZodString;
@@ -12,10 +13,7 @@ export type ValidateInput = z.infer<typeof ValidateInputSchema>;
12
13
  export interface ValidateOutput {
13
14
  valid: boolean;
14
15
  errors: ValidationIssue[];
15
- /**
16
- * Reserved for non-fatal advisories. Currently always empty; future versions
17
- * may populate this without breaking existing clients.
18
- */
19
- warnings: ValidationIssue[];
16
+ /** Non-fatal advisories (W_NO_EFFECT / W_NOT_IMPLEMENTED / W_MULTISERIES_SHAPE). `code` is a plain string until the warning catalogue stabilises; errors keep the strict ValidationCode union. */
17
+ warnings: WarningIssue[];
20
18
  }
21
19
  export declare function validateDsl(input: ValidateInput): ToolResult<ValidateOutput>;
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { parseDsl } from '../parse';
3
3
  import { validateAst } from '../dsl/validate';
4
+ import { collectWarnings } from '../dsl/semanticWarnings';
4
5
  import { toolOk } from '../errors';
5
6
  export const ValidateInputSchema = z.object({
6
7
  source: z.string(),
@@ -10,12 +11,13 @@ export function validateDsl(input) {
10
11
  if (!parsed.ok) {
11
12
  return parsed;
12
13
  }
13
- const issues = validateAst(parsed.data.ast);
14
+ const errors = validateAst(parsed.data.ast);
15
+ // Warnings are non-fatal: they never change `valid`. Only emit them when the
16
+ // structure is sound, so authors fix hard errors before chasing advisories.
17
+ const warnings = errors.length === 0 ? collectWarnings(parsed.data.ast) : [];
14
18
  return toolOk({
15
- valid: issues.length === 0,
16
- errors: issues,
17
- // `warnings` is reserved for non-fatal advisories. Currently always empty;
18
- // future versions may populate this without breaking existing clients.
19
- warnings: [],
19
+ valid: errors.length === 0,
20
+ errors,
21
+ warnings,
20
22
  });
21
23
  }
@@ -30,3 +30,20 @@ describe('validate_dsl', () => {
30
30
  }
31
31
  });
32
32
  });
33
+ describe('validate_dsl warnings', () => {
34
+ it('stays valid but surfaces a no-op warning for sort on donut', () => {
35
+ const r = validateDsl({ source: 'chart donut {\n sort = descending\n data { "A" = 1 }\n}' });
36
+ expect(r.ok).toBe(true);
37
+ if (r.ok) {
38
+ expect(r.data.valid).toBe(true);
39
+ expect(r.data.warnings.some(w => w.code === 'W_NO_EFFECT')).toBe(true);
40
+ }
41
+ });
42
+ it('emits no warnings for a clean chart', () => {
43
+ const r = validateDsl({ source: 'chart bar-vertical {\n data { "A" = 1 }\n}' });
44
+ expect(r.ok).toBe(true);
45
+ if (r.ok) {
46
+ expect(r.data.warnings).toEqual([]);
47
+ }
48
+ });
49
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blueprint-chart/mcp",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Model Context Protocol server for authoring Blueprint Chart .bpc files with LLMs.",
5
5
  "license": "MIT",
6
6
  "author": "pirhoo",
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@blueprint-chart/docs": "0.1.20",
40
- "@blueprint-chart/lib": "0.1.19",
40
+ "@blueprint-chart/lib": "0.1.25",
41
41
  "@modelcontextprotocol/sdk": "^1.0.0",
42
42
  "@napi-rs/canvas": "^0.1.71",
43
43
  "@resvg/resvg-js": "^2.6.2",