diff --git a/README.md b/README.md index 49de7cd..6ed93af 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Mirrow is a playground and production tooling for vector animators working with - SVGs are treated as first-class citizens rather than static assets. - A familiar, designer-friendly syntax. - Animation, state, and styling live together. +- Simple variable support. - Output slots into your applications for a seemless adoption. ```css diff --git a/packages/core/src/compiler/ast.ts b/packages/core/src/compiler/ast.ts index 2599ed6..b93fb29 100644 --- a/packages/core/src/compiler/ast.ts +++ b/packages/core/src/compiler/ast.ts @@ -28,11 +28,30 @@ export interface TupleLiteral { position: SourcePosition; } +export interface VariableReference { + type: "VariableReference"; + name: string; + position: SourcePosition; +} + +export interface VariableDeclaration { + name: string; + value: LiteralValue; + position: SourcePosition; +} + +export interface VarsBlock { + type: "VarsBlock"; + declarations: VariableDeclaration[]; + position: SourcePosition; +} + export type LiteralValue = | NumberLiteral | StringLiteral | IdentifierLiteral - | TupleLiteral; + | TupleLiteral + | VariableReference; export interface CssStateDirective { type: "CssState"; @@ -77,6 +96,7 @@ export interface ElementNode { export interface RootNode { type: "Root"; children: ElementNode[]; + varsBlock?: VarsBlock; } export type SpecialBlock = { diff --git a/packages/core/src/compiler/compiler.ts b/packages/core/src/compiler/compiler.ts index b970007..e8095dc 100644 --- a/packages/core/src/compiler/compiler.ts +++ b/packages/core/src/compiler/compiler.ts @@ -10,6 +10,8 @@ import type { LiteralValue, SpecialBlock, TextNode, + VarsBlock, + VariableReference, } from "./ast.js"; interface CssDirectiveEntry { @@ -22,21 +24,66 @@ interface JsDirectiveEntry { block: string; } +type VariableContext = Map; + interface CompileContext { keywords: Map; indent: string; cssDirectives: Map; jsDirectives: Map; + variables: VariableContext; } const DEFAULT_INDENT = " "; +function buildVariableContext(varsBlock?: VarsBlock): VariableContext { + const context = new Map(); + + if (!varsBlock) { + return context; + } + + for (const declaration of varsBlock.declarations) { + context.set(declaration.name, declaration.value); + } + + return context; +} + +function resolveValue( + value: LiteralValue, + variables: VariableContext +): LiteralValue { + // If it's a VariableReference, resolve it to the actual value + if (value.type === "VariableReference") { + const resolvedValue = variables.get(value.name); + if (!resolvedValue) { + throw new Error(`Variable '${value.name}' is not defined`); + } + // Recursively resolve in case the value itself contains references + return resolveValue(resolvedValue, variables); + } + + // If it's a TupleLiteral, resolve each value in the tuple + if (value.type === "TupleLiteral") { + return { + type: "TupleLiteral", + values: value.values.map((v) => resolveValue(v, variables)), + position: value.position, + }; + } + + // For other literal types, return as-is + return value; +} + export function compileAst(ast: AST, blocks: SpecialBlock[]): string { const context: CompileContext = { keywords: getSvgKeywords(), indent: DEFAULT_INDENT, cssDirectives: new Map(), jsDirectives: new Map(), + variables: buildVariableContext(ast.varsBlock), }; const markup = ast.children @@ -71,7 +118,7 @@ function compileElement( } const indent = context.indent.repeat(depth); - const attributes = renderAttributes(node, keyword); + const attributes = renderAttributes(node, keyword, context); if (node.dataId) { attributes.unshift( `data-identifier="${escapeAttributeValue(node.dataId)}"` @@ -106,7 +153,11 @@ function compileChild( return compileElement(child, context, depth); } -function renderAttributes(node: ElementNode, keyword: Keyword): string[] { +function renderAttributes( + node: ElementNode, + keyword: Keyword, + context: CompileContext +): string[] { const rendered: string[] = []; for (const attributeNode of node.attributes) { @@ -115,7 +166,7 @@ function renderAttributes(node: ElementNode, keyword: Keyword): string[] { continue; } - const value = createAttributeValue(attributeNode, spec); + const value = createAttributeValue(attributeNode, spec, context.variables); const produced = spec.produce ? spec.produce(value as never) : { [spec.name]: value }; @@ -139,9 +190,11 @@ function collectDirectives(node: ElementNode, context: CompileContext): void { if (node.cssStates.size > 0) { const cssEntries = context.cssDirectives.get(node.dataId) ?? []; for (const directive of node.cssStates) { + const blockContent = extractBlockContent(directive.block); + const resolvedContent = resolveVariablesInText(blockContent, context.variables); cssEntries.push({ state: directive.name, - block: extractBlockContent(directive.block), + block: resolvedContent, }); } context.cssDirectives.set(node.dataId, cssEntries); @@ -150,9 +203,11 @@ function collectDirectives(node: ElementNode, context: CompileContext): void { if (node.jsEvents.size > 0) { const jsEntries = context.jsDirectives.get(node.dataId) ?? []; for (const directive of node.jsEvents) { + const blockContent = extractBlockContent(directive.block); + const resolvedContent = resolveVariablesInText(blockContent, context.variables); jsEntries.push({ event: directive.name, - block: extractBlockContent(directive.block), + block: resolvedContent, }); } context.jsDirectives.set(node.dataId, jsEntries); @@ -161,9 +216,11 @@ function collectDirectives(node: ElementNode, context: CompileContext): void { function createAttributeValue( attributeNode: AttributeNode, - spec: AttributeSpec + spec: AttributeSpec, + variables: VariableContext ): unknown { - const value = attributeNode.value; + // Resolve variables before processing the value + const value = resolveValue(attributeNode.value, variables); switch (spec.type) { case "number": @@ -256,6 +313,42 @@ function extractBlockContent(block: string): string { return trimmed; } +function resolveVariablesInText( + text: string, + variables: VariableContext +): string { + // Replace $variableName with the actual value + return text.replace(/\$([a-zA-Z_-][a-zA-Z0-9_-]*)/g, (match, varName) => { + const value = variables.get(varName); + if (!value) { + throw new Error(`Variable '$${varName}' is not defined`); + } + // Convert the literal value to a string representation + return literalValueToString(value); + }); +} + +function literalValueToString(value: LiteralValue): string { + switch (value.type) { + case "NumberLiteral": + return String(value.value); + case "StringLiteral": + return value.value; + case "IdentifierLiteral": + return value.value; + case "BooleanLiteral": + return String(value.value); + case "TupleLiteral": + // For tuples, join values with commas + return value.values.map(literalValueToString).join(", "); + case "VariableReference": + // This shouldn't happen if resolveValue was called first + throw new Error( + `Unresolved variable reference: $${value.name}` + ); + } +} + function indentBlock(content: string, indent: string): string { const normalized = content.trim(); if (normalized.length === 0) { diff --git a/packages/core/src/compiler/lexer.ts b/packages/core/src/compiler/lexer.ts index a673c9f..9863f25 100644 --- a/packages/core/src/compiler/lexer.ts +++ b/packages/core/src/compiler/lexer.ts @@ -25,6 +25,7 @@ export enum TokenType { STRING = "STRING", NUMBER = "NUMBER", IDENTIFIER = "IDENTIFIER", + DOLLAR = "DOLLAR", AT = "AT", COLON = "COLON", EQUALS = "EQUALS", @@ -129,6 +130,10 @@ export class Lexer { // Isolated '-' should fall through and be treated as unknown for now. } + if (char === "$") { + return this.readVariableReference(); + } + if (this.isAlpha(char) || validIdentifierTokens.includes(char)) { return this.readIdentifier(); } @@ -365,6 +370,52 @@ export class Lexer { }; } + public readVariableReference(): Token { + const startPosition = { line: this.line, column: this.column }; + + // Consume the $ character + this.advance(); + + // Read the variable name + let value = "$"; + + // Check if there's a valid identifier after the $ + if (this.position < this.input.length) { + const firstChar = this.input[this.position]; + if (firstChar && (this.isAlpha(firstChar) || firstChar === "_")) { + // Read the identifier part + while (this.position < this.input.length) { + const char = this.input[this.position]; + if ( + !char || + (!this.isAlpha(char) && + !this.isDigit(char) && + !["_", "-"].includes(char)) + ) { + break; + } + value += char; + this.advance(); + } + } else { + // Invalid variable reference - just the $ character + throw new LexerError( + "Invalid variable reference: variable name must start with a letter or underscore", + startPosition + ); + } + } else { + // $ at end of input + throw new LexerError("Unexpected end of input after '$'", startPosition); + } + + return { + type: TokenType.DOLLAR, + value, + position: startPosition, + }; + } + public readNumber(): Token { const startPosition = { line: this.line, column: this.column }; let value = ""; diff --git a/packages/core/src/compiler/parser.ts b/packages/core/src/compiler/parser.ts index afd55f7..745e2d1 100644 --- a/packages/core/src/compiler/parser.ts +++ b/packages/core/src/compiler/parser.ts @@ -23,6 +23,9 @@ import { type SpecialBlock, type StringLiteral, type TupleLiteral, + type VariableDeclaration, + type VariableReference, + type VarsBlock, } from "./ast.js"; interface ElementBlockEntries { @@ -83,6 +86,7 @@ class Parser { private readonly keywordCatalog = getSvgKeywords(); private newGenTokens: Token[] = []; private fellThrough: boolean = false; + private symbolTable: Map = new Map(); private ATTRIBUTE_VALIDATORS: { number: AttributeValidator; string: AttributeValidator; @@ -90,8 +94,9 @@ class Parser { tuple: AttributeValidator; } - constructor(tokens: Token[]) { + constructor(tokens: Token[], symbolTable?: Map) { this.tokens = tokens; + this.symbolTable = symbolTable ?? new Map(); this.ATTRIBUTE_VALIDATORS = { number: (context, spec, value) => { if (value.type === "NumberLiteral") { @@ -106,9 +111,19 @@ class Parser { return; } + // Allow variable references - they will be resolved during compilation + if (value.type === "VariableReference") { + return; + } + failValidation(context, "expects a numeric value", value.position); }, string: (context, spec, value) => { + // Allow variable references - they will be resolved during compilation + if (value.type === "VariableReference") { + return; + } + if (value.type !== "StringLiteral") { failValidation( context, @@ -122,6 +137,11 @@ class Parser { } }, boolean: (context, spec, value) => { + // Allow variable references - they will be resolved during compilation + if (value.type === "VariableReference") { + return; + } + if (value.type !== "IdentifierLiteral") { failValidation(context, "expects 'true' or 'false'", value.position); } @@ -135,6 +155,11 @@ class Parser { } }, tuple: (context, spec, value) => { + // Allow variable references - they will be resolved during compilation + if (value.type === "VariableReference") { + return; + } + if (value.type !== "TupleLiteral") { failValidation(context, "expects a tuple", value.position); } @@ -154,6 +179,12 @@ class Parser { const entry = tupleValue.values[index]!; const expectedType = itemTypes?.[index] ?? itemTypes?.[0] ?? "number"; + // Allow variable references - they will be resolved during compilation + if (entry.type === "VariableReference") { + shouldRunValidator = false; + continue; + } + if (expectedType === "number") { if (entry.type === "NumberLiteral") { collected.push((entry as NumberLiteral).value); @@ -215,12 +246,29 @@ class Parser { parse(): [RootNode, SpecialBlock[]] { const children: ElementNode[] = []; const specialBlocks: SpecialBlock[] = []; + let varsBlock: VarsBlock | undefined = undefined; while (!this.check(TokenType.EOF)) { const peekValue = this.peek().value; const position = this.peek().position; const type = "SpecialBlock"; const codeblock = isSpecialCodeblock(peekValue); + + // Handle vars block + if ( + peekValue === "vars" && + this.check(TokenType.IDENTIFIER) && + this.peek(1).type === TokenType.BLOCK + ) { + if (varsBlock) { + throw new ParserError("Only one 'vars' block is allowed per document", position); + } + this.consume(TokenType.IDENTIFIER, "Expected 'vars' identifier"); + const blockToken = this.consume(TokenType.BLOCK, "Expected vars block"); + varsBlock = this.parseVarsBlock(blockToken, position); + continue; + } + if ( codeblock.valid && this.check(TokenType.IDENTIFIER) && @@ -263,7 +311,104 @@ class Parser { while (!this.check(TokenType.EOF)) { children.push(this.parseElement()); } - return [{ type: "Root", children }, specialBlocks]; + const rootNode: RootNode = { type: "Root", children }; + if (varsBlock) { + rootNode.varsBlock = varsBlock; + } + return [rootNode, specialBlocks]; + } + + private parseVarsBlock(blockToken: Token, position: SourcePosition): VarsBlock { + const raw = blockToken.value; + if (!raw.startsWith("{") || !raw.endsWith("}")) { + throw new ParserError("Malformed vars block", blockToken.position); + } + + const inner = raw.slice(1, raw.length - 1); + if (inner.trim().length === 0) { + return { + type: "VarsBlock", + declarations: [], + position, + }; + } + + const blockTokens = tokenize(inner); + const nestedParser = new Parser(blockTokens); + const declarations = nestedParser.parseVariableDeclarations(); + + // Build symbol table for validation + for (const declaration of declarations) { + if (this.symbolTable.has(declaration.name)) { + throw new ParserError( + `Variable '${declaration.name}' is already declared`, + declaration.position + ); + } + this.symbolTable.set(declaration.name, declaration.value); + } + + return { + type: "VarsBlock", + declarations, + position, + }; + } + + private parseVariableDeclarations(): VariableDeclaration[] { + const declarations: VariableDeclaration[] = []; + const seen = new Set(); + + while (!this.check(TokenType.EOF)) { + const nameToken = this.consume( + TokenType.IDENTIFIER, + "Expected variable name" + ); + + if (seen.has(nameToken.value)) { + throw new ParserError( + `Variable '${nameToken.value}' is already declared`, + nameToken.position + ); + } + + this.consume( + TokenType.COLON, + "Expected ':' after variable name" + ); + + const value = this.parseVariableValue(); + + declarations.push({ + name: nameToken.value, + value, + position: nameToken.position, + }); + + seen.add(nameToken.value); + } + + return declarations; + } + + private parseVariableValue(): LiteralValue { + const token = this.peek(); + + switch (token.type) { + case TokenType.STRING: + return this.parseStringLiteral(); + case TokenType.NUMBER: + return this.parseNumberLiteral(); + case TokenType.IDENTIFIER: + return this.parseIdentifierLiteral(); + case TokenType.LEFT_PAREN: + return this.parseTupleLiteral(); + default: + throw new ParserError( + `Invalid variable value: ${token.value}. Variables can only contain literal values (strings, numbers, identifiers, or tuples)`, + token.position + ); + } } private parseElement(): ElementNode { @@ -317,7 +462,7 @@ class Parser { } const blockTokens = tokenize(inner); - const nestedParser = new Parser(blockTokens); + const nestedParser = new Parser(blockTokens, this.symbolTable); return nestedParser.collectBlockEntries(keyword); } @@ -607,6 +752,8 @@ class Parser { return this.parseIdentifierLiteral(); case TokenType.LEFT_PAREN: return this.parseTupleLiteral(); + case TokenType.DOLLAR: + return this.parseVariableReference(); default: throw new ParserError( `Unexpected token '${token.value}' in attribute value`, @@ -667,6 +814,8 @@ class Parser { return this.parseNumberLiteral(); case TokenType.IDENTIFIER: return this.parseIdentifierLiteral(); + case TokenType.DOLLAR: + return this.parseVariableReference(); default: throw new ParserError( `Invalid value inside tuple: ${token.type}`, @@ -706,6 +855,30 @@ class Parser { }; } + private parseVariableReference(): VariableReference { + const token = this.consume( + TokenType.DOLLAR, + "Expected variable reference" + ); + + // Extract the variable name (remove the $ prefix) + const variableName = token.value.substring(1); + + // Validate that the variable has been declared + if (!this.symbolTable.has(variableName)) { + throw new ParserError( + `Variable '$${variableName}' is not defined`, + token.position + ); + } + + return { + type: "VariableReference", + name: variableName, + position: token.position, + }; + } + private validateAttributeValue( keyword: Keyword, attributeName: string, diff --git a/packages/core/test/compiler.test.js b/packages/core/test/compiler.test.js new file mode 100644 index 0000000..b780a5e --- /dev/null +++ b/packages/core/test/compiler.test.js @@ -0,0 +1,289 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { compile } from "../dist/index.js"; + +test("numeric variable resolves in compiled SVG", () => { + const svg = compile(`vars { width: 150 } svg { size: ($width, 100) }`); + assert.ok(svg.includes('width="150"'), "width should be resolved to 150"); + assert.ok(svg.includes('height="100"'), "height should be 100"); +}); + +test("string variable resolves in compiled SVG", () => { + const svg = compile(`vars { color: "#ff0000" } rect { at: (0, 0) size: (10, 10) fill: $color }`); + assert.ok(svg.includes('fill="#ff0000"'), "fill should be resolved to #ff0000"); +}); + +test("identifier variable resolves in compiled SVG", () => { + const svg = compile(`vars { align: center } text { at: (0, 0) textAnchor: $align }`); + assert.ok(svg.includes('text-anchor="center"'), "textAnchor should be resolved to center"); +}); + +test("multiple variables in tuple resolve correctly", () => { + const svg = compile(` + vars { + width: 150 + height: 200 + } + rect { at: (0, 0) size: ($width, $height) } + `); + assert.ok(svg.includes('width="150"'), "width should be resolved to 150"); + assert.ok(svg.includes('height="200"'), "height should be resolved to 200"); +}); + +test("variables resolve in nested elements", () => { + const svg = compile(` + vars { + radius: 50 + } + svg { size: (100, 100) + circle { at: (50, 50) r: $radius } + } + `); + assert.ok(svg.includes('r="50"'), "radius should be resolved to 50"); +}); + +test("tuple variable resolves correctly", () => { + const svg = compile(` + vars { + position: (10, 20) + } + rect { at: $position size: (50, 50) } + `); + assert.ok(svg.includes('x="10"'), "x should be resolved from tuple"); + assert.ok(svg.includes('y="20"'), "y should be resolved from tuple"); +}); + +test("compilation without vars block works", () => { + const svg = compile(`svg { size: (100, 100) }`); + assert.ok(svg.includes(' { + const svg = compile(`vars {} svg { size: (100, 100) }`); + assert.ok(svg.includes(' { + const svg = compile(`vars { boxSize: 80 } rect { at: (0, 0) size: ($boxSize, $boxSize) }`); + assert.ok(svg.includes('width="80"'), "numeric variable should resolve to 80"); + assert.ok(svg.includes('height="80"'), "numeric variable should resolve to 80"); +}); + +test("declare and use string variable", () => { + const svg = compile(`vars { strokeColor: "#333333" } rect { at: (0, 0) size: (10, 10) stroke: $strokeColor }`); + assert.ok(svg.includes('stroke="#333333"'), "string variable should resolve correctly"); +}); + +test("declare and use identifier variable", () => { + const svg = compile(`vars { anchorPos: middle } text { at: (0, 0) textAnchor: $anchorPos }`); + assert.ok(svg.includes('text-anchor="middle"'), "identifier variable should resolve correctly"); +}); + +// 2. Variables in tuples tests +test("single variable in tuple", () => { + const svg = compile(`vars { width: 120 } rect { at: (0, 0) size: ($width, 100) }`); + assert.ok(svg.includes('width="120"'), "variable in tuple should resolve"); + assert.ok(svg.includes('height="100"'), "literal in tuple should work"); +}); + +test("multiple variables in tuple", () => { + const svg = compile(` + vars { + w: 150 + h: 200 + } + rect { at: (0, 0) size: ($w, $h) } + `); + assert.ok(svg.includes('width="150"'), "first variable should resolve"); + assert.ok(svg.includes('height="200"'), "second variable should resolve"); +}); + +test("mixed literals and variables in tuple", () => { + const svg = compile(`vars { y: 30 } rect { at: (10, $y) size: (50, 50) }`); + assert.ok(svg.includes('x="10"'), "literal should work in tuple"); + assert.ok(svg.includes('y="30"'), "variable should resolve in tuple"); +}); + +// 3. Variables in CSS states tests +test("variable in @hover block", () => { + const svg = compile(` + vars { + hoverColor: "#0070f3" + } + svg { size: (100, 100) + rect { at: (0, 0) size: (50, 50) id: "box" } + @hover { + #box { stroke: $hoverColor; } + } + } + `); + assert.ok(svg.includes('stroke: #0070f3'), "variable should resolve in @hover block"); +}); + +test("multiple variables in CSS state", () => { + const svg = compile(` + vars { + activeStroke: "#ff0000" + activeWidth: 3 + } + svg { size: (100, 100) + circle { at: (50, 50) r: 20 id: "dot" } + @active { + #dot { stroke: $activeStroke; strokeWidth: $activeWidth; } + } + } + `); + assert.ok(svg.includes('stroke: #ff0000'), "stroke variable should resolve in @active"); + assert.ok(svg.includes('strokeWidth: 3'), "width variable should resolve in @active"); +}); + +test("variable in @focus state", () => { + const svg = compile(` + vars { focusFill: "yellow" } + svg { size: (100, 100) + rect { at: (0, 0) size: (40, 40) id: "r" } + @focus { + #r { fill: $focusFill; } + } + } + `); + assert.ok(svg.includes('fill: yellow'), "variable should resolve in @focus state"); +}); + +// 4. Error cases tests +test("error - using undeclared variable", () => { + assert.throws( + () => compile(`rect { at: (0, 0) size: ($undeclared, 10) }`), + { + name: "ParserError", + message: /Variable '\$undeclared' is not defined/ + } + ); +}); + +test("error - duplicate variable declarations", () => { + assert.throws( + () => compile(`vars { size: 100 size: 200 } svg { size: (100, 100) }`), + { + name: "ParserError", + message: /Variable 'size' is already declared/ + } + ); +}); + +test("error - variable reference in variable declaration", () => { + assert.throws( + () => compile(`vars { a: 10 b: $a } svg { size: (100, 100) }`), + { + name: "ParserError", + message: /Invalid variable value.*Variables can only contain literal values/ + } + ); +}); + +test("error - undeclared variable in CSS state", () => { + assert.throws( + () => compile(` + svg { size: (100, 100) + rect { at: (0, 0) size: (10, 10) id: "r" } + @hover { + #r { fill: $undeclaredColor; } + } + } + `), + { + name: "Error", + message: /Variable '\$undeclaredColor' is not defined/ + } + ); +}); + +// 5. Edge cases tests +test("variable names with hyphens", () => { + const svg = compile(` + vars { stroke-color: "#ff0000" } + rect { at: (0, 0) size: (10, 10) stroke: $stroke-color } + `); + assert.ok(svg.includes('stroke="#ff0000"'), "hyphenated variable name should work"); +}); + +test("variable names with underscores", () => { + const svg = compile(` + vars { fill_color: "blue" } + rect { at: (0, 0) size: (10, 10) fill: $fill_color } + `); + assert.ok(svg.includes('fill="blue"'), "underscore variable name should work"); +}); + +test("variable names with mixed case", () => { + const svg = compile(` + vars { bgColor: "#ffffff" } + rect { at: (0, 0) size: (10, 10) fill: $bgColor } + `); + assert.ok(svg.includes('fill="#ffffff"'), "camelCase variable name should work"); +}); + +test("multiple CSS states using same variable", () => { + const svg = compile(` + vars { highlightColor: "orange" } + svg { size: (100, 100) + rect { at: (0, 0) size: (30, 30) id: "box" } + @hover { + #box { fill: $highlightColor; } + } + @focus { + #box { stroke: $highlightColor; } + } + } + `); + assert.ok(svg.includes('fill: orange'), "variable should work in @hover"); + assert.ok(svg.includes('stroke: orange'), "variable should work in @focus"); +}); + +test("variable with boolean value", () => { + const svg = compile(` + vars { shouldFill: true } + circle { at: (50, 50) r: 20 filled: $shouldFill } + `); + assert.ok(svg.includes('filled="true"'), "boolean variable should resolve for filled attribute"); +}); + +test("tuple variable with all literals", () => { + const svg = compile(` + vars { pos: (25, 75) } + rect { at: $pos size: (40, 40) } + `); + assert.ok(svg.includes('x="25"'), "tuple variable x should resolve"); + assert.ok(svg.includes('y="75"'), "tuple variable y should resolve"); +}); + +test("many variables declared and used", () => { + const svg = compile(` + vars { + x: 10 + y: 20 + w: 100 + h: 80 + stroke: "#000" + strokeW: 2 + fillColor: "red" + } + rect { + at: ($x, $y) + size: ($w, $h) + stroke: $stroke + strokeWidth: $strokeW + fill: $fillColor + } + `); + assert.ok(svg.includes('x="10"'), "x variable should resolve"); + assert.ok(svg.includes('y="20"'), "y variable should resolve"); + assert.ok(svg.includes('width="100"'), "width variable should resolve"); + assert.ok(svg.includes('height="80"'), "height variable should resolve"); + assert.ok(svg.includes('stroke="#000"'), "stroke variable should resolve"); + assert.ok(svg.includes('stroke-width="2"'), "strokeWidth variable should resolve"); + assert.ok(svg.includes('fill="red"'), "fill variable should resolve"); +}); diff --git a/packages/core/test/parser.test.js b/packages/core/test/parser.test.js index fec535f..930d4f7 100644 --- a/packages/core/test/parser.test.js +++ b/packages/core/test/parser.test.js @@ -142,3 +142,97 @@ test("feSpotLight supports 3d tuples", () => { "specularExponent", ]); }); + +test("undeclared variable throws error", () => { + assert.throws( + () => parseFromCode(`svg { size: ($width, 100) }`), + { + name: "ParserError", + message: /Variable '\$width' is not defined/, + } + ); +}); + +test("undeclared variable in tuple throws error", () => { + assert.throws( + () => parseFromCode(`rect { at: (10, 20) size: ($w, $h) }`), + { + name: "ParserError", + message: /Variable '\$(w|h)' is not defined/, + } + ); +}); + +test("duplicate variable declaration throws error", () => { + assert.throws( + () => parseFromCode(`vars { width: 100 width: 200 } svg { size: (100, 100) }`), + { + name: "ParserError", + message: /Variable 'width' is already declared/, + } + ); +}); + +test("invalid variable value throws error", () => { + assert.throws( + () => parseFromCode(`vars { invalid: $other } svg { size: (100, 100) }`), + { + name: "ParserError", + message: /Invalid variable value.*Variables can only contain literal values/, + } + ); +}); + +test("variable reference succeeds with declared variable", () => { + const ast = parseFromCode(`vars { width: 150 } svg { size: ($width, 100) }`); + assert.ok(ast.varsBlock, "varsBlock should exist"); + assert.equal(ast.varsBlock.declarations.length, 1); + assert.equal(ast.varsBlock.declarations[0].name, "width"); + + const svg = ast.children[0]; + const sizeAttr = svg.attributes.find(attr => attr.name === "size"); + assert.ok(sizeAttr, "size attribute should exist"); + assert.equal(sizeAttr.value.type, "TupleLiteral"); + assert.equal(sizeAttr.value.values[0].type, "VariableReference"); + assert.equal(sizeAttr.value.values[0].name, "width"); +}); + +test("multiple declared variables work correctly", () => { + const ast = parseFromCode(` + vars { + width: 150 + height: 200 + color: "#ff0000" + } + rect { at: (0, 0) size: ($width, $height) fill: $color } + `); + + assert.equal(ast.varsBlock.declarations.length, 3); + const rect = ast.children[0]; + const sizeAttr = rect.attributes.find(attr => attr.name === "size"); + const fillAttr = rect.attributes.find(attr => attr.name === "fill"); + + assert.equal(sizeAttr.value.values[0].name, "width"); + assert.equal(sizeAttr.value.values[1].name, "height"); + assert.equal(fillAttr.value.name, "color"); +}); + +test("empty vars block is valid", () => { + const ast = parseFromCode(`vars {} svg { size: (100, 100) }`); + assert.ok(ast.varsBlock, "varsBlock should exist"); + assert.equal(ast.varsBlock.declarations.length, 0); +}); + +test("variables with hyphens and underscores", () => { + const ast = parseFromCode(` + vars { + my-width: 100 + my_height: 200 + } + rect { at: (0, 0) size: ($my-width, $my_height) } + `); + + assert.equal(ast.varsBlock.declarations.length, 2); + assert.equal(ast.varsBlock.declarations[0].name, "my-width"); + assert.equal(ast.varsBlock.declarations[1].name, "my_height"); +});