-
Notifications
You must be signed in to change notification settings - Fork 14
feat: support escaped quoted edge labels in DSL #28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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; | ||
| } | ||
|
Comment on lines
+302
to
+307
|
||
| 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 '<missing arrow>'; | ||
| return token.value; | ||
| } | ||
|
|
||
| const nodes: Map<string, GraphNode> = 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; | ||
|
Comment on lines
750
to
+770
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reject labeled edges that have no source node.
🐛 Suggested fix if (token.type === 'arrow') {
const nextToken = tokens[i + 1];
if (nextToken?.type === 'label') {
+ if (!lastNode) {
+ const rawLabel = nextToken.raw ?? JSON.stringify(nextToken.value);
+ throw new Error(`Invalid labeled edge syntax around ${rawLabel}: missing source node before label`);
+ }
+
const trailingArrow = tokens[i + 2];
const targetNodeToken = tokens[i + 3];
if (token.value !== '->' || trailingArrow?.type !== 'arrow' || trailingArrow.value !== '->') {🤖 Prompt for AI Agents |
||
| 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++; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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]`; | ||
|
Comment on lines
+4
to
+6
|
||
| 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(); | ||
|
Comment on lines
+10
to
+17
|
||
| 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"); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add a language tag to this DSL example fence.
This block trips markdownlint MD040 and loses syntax highlighting;
excalidrawwould match the other DSL snippets in this README.📝 Suggested change
🧰 Tools
🪛 markdownlint-cli2 (0.22.0)
[warning] 102-102: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents