Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/backend/src/services/live-story.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,11 +296,11 @@ function preservesStoryStructure(originalCode: string, candidateCode: string): b
}

function extractStructureTokens(code: string): string[] {
const structureRegex = new RegExp(
`<grid\\s+[^>]*>|<\\/grid>|<chart\\s+${TAG_ATTRS}\\/?>|<table\\s+${TAG_ATTRS}\\/?>`,
const tokenRegex = new RegExp(
String.raw`<grid\s+${TAG_ATTRS}>|<\/grid>|<chart\s+${TAG_ATTRS}\/?>|<table\s+${TAG_ATTRS}\/?>`,
'g',
);
return code.match(structureRegex) ?? [];
return code.match(tokenRegex) ?? [];
}

function extractHeadingTokens(code: string): string[] {
Expand Down
7 changes: 2 additions & 5 deletions apps/backend/src/utils/story-summary.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TAG_ATTRS } from '@nao/shared/story-segments';
import { storyBlockRegex } from '@nao/shared/story-segments';
import { parseStoryTabs } from '@nao/shared/story-tabs';
import type { StorySummary, SummarySegment } from '@nao/shared/types';

Expand All @@ -20,10 +20,7 @@ export function extractStorySummary(code: string): StorySummary {

function extractSegments(code: string): SummarySegment[] {
const segments: SummarySegment[] = [];
const blockRegex = new RegExp(
`<grid\\s+([^>]*)>([\\s\\S]*?)<\\/grid>|<chart\\s+(${TAG_ATTRS})\\/?>|<table\\s+(${TAG_ATTRS})\\/?>`,
'g',
);
const blockRegex = storyBlockRegex();
let match;
let lastIndex = 0;

Expand Down
11 changes: 6 additions & 5 deletions apps/frontend/src/components/side-panel/story-editor.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import {
chartTagRegex,
getGridClass,
parseChartAttributes,
parseChartBlock,
parseTableBlock,
splitCodeIntoSegments,
TAG_ATTRS,
tableTagRegex,
} from '@nao/shared/story-segments';
import { Extension, mergeAttributes, Node } from '@tiptap/core';
import { DragHandle } from '@tiptap/extension-drag-handle-react';
Expand Down Expand Up @@ -48,11 +49,11 @@ export function preprocessForEditor(code: string): string {
return `<div><grid-embed data-raw="${encodeForAttr(match)}"></grid-embed></div>\n\n`;
});

result = result.replace(new RegExp(`<chart\\s+${TAG_ATTRS}\\/?>`, 'g'), (match) => {
result = result.replace(chartTagRegex('g'), (match) => {
return `<div><chart-embed data-raw="${encodeForAttr(match)}"></chart-embed></div>\n\n`;
});

result = result.replace(new RegExp(`<table\\s+${TAG_ATTRS}\\/?>`, 'g'), (match) => {
result = result.replace(tableTagRegex('g'), (match) => {
return `<div><table-embed data-raw="${encodeForAttr(match)}"></table-embed></div>\n\n`;
});

Expand All @@ -67,7 +68,7 @@ function ChartBlockView({ node, updateAttributes }: ReactNodeViewProps) {
const rawTag = node.attrs.rawTag as string;

const chart = useMemo(() => {
const attrMatch = rawTag.match(new RegExp(`<chart\\s+(${TAG_ATTRS})\\/?>`));
const attrMatch = rawTag.match(chartTagRegex());
if (!attrMatch) {
return null;
}
Expand Down Expand Up @@ -151,7 +152,7 @@ function TableBlockView({ node }: ReactNodeViewProps) {
const rawTag = node.attrs.rawTag as string;

const table = useMemo(() => {
const attrMatch = rawTag.match(new RegExp(`<table\\s+(${TAG_ATTRS})\\/?>`));
const attrMatch = rawTag.match(tableTagRegex());
if (!attrMatch) {
return null;
}
Expand Down
48 changes: 33 additions & 15 deletions apps/shared/src/story-segments.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,6 @@
import { buildStoryTableBlock } from './chart-block';
import { type ColumnConditionalFormats, sanitizeConditionalFormats } from './conditional-formatting';

/**
* Matches a tag's attribute list while treating single/double-quoted values as
* opaque, so `>` and `/` inside a quoted attribute (e.g. a threshold rule's
* `">="` operator inside `formatting='{...}'`) do not prematurely terminate the
* tag. Kept as a shared constant so every block-tag regex stays consistent.
*/
export const TAG_ATTRS = `(?:[^>"']|"(?:[^"\\\\]|\\\\.)*"|'(?:[^'\\\\]|\\\\.)*')*?`;

export interface ParsedChartBlock {
queryId: string;
chartType: string;
Expand Down Expand Up @@ -37,6 +29,23 @@ export type Segment =
| { type: 'table'; table: ParsedTableBlock }
| { type: 'grid'; cols: number; children: Segment[] };

export const TAG_ATTRS = String.raw`(?:[^>"']|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')*?`;

export function chartTagRegex(flags = ''): RegExp {
return new RegExp(String.raw`<chart\s+(${TAG_ATTRS})\/?>`, flags);
}

export function tableTagRegex(flags = ''): RegExp {
return new RegExp(String.raw`<table\s+(${TAG_ATTRS})\/?>`, flags);
}

export function storyBlockRegex(): RegExp {
return new RegExp(
String.raw`<grid\s+(${TAG_ATTRS})>([\s\S]*?)<\/grid>|<chart\s+(${TAG_ATTRS})\/?>|<table\s+(${TAG_ATTRS})\/?>`,
'g',
);
}

function unescapeAttributeValue(value: string): string {
return value.replace(/\\(["'\\])/g, '$1');
}
Expand Down Expand Up @@ -157,15 +166,27 @@ export function getGridClass(cols: number): string {
return GRID_CLASSES[Math.min(cols, 4)] ?? GRID_CLASSES[2];
}

function tryParseSeriesJson(value: string): ParsedChartBlock['series'] | null {
export function parseSeriesJsonArray(value: string): unknown[] | null {
const parsed = tryJsonParse(value) ?? tryJsonParse(escapeStrayBackslashes(value));
return Array.isArray(parsed) ? parsed : null;
}

function tryJsonParse(value: string): unknown {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : null;
return JSON.parse(value);
} catch {
return null;
}
}

function escapeStrayBackslashes(value: string): string {
return value.replace(/\\(?!(?:["\\/bfnrt]|u[0-9a-fA-F]{4}))/g, '\\\\');
}

function tryParseSeriesJson(value: string): ParsedChartBlock['series'] | null {
return parseSeriesJsonArray(value) as ParsedChartBlock['series'] | null;
}

function parseOptionalNumberAttr(value: string | undefined): number | undefined {
return value !== undefined && value !== '' && Number.isFinite(Number(value)) ? Number(value) : undefined;
}
Expand Down Expand Up @@ -197,10 +218,7 @@ function extractSeriesFromRawAttrs(attrString: string): ParsedChartBlock['series

export function splitCodeIntoSegments(code: string): Segment[] {
const segments: Segment[] = [];
const blockRegex = new RegExp(
`<grid\\s+([^>]*)>([\\s\\S]*?)<\\/grid>|<chart\\s+(${TAG_ATTRS})\\/?>|<table\\s+(${TAG_ATTRS})\\/?>`,
'g',
);
const blockRegex = storyBlockRegex();
let match;
let lastIndex = 0;

Expand Down
42 changes: 7 additions & 35 deletions apps/shared/src/story-validation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { parseChartAttributes, TAG_ATTRS } from './story-segments';
import { parseChartAttributes, parseSeriesJsonArray, TAG_ATTRS } from './story-segments';

export interface StoryValidationError {
message: string;
Expand Down Expand Up @@ -117,7 +117,7 @@ function validateTabsBlocks(code: string): StoryValidationError[] {

function validateChartBlocks(code: string): StoryValidationError[] {
const errors: StoryValidationError[] = [];
const chartRegex = new RegExp(`<chart\\b(${TAG_ATTRS})(\\/?)>`, 'g');
const chartRegex = new RegExp(String.raw`<chart\b(${TAG_ATTRS})(\/?)>`, 'g');
let match: RegExpExecArray | null;

while ((match = chartRegex.exec(code)) !== null) {
Expand Down Expand Up @@ -162,7 +162,7 @@ function validateChartBlocks(code: string): StoryValidationError[] {
});
}

const seriesError = validateChartSeries(attrs, attrString ?? '', position, fullMatch.length);
const seriesError = validateChartSeries(attrs, position, fullMatch.length);
if (seriesError) {
errors.push(seriesError);
}
Expand All @@ -173,7 +173,6 @@ function validateChartBlocks(code: string): StoryValidationError[] {

function validateChartSeries(
attrs: Record<string, string>,
attrString: string,
position: { line: number; column: number },
length: number,
): StoryValidationError | null {
Expand All @@ -190,13 +189,9 @@ function validateChartSeries(
return null;
}

const rawSeries = extractRawSeriesBracket(attrString);
const jsonSource = rawSeries ?? attrs.series;
const parsed = parseSeriesJsonArray(attrs.series);

let parsed: unknown;
try {
parsed = JSON.parse(jsonSource);
} catch {
if (parsed === null) {
return {
message: 'Chart `series` attribute must be a valid JSON array.',
line: position.line,
Expand All @@ -205,7 +200,7 @@ function validateChartSeries(
};
}

if (!Array.isArray(parsed) || parsed.length === 0) {
if (parsed.length === 0) {
return {
message: 'Chart `series` attribute must be a non-empty JSON array.',
line: position.line,
Expand All @@ -228,32 +223,9 @@ function validateChartSeries(
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 = new RegExp(`<table\\b(${TAG_ATTRS})(\\/?)>`, 'g');
const tableRegex = new RegExp(String.raw`<table\b(${TAG_ATTRS})(\/?)>`, 'g');
let match: RegExpExecArray | null;

while ((match = tableRegex.exec(code)) !== null) {
Expand Down
123 changes: 123 additions & 0 deletions apps/shared/tests/story-segments.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest';

import { buildStoryChartBlock } from '../src/chart-block';
import { splitCodeIntoSegments } from '../src/story-segments';

function chartOf(code: string) {
const segment = splitCodeIntoSegments(code).find((s) => s.type === 'chart');
return segment?.type === 'chart' ? segment.chart : null;
}

function seriesOf(code: string) {
return chartOf(code)?.series ?? null;
}

describe('splitCodeIntoSegments chart series', () => {
it('round-trips a label containing a backslash built via buildStoryChartBlock', () => {
const code = buildStoryChartBlock({
query_id: 'q1',
chart_type: 'bar',
x_axis_key: 'month',
series: [{ data_key: 'rev', color: 'var(--chart-1)', label: 'Disc\\Rebate' }],
title: 'Revenue',
});

expect(seriesOf(code)).toEqual([{ data_key: 'rev', color: 'var(--chart-1)', label: 'Disc\\Rebate' }]);
});

it('recovers a hand-authored series where a backslash was not JSON-escaped', () => {
const code =
'<chart query_id="q1" chart_type="bar" x_axis_key="month" series=\'[{"data_key":"rev","label":"Disc\\Rebate"}]\' />';

expect(seriesOf(code)).toEqual([{ data_key: 'rev', label: 'Disc\\Rebate' }]);
});

it('recovers a hand-authored series with a malformed unicode escape', () => {
const code =
'<chart query_id="q1" chart_type="bar" x_axis_key="month" series=\'[{"data_key":"rev","label":"A\\uZZZZ"}]\' />';

expect(seriesOf(code)).toEqual([{ data_key: 'rev', label: 'A\\uZZZZ' }]);
});

it('recovers a hand-authored series with a truncated unicode escape', () => {
const code =
'<chart query_id="q1" chart_type="bar" x_axis_key="month" series=\'[{"data_key":"rev","label":"A\\u00"}]\' />';

expect(seriesOf(code)).toEqual([{ data_key: 'rev', label: 'A\\u00' }]);
});

it('preserves a well-formed unicode escape', () => {
const code =
'<chart query_id="q1" chart_type="bar" x_axis_key="month" series=\'[{"data_key":"rev","label":"A\\u0041"}]\' />';

expect(seriesOf(code)).toEqual([{ data_key: 'rev', label: 'AA' }]);
});

it('round-trips a label containing a double quote', () => {
const code = buildStoryChartBlock({
query_id: 'q1',
chart_type: 'bar',
x_axis_key: 'month',
series: [{ data_key: 'rev', color: 'var(--chart-1)', label: 'a "quoted" label' }],
title: 'Revenue',
});

expect(seriesOf(code)).toEqual([{ data_key: 'rev', color: 'var(--chart-1)', label: 'a "quoted" label' }]);
});
});

describe('splitCodeIntoSegments slash-in-attribute handling', () => {
it('parses a chart whose title contains a slash', () => {
const code =
'<chart query_id="query_8af4f8ab" chart_type="line" x_axis_key="week_start" x_axis_type="date" series=\'[{"data_key":"number_of_orders","color":"#2563eb","label":"Number of orders","is_total":false}]\' title="13/07 update" />';

const chart = chartOf(code);
expect(chart?.title).toBe('13/07 update');
expect(chart?.series).toEqual([
{ data_key: 'number_of_orders', color: '#2563eb', label: 'Number of orders', is_total: false },
]);
});

it('parses a series label containing a slash', () => {
const code = buildStoryChartBlock({
query_id: 'q1',
chart_type: 'bar',
x_axis_key: 'month',
series: [{ data_key: 'rev', color: 'var(--chart-1)', label: 'rev/cost' }],
title: 'Ratio',
});

expect(seriesOf(code)).toEqual([{ data_key: 'rev', color: 'var(--chart-1)', label: 'rev/cost' }]);
});

it('handles a slash in the title together with a backslash in a label', () => {
const code = buildStoryChartBlock({
query_id: 'q1',
chart_type: 'bar',
x_axis_key: 'month',
series: [{ data_key: 'rev', color: 'var(--chart-1)', label: 'Disc\\Rebate' }],
title: '13/07 update',
});

const chart = chartOf(code);
expect(chart?.title).toBe('13/07 update');
expect(chart?.series).toEqual([{ data_key: 'rev', color: 'var(--chart-1)', label: 'Disc\\Rebate' }]);
});

it('parses a chart whose title contains a greater-than sign', () => {
const code =
'<chart query_id="q1" chart_type="bar" x_axis_key="month" series=\'[{"data_key":"rev"}]\' title="rev > 100" />';

const chart = chartOf(code);
expect(chart?.title).toBe('rev > 100');
expect(chart?.series).toEqual([{ data_key: 'rev' }]);
});

it('still parses a plain self-closing chart tag', () => {
const code = '<chart query_id="q1" chart_type="bar" x_axis_key="month" data_key="rev" title="Revenue" />';

const chart = chartOf(code);
expect(chart?.title).toBe('Revenue');
expect(chart?.series).toEqual([{ data_key: 'rev', color: 'var(--chart-1)', label: undefined }]);
});
});
18 changes: 18 additions & 0 deletions apps/shared/tests/story-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,24 @@ describe('validateStoryCode', () => {
expect(errors.some((e) => e.message.includes('non-empty JSON array'))).toBe(true);
});

it('accepts a series label containing a backslash', () => {
const code =
'<chart query_id="q1" chart_type="line" x_axis_key="month" series=\'[{"data_key":"rev","label":"Disc\\Rebate"}]\' title="x" />';
expect(validateStoryCode(code)).toEqual([]);
});

it('accepts a series label containing a bracket', () => {
const code =
'<chart query_id="q1" chart_type="line" x_axis_key="month" series=\'[{"data_key":"rev","label":"a]b"}]\' title="x" />';
expect(validateStoryCode(code)).toEqual([]);
});

it('accepts a self-closing chart whose title contains a slash', () => {
const code =
'<chart query_id="q1" chart_type="line" x_axis_key="week" series=\'[{"data_key":"orders"}]\' title="13/07 update" />';
expect(validateStoryCode(code)).toEqual([]);
});

it('flags series entries without data_key', () => {
const code =
'<chart query_id="q1" chart_type="line" x_axis_key="month" series=\'[{"color":"red"}]\' title="x" />';
Expand Down
Loading