diff --git a/apps/backend/src/agents/tools/index.ts b/apps/backend/src/agents/tools/index.ts index 9953d26fb..ba95c6da6 100644 --- a/apps/backend/src/agents/tools/index.ts +++ b/apps/backend/src/agents/tools/index.ts @@ -127,9 +127,13 @@ export const getTools = ( if ('story' in result) { const mapsEnabled = 'display_map' in result; + const storyPluginsEnabled = agentSettings?.storyPlugins?.enabled === true; result = { ...result, - story: { ...result.story, description: buildStoryToolDescription({ mapsEnabled }) }, + story: { + ...result.story, + description: buildStoryToolDescription({ mapsEnabled, storyPluginsEnabled }), + }, }; } diff --git a/apps/backend/src/agents/tools/story.ts b/apps/backend/src/agents/tools/story.ts index 9e2271644..69bbfbeeb 100644 --- a/apps/backend/src/agents/tools/story.ts +++ b/apps/backend/src/agents/tools/story.ts @@ -22,7 +22,18 @@ const STORY_FILTER_DESCRIPTION = [ 'When adding filters to existing charts, prefer execute_sql with query_id set to the existing query so chart/table tags keep the same query_id.', ].join(' '); -export function buildStoryToolDescription({ mapsEnabled = false }: { mapsEnabled?: boolean } = {}) { +const STORY_PLUGIN_DESCRIPTION = [ + 'Plugins are embedded via ... blocks.', + 'Use a plugin only when built-in chart, table, map, or markdown blocks cannot express the desired interactive component.', + 'Plugin code must be a self-contained vanilla JavaScript ES module with export default function render(element) that renders into the provided DOM element.', + 'Do not use imports or make network calls to external services.', + `Example: export default function render(element) { let count = 0; element.innerHTML = ''; const button = element.querySelector('button'); button.onclick = () => { count += 1; button.textContent = 'Count: ' + count; }; }.`, +].join(' '); + +export function buildStoryToolDescription({ + mapsEnabled = false, + storyPluginsEnabled = false, +}: { mapsEnabled?: boolean; storyPluginsEnabled?: boolean } = {}) { return [ 'Create or modify a nao Story — an interactive document combining markdown text and chart visualizations.', 'Use "create" to initialize a new story, "update" to search-and-replace within it (producing a new version),', @@ -33,8 +44,9 @@ export function buildStoryToolDescription({ mapsEnabled = false }: { mapsEnabled ...(mapsEnabled ? ['Maps are embedded via .'] : []), + ...(storyPluginsEnabled ? [STORY_PLUGIN_DESCRIPTION] : []), ...(env.BETA_STORY_FILTERS_ENABLED ? [STORY_FILTER_DESCRIPTION] : []), - `Use ... to place 2–4 charts/tables${mapsEnabled ? '/maps' : ''} side by side; its direct /${mapsEnabled ? '/' : ''} blocks are the columns.`, + `Use ... to place 2–4 charts/tables${mapsEnabled ? '/maps' : ''}${storyPluginsEnabled ? '/plugins' : ''} side by side; its direct /
${mapsEnabled ? '/' : ''}${storyPluginsEnabled ? '/' : ''} blocks are the columns.`, 'For unequal columns add widths="w1,w2,..." to the — one positive integer per column giving its relative width (e.g. widths="2,1" makes the first column twice as wide as the second). The number of values must equal the number of columns; omit widths for equal columns. Choose widths that fit the content, e.g. a wide time-series next to a narrow KPI or pie.', 'Use consecutive ... blocks to organize a story into top-level tabs.', 'Default to a single flowing story. Use tabs only when the user asks for tabs, or when the content splits into clearly distinct sections that are better separated than stacked (e.g. overview vs. detail, one topic/department/metric per tab). Avoid tabs for a short or single-topic story. Always follow the user\'s explicit request (e.g. "a tab per chart" means one chart per tab). When using tabs, the entire story must consist of ... blocks — no content outside a tab.', diff --git a/apps/backend/src/components/ai/live-story-refresh-prompt.tsx b/apps/backend/src/components/ai/live-story-refresh-prompt.tsx index baf9a3a2b..8217d6a33 100644 --- a/apps/backend/src/components/ai/live-story-refresh-prompt.tsx +++ b/apps/backend/src/components/ai/live-story-refresh-prompt.tsx @@ -32,8 +32,9 @@ export function LiveStoryRefreshPrompt({ Preserve every heading exactly as written. - Preserve every {''}, {'
'}, {''}, {''}, {''}, and{' '} - {''} tag exactly as written and in the same order. + Preserve every {''}, {'
'}, {''}, {''},{' '} + {'...'}, {''}, and {''} tag exactly as written and in the same + order. Do not add, remove, or reorder structural blocks. Try to keep the formatting as close as possible to diff --git a/apps/backend/src/queries/project.queries.ts b/apps/backend/src/queries/project.queries.ts index 7d361cc60..0c78fef25 100644 --- a/apps/backend/src/queries/project.queries.ts +++ b/apps/backend/src/queries/project.queries.ts @@ -237,6 +237,10 @@ export const updateAgentSettings = async (projectId: string, settings: AgentSett ...current.webSearch, ...settings.webSearch, }, + storyPlugins: { + ...current.storyPlugins, + ...settings.storyPlugins, + }, pythonExecution: { ...current.pythonExecution, ...settings.pythonExecution, diff --git a/apps/backend/src/trpc/project.routes.ts b/apps/backend/src/trpc/project.routes.ts index e7986842e..0ade2ef94 100644 --- a/apps/backend/src/trpc/project.routes.ts +++ b/apps/backend/src/trpc/project.routes.ts @@ -851,6 +851,11 @@ export const projectRoutes = { mode: z.enum(['provider']).optional(), }) .optional(), + storyPlugins: z + .object({ + enabled: z.boolean().optional(), + }) + .optional(), }), ) .mutation(async ({ ctx, input }) => { @@ -863,6 +868,7 @@ export const projectRoutes = { sql: { ...existing.sql, ...input.sql }, pythonExecution: { ...existing.pythonExecution, ...input.pythonExecution }, webSearch: { ...existing.webSearch, ...input.webSearch }, + storyPlugins: { ...existing.storyPlugins, ...input.storyPlugins }, }; posthog.capture(ctx.user.id, PostHogEvent.ProjectAgentSettingsUpdated, { project_id: ctx.project.id, @@ -876,6 +882,7 @@ export const projectRoutes = { memory_enabled: merged.memoryEnabled, web_search_enabled: merged.webSearch?.enabled, web_search_mode: merged.webSearch?.mode, + story_plugins_enabled: merged.storyPlugins?.enabled, }); return projectQueries.updateAgentSettings(ctx.project.id, merged); }), diff --git a/apps/backend/src/types/agent-settings.ts b/apps/backend/src/types/agent-settings.ts index 560ddbe11..486165af4 100644 --- a/apps/backend/src/types/agent-settings.ts +++ b/apps/backend/src/types/agent-settings.ts @@ -22,4 +22,7 @@ export interface AgentSettings { enabled?: boolean; mode?: WebSearchMode; }; + storyPlugins?: { + enabled?: boolean; + }; } diff --git a/apps/backend/src/utils/story-html.tsx b/apps/backend/src/utils/story-html.tsx index 7f639e08d..379f264b7 100644 --- a/apps/backend/src/utils/story-html.tsx +++ b/apps/backend/src/utils/story-html.tsx @@ -348,6 +348,8 @@ function StorySegment({ segment, queryData }: { segment: Segment; queryData: Que return ; case 'filter': return null; + case 'plugin': + return null; case 'grid': return ; } diff --git a/apps/backend/tests/story-tool-description.test.ts b/apps/backend/tests/story-tool-description.test.ts new file mode 100644 index 000000000..863c06fc2 --- /dev/null +++ b/apps/backend/tests/story-tool-description.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../src/db/db', () => ({ db: {} })); + +import { getTools } from '../src/agents/tools'; +import { buildStoryToolDescription } from '../src/agents/tools/story'; +import type { AgentSettings } from '../src/types/agent-settings'; + +describe('story plugin tool description', () => { + it('does not mention plugins when story plugins are disabled', () => { + expect(buildStoryToolDescription().toLowerCase()).not.toContain('plugin'); + expect(storyDescription({ storyPlugins: { enabled: false } }).toLowerCase()).not.toContain('plugin'); + }); + + it('documents the plugin contract and restrictions when enabled', () => { + const description = storyDescription({ storyPlugins: { enabled: true } }); + + expect(description).toContain('...'); + expect(description).toContain('export default function render(element)'); + expect(description).toContain('self-contained vanilla JavaScript'); + expect(description).toContain('Do not use imports or make network calls'); + expect(description).toContain('only when built-in chart, table, map, or markdown blocks cannot express'); + expect(description).toContain(''); + }); +}); + +function storyDescription(agentSettings: AgentSettings): string { + const storyTool = getTools(agentSettings, undefined, { testMode: true }).story as { description?: string }; + return storyTool.description ?? ''; +} diff --git a/apps/frontend/src/components/settings-search-index.ts b/apps/frontend/src/components/settings-search-index.ts index a9fb25fc7..722ed0a45 100644 --- a/apps/frontend/src/components/settings-search-index.ts +++ b/apps/frontend/src/components/settings-search-index.ts @@ -263,6 +263,14 @@ export const settingsSearchIndex: SettingsSearchEntry[] = [ keywords: ['display map', 'choropleth', 'points', 'scatter', 'bubble', 'geospatial'], adminOnly: true, }, + { + page: '/settings/project/agent', + pageLabel: 'Agent', + title: 'Vibe coded plugins', + description: 'Allow the agent to generate custom interactive plugin blocks inside stories.', + keywords: ['plugin', 'vibe', 'story', 'custom component'], + adminOnly: true, + }, { page: '/settings/project/agent', pageLabel: 'Agent', diff --git a/apps/frontend/src/components/settings/story-plugins.tsx b/apps/frontend/src/components/settings/story-plugins.tsx new file mode 100644 index 000000000..2ac7405e6 --- /dev/null +++ b/apps/frontend/src/components/settings/story-plugins.tsx @@ -0,0 +1,51 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { SettingsCard } from '@/components/ui/settings-card'; +import { SettingsControlRow } from '@/components/ui/settings-toggle-row'; +import { Switch } from '@/components/ui/switch'; +import { trpc } from '@/main'; + +interface SettingsStoryPluginsProps { + isAdmin: boolean; +} + +export function SettingsStoryPlugins({ isAdmin }: SettingsStoryPluginsProps) { + const queryClient = useQueryClient(); + const agentSettings = useQuery(trpc.project.getAgentSettings.queryOptions()); + + const updateAgentSettings = useMutation( + trpc.project.updateAgentSettings.mutationOptions({ + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.project.getAgentSettings.queryOptions().queryKey, + }); + }, + }), + ); + + const storyPluginsEnabled = agentSettings.data?.storyPlugins?.enabled ?? false; + + const handleStoryPluginsChange = (enabled: boolean) => { + updateAgentSettings.mutate({ storyPlugins: { enabled } }); + }; + + return ( + + + } + /> + + ); +} diff --git a/apps/frontend/src/components/side-panel/hooks/use-story-editor.ts b/apps/frontend/src/components/side-panel/hooks/use-story-editor.ts index 6a901b412..0925e46ef 100644 --- a/apps/frontend/src/components/side-panel/hooks/use-story-editor.ts +++ b/apps/frontend/src/components/side-panel/hooks/use-story-editor.ts @@ -420,7 +420,8 @@ export function useStoryEditor({ code, editorRef, onSave }: UseStoryEditorParams hoveredNode != null && (hoveredNode.type.name === 'gridBlock' || hoveredNode.type.name === 'chartBlock' || - hoveredNode.type.name === 'tableBlock') + hoveredNode.type.name === 'tableBlock' || + hoveredNode.type.name === 'pluginBlock') ) { event.preventDefault(); return; @@ -490,7 +491,10 @@ export function useStoryEditor({ code, editorRef, onSave }: UseStoryEditorParams const node = editor.state.doc.nodeAt(pos); if ( node != null && - (node.type.name === 'gridBlock' || node.type.name === 'chartBlock' || node.type.name === 'tableBlock') + (node.type.name === 'gridBlock' || + node.type.name === 'chartBlock' || + node.type.name === 'tableBlock' || + node.type.name === 'pluginBlock') ) { return; } diff --git a/apps/frontend/src/components/side-panel/story-editor-block-drag.tsx b/apps/frontend/src/components/side-panel/story-editor-block-drag.tsx index 50e85f99c..7f281e1fb 100644 --- a/apps/frontend/src/components/side-panel/story-editor-block-drag.tsx +++ b/apps/frontend/src/components/side-panel/story-editor-block-drag.tsx @@ -37,12 +37,12 @@ export function useStoryBlockDrag({ node, editor, getPos }: Pick ); + case 'plugin': + return ; case 'grid': return (
@@ -132,10 +135,11 @@ function GridBlockView(props: ReactNodeViewProps) { > {segments.map((segment, i) => { // Markdown columns cannot be dragged out (createBlockNode would - // turn their markdown into literal text), so only chart/table - // columns get a move handle. + // turn their markdown into literal text), so only supported story + // block columns get a move handle. const columnGrip = - segments.length >= 2 && (segment.type === 'chart' || segment.type === 'table') ? ( + segments.length >= 2 && + (segment.type === 'chart' || segment.type === 'table' || segment.type === 'plugin') ? (
and tags with HTML-safe elements that + * Replaces custom story block tags with HTML-safe elements that * Tiptap's DOMParser can match against custom node extensions. */ export function preprocessForEditor(code: string): string { @@ -25,6 +25,10 @@ export function preprocessForEditor(code: string): string { return `
\n\n`; }); + result = result.replace(new RegExp(`[\\s\\S]*?<\\/plugin>`, 'g'), (match) => { + return `
\n\n`; + }); + result = result.replace(new RegExp(``, 'g'), (match) => { return `
\n\n`; }); @@ -51,6 +55,9 @@ export function createBlockNode(schema: Schema, markup: string): PMNode | null { if (trimmedMarkup.startsWith(' diff --git a/apps/frontend/src/components/story-plugin-embed.tsx b/apps/frontend/src/components/story-plugin-embed.tsx new file mode 100644 index 000000000..17da5e091 --- /dev/null +++ b/apps/frontend/src/components/story-plugin-embed.tsx @@ -0,0 +1,82 @@ +import { memo, useEffect, useRef, useState } from 'react'; +import type { ParsedPluginBlock } from '@nao/shared/story-segments'; + +type PluginCleanup = void | (() => void); +type PluginStatus = { state: 'loading' | 'ready' } | { state: 'error'; message: string }; + +interface PluginModule { + default: (element: HTMLElement) => PluginCleanup | Promise; +} + +export const StoryPluginEmbed = memo(function StoryPluginEmbed({ plugin }: { plugin: ParsedPluginBlock }) { + const containerRef = useRef(null); + const [status, setStatus] = useState({ state: 'loading' }); + + useEffect(() => { + const element = containerRef.current; + if (!element) { + return; + } + + let disposed = false; + let cleanup: PluginCleanup; + const moduleUrl = URL.createObjectURL(new Blob([plugin.code], { type: 'text/javascript' })); + + element.replaceChildren(); + setStatus({ state: 'loading' }); + void import(/* @vite-ignore */ moduleUrl) + .then((loaded: unknown) => { + if (!isPluginModule(loaded)) { + throw new Error('The module must export a default render(element) function.'); + } + return loaded.default(element); + }) + .then((nextCleanup) => { + if (disposed) { + nextCleanup?.(); + return; + } + cleanup = nextCleanup; + setStatus({ state: 'ready' }); + }) + .catch((error: unknown) => { + if (!disposed) { + element.replaceChildren(); + setStatus({ state: 'error', message: toErrorMessage(error) }); + } + }); + + return () => { + disposed = true; + cleanup?.(); + element.replaceChildren(); + URL.revokeObjectURL(moduleUrl); + }; + }, [plugin.code]); + + return ( +
+ {plugin.title ? ( +
{plugin.title}
+ ) : null} +
+
+ {status.state !== 'ready' ? ( +
+ {status.state === 'error' ? `Could not render plugin: ${status.message}` : 'Loading plugin...'} +
+ ) : null} +
+
+ ); +}); + +function isPluginModule(value: unknown): value is PluginModule { + return ( + typeof value === 'object' && value !== null && typeof (value as { default?: unknown }).default === 'function' + ); +} + +function toErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/apps/frontend/src/components/story-rendering.tsx b/apps/frontend/src/components/story-rendering.tsx index 0d6ec9a9a..ff3b42a5a 100644 --- a/apps/frontend/src/components/story-rendering.tsx +++ b/apps/frontend/src/components/story-rendering.tsx @@ -4,6 +4,7 @@ import { Streamdown } from 'streamdown'; import type { ParsedChartBlock, ParsedMapBlock, ParsedTableBlock, Segment } from '@nao/shared/story-segments'; import { MarkdownTable } from '@/components/chat-messages/markdown-table'; +import { StoryPluginEmbed } from '@/components/story-plugin-embed'; import { StoryGridProvider } from '@/contexts/story-grid'; import { markdownPlugins } from '@/lib/markdown'; @@ -50,6 +51,8 @@ export const SegmentList = memo(function SegmentList({ return {renderMap(segment.map, i)}; case 'filter': return null; + case 'plugin': + return ; case 'grid': return ( ) : segment.type === 'grid' ? ( + ); diff --git a/apps/shared/src/story-segments.ts b/apps/shared/src/story-segments.ts index c3d87f0c9..d53f9c370 100644 --- a/apps/shared/src/story-segments.ts +++ b/apps/shared/src/story-segments.ts @@ -75,12 +75,19 @@ export interface ParsedFilterBlock { rawTag?: string; } +export interface ParsedPluginBlock { + title: string | null; + code: string; + rawContent: string; +} + export type Segment = | { type: 'markdown'; content: string } | { type: 'chart'; chart: ParsedChartBlock } | { type: 'table'; table: ParsedTableBlock } | { type: 'map'; map: ParsedMapBlock } | { type: 'filter'; filter: ParsedFilterBlock } + | { type: 'plugin'; plugin: ParsedPluginBlock } | { type: 'grid'; cols: number; widths: number[] | null; children: Segment[] }; export const TAG_ATTRS = String.raw`(?:[^>"']|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')*?`; @@ -99,7 +106,7 @@ export function mapTagRegex(flags = ''): RegExp { export function storyBlockRegex(): RegExp { return new RegExp( - String.raw`([\s\S]*?)<\/grid>||||`, + String.raw`([\s\S]*?)<\/grid>|||||([\s\S]*?)<\/plugin>`, 'g', ); } @@ -259,6 +266,15 @@ export function parseFilterBlock(attrString: string): ParsedFilterBlock | null { }; } +export function parsePluginBlock(attrString: string, code: string, rawContent: string): ParsedPluginBlock { + const attrs = parseChartAttributes(attrString); + return { + title: attrs.title || null, + code, + rawContent, + }; +} + export function parseStringArrayAttribute(value: string | undefined): string[] | undefined { if (!value) { return undefined; @@ -736,6 +752,11 @@ export function splitCodeIntoSegments(code: string): Segment[] { if (map) { segments.push({ type: 'map', map: { ...map, rawTag: match[0] } }); } + } else if (match[8] !== undefined) { + segments.push({ + type: 'plugin', + plugin: parsePluginBlock(match[7] ?? '', match[8], match[0]), + }); } lastIndex = match.index + match[0].length; diff --git a/apps/shared/src/story-validation.ts b/apps/shared/src/story-validation.ts index e9273cf73..8fb2a08f6 100644 --- a/apps/shared/src/story-validation.ts +++ b/apps/shared/src/story-validation.ts @@ -30,7 +30,7 @@ const VALID_Y_AXIS_SIDES = new Set(YAxisSideEnum.options); /** * Validates the structure of a story's markdown code, looking for common - * authoring mistakes in ,
and blocks. + * authoring mistakes in ,
, and blocks. * * Returns a list of errors with 1-based line/column coordinates suitable for * driving Monaco editor markers. @@ -42,12 +42,47 @@ export function validateStoryCode(code: string): StoryValidationError[] { errors.push(...validateChartBlocks(code)); errors.push(...validateTableBlocks(code)); errors.push(...validateFilterBlocks(code)); + errors.push(...validatePluginBlocks(code)); errors.push(...validateTabsBlocks(code)); errors.push(...validateUnterminatedTags(code)); return errors.sort((a, b) => a.line - b.line || a.column - b.column); } +function validatePluginBlocks(code: string): StoryValidationError[] { + const errors: StoryValidationError[] = []; + const openTagRegex = new RegExp(String.raw``, 'g'); + let match: RegExpExecArray | null; + + while ((match = openTagRegex.exec(code)) !== null) { + const position = getPosition(code, match.index); + const closeIndex = code.indexOf('', openTagRegex.lastIndex); + if (closeIndex === -1) { + errors.push({ + message: ' tag is missing a matching closing tag.', + line: position.line, + column: position.column, + length: match[0].length, + }); + break; + } + + const pluginCode = code.slice(openTagRegex.lastIndex, closeIndex); + if (!pluginCode.trim()) { + errors.push({ + message: 'Plugin code must not be empty.', + line: position.line, + column: position.column, + length: closeIndex + ''.length - match.index, + }); + } + + openTagRegex.lastIndex = closeIndex + ''.length; + } + + return errors; +} + function validateTabsBlocks(code: string): StoryValidationError[] { const errors: StoryValidationError[] = []; const tabOpeners = [...code.matchAll(new RegExp(``, 'g'))]; diff --git a/apps/shared/tests/story-segments.test.ts b/apps/shared/tests/story-segments.test.ts index 645fe881d..ecea7c51b 100644 --- a/apps/shared/tests/story-segments.test.ts +++ b/apps/shared/tests/story-segments.test.ts @@ -26,6 +26,63 @@ const CHART_TWO = ''; const CHART_THREE = ''; +const PLUGIN = ` +export default function render(element) { + const value = 3; + element.textContent = value < 5 ? 'Below target' : 'Above > target'; +} +`; + +describe('plugin story blocks', () => { + it('parses markdown, charts, and plugin code without interpreting JavaScript markup', () => { + const code = ['# Revenue', CHART_ONE, PLUGIN, 'Closing note.'].join('\n\n'); + const segments = splitCodeIntoSegments(code); + + expect(segments).toMatchObject([ + { type: 'markdown', content: '# Revenue' }, + { type: 'chart', chart: { queryId: 'q1', title: 'Revenue', rawTag: CHART_ONE } }, + { + type: 'plugin', + plugin: { + title: 'Threshold indicator', + code: [ + '', + 'export default function render(element) {', + '\tconst value = 3;', + "\telement.textContent = value < 5 ? 'Below target' : 'Above > target';", + '}', + '', + ].join('\n'), + rawContent: PLUGIN, + }, + }, + { type: 'markdown', content: 'Closing note.' }, + ]); + }); + + it('uses a null title when the plugin has no title attribute', () => { + expect(splitCodeIntoSegments('export default function render() {}')).toEqual([ + { + type: 'plugin', + plugin: { + title: null, + code: 'export default function render() {}', + rawContent: 'export default function render() {}', + }, + }, + ]); + }); + + it('parses a plugin as a grid column', () => { + expect(splitCodeIntoSegments(`${CHART_ONE}${PLUGIN}`)).toMatchObject([ + { + type: 'grid', + widths: [1, 1], + children: [{ type: 'chart' }, { type: 'plugin', plugin: { rawContent: PLUGIN } }], + }, + ]); + }); +}); describe('grid widths', () => { it('resolves valid widths', () => { diff --git a/apps/shared/tests/story-validation.test.ts b/apps/shared/tests/story-validation.test.ts index 94cf47b4e..112859fce 100644 --- a/apps/shared/tests/story-validation.test.ts +++ b/apps/shared/tests/story-validation.test.ts @@ -138,6 +138,34 @@ describe('validateStoryCode', () => { }); }); + describe('plugin validation', () => { + it('accepts non-empty JavaScript containing less-than and greater-than operators', () => { + const code = [ + '', + 'export default function render(element) {', + "\telement.textContent = 3 < 5 ? 'Below' : 'Above > target';", + '}', + '', + ].join('\n'); + + expect(validateStoryCode(code)).toEqual([]); + }); + + it('flags empty plugin code', () => { + const errors = validateStoryCode('\n \t\n'); + + expect(errors).toHaveLength(1); + expect(errors[0].message).toBe('Plugin code must not be empty.'); + }); + + it('flags a missing plugin closing tag', () => { + const errors = validateStoryCode('export default function render() {}'); + + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain('matching '); + }); + }); + describe('grid validation', () => { it('flags unterminated grid blocks', () => { const code =