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..730cf57b7 --- /dev/null +++ b/apps/shared/src/story-validation.ts @@ -0,0 +1,321 @@ +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, 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({ + 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, 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({ + 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 { + const lineStart = code.lastIndexOf('\n', index - 1) + 1; + const linePrefix = code.slice(lineStart, index); + return /^\s*\|/.test(linePrefix) || /\|\s*$/.test(linePrefix); +} + +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..3571c6200 --- /dev/null +++ b/apps/shared/tests/story-validation.test.ts @@ -0,0 +1,150 @@ +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); + }); + + 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', () => { + 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([]); + }); + + 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([]); + }); + + 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', () => { + 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); + }); +});