Skip to content
Open
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
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
```
Comment on lines 102 to +107
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add a language tag to this DSL example fence.

This block trips markdownlint MD040 and loses syntax highlighting; excalidraw would match the other DSL snippets in this README.

📝 Suggested change
-```
+```excalidraw
 (Start) -> [Enter Credentials] -> {Valid?}
 {Valid?} -> "yes" -> [Dashboard] -> (End)
 {Valid?} -> 'no' -> [Show Error] -> [Enter Credentials]
 [API] -> "GET /users?name=\"pp\" \\ cache" -> [Client]
-```
+```
🧰 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
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 102 - 107, The fenced DSL block containing "(Start)
-> [Enter Credentials] -> {Valid?} ..." is missing a language tag which trips
markdownlint MD040; update that fenced code block in README.md by adding the
"excalidraw" language tag (i.e., change the opening ``` to ```excalidraw) so the
snippet (including lines with {Valid?} -> "yes" -> [Dashboard] and [API] -> "GET
/users?name=\"pp\" \\ cache" -> [Client]) is syntax-highlighted and consistent
with the other DSL examples.


### 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
Expand Down
92 changes: 74 additions & 18 deletions src/parser/dsl-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ interface Token {
nodeType?: NodeType;
nodeStyle?: NodeStyle;
dashed?: boolean;
raw?: string;
// Image-specific properties
imageSrc?: string;
imageWidth?: number;
Expand Down Expand Up @@ -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
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseQuotedLabel has a redundant conditional: both branches append escaped, so the if (escaped === quote || escaped === "\\") block is dead code. Simplify to a single append (or adjust logic if you intended different behavior for non-quote/non-backslash escapes).

Copilot uses AI. Check for mistakes.
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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reject labeled edges that have no source node.

-> "x" -> [B] currently passes this branch, sets pendingLabel, and then gets silently discarded because lastNode is still null. The parser should fail fast here instead of accepting malformed DSL. A regression test for that exact input would be worth adding with the fix.

🐛 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
Verify each finding against the current code and only fix it if needed.

In `@src/parser/dsl-parser.ts` around lines 750 - 770, The parser currently
accepts labeled-edge syntax even when there is no source node (e.g., `-> "x" ->
[B]`) because the branch that handles arrow + label sets
pendingLabel/pendingDashed and advances i without verifying a source; update the
branch in the function handling tokens (the code referencing token, nextToken,
trailingArrow, targetNodeToken, pendingLabel, pendingDashed, i) to verify a
valid source (e.g., that lastNode is not null or the previous token was a
node/image) before setting pendingLabel; if no source exists, throw a
descriptive Error (similar to the other labeled-edge errors) instead of silently
accepting, and add a regression test for the `-> "x" -> [B]` case.

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++;
Expand Down
29 changes: 29 additions & 0 deletions tests/integration/exporter/edge-labels.test.ts
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
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These integration tests call createFlowchartFromDSL (which runs ELK layout) but don't set an explicit timeout, while other exporter integration tests use 30s. Consider adding a timeout to avoid CI flakiness on slower runners.

Copilot uses AI. Check for mistakes.
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
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test uses text.id before asserting text is defined. If the label binding breaks, this will throw a TypeError and hide the actual failure. Assert text exists before dereferencing (and consider finding text only after verifying arrow).

Copilot uses AI. Check for mistakes.
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");
});
});
35 changes: 35 additions & 0 deletions tests/unit/parser/dsl-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading