@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.
- package/README.md +31 -15
- package/dist/cli.js +15 -2
- package/dist/dsl/capabilityMatrix.d.ts +22 -0
- package/dist/dsl/capabilityMatrix.js +37 -0
- package/dist/dsl/capabilityMatrix.test.d.ts +1 -0
- package/dist/dsl/capabilityMatrix.test.js +49 -0
- package/dist/dsl/chartTypes.d.ts +16 -0
- package/dist/dsl/chartTypes.js +37 -0
- package/dist/dsl/chartTypes.test.d.ts +1 -0
- package/dist/dsl/chartTypes.test.js +32 -0
- package/dist/dsl/dataKey.d.ts +25 -0
- package/dist/dsl/dataKey.js +42 -0
- package/dist/dsl/dataKey.test.d.ts +1 -0
- package/dist/dsl/dataKey.test.js +35 -0
- package/dist/dsl/goalRanking.d.ts +7 -0
- package/dist/dsl/goalRanking.js +76 -0
- package/dist/dsl/goalRanking.test.d.ts +1 -0
- package/dist/dsl/goalRanking.test.js +83 -0
- package/dist/dsl/parseErrorHints.d.ts +12 -0
- package/dist/dsl/parseErrorHints.js +32 -0
- package/dist/dsl/parseErrorHints.test.d.ts +1 -0
- package/dist/dsl/parseErrorHints.test.js +26 -0
- package/dist/dsl/semanticWarnings.d.ts +7 -0
- package/dist/dsl/semanticWarnings.js +66 -0
- package/dist/dsl/semanticWarnings.test.d.ts +1 -0
- package/dist/dsl/semanticWarnings.test.js +32 -0
- package/dist/dsl/suggest.d.ts +1 -0
- package/dist/dsl/suggest.js +66 -0
- package/dist/dsl/suggest.test.d.ts +1 -0
- package/dist/dsl/suggest.test.js +34 -0
- package/dist/dsl/universalProperties.d.ts +30 -0
- package/dist/dsl/universalProperties.js +52 -0
- package/dist/dsl/universalProperties.test.d.ts +1 -0
- package/dist/dsl/universalProperties.test.js +26 -0
- package/dist/dsl/validate.d.ts +10 -0
- package/dist/dsl/validate.js +68 -0
- package/dist/dsl/validate.test.d.ts +1 -0
- package/dist/dsl/validate.test.js +73 -0
- package/dist/errors.d.ts +20 -1
- package/dist/errors.js +1 -0
- package/dist/errors.test.js +21 -0
- package/dist/lib/zodToJsonSchema.d.ts +10 -5
- package/dist/lib/zodToJsonSchema.js +14 -6
- package/dist/links/buildUrls.d.ts +14 -0
- package/dist/links/buildUrls.js +20 -0
- package/dist/links/buildUrls.test.d.ts +1 -0
- package/dist/links/buildUrls.test.js +28 -0
- package/dist/links/editorConfig.d.ts +4 -0
- package/dist/links/editorConfig.js +15 -0
- package/dist/links/editorConfig.test.d.ts +1 -0
- package/dist/links/editorConfig.test.js +28 -0
- package/dist/links/encode.d.ts +11 -0
- package/dist/links/encode.js +19 -0
- package/dist/links/encode.test.d.ts +1 -0
- package/dist/links/encode.test.js +37 -0
- package/dist/parse.js +14 -6
- package/dist/parse.test.js +8 -0
- package/dist/prompts/authorChart.js +23 -18
- package/dist/prompts/authorChart.test.js +6 -0
- package/dist/render/diagnose.d.ts +19 -0
- package/dist/render/diagnose.js +100 -0
- package/dist/render/diagnose.test.d.ts +1 -0
- package/dist/render/diagnose.test.js +53 -0
- package/dist/render/frame.d.ts +10 -0
- package/dist/render/frame.js +10 -0
- package/dist/render/frame.test.d.ts +1 -0
- package/dist/render/frame.test.js +12 -0
- package/dist/render/jsdomEnv.d.ts +2 -1
- package/dist/render/jsdomEnv.js +14 -1
- package/dist/render/jsdomEnv.test.js +36 -2
- package/dist/render/renderSceneState.d.ts +5 -1
- package/dist/render/renderSceneState.js +4 -3
- package/dist/render/renderSceneState.test.js +13 -7
- package/dist/render/validatePipeline.d.ts +23 -0
- package/dist/render/validatePipeline.js +41 -0
- package/dist/render/validatePipeline.test.d.ts +1 -0
- package/dist/render/validatePipeline.test.js +34 -0
- package/dist/resources/docsReader.d.ts +4 -1
- package/dist/resources/docsReader.js +23 -6
- package/dist/resources/docsReader.test.js +27 -2
- package/dist/resources/index.d.ts +1 -1
- package/dist/resources/samples.d.ts +1 -2
- package/dist/server.d.ts +9 -0
- package/dist/server.js +75 -5
- package/dist/server.test.js +105 -4
- package/dist/tools/describeChartType.d.ts +41 -0
- package/dist/tools/describeChartType.js +143 -0
- package/dist/tools/describeChartType.test.d.ts +1 -0
- package/dist/tools/describeChartType.test.js +78 -0
- package/dist/tools/exportChart.d.ts +17 -0
- package/dist/tools/exportChart.js +31 -0
- package/dist/tools/exportChart.test.d.ts +1 -0
- package/dist/tools/exportChart.test.js +43 -0
- package/dist/tools/getExample.d.ts +20 -0
- package/dist/tools/getExample.js +55 -0
- package/dist/tools/getExample.test.d.ts +1 -0
- package/dist/tools/getExample.test.js +40 -0
- package/dist/tools/getGrammar.d.ts +17 -0
- package/dist/tools/getGrammar.js +38 -0
- package/dist/tools/getGrammar.test.d.ts +1 -0
- package/dist/tools/getGrammar.test.js +35 -0
- package/dist/tools/inspect.d.ts +8 -1
- package/dist/tools/inspect.js +40 -7
- package/dist/tools/inspect.test.js +62 -13
- package/dist/tools/listChartTypes.d.ts +14 -0
- package/dist/tools/listChartTypes.js +42 -0
- package/dist/tools/listChartTypes.test.d.ts +1 -0
- package/dist/tools/listChartTypes.test.js +42 -0
- package/dist/tools/listPalettes.d.ts +13 -0
- package/dist/tools/listPalettes.js +12 -0
- package/dist/tools/listPalettes.test.d.ts +1 -0
- package/dist/tools/listPalettes.test.js +15 -0
- package/dist/tools/recommend.js +3 -1
- package/dist/tools/recommend.test.js +40 -0
- package/dist/tools/render.d.ts +14 -12
- package/dist/tools/render.js +96 -28
- package/dist/tools/render.test.js +137 -1
- package/dist/tools/searchExamples.d.ts +28 -0
- package/dist/tools/searchExamples.js +54 -0
- package/dist/tools/searchExamples.test.d.ts +1 -0
- package/dist/tools/searchExamples.test.js +32 -0
- package/dist/tools/validate.d.ts +9 -3
- package/dist/tools/validate.js +11 -1
- package/dist/tools/validate.test.js +33 -11
- package/dist/transports/http.d.ts +4 -2
- package/dist/transports/http.js +232 -23
- package/dist/transports/http.test.js +158 -22
- package/package.json +5 -3
- package/public/apple-touch-icon.png +0 -0
- package/public/favicon.png +0 -0
- package/public/favicon.svg +9 -0
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import type { ZodTypeAny } from 'zod';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* Convert a Zod schema to a JSON Schema object suitable for MCP `tools/list`.
|
|
4
|
+
*
|
|
5
|
+
* MCP clients (notably claude.ai web) introspect this schema to know what
|
|
6
|
+
* parameters a tool accepts. A permissive stub left tools effectively
|
|
7
|
+
* un-invokable because the param shapes weren't discoverable.
|
|
8
|
+
*
|
|
9
|
+
* `zod-to-json-schema` produces a `$schema`-prefixed draft-07 document; we
|
|
10
|
+
* unwrap that to just the inline schema body so it slots cleanly into the
|
|
11
|
+
* MCP tool descriptor.
|
|
7
12
|
*/
|
|
8
|
-
export declare function zodToJsonSchema(
|
|
13
|
+
export declare function zodToJsonSchema(schema: ZodTypeAny): Record<string, unknown>;
|
|
@@ -1,9 +1,17 @@
|
|
|
1
|
+
import { zodToJsonSchema as toJsonSchema } from 'zod-to-json-schema';
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Convert a Zod schema to a JSON Schema object suitable for MCP `tools/list`.
|
|
4
|
+
*
|
|
5
|
+
* MCP clients (notably claude.ai web) introspect this schema to know what
|
|
6
|
+
* parameters a tool accepts. A permissive stub left tools effectively
|
|
7
|
+
* un-invokable because the param shapes weren't discoverable.
|
|
8
|
+
*
|
|
9
|
+
* `zod-to-json-schema` produces a `$schema`-prefixed draft-07 document; we
|
|
10
|
+
* unwrap that to just the inline schema body so it slots cleanly into the
|
|
11
|
+
* MCP tool descriptor.
|
|
6
12
|
*/
|
|
7
|
-
export function zodToJsonSchema(
|
|
8
|
-
|
|
13
|
+
export function zodToJsonSchema(schema) {
|
|
14
|
+
const full = toJsonSchema(schema, { target: 'jsonSchema7', $refStrategy: 'none' });
|
|
15
|
+
const { $schema: _$schema, ...rest } = full;
|
|
16
|
+
return rest;
|
|
9
17
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type DocUrlGroup = 'handbook' | 'guide' | 'charts' | 'reference/dsl' | 'reference/api';
|
|
2
|
+
/**
|
|
3
|
+
* Editable "open & copy" deep-link: hydrates a fresh editor session.
|
|
4
|
+
* The `/copy` route reads `bpc64` from the query string and decodes it as
|
|
5
|
+
* URL-SAFE base64 (`decodeUrlSafeBase64`). `encodeURIComponent` mirrors the
|
|
6
|
+
* editor/docs code; it is a no-op on url-safe base64 (alphabet is `[A-Za-z0-9-_]`)
|
|
7
|
+
* but kept for fidelity. NOTE: distinct from `/render?bpc64=` below, which
|
|
8
|
+
* decodes STANDARD base64 via `atob` despite the identical param name.
|
|
9
|
+
*/
|
|
10
|
+
export declare function buildCopyUrl(source: string, editorBase: string): string;
|
|
11
|
+
/** Read-only render deep-link, suitable as an iframe `src` (standard base64). */
|
|
12
|
+
export declare function buildEmbedUrl(source: string, editorBase: string): string;
|
|
13
|
+
/** Public docs page URL for a docs group + slug. */
|
|
14
|
+
export declare function buildDocUrl(group: DocUrlGroup, slug: string, docsBase: string): string;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { toUrlSafeB64, toStandardB64 } from './encode';
|
|
2
|
+
/**
|
|
3
|
+
* Editable "open & copy" deep-link: hydrates a fresh editor session.
|
|
4
|
+
* The `/copy` route reads `bpc64` from the query string and decodes it as
|
|
5
|
+
* URL-SAFE base64 (`decodeUrlSafeBase64`). `encodeURIComponent` mirrors the
|
|
6
|
+
* editor/docs code; it is a no-op on url-safe base64 (alphabet is `[A-Za-z0-9-_]`)
|
|
7
|
+
* but kept for fidelity. NOTE: distinct from `/render?bpc64=` below, which
|
|
8
|
+
* decodes STANDARD base64 via `atob` despite the identical param name.
|
|
9
|
+
*/
|
|
10
|
+
export function buildCopyUrl(source, editorBase) {
|
|
11
|
+
return `${editorBase}/#/copy?bpc64=${encodeURIComponent(toUrlSafeB64(source))}`;
|
|
12
|
+
}
|
|
13
|
+
/** Read-only render deep-link, suitable as an iframe `src` (standard base64). */
|
|
14
|
+
export function buildEmbedUrl(source, editorBase) {
|
|
15
|
+
return `${editorBase}/#/render?bpc64=${encodeURIComponent(toStandardB64(source))}`;
|
|
16
|
+
}
|
|
17
|
+
/** Public docs page URL for a docs group + slug. */
|
|
18
|
+
export function buildDocUrl(group, slug, docsBase) {
|
|
19
|
+
return `${docsBase}/${group}/${slug}`;
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildCopyUrl, buildEmbedUrl, buildDocUrl } from './buildUrls';
|
|
3
|
+
const EDITOR = 'https://blueprintchart.com';
|
|
4
|
+
const DOCS = 'https://docs.blueprintchart.com';
|
|
5
|
+
const SRC = 'chart bar-vertical {\n}\n';
|
|
6
|
+
describe('buildCopyUrl', () => {
|
|
7
|
+
it('builds a /copy?bpc64= deep-link with url-safe base64', () => {
|
|
8
|
+
expect(buildCopyUrl(SRC, EDITOR)).toBe('https://blueprintchart.com/#/copy?bpc64=Y2hhcnQgYmFyLXZlcnRpY2FsIHsKfQo');
|
|
9
|
+
});
|
|
10
|
+
it('produces a url-safe payload (no +, /, or = to encode)', () => {
|
|
11
|
+
const url = buildCopyUrl(SRC, EDITOR);
|
|
12
|
+
const payload = url.split('bpc64=')[1];
|
|
13
|
+
expect(payload).not.toMatch(/[+/=]|%/);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
describe('buildEmbedUrl', () => {
|
|
17
|
+
it('builds a hash /render link with URI-encoded standard base64', () => {
|
|
18
|
+
expect(buildEmbedUrl(SRC, EDITOR)).toBe('https://blueprintchart.com/#/render?bpc64=Y2hhcnQgYmFyLXZlcnRpY2FsIHsKfQo%3D');
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
describe('buildDocUrl', () => {
|
|
22
|
+
it('maps the charts group to the /charts docs path', () => {
|
|
23
|
+
expect(buildDocUrl('charts', 'bar-vertical', DOCS)).toBe('https://docs.blueprintchart.com/charts/bar-vertical');
|
|
24
|
+
});
|
|
25
|
+
it('maps reference/dsl to a nested path', () => {
|
|
26
|
+
expect(buildDocUrl('reference/dsl', 'properties', DOCS)).toBe('https://docs.blueprintchart.com/reference/dsl/properties');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Editor app base URL (e.g. https://blueprintchart.com), or undefined when unset. */
|
|
2
|
+
export declare function getEditorBaseUrl(): string | undefined;
|
|
3
|
+
/** Docs site base URL (e.g. https://docs.blueprintchart.com), or undefined when unset. */
|
|
4
|
+
export declare function getDocsBaseUrl(): string | undefined;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
function normalize(raw) {
|
|
2
|
+
const trimmed = raw?.trim();
|
|
3
|
+
if (!trimmed) {
|
|
4
|
+
return undefined;
|
|
5
|
+
}
|
|
6
|
+
return trimmed.replace(/\/+$/, '');
|
|
7
|
+
}
|
|
8
|
+
/** Editor app base URL (e.g. https://blueprintchart.com), or undefined when unset. */
|
|
9
|
+
export function getEditorBaseUrl() {
|
|
10
|
+
return normalize(process.env.BLUEPRINT_CHART_EDITOR_URL);
|
|
11
|
+
}
|
|
12
|
+
/** Docs site base URL (e.g. https://docs.blueprintchart.com), or undefined when unset. */
|
|
13
|
+
export function getDocsBaseUrl() {
|
|
14
|
+
return normalize(process.env.BLUEPRINT_CHART_DOCS_URL);
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { getEditorBaseUrl, getDocsBaseUrl } from './editorConfig';
|
|
3
|
+
afterEach(() => {
|
|
4
|
+
delete process.env.BLUEPRINT_CHART_EDITOR_URL;
|
|
5
|
+
delete process.env.BLUEPRINT_CHART_DOCS_URL;
|
|
6
|
+
});
|
|
7
|
+
describe('getEditorBaseUrl', () => {
|
|
8
|
+
it('returns undefined when unset', () => {
|
|
9
|
+
expect(getEditorBaseUrl()).toBeUndefined();
|
|
10
|
+
});
|
|
11
|
+
it('returns undefined when blank/whitespace', () => {
|
|
12
|
+
process.env.BLUEPRINT_CHART_EDITOR_URL = ' ';
|
|
13
|
+
expect(getEditorBaseUrl()).toBeUndefined();
|
|
14
|
+
});
|
|
15
|
+
it('strips trailing slashes and trims', () => {
|
|
16
|
+
process.env.BLUEPRINT_CHART_EDITOR_URL = ' https://blueprintchart.com// ';
|
|
17
|
+
expect(getEditorBaseUrl()).toBe('https://blueprintchart.com');
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe('getDocsBaseUrl', () => {
|
|
21
|
+
it('returns undefined when unset', () => {
|
|
22
|
+
expect(getDocsBaseUrl()).toBeUndefined();
|
|
23
|
+
});
|
|
24
|
+
it('normalizes a set value', () => {
|
|
25
|
+
process.env.BLUEPRINT_CHART_DOCS_URL = 'https://docs.blueprintchart.com/';
|
|
26
|
+
expect(getDocsBaseUrl()).toBe('https://docs.blueprintchart.com');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 4648 §5 url-safe base64 with padding stripped — the encoding the editor's
|
|
3
|
+
* `/#/copy?bpc64=` route decodes (via `decodeUrlSafeBase64`). Mirrors
|
|
4
|
+
* `toUrlSafeB64` in the editor/docs.
|
|
5
|
+
*/
|
|
6
|
+
export declare function toUrlSafeB64(input: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Standard (padded) base64 — the form the editor's `/#/render?bpc64=` route
|
|
9
|
+
* decodes via `atob`. Callers must `encodeURIComponent` it for the query string.
|
|
10
|
+
*/
|
|
11
|
+
export declare function toStandardB64(input: string): string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC 4648 §5 url-safe base64 with padding stripped — the encoding the editor's
|
|
3
|
+
* `/#/copy?bpc64=` route decodes (via `decodeUrlSafeBase64`). Mirrors
|
|
4
|
+
* `toUrlSafeB64` in the editor/docs.
|
|
5
|
+
*/
|
|
6
|
+
export function toUrlSafeB64(input) {
|
|
7
|
+
return Buffer.from(input, 'utf-8')
|
|
8
|
+
.toString('base64')
|
|
9
|
+
.replace(/\+/g, '-')
|
|
10
|
+
.replace(/\//g, '_')
|
|
11
|
+
.replace(/=+$/, '');
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Standard (padded) base64 — the form the editor's `/#/render?bpc64=` route
|
|
15
|
+
* decodes via `atob`. Callers must `encodeURIComponent` it for the query string.
|
|
16
|
+
*/
|
|
17
|
+
export function toStandardB64(input) {
|
|
18
|
+
return Buffer.from(input, 'utf-8').toString('base64');
|
|
19
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { samples } from '@blueprint-chart/lib';
|
|
3
|
+
import { toUrlSafeB64, toStandardB64 } from './encode';
|
|
4
|
+
/** Decode a url-safe, unpadded base64 string back to UTF-8 (mirrors the editor's /copy decoder). */
|
|
5
|
+
function decodeUrlSafe(raw) {
|
|
6
|
+
const padded = raw.replace(/-/g, '+').replace(/_/g, '/');
|
|
7
|
+
const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
|
|
8
|
+
return Buffer.from(padded + pad, 'base64').toString('utf8');
|
|
9
|
+
}
|
|
10
|
+
describe('toUrlSafeB64', () => {
|
|
11
|
+
it('encodes ASCII with no padding and a url-safe alphabet', () => {
|
|
12
|
+
const out = toUrlSafeB64('chart bar-vertical {\n}\n');
|
|
13
|
+
expect(out).toBe('Y2hhcnQgYmFyLXZlcnRpY2FsIHsKfQo');
|
|
14
|
+
expect(out).not.toContain('=');
|
|
15
|
+
expect(out).not.toMatch(/[+/]/);
|
|
16
|
+
});
|
|
17
|
+
it('round-trips non-ASCII (en dash, euro) through UTF-8', () => {
|
|
18
|
+
const src = 'Price – €5';
|
|
19
|
+
expect(toUrlSafeB64(src)).toBe('UHJpY2Ug4oCTIOKCrDU');
|
|
20
|
+
expect(decodeUrlSafe(toUrlSafeB64(src))).toBe(src);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
describe('toStandardB64', () => {
|
|
24
|
+
it('encodes with standard alphabet and padding (atob-compatible)', () => {
|
|
25
|
+
expect(toStandardB64('chart bar-vertical {\n}\n')).toBe('Y2hhcnQgYmFyLXZlcnRpY2FsIHsKfQo=');
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
describe('letter-frequency golden link', () => {
|
|
29
|
+
// The url-safe base64 of the letter-frequency sample, as hardcoded in the
|
|
30
|
+
// editor's LandingMcp.vue (`/#/copy?bpc64=<this>`). MCP output must match exactly.
|
|
31
|
+
const LANDING_B64 = 'Y2hhcnQgYmFyLXZlcnRpY2FsIHsKICB0aXRsZSA9ICJFIGlzIHRoZSBtb3N0IGZyZXF1ZW50IGxldHRlciBpbiBFbmdsaXNoIgogIGRlc2NyaXB0aW9uID0gIkhvdyBvZnRlbiBlYWNoIGxldHRlciBhcHBlYXJzIGluIHR5cGljYWwgRW5nbGlzaCB0ZXh0IgogIGJ5bGluZSA9ICJQaWVycmUgUm9tZXJhIgogIHNvdXJjZSA9ICJMZXdhbmQsIENyeXB0b2xvZ2ljYWwgTWF0aGVtYXRpY3MiCiAgc291cmNlVXJsID0gImh0dHBzOi8vZW4ud2lraXBlZGlhLm9yZy93aWtpL0xldHRlcl9mcmVxdWVuY3kiCiAgbm90ZSA9ICJCYXNlZCBvbiBhbmFseXNpcyBvZiA0MCwwMDAgd29yZHMgZnJvbSBFbmdsaXNoIHByb3NlIgogIGNvbG9yUGFsZXR0ZSA9ICJMb25kb24iCiAgc29ydCA9IGRlc2NlbmRpbmcKICB2YWx1ZUxhYmVscyA9IHRydWUKICB2ZXJ0aWNhbExhYmVsUG9zaXRpb24gPSBvZmYKICB2ZXJ0aWNhbEdyaWRTdHlsZSA9IG5vbmUKCiAgaGlnaGxpZ2h0ICJFIgoKICBkYXRhIHsKICAgICJFIiA9IDEyLjcwCiAgICAiVCIgPSA5LjA2CiAgICAiQSIgPSA4LjE3CiAgICAiTyIgPSA3LjUxCiAgICAiSSIgPSA2Ljk3CiAgICAiTiIgPSA2Ljc1CiAgICAiUyIgPSA2LjMzCiAgICAiSCIgPSA2LjA5CiAgICAiUiIgPSA1Ljk5CiAgICAiRCIgPSA0LjI1CiAgfQp9Cg';
|
|
32
|
+
it('matches the editor landing-page copy link byte-for-byte', () => {
|
|
33
|
+
const sample = samples.find(s => s.id === 'letter-frequency');
|
|
34
|
+
expect(sample).toBeDefined();
|
|
35
|
+
expect(toUrlSafeB64(sample.dsl)).toBe(LANDING_B64);
|
|
36
|
+
});
|
|
37
|
+
});
|
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 [,
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/parse.test.js
CHANGED
|
@@ -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);
|
|
@@ -1,30 +1,35 @@
|
|
|
1
1
|
const BODY = `You are authoring a Blueprint Chart (\`.bpc\`) file for a user.
|
|
2
2
|
|
|
3
3
|
Workflow:
|
|
4
|
-
1.
|
|
5
|
-
2.
|
|
6
|
-
3.
|
|
7
|
-
4.
|
|
8
|
-
5.
|
|
9
|
-
6.
|
|
10
|
-
7. Call \`
|
|
11
|
-
8. Call \`
|
|
12
|
-
9.
|
|
13
|
-
10.
|
|
4
|
+
1. Call \`list_chart_types\` to see what's renderable. (Or read \`bpc://handbook/choosing\` if your client supports resources.)
|
|
5
|
+
2. If unsure which type to use, call \`recommend_chart_type({ columnTypes, rowCount, goal? })\`.
|
|
6
|
+
3. Call \`describe_chart_type({ name: "<type>" })\` for properties, when-to-use, and a data-shape example.
|
|
7
|
+
4. Call \`get_example({ chartType: "<type>" })\` (or \`{ name: "<sample-id>" }\`) to copy a canonical .bpc as a starting point.
|
|
8
|
+
5. Write the \`.bpc\` source.
|
|
9
|
+
6. Call \`validate_dsl\` — read \`errors[]\`: each entry has \`code\`, \`message\`, \`suggestion\`. Fix and retry.
|
|
10
|
+
7. Call \`inspect_dsl\` to sanity-check structure: \`data.rowCount\` confirms rows parsed, \`hasHighlights\`/\`hasColorizes\` confirm overrides.
|
|
11
|
+
8. Call \`render({ source, format: "png" })\` for a visual. If \`errors[]\` is non-empty, each entry has \`code\` and a usable \`suggestion\`.
|
|
12
|
+
9. Return the final \`.bpc\` and rendered chart to the user.
|
|
13
|
+
10. If the user wants to share or embed the chart, call \`export_chart({ source })\` and give them \`copyUrl\` (editable — anyone can open and copy it) or \`embedUrl\` (a read-only render URL for an iframe). Requires the server to have BLUEPRINT_CHART_EDITOR_URL configured; otherwise it returns \`E_CONFIG\`.
|
|
14
14
|
|
|
15
|
-
Resources you can read:
|
|
16
|
-
- \`bpc://grammar\` — DSL syntax reference (
|
|
15
|
+
Resources you can read (if your client supports MCP resources):
|
|
16
|
+
- \`bpc://grammar\` — DSL syntax reference (use \`get_grammar\` as a tool equivalent)
|
|
17
17
|
- \`bpc://handbook/{slug}\` — dataviz pedagogy (choosing, design-principles, color, typography, annotations, accessibility, ...)
|
|
18
18
|
- \`bpc://guide/{slug}\` — Blueprint Chart guides (scenes, palettes, data-transforms, ...)
|
|
19
|
-
- \`bpc://chart-types/{slug}\` — per-chart-type docs
|
|
20
|
-
- \`bpc://samples/{id}\` — canonical \`.bpc\` examples
|
|
19
|
+
- \`bpc://chart-types/{slug}\` — per-chart-type docs (use \`describe_chart_type\` as a tool equivalent)
|
|
20
|
+
- \`bpc://samples/{id}\` — canonical \`.bpc\` examples (use \`get_example\` as a tool equivalent)
|
|
21
21
|
- \`bpc://reference/dsl/{slug}\`, \`bpc://reference/api/{slug}\` — full reference
|
|
22
22
|
|
|
23
23
|
Tools:
|
|
24
|
-
- \`validate_dsl({source})\` — parse,
|
|
25
|
-
- \`inspect_dsl({source})\` — parsed summary
|
|
26
|
-
- \`recommend_chart_type({columnTypes, rowCount, goal?})\` — ranked suggestions
|
|
27
|
-
- \`render({source, format, scene?, width?, height?})\` — SVG (default) or PNG
|
|
24
|
+
- \`validate_dsl({source})\` — parse; returns \`{ valid, errors[], warnings[] }\` — each error has \`code\`, \`message\`, \`suggestion\`
|
|
25
|
+
- \`inspect_dsl({source})\` — parsed summary: \`chartType\`, \`scenes\`, \`seriesCount\`, \`rowCount\`, \`hasHighlights\`, \`hasColorizes\`, etc.
|
|
26
|
+
- \`recommend_chart_type({columnTypes, rowCount, goal?})\` — ranked chart-type suggestions
|
|
27
|
+
- \`render({source, format, scene?, width?, height?})\` — SVG (default) or PNG; \`errors[]\` on failure (each has \`code\` + \`suggestion\`)
|
|
28
|
+
- \`list_chart_types()\` — list all renderable chart types (tool equivalent of \`bpc://handbook/choosing\`)
|
|
29
|
+
- \`describe_chart_type({name})\` — properties, when-to-use, data-shape for one chart type (tool equivalent of \`bpc://chart-types/{slug}\`)
|
|
30
|
+
- \`get_example({chartType?, name?})\` — fetch a canonical \`.bpc\` sample (tool equivalent of \`bpc://samples/{id}\`)
|
|
31
|
+
- \`get_grammar()\` — full DSL syntax reference (tool equivalent of \`bpc://grammar\`)
|
|
32
|
+
- \`export_chart({source})\` — validate then return \`{ copyUrl, embedUrl, frame }\` shareable editor URLs (returns \`E_CONFIG\` if the server has no editor URL configured)
|
|
28
33
|
|
|
29
34
|
Be patient with errors — the feedback loop is the point.`;
|
|
30
35
|
export function authorChartPrompt() {
|
|
@@ -10,4 +10,10 @@ describe('authorChartPrompt', () => {
|
|
|
10
10
|
expect(first.content.text).toMatch(/bpc:\/\/grammar/);
|
|
11
11
|
expect(first.content.text).toMatch(/validate_dsl/);
|
|
12
12
|
});
|
|
13
|
+
it('mentions export_chart and the export step', () => {
|
|
14
|
+
const prompt = authorChartPrompt();
|
|
15
|
+
const text = prompt.messages[0].content.text;
|
|
16
|
+
expect(text).toContain('export_chart');
|
|
17
|
+
expect(text).toMatch(/copyUrl/);
|
|
18
|
+
});
|
|
13
19
|
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type RenderDiagnosticCode = 'E_PARSE' | 'E_UNKNOWN_CHART_TYPE' | 'E_NO_DATA' | 'E_NO_RESOLVED_SERIES' | 'E_UNKNOWN_SCENE_INDEX' | 'E_UNRESOLVED_COLORIZE' | 'E_UNRESOLVED_HIGHLIGHT';
|
|
2
|
+
export interface RenderDiagnostic {
|
|
3
|
+
code: RenderDiagnosticCode;
|
|
4
|
+
path: string;
|
|
5
|
+
message: string;
|
|
6
|
+
context?: Record<string, unknown>;
|
|
7
|
+
suggestion?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface DiagnoseOptions {
|
|
10
|
+
sceneIndex?: number;
|
|
11
|
+
}
|
|
12
|
+
type DiagnoseResult = {
|
|
13
|
+
ok: true;
|
|
14
|
+
} | {
|
|
15
|
+
ok: false;
|
|
16
|
+
diagnostics: RenderDiagnostic[];
|
|
17
|
+
};
|
|
18
|
+
export declare function diagnoseRender(source: string, opts?: DiagnoseOptions): DiagnoseResult;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { parse, astToDefinition, resolveScene, getChart } from '@blueprint-chart/lib';
|
|
2
|
+
import { canonicalChartType, listCanonicalChartTypes } from '../dsl/chartTypes';
|
|
3
|
+
import { nearestSuggestion } from '../dsl/suggest';
|
|
4
|
+
function colorizeTargets(ast) {
|
|
5
|
+
return (ast.colorizes ?? []).map(c => c.target);
|
|
6
|
+
}
|
|
7
|
+
function highlightTargets(ast) {
|
|
8
|
+
return (ast.highlights ?? []).map(h => h.target);
|
|
9
|
+
}
|
|
10
|
+
export function diagnoseRender(source, opts = {}) {
|
|
11
|
+
let ast;
|
|
12
|
+
try {
|
|
13
|
+
ast = parse(source);
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
diagnostics: [{
|
|
19
|
+
code: 'E_PARSE',
|
|
20
|
+
path: 'source',
|
|
21
|
+
message: err instanceof Error ? err.message : String(err),
|
|
22
|
+
}],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
const diagnostics = [];
|
|
26
|
+
// Unknown chart type — silent no-op in render-chart.ts:52-55
|
|
27
|
+
const canonical = canonicalChartType(ast.chartType);
|
|
28
|
+
if (!canonical || !getChart(canonical)) {
|
|
29
|
+
const known = listCanonicalChartTypes();
|
|
30
|
+
diagnostics.push({
|
|
31
|
+
code: 'E_UNKNOWN_CHART_TYPE',
|
|
32
|
+
path: 'chart',
|
|
33
|
+
message: `No renderer registered for chart type "${ast.chartType}".`,
|
|
34
|
+
context: { got: ast.chartType, known },
|
|
35
|
+
suggestion: nearestSuggestion(ast.chartType, known),
|
|
36
|
+
});
|
|
37
|
+
return { ok: false, diagnostics };
|
|
38
|
+
}
|
|
39
|
+
const definition = astToDefinition(ast);
|
|
40
|
+
// Empty data — silent return in render-chart.ts:25-31
|
|
41
|
+
if (definition.data.labels.length === 0) {
|
|
42
|
+
diagnostics.push({
|
|
43
|
+
code: 'E_NO_DATA',
|
|
44
|
+
path: 'data',
|
|
45
|
+
message: 'Chart has no parsed data rows. Add quoted-string labels with numeric values to the data block.',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
// Scene index out of range
|
|
49
|
+
const sceneCount = (ast.scenes ?? []).length;
|
|
50
|
+
if (opts.sceneIndex !== undefined && opts.sceneIndex >= sceneCount) {
|
|
51
|
+
diagnostics.push({
|
|
52
|
+
code: 'E_UNKNOWN_SCENE_INDEX',
|
|
53
|
+
path: 'scene',
|
|
54
|
+
message: `Requested scene index ${opts.sceneIndex} but chart has ${sceneCount} scene(s).`,
|
|
55
|
+
context: { requested: opts.sceneIndex, availableSceneCount: sceneCount },
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
// Unresolved colorize/highlight targets
|
|
59
|
+
const labels = new Set(definition.data.labels);
|
|
60
|
+
const seriesNames = new Set();
|
|
61
|
+
for (const s of definition.data.series ?? []) {
|
|
62
|
+
if (s.name) {
|
|
63
|
+
seriesNames.add(s.name);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const target of colorizeTargets(ast)) {
|
|
67
|
+
if (!labels.has(target) && !seriesNames.has(target)) {
|
|
68
|
+
diagnostics.push({
|
|
69
|
+
code: 'E_UNRESOLVED_COLORIZE',
|
|
70
|
+
path: 'colorize',
|
|
71
|
+
message: `colorize "${target}" does not match any data label or series name.`,
|
|
72
|
+
context: { target, availableLabels: [...labels], availableSeries: [...seriesNames] },
|
|
73
|
+
suggestion: nearestSuggestion(target, [...labels, ...seriesNames]),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const target of highlightTargets(ast)) {
|
|
78
|
+
if (!labels.has(target) && !seriesNames.has(target)) {
|
|
79
|
+
diagnostics.push({
|
|
80
|
+
code: 'E_UNRESOLVED_HIGHLIGHT',
|
|
81
|
+
path: 'highlight',
|
|
82
|
+
message: `highlight "${target}" does not match any data label or series name.`,
|
|
83
|
+
context: { target, availableLabels: [...labels], availableSeries: [...seriesNames] },
|
|
84
|
+
suggestion: nearestSuggestion(target, [...labels, ...seriesNames]),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Resolve scene (defensive — surfaces internal errors)
|
|
89
|
+
try {
|
|
90
|
+
resolveScene(definition, opts.sceneIndex);
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
diagnostics.push({
|
|
94
|
+
code: 'E_NO_RESOLVED_SERIES',
|
|
95
|
+
path: 'scene',
|
|
96
|
+
message: `Failed to resolve scene state: ${err instanceof Error ? err.message : String(err)}`,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
return diagnostics.length === 0 ? { ok: true } : { ok: false, diagnostics };
|
|
100
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { samples } from '@blueprint-chart/lib';
|
|
3
|
+
import { diagnoseRender } from './diagnose';
|
|
4
|
+
describe('diagnoseRender', () => {
|
|
5
|
+
it('returns ok for every shipped sample', () => {
|
|
6
|
+
for (const s of samples) {
|
|
7
|
+
const r = diagnoseRender(s.dsl);
|
|
8
|
+
expect(r.ok, `sample ${s.id}: ${JSON.stringify(r)}`).toBe(true);
|
|
9
|
+
}
|
|
10
|
+
});
|
|
11
|
+
it('reports E_UNKNOWN_CHART_TYPE for chart bar', () => {
|
|
12
|
+
const r = diagnoseRender('chart bar { data { "E" = 1 } }');
|
|
13
|
+
expect(r.ok).toBe(false);
|
|
14
|
+
if (!r.ok) {
|
|
15
|
+
const issue = r.diagnostics.find(d => d.code === 'E_UNKNOWN_CHART_TYPE');
|
|
16
|
+
expect(issue).toBeDefined();
|
|
17
|
+
expect(issue.suggestion).toMatch(/^bar-/);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
it('reports E_NO_DATA when data is empty', () => {
|
|
21
|
+
const r = diagnoseRender('chart bar-vertical {}');
|
|
22
|
+
expect(r.ok).toBe(false);
|
|
23
|
+
if (!r.ok) {
|
|
24
|
+
expect(r.diagnostics.some(d => d.code === 'E_NO_DATA')).toBe(true);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
it('reports E_UNKNOWN_SCENE_INDEX when sceneIndex is out of range', () => {
|
|
28
|
+
const r = diagnoseRender('chart bar-vertical { data { "E" = 1 } }', { sceneIndex: 5 });
|
|
29
|
+
expect(r.ok).toBe(false);
|
|
30
|
+
if (!r.ok) {
|
|
31
|
+
const issue = r.diagnostics.find(d => d.code === 'E_UNKNOWN_SCENE_INDEX');
|
|
32
|
+
expect(issue).toBeDefined();
|
|
33
|
+
expect(issue.context?.availableSceneCount).toBe(0);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
it('reports E_UNRESOLVED_COLORIZE when colorize target is not a label', () => {
|
|
37
|
+
const r = diagnoseRender('chart bar-vertical { data { "E" = 1 } colorize "Z" { color = "red" } }');
|
|
38
|
+
expect(r.ok).toBe(false);
|
|
39
|
+
if (!r.ok) {
|
|
40
|
+
const issue = r.diagnostics.find(d => d.code === 'E_UNRESOLVED_COLORIZE');
|
|
41
|
+
expect(issue).toBeDefined();
|
|
42
|
+
expect(issue.context?.target).toBe('Z');
|
|
43
|
+
expect(issue.context?.availableLabels).toEqual(['E']);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
it('reports E_PARSE on malformed source', () => {
|
|
47
|
+
const r = diagnoseRender('@@@ not valid');
|
|
48
|
+
expect(r.ok).toBe(false);
|
|
49
|
+
if (!r.ok) {
|
|
50
|
+
expect(r.diagnostics[0].code).toBe('E_PARSE');
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ChartNode } from '@blueprint-chart/lib';
|
|
2
|
+
export interface FrameMetadata {
|
|
3
|
+
title?: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
byline?: string;
|
|
6
|
+
source?: string;
|
|
7
|
+
sourceUrl?: string;
|
|
8
|
+
note?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function extractFrameMetadata(ast: ChartNode): FrameMetadata;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const FRAME_KEYS = ['title', 'description', 'byline', 'source', 'sourceUrl', 'note'];
|
|
2
|
+
export function extractFrameMetadata(ast) {
|
|
3
|
+
const frame = {};
|
|
4
|
+
for (const prop of ast.properties ?? []) {
|
|
5
|
+
if (FRAME_KEYS.includes(prop.key)) {
|
|
6
|
+
frame[prop.key] = String(prop.value).replace(/^"|"$/g, '');
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
return frame;
|
|
10
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parse } from '@blueprint-chart/lib';
|
|
3
|
+
import { extractFrameMetadata } from './frame';
|
|
4
|
+
describe('extractFrameMetadata', () => {
|
|
5
|
+
it('pulls frame keys off the chart AST and strips surrounding quotes', () => {
|
|
6
|
+
const ast = parse('chart bar-vertical {\n title = "Hi"\n source = "ICIJ"\n data {\n "A" = 1\n }\n}\n');
|
|
7
|
+
const frame = extractFrameMetadata(ast);
|
|
8
|
+
expect(frame.title).toBe('Hi');
|
|
9
|
+
expect(frame.source).toBe('ICIJ');
|
|
10
|
+
expect(frame.byline).toBeUndefined();
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -2,7 +2,8 @@ import { type DOMWindow } from 'jsdom';
|
|
|
2
2
|
export interface JsdomEnv {
|
|
3
3
|
window: DOMWindow;
|
|
4
4
|
container: HTMLElement;
|
|
5
|
-
|
|
5
|
+
serializeSvg: () => string;
|
|
6
|
+
serializeFrame: () => string | undefined;
|
|
6
7
|
cleanup: () => void;
|
|
7
8
|
}
|
|
8
9
|
export interface JsdomEnvOptions {
|
package/dist/render/jsdomEnv.js
CHANGED
|
@@ -19,10 +19,23 @@ export function createJsdomEnv(opts) {
|
|
|
19
19
|
return {
|
|
20
20
|
window: dom.window,
|
|
21
21
|
container,
|
|
22
|
-
|
|
22
|
+
serializeSvg: () => {
|
|
23
|
+
// When thumbnail=false the lib wraps the SVG in a div.bc-frame. The chart
|
|
24
|
+
// SVG lives inside div.bc-frame-body. Extract it precisely so we don't
|
|
25
|
+
// accidentally grab the small logo SVG in the footer.
|
|
26
|
+
const frameBody = container.querySelector('.bc-frame-body');
|
|
27
|
+
if (frameBody) {
|
|
28
|
+
const svg = frameBody.querySelector('svg');
|
|
29
|
+
return svg ? svg.outerHTML : '';
|
|
30
|
+
}
|
|
31
|
+
// Fallback for bare renders (no frame): grab the direct SVG.
|
|
23
32
|
const svg = container.querySelector('svg');
|
|
24
33
|
return svg ? svg.outerHTML : '';
|
|
25
34
|
},
|
|
35
|
+
serializeFrame: () => {
|
|
36
|
+
const frame = container.querySelector('.bc-frame');
|
|
37
|
+
return frame ? frame.outerHTML : undefined;
|
|
38
|
+
},
|
|
26
39
|
cleanup: () => dom.window.close(),
|
|
27
40
|
};
|
|
28
41
|
}
|