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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ excalidraw-cli convert diagram.excalidraw --format svg --no-export-background
| `(Label)` | Ellipse | Start/End points |
| `[[Label]]` | Database | Data storage |
| `[Label @fillStyle:hachure @backgroundColor:#a5d8ff]` | Styled node | Add inline node style attributes |
| `->` | Arrow | Connection |
| `->` | Arrow | Forward connection |
| `<-` | Reverse Arrow | Reverse connection, logically parsed as right-to-left |
| `<->` | Bidirectional Arrow | Connection with arrowheads on both ends |
| `-->` | Dashed Arrow | Dashed connection |
| `-> "text" ->` | Labeled Arrow | Connection with label |

Expand All @@ -102,6 +104,8 @@ excalidraw-cli convert diagram.excalidraw --format svg --no-export-background
(Start) -> [Enter Credentials] -> {Valid?}
{Valid?} -> "yes" -> [Dashboard] -> (End)
{Valid?} -> "no" -> [Show Error] -> [Enter Credentials]
[Reviewer] <- [Approved]
[Client] <-> [API]
```

### Node Styling
Expand Down
12 changes: 6 additions & 6 deletions src/factory/connection-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ function mapEdgeStyle(style?: EdgeStyle): Partial<ExcalidrawArrow> {
if (style.strokeWidth !== undefined) result.strokeWidth = style.strokeWidth;
if (style.strokeStyle !== undefined) result.strokeStyle = style.strokeStyle;
if (style.roughness !== undefined) result.roughness = style.roughness;
result.startArrowhead = style.startArrowhead ?? null;
result.endArrowhead = style.endArrowhead ?? 'arrow';
if (style.startArrowhead !== undefined) result.startArrowhead = style.startArrowhead;
if (style.endArrowhead !== undefined) result.endArrowhead = style.endArrowhead;
return result;
}

Expand Down Expand Up @@ -75,8 +75,8 @@ export function createArrow(
lastCommittedPoint: null,
startBinding: startBindingInfo.binding,
endBinding: endBindingInfo.binding,
startArrowhead: styleProps.startArrowhead ?? null,
endArrowhead: styleProps.endArrowhead ?? 'arrow',
startArrowhead: styleProps.startArrowhead !== undefined ? styleProps.startArrowhead : null,
endArrowhead: styleProps.endArrowhead !== undefined ? styleProps.endArrowhead : 'arrow',
elbowed: false,
} as ExcalidrawArrow;
}
Expand Down Expand Up @@ -120,8 +120,8 @@ export function createArrowWithBindings(
lastCommittedPoint: null,
startBinding,
endBinding,
startArrowhead: styleProps.startArrowhead ?? null,
endArrowhead: styleProps.endArrowhead ?? 'arrow',
startArrowhead: styleProps.startArrowhead !== undefined ? styleProps.startArrowhead : null,
endArrowhead: styleProps.endArrowhead !== undefined ? styleProps.endArrowhead : 'arrow',
elbowed: false,
} as ExcalidrawArrow;
}
67 changes: 48 additions & 19 deletions src/parser/dsl-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ interface Token {
nodeType?: NodeType;
nodeStyle?: NodeStyle;
dashed?: boolean;
reversed?: boolean;
bidirectional?: boolean;
// Image-specific properties
imageSrc?: string;
imageWidth?: number;
Expand Down Expand Up @@ -441,6 +443,20 @@ function tokenize(input: string): Token[] {
continue;
}

// Bidirectional arrow <->
if (input[i] === '<' && input[i + 1] === '-' && input[i + 2] === '>') {
tokens.push({ type: 'arrow', value: '<->', bidirectional: true });
i += 3;
continue;
}

// Reverse arrow <-
if (input[i] === '<' && input[i + 1] === '-') {
tokens.push({ type: 'arrow', value: '<-', reversed: true });
i += 2;
continue;
}

// Dashed arrow -->
if (input[i] === '-' && input[i + 1] === '-' && input[i + 2] === '>') {
tokens.push({ type: 'arrow', value: '-->', dashed: true });
Expand Down Expand Up @@ -592,6 +608,32 @@ export function parseDSL(input: string): FlowchartGraph {
let lastNode: GraphNode | null = null;
let pendingLabel: string | null = null;
let pendingDashed = false;
let pendingReversed = false;
let pendingBidirectional = false;

function createPendingEdge(sourceNode: GraphNode, targetNode: GraphNode): void {
const style = {
...(pendingDashed ? { strokeStyle: 'dashed' as const } : {}),
...(pendingBidirectional
? { startArrowhead: 'arrow' as const, endArrowhead: 'arrow' as const }
: pendingReversed
? { startArrowhead: 'arrow' as const, endArrowhead: null }
: {}),
Comment on lines +617 to +621
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.

For <- you’re currently (a) swapping source/target (so the edge is already B→A) and (b) also forcing startArrowhead: 'arrow' + endArrowhead: null. Those two together invert the rendered arrow direction relative to the logical edge. If the edge direction is reversed via source/target swap, the arrowheads should typically remain the default (start null, end 'arrow') or explicitly set startArrowhead: null, endArrowhead: 'arrow'—but not start-only.

Copilot uses AI. Check for mistakes.
};

edges.push({
id: nanoid(10),
source: pendingReversed ? targetNode.id : sourceNode.id,
target: pendingReversed ? sourceNode.id : targetNode.id,
label: pendingLabel || undefined,
style: Object.keys(style).length > 0 ? style : undefined,
});

pendingLabel = null;
pendingDashed = false;
pendingReversed = false;
pendingBidirectional = false;
}

while (i < tokens.length) {
const token = tokens[i];
Expand All @@ -600,6 +642,8 @@ export function parseDSL(input: string): FlowchartGraph {
lastNode = null;
pendingLabel = null;
pendingDashed = false;
pendingReversed = false;
pendingBidirectional = false;
i++;
continue;
}
Expand Down Expand Up @@ -661,15 +705,7 @@ export function parseDSL(input: string): FlowchartGraph {
const node = getOrCreateNode(token.value, 'image', imageData);

if (lastNode) {
edges.push({
id: nanoid(10),
source: lastNode.id,
target: node.id,
label: pendingLabel || undefined,
style: pendingDashed ? { strokeStyle: 'dashed' } : undefined,
});
pendingLabel = null;
pendingDashed = false;
createPendingEdge(lastNode, node);
}

lastNode = node;
Expand All @@ -696,16 +732,7 @@ export function parseDSL(input: string): FlowchartGraph {
const node = getOrCreateNode(token.value, token.nodeType!, undefined, token.nodeStyle);

if (lastNode) {
// Create edge from lastNode to this node
edges.push({
id: nanoid(10),
source: lastNode.id,
target: node.id,
label: pendingLabel || undefined,
style: pendingDashed ? { strokeStyle: 'dashed' } : undefined,
});
pendingLabel = null;
pendingDashed = false;
createPendingEdge(lastNode, node);
}

lastNode = node;
Expand All @@ -715,6 +742,8 @@ export function parseDSL(input: string): FlowchartGraph {

if (token.type === 'arrow') {
pendingDashed = token.dashed || false;
pendingReversed = token.reversed || false;
pendingBidirectional = token.bidirectional || false;
i++;
continue;
}
Expand Down
19 changes: 19 additions & 0 deletions tests/integration/exporter/cli-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,25 @@ describe('CLI export command', () => {
expect(existsSync(autoSvg)).toBe(true);
}, 60000);

it('should preserve reverse and bidirectional arrowheads through CLI create output', () => {
const outputFile = tmpFile('arrow-directions.excalidraw');

runCLI([
'create',
'--inline',
'[A] <- [B]\n[C] <-> [D]',
'-o',
outputFile,
]);

const file = JSON.parse(readFileSync(outputFile, 'utf-8'));
const arrows = file.elements.filter((element: { type: string }) => element.type === 'arrow');

expect(arrows).toHaveLength(2);
expect(arrows[0]).toMatchObject({ startArrowhead: 'arrow', endArrowhead: null });
expect(arrows[1]).toMatchObject({ startArrowhead: 'arrow', endArrowhead: 'arrow' });
Comment on lines +135 to +139
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.

This test assumes the first/second arrow element corresponds to a specific edge. Since edge ordering can change during ELK layout (and therefore element emission order), asserting on arrows[0] vs arrows[1] may be flaky. Make the assertion order-independent (e.g., compare sorted arrowhead pairs or find arrows by edge id / bindings). Also ensure the expected arrowhead placement for <- aligns with the chosen semantics (swap endpoints vs flip arrowheads).

Copilot uses AI. Check for mistakes.
}, 60000);

it('should export with --verbose flag', () => {
const inputFile = tmpFile('verbose-test.excalidraw');
writeFileSync(inputFile, JSON.stringify(createMinimalFile()), 'utf-8');
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/parser/dsl-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@ describe('DSL Parser', () => {
expect(result.nodes[0].label).toBe('email@company.com');
expect(result.nodes[0].style).toBeUndefined();
});

it('should generate Excalidraw arrows for reverse and bidirectional connections', async () => {
const graph = parseDSL(`[A] <- [B]
[C] <-> [D]`);
const layouted = await layoutGraph(graph);
const file = generateExcalidraw(layouted);
const arrows = file.elements.filter((element) => element.type === 'arrow');

expect(arrows).toHaveLength(2);
expect(arrows[0]).toMatchObject({ startArrowhead: 'arrow', endArrowhead: null });
expect(arrows[1]).toMatchObject({ startArrowhead: 'arrow', endArrowhead: 'arrow' });
Comment on lines +74 to +78
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.

This test relies on arrows[0] / arrows[1] ordering after running ELK layout. layoutGraph() builds layoutedEdges by iterating layoutResult.edges, and ELK doesn’t guarantee stable edge ordering, so this can be flaky. Prefer asserting on the multiset of {startArrowhead,endArrowhead} pairs (order-independent) or locating each arrow by its bound element IDs / edge IDs.

Copilot uses AI. Check for mistakes.
});
});

describe('connection parsing', () => {
Expand All @@ -86,6 +98,28 @@ describe('DSL Parser', () => {
expect(result.edges[0].style?.strokeStyle).toBe('dashed');
});

it('should parse reverse connections as logical right-to-left edges', () => {
const result = parseDSL('[A] <- [B]');
expect(result.edges).toHaveLength(1);
expect(result.edges[0].source).toBe(result.nodes[1].id);
expect(result.edges[0].target).toBe(result.nodes[0].id);
expect(result.edges[0].style).toEqual({
startArrowhead: 'arrow',
endArrowhead: null,
});
Comment on lines +105 to +109
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.

This test asserts startArrowhead: 'arrow', endArrowhead: null for a reverse connection while also asserting that source/target are swapped (logical B→A). That combination makes the arrowhead placement inconsistent with the edge direction in Excalidraw (single-headed arrows normally use endArrowhead: 'arrow' on the target side). Consider updating the expected style to default arrowheads (start null, end 'arrow') or otherwise align with whatever convention you choose (either swap endpoints or flip arrowheads, but not both).

Copilot uses AI. Check for mistakes.
});

it('should parse bidirectional connections with arrowheads on both ends', () => {
const result = parseDSL('[A] <-> [B]');
expect(result.edges).toHaveLength(1);
expect(result.edges[0].source).toBe(result.nodes[0].id);
expect(result.edges[0].target).toBe(result.nodes[1].id);
expect(result.edges[0].style).toEqual({
startArrowhead: 'arrow',
endArrowhead: 'arrow',
});
});

it('should parse chains of connections', () => {
const result = parseDSL('[A] -> [B] -> [C]');
expect(result.nodes).toHaveLength(3);
Expand Down
Loading