diff --git a/README.md b/README.md index 20d7efe..edc2009 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ excalidraw-cli convert diagram.excalidraw --format svg --no-export-background | `{Label}` | Diamond | Decisions, conditionals | | `(Label)` | Ellipse | Start/End points | | `[[Label]]` | Database | Data storage | +| `[Label @fillStyle:hachure @backgroundColor:#a5d8ff]` | Styled node | Add inline node style attributes | | `->` | Arrow | Connection | | `-->` | Dashed Arrow | Dashed connection | | `-> "text" ->` | Labeled Arrow | Connection with label | @@ -99,6 +100,41 @@ excalidraw-cli convert diagram.excalidraw --format svg --no-export-background {Valid?} -> "no" -> [Show Error] -> [Enter Credentials] ``` +### Node Styling + +Add shape-level styling directly to nodes with inline `@key:value` attributes: + +```excalidraw +(Start) -> [Enter Credentials @fillStyle:hachure @backgroundColor:#a5d8ff] -> {Valid?} +{Valid?} -> "no" -> [Show Error @backgroundColor:#ffc9c9 @strokeStyle:dashed] -> [Enter Credentials] +``` + +For repeated or shared nodes, define defaults with an `@node` block: + +```excalidraw +@node [Enter Credentials] + fillStyle: solid + backgroundColor: #a5d8ff + +(Start) -> [Enter Credentials @fillStyle:hachure] -> {Valid?} +``` + +Supported node style keys: + +- `fillStyle`: `solid`, `hachure`, `cross-hatch` +- `backgroundColor` +- `strokeColor` +- `strokeWidth` +- `strokeStyle`: `solid`, `dashed`, `dotted` +- `roughness` +- `opacity` + +Precedence rules: + +- `@node` blocks provide defaults for matching nodes +- inline node attributes override block values +- repeated references to the same node merge explicit style properties with later values winning per property + ### Directives ``` diff --git a/man/excalidraw-cli.1 b/man/excalidraw-cli.1 index 28072da..a02f737 100644 --- a/man/excalidraw-cli.1 +++ b/man/excalidraw-cli.1 @@ -173,6 +173,14 @@ Ellipse node. .B [[Label]] Database node. .TP +.BI "[Label @key:value ...]" +Rectangle node with inline style attributes. +.TP +.BI "@node [Label]" +Start a node-style block followed by indented +.I key: value +lines. +.TP .B ![path] Image node using the default image size. .TP @@ -193,6 +201,34 @@ Nodes are deduplicated by label and type. Chains such as .B [A] -> [B] -> [C] produce one edge per hop. +.SS "Node styling" +.P +Shape nodes can define style properties inline: +.P +.B [Login @fillStyle:hachure @backgroundColor:#a5d8ff] +.P +Supported style keys are +.BR fillStyle , +.BR backgroundColor , +.BR strokeColor , +.BR strokeWidth , +.BR strokeStyle , +.BR roughness , +and +.BR opacity . +.P +Node styles can also be declared in a block: +.P +.RS +.nf +@node [Login] + fillStyle: hachure + backgroundColor: #a5d8ff +.fi +.RE +.P +When both forms are used, block styles provide defaults and inline attributes +override them on matching nodes. .SS "Comments" .TP .B # comment @@ -378,6 +414,9 @@ excalidraw-cli create --format json --inline '{"nodes":[{"id":"a","type":"rectan .B Create from stdin echo "[A] -> [B]" | excalidraw-cli create --stdin -o diagram.excalidraw .TP +.B Create a styled node from inline DSL +excalidraw-cli create --inline "(Start) -> [Login @fillStyle:hachure @backgroundColor:#a5d8ff] -> (End)" -o styled.excalidraw +.TP .B Write to stdout excalidraw-cli create flowchart.dsl -o - .TP diff --git a/src/parser/dsl-parser.ts b/src/parser/dsl-parser.ts index 358f355..25225bb 100644 --- a/src/parser/dsl-parser.ts +++ b/src/parser/dsl-parser.ts @@ -30,10 +30,13 @@ import type { GraphEdge, LayoutOptions, NodeType, + NodeStyle, PositionedImage, ScatterConfig, DecorationAnchor, ImageSource, + FillStyle, + StrokeStyle, } from '../types/dsl.js'; import { DEFAULT_LAYOUT_OPTIONS } from '../types/dsl.js'; @@ -41,6 +44,7 @@ interface Token { type: 'node' | 'arrow' | 'label' | 'directive' | 'newline' | 'image' | 'decorate'; value: string; nodeType?: NodeType; + nodeStyle?: NodeStyle; dashed?: boolean; // Image-specific properties imageSrc?: string; @@ -50,6 +54,234 @@ interface Token { decorationAnchor?: DecorationAnchor; } +const SUPPORTED_NODE_STYLE_KEYS = [ + 'fillStyle', + 'backgroundColor', + 'strokeColor', + 'strokeWidth', + 'strokeStyle', + 'roughness', + 'opacity', +] as const; + +type SupportedNodeStyleKey = (typeof SUPPORTED_NODE_STYLE_KEYS)[number]; + +interface NodeSelector { + label: string; + type: NodeType; +} + +interface PreprocessedDSL { + content: string; + nodeStyles: Map; +} + +function isSupportedNodeStyleKey(key: string): key is SupportedNodeStyleKey { + return SUPPORTED_NODE_STYLE_KEYS.includes(key as SupportedNodeStyleKey); +} + +function parseNumericNodeStyleValue(key: SupportedNodeStyleKey, value: string): number { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + throw new Error(`Invalid numeric value for ${key}: ${value}`); + } + return numeric; +} + +function parseNodeStyleValue(key: SupportedNodeStyleKey, rawValue: string): Partial { + const value = rawValue.trim(); + if (!value) { + throw new Error(`Missing value for node style key: ${key}`); + } + + switch (key) { + case 'fillStyle': { + const validFillStyles: FillStyle[] = ['solid', 'hachure', 'cross-hatch']; + if (!validFillStyles.includes(value as FillStyle)) { + throw new Error(`Invalid fillStyle value: ${value}`); + } + return { fillStyle: value as FillStyle }; + } + case 'strokeStyle': { + const validStrokeStyles: StrokeStyle[] = ['solid', 'dashed', 'dotted']; + if (!validStrokeStyles.includes(value as StrokeStyle)) { + throw new Error(`Invalid strokeStyle value: ${value}`); + } + return { strokeStyle: value as StrokeStyle }; + } + case 'backgroundColor': + return { backgroundColor: value }; + case 'strokeColor': + return { strokeColor: value }; + case 'strokeWidth': + return { strokeWidth: parseNumericNodeStyleValue(key, value) }; + case 'roughness': + return { roughness: parseNumericNodeStyleValue(key, value) }; + case 'opacity': + return { opacity: parseNumericNodeStyleValue(key, value) }; + default: + return {}; + } +} + +function parseNodeStyleEntries(entries: Array<[string, string]>, context: string): NodeStyle { + let style: NodeStyle = {}; + + for (const [key, rawValue] of entries) { + if (!isSupportedNodeStyleKey(key)) { + throw new Error(`Unsupported node style key "${key}" in ${context}`); + } + + style = { ...style, ...parseNodeStyleValue(key, rawValue) }; + } + + return style; +} + +function mergeNodeStyles(base?: NodeStyle, overrides?: NodeStyle): NodeStyle | undefined { + if (!base && !overrides) return undefined; + return { + ...(base ?? {}), + ...(overrides ?? {}), + }; +} + +function getNodeKey(label: string, type: NodeType): string { + return `${type}:${label}`; +} + +function parseNodeBody(rawLabel: string): { label: string; style?: NodeStyle } { + const trimmed = rawLabel.trim(); + if (!trimmed) { + throw new Error('Node label cannot be empty'); + } + + let working = trimmed; + const styleEntries: Array<[string, string]> = []; + + while (true) { + const match = working.match(/^(.*\S)\s+@([A-Za-z][A-Za-z0-9]*):(\S+)$/); + if (!match) { + break; + } + + const [, precedingLabel, key, value] = match; + if (!isSupportedNodeStyleKey(key)) { + throw new Error(`Unsupported node style key "${key}" in node "${trimmed}"`); + } + + styleEntries.unshift([key, value]); + working = precedingLabel; + } + + const label = working.trim(); + if (!label) { + throw new Error(`Node label cannot be empty in node "${trimmed}"`); + } + + return { + label, + style: styleEntries.length > 0 ? parseNodeStyleEntries(styleEntries, `node "${label}"`) : undefined, + }; +} + +function parseNodeSelector(selector: string): NodeSelector { + const trimmed = selector.trim(); + let rawLabel = ''; + let type: NodeType; + + if (trimmed.startsWith('[[') && trimmed.endsWith(']]')) { + rawLabel = trimmed.slice(2, -2); + type = 'database'; + } else if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + rawLabel = trimmed.slice(1, -1); + type = 'rectangle'; + } else if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + rawLabel = trimmed.slice(1, -1); + type = 'diamond'; + } else if (trimmed.startsWith('(') && trimmed.endsWith(')')) { + rawLabel = trimmed.slice(1, -1); + type = 'ellipse'; + } else { + throw new Error(`Invalid @node selector: ${selector}`); + } + + const { label, style } = parseNodeBody(rawLabel); + if (style) { + throw new Error(`@node selector cannot include inline styles: ${selector}`); + } + + return { label, type }; +} + +function preprocessNodeStyleBlocks(input: string): PreprocessedDSL { + const lines = input.split('\n'); + const outputLines: string[] = []; + const nodeStyles = new Map(); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!trimmed.startsWith('@node ')) { + outputLines.push(line); + continue; + } + + const selector = trimmed.slice('@node'.length).trim(); + const { label, type } = parseNodeSelector(selector); + let blockStyle: NodeStyle | undefined; + let hasStyleLine = false; + + outputLines.push(''); + + while (i + 1 < lines.length && /^[\t ]+/.test(lines[i + 1])) { + i++; + const styleLine = lines[i].trim(); + outputLines.push(''); + + if (!styleLine || styleLine.startsWith('#')) { + continue; + } + + const separatorIndex = styleLine.indexOf(':'); + if (separatorIndex === -1) { + throw new Error(`Invalid @node style entry: ${styleLine}`); + } + + const key = styleLine.slice(0, separatorIndex).trim(); + const value = styleLine.slice(separatorIndex + 1).trim(); + blockStyle = mergeNodeStyles( + blockStyle, + parseNodeStyleEntries([[key, value]], `@node ${selector}`) + ); + hasStyleLine = true; + } + + if (!hasStyleLine) { + throw new Error(`@node ${selector} must include at least one indented style line`); + } + + const nodeKey = getNodeKey(label, type); + nodeStyles.set(nodeKey, mergeNodeStyles(nodeStyles.get(nodeKey), blockStyle)!); + } + + return { + content: outputLines.join('\n'), + nodeStyles, + }; +} + +function createNodeToken(rawLabel: string, nodeType: NodeType): Token { + const { label, style } = parseNodeBody(rawLabel); + return { + type: 'node', + value: label, + nodeType, + nodeStyle: style, + }; +} + /** * Tokenize DSL input into tokens */ @@ -160,7 +392,7 @@ function tokenize(input: string): Token[] { i++; } i += 2; // skip ]] - tokens.push({ type: 'node', value: label.trim(), nodeType: 'database' }); + tokens.push(createNodeToken(label, 'database')); continue; } @@ -175,7 +407,7 @@ function tokenize(input: string): Token[] { if (depth > 0) label += input[i]; i++; } - tokens.push({ type: 'node', value: label.trim(), nodeType: 'rectangle' }); + tokens.push(createNodeToken(label, 'rectangle')); continue; } @@ -190,7 +422,7 @@ function tokenize(input: string): Token[] { if (depth > 0) label += input[i]; i++; } - tokens.push({ type: 'node', value: label.trim(), nodeType: 'diamond' }); + tokens.push(createNodeToken(label, 'diamond')); continue; } @@ -205,7 +437,7 @@ function tokenize(input: string): Token[] { if (depth > 0) label += input[i]; i++; } - tokens.push({ type: 'node', value: label.trim(), nodeType: 'ellipse' }); + tokens.push(createNodeToken(label, 'ellipse')); continue; } @@ -314,7 +546,8 @@ function parseScatterDirective(value: string): ScatterConfig | null { * Parse tokens into a FlowchartGraph */ export function parseDSL(input: string): FlowchartGraph { - const tokens = tokenize(input); + const { content, nodeStyles } = preprocessNodeStyleBlocks(input); + const tokens = tokenize(content); const nodes: Map = new Map(); const edges: GraphEdge[] = []; @@ -327,22 +560,32 @@ export function parseDSL(input: string): FlowchartGraph { function getOrCreateNode( label: string, type: NodeType, - imageData?: ImageSource + imageData?: ImageSource, + inlineStyle?: NodeStyle ): GraphNode { // Use label as key for deduplication - const key = `${type}:${label}`; + const key = getNodeKey(label, type); if (!nodes.has(key)) { const node: GraphNode = { id: nanoid(10), type, label, + style: mergeNodeStyles(nodeStyles.get(key), inlineStyle), }; if (imageData) { node.image = imageData; } nodes.set(key, node); } - return nodes.get(key)!; + + const node = nodes.get(key)!; + if (!node.style && nodeStyles.has(key)) { + node.style = mergeNodeStyles(node.style, nodeStyles.get(key)); + } + if (inlineStyle) { + node.style = mergeNodeStyles(node.style, inlineStyle); + } + return node; } let i = 0; @@ -450,7 +693,7 @@ export function parseDSL(input: string): FlowchartGraph { } if (token.type === 'node') { - const node = getOrCreateNode(token.value, token.nodeType!); + const node = getOrCreateNode(token.value, token.nodeType!, undefined, token.nodeStyle); if (lastNode) { // Create edge from lastNode to this node diff --git a/tests/unit/parser/dsl-parser.test.ts b/tests/unit/parser/dsl-parser.test.ts index 18718f6..e14b201 100644 --- a/tests/unit/parser/dsl-parser.test.ts +++ b/tests/unit/parser/dsl-parser.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from 'vitest'; import { parseDSL } from '../../../src/parser/dsl-parser.js'; +import { layoutGraph } from '../../../src/layout/elk-layout.js'; +import { generateExcalidraw } from '../../../src/generator/excalidraw-generator.js'; describe('DSL Parser', () => { describe('node parsing', () => { @@ -30,6 +32,39 @@ describe('DSL Parser', () => { expect(result.nodes[0].type).toBe('database'); expect(result.nodes[0].label).toBe('Database'); }); + + it('should parse inline styles on rectangle nodes', () => { + const result = parseDSL('[Process Step @fillStyle:hachure @backgroundColor:#a5d8ff]'); + expect(result.nodes).toHaveLength(1); + expect(result.nodes[0].label).toBe('Process Step'); + expect(result.nodes[0].style).toEqual({ + fillStyle: 'hachure', + backgroundColor: '#a5d8ff', + }); + }); + + it('should parse inline styles on non-rectangle nodes', () => { + const result = parseDSL( + '{Decision? @strokeStyle:dotted}\n(End @opacity:80)\n[[DB @strokeWidth:3 @roughness:2]]' + ); + + expect(result.nodes.find((node) => node.label === 'Decision?')?.style).toEqual({ + strokeStyle: 'dotted', + }); + expect(result.nodes.find((node) => node.label === 'End')?.style).toEqual({ + opacity: 80, + }); + expect(result.nodes.find((node) => node.label === 'DB')?.style).toEqual({ + strokeWidth: 3, + roughness: 2, + }); + }); + + it('should preserve labels containing @ when they are not style attributes', () => { + const result = parseDSL('[email@company.com]'); + expect(result.nodes[0].label).toBe('email@company.com'); + expect(result.nodes[0].style).toBeUndefined(); + }); }); describe('connection parsing', () => { @@ -68,6 +103,22 @@ describe('DSL Parser', () => { const result = parseDSL('@spacing 100\n[A] -> [B]'); expect(result.options.nodeSpacing).toBe(100); }); + + it('should parse @node style blocks and merge them into matching nodes', () => { + const result = parseDSL(` + @node [Login] + fillStyle: hachure + backgroundColor: #a5d8ff + + (Start) -> [Login] -> (End) + `); + + const loginNode = result.nodes.find((node) => node.label === 'Login'); + expect(loginNode?.style).toEqual({ + fillStyle: 'hachure', + backgroundColor: '#a5d8ff', + }); + }); }); describe('complex flowcharts', () => { @@ -88,6 +139,29 @@ describe('DSL Parser', () => { const bNodes = result.nodes.filter((n) => n.label === 'B'); expect(bNodes).toHaveLength(1); }); + + it('should allow later inline styles to override @node block defaults', () => { + const result = parseDSL(` + @node [A] + fillStyle: solid + backgroundColor: #ffffff + + [A @fillStyle:hachure] -> [B] + `); + + expect(result.nodes.find((node) => node.label === 'A')?.style).toEqual({ + fillStyle: 'hachure', + backgroundColor: '#ffffff', + }); + }); + + it('should merge explicit styles across repeated node references', () => { + const result = parseDSL('[A @fillStyle:hachure] -> [B]\n[B] -> [A @backgroundColor:#ffc9c9]'); + expect(result.nodes.find((node) => node.label === 'A')?.style).toEqual({ + fillStyle: 'hachure', + backgroundColor: '#ffc9c9', + }); + }); }); describe('image parsing', () => { @@ -196,4 +270,48 @@ describe('DSL Parser', () => { expect(result.scatter![0].height).toBe(30); }); }); + + describe('style validation', () => { + it('should reject unsupported inline style keys', () => { + expect(() => parseDSL('[A @borderRadius:4]')).toThrow( + 'Unsupported node style key "borderRadius" in node "A @borderRadius:4"' + ); + }); + + it('should reject invalid fillStyle values', () => { + expect(() => parseDSL('[A @fillStyle:zigzag]')).toThrow('Invalid fillStyle value: zigzag'); + }); + + it('should reject invalid numeric style values', () => { + expect(() => parseDSL('[A @opacity:abc]')).toThrow('Invalid numeric value for opacity: abc'); + }); + + it('should reject malformed @node block entries', () => { + expect(() => parseDSL('@node [A]\n fillStyle hachure')).toThrow( + 'Invalid @node style entry: fillStyle hachure' + ); + }); + + it('should reject @node blocks without style lines', () => { + expect(() => parseDSL('@node [A]\n[A] -> [B]')).toThrow( + '@node [A] must include at least one indented style line' + ); + }); + }); + + describe('style generation', () => { + it('should carry parsed node styles into generated Excalidraw elements', async () => { + const graph = parseDSL('[Styled @fillStyle:hachure @backgroundColor:#a5d8ff]'); + const layoutedGraph = await layoutGraph(graph); + const excalidrawFile = generateExcalidraw(layoutedGraph); + const shape = excalidrawFile.elements.find( + (element) => element.type === 'rectangle' && element.id === graph.nodes[0].id + ); + + expect(shape).toMatchObject({ + fillStyle: 'hachure', + backgroundColor: '#a5d8ff', + }); + }); + }); });