From f22953694da52c424d15ed455982fcd2cd77f47e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Apr 2026 09:11:56 +0000 Subject: [PATCH 1/4] feat(story): make code view editable with validation - StoryCodeView now supports editing story code with Monaco editor - validateStoryCode utility in @nao/shared checks chart/table/grid syntax - Invalid attributes surface as inline Monaco markers and a banner - Save is disabled while validation errors are present - Saving creates a new story version via the existing createVersion mutation - Editing is disabled in readonly mode (shared story view) --- .../hooks/use-story-viewer-version-actions.ts | 43 ++- .../components/side-panel/story-code-view.tsx | 164 +++++++++- .../components/side-panel/story-header.tsx | 29 +- .../components/side-panel/story-viewer.tsx | 26 +- apps/shared/package.json | 3 +- apps/shared/src/story-validation.ts | 300 ++++++++++++++++++ apps/shared/tests/story-validation.test.ts | 125 ++++++++ 7 files changed, 677 insertions(+), 13 deletions(-) create mode 100644 apps/shared/src/story-validation.ts create mode 100644 apps/shared/tests/story-validation.test.ts diff --git a/apps/frontend/src/components/side-panel/hooks/use-story-viewer-version-actions.ts b/apps/frontend/src/components/side-panel/hooks/use-story-viewer-version-actions.ts index 878d7a51e..94f5eaed4 100644 --- a/apps/frontend/src/components/side-panel/hooks/use-story-viewer-version-actions.ts +++ b/apps/frontend/src/components/side-panel/hooks/use-story-viewer-version-actions.ts @@ -4,6 +4,7 @@ import { getEditorMarkdown } from '../story-editor'; import type { MutableRefObject } from 'react'; import type { Editor as TiptapEditor } from '@tiptap/react'; import type { StoryViewMode } from '../story-viewer.types'; +import type { StoryCodeViewHandle } from '../story-code-view'; import { trpc } from '@/main'; interface UseStoryViewerVersionActionsParams { @@ -13,6 +14,8 @@ interface UseStoryViewerVersionActionsParams { currentVersionCode?: string; isViewingLatest: boolean; tiptapEditorRef: MutableRefObject; + codeViewRef: MutableRefObject; + viewMode: StoryViewMode; setViewMode: (mode: StoryViewMode) => void; } @@ -23,6 +26,8 @@ export const useStoryViewerVersionActions = ({ currentVersionCode, isViewingLatest, tiptapEditorRef, + codeViewRef, + viewMode, setViewMode, }: UseStoryViewerVersionActionsParams) => { const queryClient = useQueryClient(); @@ -58,13 +63,33 @@ export const useStoryViewerVersionActions = ({ ); const handleSave = useCallback(() => { - const editor = tiptapEditorRef.current; const hasVersionData = storyTitle !== undefined && currentVersionCode !== undefined; - if (!editor || !hasVersionData) { + if (!hasVersionData) { + return; + } + + let newCode: string | null = null; + if (viewMode === 'edit') { + const editor = tiptapEditorRef.current; + if (!editor) { + return; + } + newCode = getEditorMarkdown(editor); + } else if (viewMode === 'code') { + const codeView = codeViewRef.current; + if (!codeView) { + return; + } + if (codeView.getErrors().length > 0) { + return; + } + newCode = codeView.getCode(); + } + + if (newCode === null) { return; } - const newCode = getEditorMarkdown(editor); if (newCode === currentVersionCode) { setViewMode('preview'); return; @@ -79,7 +104,17 @@ export const useStoryViewerVersionActions = ({ }); setViewMode('preview'); - }, [chatId, storySlug, storyTitle, currentVersionCode, tiptapEditorRef, createVersionMutation, setViewMode]); + }, [ + chatId, + storySlug, + storyTitle, + currentVersionCode, + tiptapEditorRef, + codeViewRef, + viewMode, + createVersionMutation, + setViewMode, + ]); const handleRestore = useCallback(() => { const hasVersionData = storyTitle !== undefined && currentVersionCode !== undefined; diff --git a/apps/frontend/src/components/side-panel/story-code-view.tsx b/apps/frontend/src/components/side-panel/story-code-view.tsx index caae7ae9b..6e1eb8a8f 100644 --- a/apps/frontend/src/components/side-panel/story-code-view.tsx +++ b/apps/frontend/src/components/side-panel/story-code-view.tsx @@ -1,5 +1,11 @@ -import { memo } from 'react'; import { Editor } from '@monaco-editor/react'; +import { validateStoryCode } from '@nao/shared/story-validation'; +import { AlertTriangle } from 'lucide-react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { StoryValidationError } from '@nao/shared/story-validation'; +import type { Monaco } from '@monaco-editor/react'; +import type { editor } from 'monaco-editor'; +import { useEditorTheme } from '@/hooks/use-editor-theme'; const MONACO_OPTIONS = { minimap: { enabled: false }, @@ -9,13 +15,161 @@ const MONACO_OPTIONS = { scrollBeyondLastLine: false, padding: { top: 16, bottom: 16 }, wordWrap: 'on' as const, - readOnly: true, + fontSize: 13, }; -export const StoryCodeView = memo(function StoryCodeView({ code }: { code: string }) { +export interface StoryCodeViewHandle { + getCode: () => string; + getErrors: () => StoryValidationError[]; +} + +interface StoryCodeViewProps { + code: string; + readOnly?: boolean; + codeRef?: React.MutableRefObject; + onDirtyChange?: (dirty: boolean) => void; + onValidChange?: (valid: boolean) => void; + onSave?: () => void; +} + +const MARKER_OWNER = 'nao-story-validation'; + +export const StoryCodeView = memo(function StoryCodeView({ + code, + readOnly = false, + codeRef, + onDirtyChange, + onValidChange, + onSave, +}: StoryCodeViewProps) { + const editorTheme = useEditorTheme(); + const [draft, setDraft] = useState(code); + const [errors, setErrors] = useState(() => (readOnly ? [] : validateStoryCode(code))); + const editorInstanceRef = useRef(null); + const monacoRef = useRef(null); + const onSaveRef = useRef(onSave); + onSaveRef.current = onSave; + + useEffect(() => { + setDraft(code); + setErrors(readOnly ? [] : validateStoryCode(code)); + }, [code, readOnly]); + + useEffect(() => { + onDirtyChange?.(draft !== code); + }, [draft, code, onDirtyChange]); + + useEffect(() => { + onValidChange?.(errors.length === 0); + }, [errors, onValidChange]); + + useEffect(() => { + if (!codeRef) { + return; + } + codeRef.current = { + getCode: () => draft, + getErrors: () => errors, + }; + return () => { + codeRef.current = null; + }; + }, [codeRef, draft, errors]); + + useEffect(() => { + const monaco = monacoRef.current; + const instance = editorInstanceRef.current; + if (!monaco || !instance) { + return; + } + const model = instance.getModel(); + if (!model) { + return; + } + const markers = errors.map((error) => ({ + severity: monaco.MarkerSeverity.Error, + message: error.message, + startLineNumber: error.line, + startColumn: error.column, + endLineNumber: error.line, + endColumn: error.column + Math.max(error.length, 1), + })); + monaco.editor.setModelMarkers(model, MARKER_OWNER, markers); + }, [errors]); + + const handleMount = useCallback((instance: editor.IStandaloneCodeEditor, monaco: Monaco) => { + editorInstanceRef.current = instance; + monacoRef.current = monaco; + + instance.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { + onSaveRef.current?.(); + }); + }, []); + + useEffect(() => { + return () => { + const monaco = monacoRef.current; + const instance = editorInstanceRef.current; + if (monaco && instance) { + const model = instance.getModel(); + if (model) { + monaco.editor.setModelMarkers(model, MARKER_OWNER, []); + } + } + }; + }, []); + + const handleChange = useCallback( + (value: string | undefined) => { + const next = value ?? ''; + setDraft(next); + setErrors(readOnly ? [] : validateStoryCode(next)); + }, + [readOnly], + ); + + const options = useMemo( + () => ({ + ...MONACO_OPTIONS, + readOnly, + }), + [readOnly], + ); + return ( -
- +
+ {!readOnly && errors.length > 0 && } +
+ +
); }); + +function ValidationErrorBanner({ errors }: { errors: StoryValidationError[] }) { + return ( +
+
+ + + {errors.length} validation {errors.length === 1 ? 'error' : 'errors'} + +
+
    + {errors.slice(0, 5).map((error, i) => ( +
  • + L{error.line}: {error.message} +
  • + ))} + {errors.length > 5 &&
  • and {errors.length - 5} more...
  • } +
+
+ ); +} diff --git a/apps/frontend/src/components/side-panel/story-header.tsx b/apps/frontend/src/components/side-panel/story-header.tsx index d7d967d6a..626fbe542 100644 --- a/apps/frontend/src/components/side-panel/story-header.tsx +++ b/apps/frontend/src/components/side-panel/story-header.tsx @@ -58,6 +58,8 @@ export interface StoryHeaderProps { onRefreshData: () => void; onOpenLiveSettings: () => void; onClose: () => void; + isCodeDirty?: boolean; + isCodeValid?: boolean; } export const StoryHeader = memo(function StoryHeader({ @@ -87,11 +89,14 @@ export const StoryHeader = memo(function StoryHeader({ onRefreshData, onOpenLiveSettings, onClose, + isCodeDirty = false, + isCodeValid = true, }: StoryHeaderProps) { const isMobile = useIsMobile(); const otherStories = useMemo(() => allStories.filter((s) => s.id !== storySlug), [allStories, storySlug]); const hasMultiple = otherStories.length > 0; - const showSubHeader = viewMode === 'edit' || !isViewingLatest; + const isEditingCode = viewMode === 'code' && isCodeDirty && !isReadonlyMode; + const showSubHeader = viewMode === 'edit' || isEditingCode || !isViewingLatest; const titleElement = hasMultiple ? ( @@ -293,6 +298,28 @@ export const StoryHeader = memo(function StoryHeader({
+ ) : isEditingCode ? ( + <> + + {isCodeValid ? 'Editing code' : 'Fix validation errors to save'} + +
+ + +
+ ) : ( <> diff --git a/apps/frontend/src/components/side-panel/story-viewer.tsx b/apps/frontend/src/components/side-panel/story-viewer.tsx index 1078a2ccc..329860d38 100644 --- a/apps/frontend/src/components/side-panel/story-viewer.tsx +++ b/apps/frontend/src/components/side-panel/story-viewer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState, useSyncExternalStore } from 'react'; +import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react'; import { useQuery } from '@tanstack/react-query'; import { ShareStoryDialog } from '../share-dialog.story'; import { StoryEditor } from './story-editor'; @@ -18,6 +18,7 @@ import { useStoryViewerVersionActions } from './hooks/use-story-viewer-version-a import { useStoryViewerVersions } from './hooks/use-story-viewer-versions'; import { useStoryViewerViewMode } from './hooks/use-story-viewer-view-mode'; import type { Editor as TiptapEditor } from '@tiptap/react'; +import type { StoryCodeViewHandle } from './story-code-view'; import { useSidePanel } from '@/contexts/side-panel'; import { ReadonlyAgentMessagesProvider, useOptionalAgentContext } from '@/contexts/agent.provider'; import { Spinner } from '@/components/ui/spinner'; @@ -32,6 +33,9 @@ interface StoryViewerProps { export function StoryViewer({ chatId, storySlug, isReadonlyMode: readonlyProp }: StoryViewerProps) { const tiptapEditorRef = useRef(null); + const codeViewRef = useRef(null); + const [isCodeDirty, setIsCodeDirty] = useState(false); + const [isCodeValid, setIsCodeValid] = useState(true); const scrollContainerRef = useRef(null); const { close: closeSidePanel, isReadonlyMode: contextReadonlyMode, shareId } = useSidePanel(); const isReadonlyMode = readonlyProp ?? contextReadonlyMode; @@ -83,6 +87,8 @@ export function StoryViewer({ chatId, storySlug, isReadonlyMode: readonlyProp }: currentVersionCode: currentVersion?.code, isViewingLatest, tiptapEditorRef, + codeViewRef, + viewMode, setViewMode, }); const { isShareDialogOpen, setIsShareDialogOpen, isShared } = useStoryViewerSharing({ @@ -113,6 +119,13 @@ export function StoryViewer({ chatId, storySlug, isReadonlyMode: readonlyProp }: ); const { switchStory } = useStoryViewerSwitchStory({ renderStoryViewer }); + useEffect(() => { + if (viewMode !== 'code') { + setIsCodeDirty(false); + setIsCodeValid(true); + } + }, [viewMode]); + useStoryViewerStreamScroll({ scrollContainerRef, isStreaming: Boolean(draftStory?.isStreaming), @@ -164,6 +177,8 @@ export function StoryViewer({ chatId, storySlug, isReadonlyMode: readonlyProp }: onRefreshData={handleRefreshData} onOpenLiveSettings={handleOpenLiveSettings} onClose={closeSidePanel} + isCodeDirty={isCodeDirty} + isCodeValid={isCodeValid} /> {Boolean(archivedAt) && } @@ -180,7 +195,14 @@ export function StoryViewer({ chatId, storySlug, isReadonlyMode: readonlyProp }: ) : viewMode === 'edit' ? ( ) : ( - + )} diff --git a/apps/shared/package.json b/apps/shared/package.json index 24a716682..40094f560 100644 --- a/apps/shared/package.json +++ b/apps/shared/package.json @@ -9,7 +9,8 @@ "./posthog": "./src/posthog.ts", "./tools": "./src/tools/index.ts", "./story-segments": "./src/story-segments.ts", - "./story-table-utils": "./src/story-table-utils.ts" + "./story-table-utils": "./src/story-table-utils.ts", + "./story-validation": "./src/story-validation.ts" }, "scripts": { "test": "vitest run", diff --git a/apps/shared/src/story-validation.ts b/apps/shared/src/story-validation.ts new file mode 100644 index 000000000..9ba018afc --- /dev/null +++ b/apps/shared/src/story-validation.ts @@ -0,0 +1,300 @@ +import { parseChartAttributes } from './story-segments'; + +export interface StoryValidationError { + message: string; + line: number; + column: number; + length: number; +} + +const REQUIRED_CHART_ATTRS = ['query_id', 'chart_type', 'x_axis_key'] as const; +const REQUIRED_TABLE_ATTRS = ['query_id'] as const; + +const VALID_CHART_TYPES = new Set([ + 'bar', + 'stacked_bar', + 'line', + 'area', + 'stacked_area', + 'pie', + 'kpi_card', + 'scatter', + 'radar', +]); + +const VALID_X_AXIS_TYPES = new Set(['date', 'number', 'category']); + +/** + * Validates the structure of a story's markdown code, looking for common + * authoring mistakes in , and blocks. + * + * Returns a list of errors with 1-based line/column coordinates suitable for + * driving Monaco editor markers. + */ +export function validateStoryCode(code: string): StoryValidationError[] { + const errors: StoryValidationError[] = []; + + errors.push(...validateGridBlocks(code)); + errors.push(...validateChartBlocks(code)); + errors.push(...validateTableBlocks(code)); + errors.push(...validateUnterminatedTags(code)); + + return errors.sort((a, b) => a.line - b.line || a.column - b.column); +} + +function validateChartBlocks(code: string): StoryValidationError[] { + const errors: StoryValidationError[] = []; + const chartRegex = /]*?)\/?>/g; + let match: RegExpExecArray | null; + + while ((match = chartRegex.exec(code)) !== null) { + const [fullMatch, attrString] = match; + const position = getPosition(code, match.index); + const attrs = parseChartAttributes(attrString ?? ''); + + const missing = REQUIRED_CHART_ATTRS.filter((attr) => !attrs[attr]); + if (missing.length > 0) { + errors.push({ + message: `Chart is missing required attribute${missing.length === 1 ? '' : 's'}: ${missing.join(', ')}.`, + line: position.line, + column: position.column, + length: fullMatch.length, + }); + } + + if (attrs.chart_type && !VALID_CHART_TYPES.has(attrs.chart_type)) { + errors.push({ + message: `Invalid chart_type "${attrs.chart_type}". Valid types: ${[...VALID_CHART_TYPES].join(', ')}.`, + line: position.line, + column: position.column, + length: fullMatch.length, + }); + } + + if (attrs.x_axis_type && !VALID_X_AXIS_TYPES.has(attrs.x_axis_type)) { + errors.push({ + message: `Invalid x_axis_type "${attrs.x_axis_type}". Valid values: ${[...VALID_X_AXIS_TYPES].join(', ')}.`, + line: position.line, + column: position.column, + length: fullMatch.length, + }); + } + + const seriesError = validateChartSeries(attrs, attrString ?? '', position, fullMatch.length); + if (seriesError) { + errors.push(seriesError); + } + } + + return errors; +} + +function validateChartSeries( + attrs: Record, + attrString: string, + position: { line: number; column: number }, + length: number, +): StoryValidationError | null { + if (attrs.series === undefined && attrs.data_key === undefined) { + return { + message: 'Chart must define either a `series=[...]` array or a `data_key` attribute.', + line: position.line, + column: position.column, + length, + }; + } + + if (attrs.series === undefined) { + return null; + } + + const rawSeries = extractRawSeriesBracket(attrString); + const jsonSource = rawSeries ?? attrs.series; + + let parsed: unknown; + try { + parsed = JSON.parse(jsonSource); + } catch { + return { + message: 'Chart `series` attribute must be a valid JSON array.', + line: position.line, + column: position.column, + length, + }; + } + + if (!Array.isArray(parsed) || parsed.length === 0) { + return { + message: 'Chart `series` attribute must be a non-empty JSON array.', + line: position.line, + column: position.column, + length, + }; + } + + for (const item of parsed) { + if (!item || typeof item !== 'object' || typeof (item as { data_key?: unknown }).data_key !== 'string') { + return { + message: 'Each chart series entry must be an object with a string `data_key` property.', + line: position.line, + column: position.column, + length, + }; + } + } + + return null; +} + +function extractRawSeriesBracket(attrString: string): string | null { + const seriesIdx = attrString.search(/\bseries\s*=/); + if (seriesIdx === -1) { + return null; + } + const bracketStart = attrString.indexOf('[', seriesIdx); + if (bracketStart === -1) { + return null; + } + let depth = 0; + for (let i = bracketStart; i < attrString.length; i++) { + if (attrString[i] === '[') { + depth++; + } else if (attrString[i] === ']') { + depth--; + if (depth === 0) { + return attrString.slice(bracketStart, i + 1); + } + } + } + return null; +} + +function validateTableBlocks(code: string): StoryValidationError[] { + const errors: StoryValidationError[] = []; + const tableRegex = /]*?)\/?>/g; + let match: RegExpExecArray | null; + + while ((match = tableRegex.exec(code)) !== null) { + const [fullMatch, attrString] = match; + if (isMarkdownTable(code, match.index)) { + continue; + } + const position = getPosition(code, match.index); + const attrs = parseChartAttributes(attrString ?? ''); + const missing = REQUIRED_TABLE_ATTRS.filter((attr) => !attrs[attr]); + if (missing.length > 0) { + errors.push({ + message: `Table is missing required attribute${missing.length === 1 ? '' : 's'}: ${missing.join(', ')}.`, + line: position.line, + column: position.column, + length: fullMatch.length, + }); + } + } + + return errors; +} + +function isMarkdownTable(code: string, index: number): boolean { + return code.startsWith('|', index) || /\|\s*$/.test(code.slice(0, index)); +} + +function validateGridBlocks(code: string): StoryValidationError[] { + const errors: StoryValidationError[] = []; + const openTagRegex = /]*)>/g; + let match: RegExpExecArray | null; + + while ((match = openTagRegex.exec(code)) !== null) { + const position = getPosition(code, match.index); + const closeIdx = findMatchingClose(code, openTagRegex.lastIndex); + if (closeIdx === -1) { + errors.push({ + message: ' tag is missing a matching closing tag.', + line: position.line, + column: position.column, + length: match[0].length, + }); + continue; + } + + const attrs = parseChartAttributes(match[1] ?? ''); + if (attrs.cols !== undefined) { + const cols = Number(attrs.cols); + if (!Number.isInteger(cols) || cols < 1 || cols > 4) { + errors.push({ + message: `Grid \`cols\` must be an integer between 1 and 4 (got "${attrs.cols}").`, + line: position.line, + column: position.column, + length: match[0].length, + }); + } + } + } + + return errors; +} + +function findMatchingClose(code: string, startIndex: number): number { + let depth = 1; + let index = startIndex; + const openRegex = /]*>/g; + const closeRegex = /<\/grid\s*>/g; + openRegex.lastIndex = index; + closeRegex.lastIndex = index; + + while (depth > 0) { + openRegex.lastIndex = index; + closeRegex.lastIndex = index; + const next = openRegex.exec(code); + const close = closeRegex.exec(code); + if (!close) { + return -1; + } + if (next && next.index < close.index) { + depth++; + index = next.index + next[0].length; + } else { + depth--; + index = close.index + close[0].length; + if (depth === 0) { + return close.index; + } + } + } + return -1; +} + +function validateUnterminatedTags(code: string): StoryValidationError[] { + const errors: StoryValidationError[] = []; + const tagRegex = /<(chart|table)\b[^>]*$/gm; + let match: RegExpExecArray | null; + + while ((match = tagRegex.exec(code)) !== null) { + if (match[0].includes('>')) { + continue; + } + const position = getPosition(code, match.index); + errors.push({ + message: `<${match[1]}> tag is not properly closed — did you forget "/>"?`, + line: position.line, + column: position.column, + length: match[0].length, + }); + } + + return errors; +} + +function getPosition(code: string, offset: number): { line: number; column: number } { + let line = 1; + let column = 1; + for (let i = 0; i < offset; i++) { + if (code[i] === '\n') { + line++; + column = 1; + } else { + column++; + } + } + return { line, column }; +} diff --git a/apps/shared/tests/story-validation.test.ts b/apps/shared/tests/story-validation.test.ts new file mode 100644 index 000000000..a59ac8681 --- /dev/null +++ b/apps/shared/tests/story-validation.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; + +import { validateStoryCode } from '../src/story-validation'; + +describe('validateStoryCode', () => { + it('returns no errors for well-formed code', () => { + const code = [ + '# Revenue report', + '', + 'Some markdown content here.', + '', + '', + '', + '
', + '', + '', + '', + '', + '', + ].join('\n'); + + expect(validateStoryCode(code)).toEqual([]); + }); + + it('accepts plain markdown without any embed tags', () => { + expect(validateStoryCode('# title\n\nHello **world**!')).toEqual([]); + }); + + describe('chart validation', () => { + it('flags missing required attributes', () => { + const code = ''; + const errors = validateStoryCode(code); + expect(errors.some((e) => /missing required attributes: chart_type, x_axis_key/.test(e.message))).toBe( + true, + ); + }); + + it('flags invalid chart_type', () => { + const code = ''; + const errors = validateStoryCode(code); + expect(errors).toHaveLength(1); + expect(errors[0].message).toMatch(/Invalid chart_type "donut"/); + }); + + it('flags invalid x_axis_type', () => { + const code = + ''; + const errors = validateStoryCode(code); + expect(errors.some((e) => e.message.includes('Invalid x_axis_type "bogus"'))).toBe(true); + }); + + it('flags a chart without series or data_key', () => { + const code = ''; + const errors = validateStoryCode(code); + expect(errors.some((e) => e.message.includes('series=[...]'))).toBe(true); + }); + + it('flags a chart with malformed JSON series', () => { + const code = ''; + const errors = validateStoryCode(code); + expect(errors.some((e) => e.message.toLowerCase().includes('valid json array'))).toBe(true); + }); + + it('flags a chart with an empty series array', () => { + const code = ''; + const errors = validateStoryCode(code); + expect(errors.some((e) => e.message.includes('non-empty JSON array'))).toBe(true); + }); + + it('flags series entries without data_key', () => { + const code = + ''; + const errors = validateStoryCode(code); + expect(errors.some((e) => e.message.includes('data_key'))).toBe(true); + }); + }); + + describe('table validation', () => { + it('flags tables missing query_id', () => { + const code = '
'; + const errors = validateStoryCode(code); + expect(errors).toHaveLength(1); + expect(errors[0].message).toMatch(/missing required attribute: query_id/); + }); + + it('does not flag markdown tables', () => { + const code = '| foo | bar |\n| --- | --- |\n| 1 | 2 |'; + expect(validateStoryCode(code)).toEqual([]); + }); + }); + + describe('grid validation', () => { + it('flags unterminated grid blocks', () => { + const code = + '\n'; + const errors = validateStoryCode(code); + expect(errors.some((e) => e.message.includes('matching '))).toBe(true); + }); + + it('flags invalid cols values', () => { + const code = '\n'; + const errors = validateStoryCode(code); + expect(errors.some((e) => e.message.includes('between 1 and 4'))).toBe(true); + }); + + it('supports nested grids', () => { + const code = [ + '', + '', + '', + '', + '', + '', + ].join('\n'); + expect(validateStoryCode(code)).toEqual([]); + }); + }); + + it('reports line and column for errors', () => { + const code = ['# intro', '', 'some text', '', ''].join('\n'); + const errors = validateStoryCode(code); + expect(errors[0].line).toBe(5); + expect(errors[0].column).toBe(1); + }); +}); From dff3298affc8c9761ec7b35174734bac45c625fc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Apr 2026 14:57:28 +0000 Subject: [PATCH 2/4] fix(story-validation): scope markdown-table check to current line The previous isMarkdownTable used a regex against the full preceding document (/\|\s*$/.test(code.slice(0, index))) without the multiline flag, so any prior markdown table row anywhere earlier in the document would cause subsequent
tags to be silently skipped during validation. Now we only inspect the prefix of the current line and check whether it starts with a pipe character. --- apps/shared/src/story-validation.ts | 4 +++- apps/shared/tests/story-validation.test.ts | 13 +++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/shared/src/story-validation.ts b/apps/shared/src/story-validation.ts index 9ba018afc..311233f7c 100644 --- a/apps/shared/src/story-validation.ts +++ b/apps/shared/src/story-validation.ts @@ -196,7 +196,9 @@ function validateTableBlocks(code: string): StoryValidationError[] { } function isMarkdownTable(code: string, index: number): boolean { - return code.startsWith('|', index) || /\|\s*$/.test(code.slice(0, index)); + const lineStart = code.lastIndexOf('\n', index - 1) + 1; + const linePrefix = code.slice(lineStart, index); + return /^\s*\|/.test(linePrefix); } function validateGridBlocks(code: string): StoryValidationError[] { diff --git a/apps/shared/tests/story-validation.test.ts b/apps/shared/tests/story-validation.test.ts index a59ac8681..35864897b 100644 --- a/apps/shared/tests/story-validation.test.ts +++ b/apps/shared/tests/story-validation.test.ts @@ -87,6 +87,19 @@ describe('validateStoryCode', () => { const code = '| foo | bar |\n| --- | --- |\n| 1 | 2 |'; expect(validateStoryCode(code)).toEqual([]); }); + + it('still validates
tags that follow a markdown table in the document', () => { + const code = ['| a | b |', '| - | - |', '| 1 | 2 |', '', '
'].join('\n'); + const errors = validateStoryCode(code); + expect(errors).toHaveLength(1); + expect(errors[0].message).toMatch(/missing required attribute: query_id/); + expect(errors[0].line).toBe(5); + }); + + it('skips
tags embedded inside a markdown table cell', () => { + const code = '| a |
|\n| - | - |\n| 1 | 2 |'; + expect(validateStoryCode(code)).toEqual([]); + }); }); describe('grid validation', () => { From 63751cc26266c9e5e19f62415a3d8bfb0367a2b3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 30 Apr 2026 15:03:02 +0000 Subject: [PATCH 3/4] fix(story-validation): flag /
tags missing self-closing slash The chart/table regexes treated the slash before the closing > as optional (/]*?)\\/?>/), so a tag like (no slash) silently passed validation even though the embed convention requires self-closing tags (per apps/shared/src/tools/story.ts). Now we capture the slash and emit a dedicated error message when it is absent, while still happily accepting the canonical . --- apps/shared/src/story-validation.ts | 27 ++++++++++++++++++---- apps/shared/tests/story-validation.test.ts | 12 ++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/apps/shared/src/story-validation.ts b/apps/shared/src/story-validation.ts index 311233f7c..4a3067dbd 100644 --- a/apps/shared/src/story-validation.ts +++ b/apps/shared/src/story-validation.ts @@ -44,14 +44,23 @@ export function validateStoryCode(code: string): StoryValidationError[] { function validateChartBlocks(code: string): StoryValidationError[] { const errors: StoryValidationError[] = []; - const chartRegex = /]*?)\/?>/g; + const chartRegex = /]*?)(\/?)>/g; let match: RegExpExecArray | null; while ((match = chartRegex.exec(code)) !== null) { - const [fullMatch, attrString] = match; + const [fullMatch, attrString, slash] = match; const position = getPosition(code, match.index); const attrs = parseChartAttributes(attrString ?? ''); + if (slash !== '/') { + errors.push({ + message: ' tag must be self-closing — use "/>" instead of ">".', + line: position.line, + column: position.column, + length: fullMatch.length, + }); + } + const missing = REQUIRED_CHART_ATTRS.filter((attr) => !attrs[attr]); if (missing.length > 0) { errors.push({ @@ -171,16 +180,26 @@ function extractRawSeriesBracket(attrString: string): string | null { function validateTableBlocks(code: string): StoryValidationError[] { const errors: StoryValidationError[] = []; - const tableRegex = /]*?)\/?>/g; + const tableRegex = /]*?)(\/?)>/g; let match: RegExpExecArray | null; while ((match = tableRegex.exec(code)) !== null) { - const [fullMatch, attrString] = match; + const [fullMatch, attrString, slash] = match; if (isMarkdownTable(code, match.index)) { continue; } const position = getPosition(code, match.index); const attrs = parseChartAttributes(attrString ?? ''); + + if (slash !== '/') { + errors.push({ + message: '
tag must be self-closing — use "/>" instead of ">".', + line: position.line, + column: position.column, + length: fullMatch.length, + }); + } + const missing = REQUIRED_TABLE_ATTRS.filter((attr) => !attrs[attr]); if (missing.length > 0) { errors.push({ diff --git a/apps/shared/tests/story-validation.test.ts b/apps/shared/tests/story-validation.test.ts index 35864897b..3571c6200 100644 --- a/apps/shared/tests/story-validation.test.ts +++ b/apps/shared/tests/story-validation.test.ts @@ -73,6 +73,12 @@ describe('validateStoryCode', () => { const errors = validateStoryCode(code); expect(errors.some((e) => e.message.includes('data_key'))).toBe(true); }); + + it('flags tags closed with ">" instead of "/>"', () => { + const code = ''; + const errors = validateStoryCode(code); + expect(errors.some((e) => e.message.includes('self-closing'))).toBe(true); + }); }); describe('table validation', () => { @@ -100,6 +106,12 @@ describe('validateStoryCode', () => { const code = '| a |
|\n| - | - |\n| 1 | 2 |'; expect(validateStoryCode(code)).toEqual([]); }); + + it('flags
tags closed with ">" instead of "/>"', () => { + const code = '
'; + const errors = validateStoryCode(code); + expect(errors.some((e) => e.message.includes('self-closing'))).toBe(true); + }); }); describe('grid validation', () => { From c6a48824f4d31b1318e035cf4d71b852c204da9c Mon Sep 17 00:00:00 2001 From: Christophe Blefari Date: Thu, 30 Apr 2026 18:03:18 +0200 Subject: [PATCH 4/4] Update apps/shared/src/story-validation.ts Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> Signed-off-by: Christophe Blefari --- apps/shared/src/story-validation.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/shared/src/story-validation.ts b/apps/shared/src/story-validation.ts index 4a3067dbd..730cf57b7 100644 --- a/apps/shared/src/story-validation.ts +++ b/apps/shared/src/story-validation.ts @@ -217,7 +217,7 @@ function validateTableBlocks(code: string): StoryValidationError[] { function isMarkdownTable(code: string, index: number): boolean { const lineStart = code.lastIndexOf('\n', index - 1) + 1; const linePrefix = code.slice(lineStart, index); - return /^\s*\|/.test(linePrefix); + return /^\s*\|/.test(linePrefix) || /\|\s*$/.test(linePrefix); } function validateGridBlocks(code: string): StoryValidationError[] {