diff --git a/README.md b/README.md index dec5e7d..bdf7bcc 100644 --- a/README.md +++ b/README.md @@ -94,14 +94,31 @@ excalidraw-cli convert diagram.excalidraw --format svg --no-export-background | `[Label @fillStyle:hachure @backgroundColor:#a5d8ff]` | Styled node | Add inline node style attributes | | `->` | Arrow | Connection | | `-->` | Dashed Arrow | Dashed connection | -| `-> "text" ->` | Labeled Arrow | Connection with label | +| `-> "text" ->` | Labeled Arrow | Connection with a double-quoted label | +| `-> 'text' ->` | Labeled Arrow | Connection with a single-quoted label | ### Example DSL ``` (Start) -> [Enter Credentials] -> {Valid?} {Valid?} -> "yes" -> [Dashboard] -> (End) -{Valid?} -> "no" -> [Show Error] -> [Enter Credentials] +{Valid?} -> 'no' -> [Show Error] -> [Enter Credentials] +[API] -> "GET /users?name=\"pp\" \\ cache" -> [Client] +``` + +### Edge label escaping + +Edge labels must use the fully specified form `[A] -> "label" -> [B]` (or single quotes instead of double quotes). +Mixed forms like `[A] --> "x" -> [B]` are rejected on purpose, because guessing there is how parsers start doing dumb shit. + +Shell escaping tips: + +```bash +# easiest: wrap the whole DSL in single quotes, use double-quoted labels inside +excalidraw-cli create --inline '[API] -> "GET /users?name=\"pp\"" -> [Client]' -o api.excalidraw + +# if the label itself needs apostrophes, flip it +excalidraw-cli create --inline "[Decision] -> 'team\'s call' -> [Next]" -o decision.excalidraw ``` ### Node Styling diff --git a/src/parser/dsl-parser.ts b/src/parser/dsl-parser.ts index 25225bb..ae03948 100644 --- a/src/parser/dsl-parser.ts +++ b/src/parser/dsl-parser.ts @@ -46,6 +46,7 @@ interface Token { nodeType?: NodeType; nodeStyle?: NodeStyle; dashed?: boolean; + raw?: string; // Image-specific properties imageSrc?: string; imageWidth?: number; @@ -285,6 +286,44 @@ function createNodeToken(rawLabel: string, nodeType: NodeType): Token { /** * Tokenize DSL input into tokens */ +function parseQuotedLabel(input: string, startIndex: number): { value: string; raw: string; nextIndex: number } { + const quote = input[startIndex]; + let i = startIndex + 1; + let value = ''; + + while (i < input.length) { + const char = input[i]; + + if (char === '\\') { + if (i + 1 >= input.length) { + throw new Error(`Unterminated escape sequence in edge label starting at index ${startIndex}`); + } + + const escaped = input[i + 1]; + if (escaped === quote || escaped === '\\') { + value += escaped; + } else { + value += escaped; + } + i += 2; + continue; + } + + if (char === quote) { + return { + value, + raw: input.slice(startIndex, i + 1), + nextIndex: i + 1, + }; + } + + value += char; + i++; + } + + throw new Error(`Unterminated edge label starting at index ${startIndex}`); +} + function tokenize(input: string): Token[] { const tokens: Token[] = []; let i = 0; @@ -455,21 +494,11 @@ function tokenize(input: string): Token[] { continue; } - // Quoted label "text" - if (input[i] === '"') { - i++; - let label = ''; - while (i < len && input[i] !== '"') { - if (input[i] === '\\' && i + 1 < len) { - i++; - label += input[i]; - } else { - label += input[i]; - } - i++; - } - i++; // skip closing " - tokens.push({ type: 'label', value: label }); + // Quoted label "text" or 'text' + if (input[i] === '"' || input[i] === "'") { + const parsed = parseQuotedLabel(input, i); + tokens.push({ type: 'label', value: parsed.value, raw: parsed.raw }); + i = parsed.nextIndex; continue; } @@ -549,6 +578,11 @@ export function parseDSL(input: string): FlowchartGraph { const { content, nodeStyles } = preprocessNodeStyleBlocks(input); const tokens = tokenize(content); + function formatArrowToken(token: Token | undefined): string { + if (!token || token.type !== 'arrow') return ''; + return token.value; + } + const nodes: Map = new Map(); const edges: GraphEdge[] = []; const options: LayoutOptions = { ...DEFAULT_LAYOUT_OPTIONS }; @@ -714,15 +748,37 @@ export function parseDSL(input: string): FlowchartGraph { } if (token.type === 'arrow') { + const nextToken = tokens[i + 1]; + if (nextToken?.type === 'label') { + const trailingArrow = tokens[i + 2]; + const targetNodeToken = tokens[i + 3]; + + if (token.value !== '->' || trailingArrow?.type !== 'arrow' || trailingArrow.value !== '->') { + const rawLabel = nextToken.raw ?? JSON.stringify(nextToken.value); + throw new Error( + `Invalid labeled edge syntax around ${rawLabel}: expected [A] -> ${rawLabel} -> [B], got ${formatArrowToken(token)} ${rawLabel} ${formatArrowToken(trailingArrow)}` + ); + } + + if (!targetNodeToken || (targetNodeToken.type !== 'node' && targetNodeToken.type !== 'image')) { + const rawLabel = nextToken.raw ?? JSON.stringify(nextToken.value); + throw new Error(`Invalid labeled edge syntax around ${rawLabel}: missing target node after label`); + } + + pendingDashed = false; + pendingLabel = nextToken.value; + i += 3; + continue; + } + pendingDashed = token.dashed || false; i++; continue; } if (token.type === 'label') { - pendingLabel = token.value; - i++; - continue; + const rawLabel = token.raw ?? JSON.stringify(token.value); + throw new Error(`Edge label ${rawLabel} must appear between arrows as [A] -> ${rawLabel} -> [B]`); } i++; diff --git a/tests/integration/exporter/edge-labels.test.ts b/tests/integration/exporter/edge-labels.test.ts new file mode 100644 index 0000000..cb6c502 --- /dev/null +++ b/tests/integration/exporter/edge-labels.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { createFlowchartFromDSL } from '../../../src/index.js'; + +describe('edge label integration', () => { + it('binds escaped edge labels to arrows in generated Excalidraw output', async () => { + const dsl = String.raw`[API] -> "GET /users?name=\"pp\" \\ path" -> [Client]`; + const raw = await createFlowchartFromDSL(dsl); + const file = JSON.parse(raw); + + const arrow = file.elements.find((element: any) => element.type === 'arrow'); + const text = file.elements.find( + (element: any) => element.type === 'text' && element.containerId === arrow?.id + ); + + expect(arrow).toBeTruthy(); + expect(arrow.boundElements).toEqual([{ id: text.id, type: 'text' }]); + expect(text).toBeTruthy(); + expect(text.text).toBe('GET /users?name="pp" \\ path'); + expect(text.containerId).toBe(arrow.id); + }); + + it('supports single-quoted edge labels end to end', async () => { + const raw = await createFlowchartFromDSL(String.raw`[Decision] -> 'team\'s choice' -> [Next]`); + const file = JSON.parse(raw); + + const text = file.elements.find((element: any) => element.type === 'text' && element.containerId); + expect(text.text).toBe("team's choice"); + }); +}); diff --git a/tests/unit/parser/dsl-parser.test.ts b/tests/unit/parser/dsl-parser.test.ts index e14b201..a649988 100644 --- a/tests/unit/parser/dsl-parser.test.ts +++ b/tests/unit/parser/dsl-parser.test.ts @@ -81,6 +81,41 @@ describe('DSL Parser', () => { expect(result.edges[0].label).toBe('yes'); }); + it('should parse single-quoted labeled connections', () => { + const result = parseDSL("[A] -> 'yes' -> [B]"); + expect(result.edges[0].label).toBe('yes'); + }); + + it('should preserve escaped quotes and backslashes in edge labels', () => { + const result = parseDSL(String.raw`[API] -> "say \"hello\" at C:\\temp" -> [Client]`); + expect(result.edges[0].label).toBe(String.raw`say "hello" at C:\temp`); + }); + + it('should preserve apostrophes in single-quoted edge labels', () => { + const result = parseDSL(String.raw`[A] -> 'team\'s call' -> [B]`); + expect(result.edges[0].label).toBe("team's call"); + }); + + it('should reject malformed labeled edge arrow combinations', () => { + expect(() => parseDSL('[A] --> "x" -> [B]')).toThrow( + 'Invalid labeled edge syntax around "x": expected [A] -> "x" -> [B], got --> "x" ->' + ); + expect(() => parseDSL('[A] -> "x" --> [B]')).toThrow( + 'Invalid labeled edge syntax around "x": expected [A] -> "x" -> [B], got -> "x" -->' + ); + expect(() => parseDSL('[A] <- "x" [B]')).toThrow('Edge label "x" must appear between arrows as [A] -> "x" -> [B]'); + }); + + it('should reject labeled edges with missing target node', () => { + expect(() => parseDSL('[A] -> "x" ->')).toThrow( + 'Invalid labeled edge syntax around "x": missing target node after label' + ); + }); + + it('should reject unterminated edge labels', () => { + expect(() => parseDSL('[A] -> "oops -> [B]')).toThrow('Unterminated edge label'); + }); + it('should parse dashed connections', () => { const result = parseDSL('[A] --> [B]'); expect(result.edges[0].style?.strokeStyle).toBe('dashed');