Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 21 additions & 1 deletion packages/core/src/compiler/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -77,6 +96,7 @@ export interface ElementNode {
export interface RootNode {
type: "Root";
children: ElementNode[];
varsBlock?: VarsBlock;
}

export type SpecialBlock = {
Expand Down
107 changes: 100 additions & 7 deletions packages/core/src/compiler/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type {
LiteralValue,
SpecialBlock,
TextNode,
VarsBlock,
VariableReference,
} from "./ast.js";

interface CssDirectiveEntry {
Expand All @@ -22,21 +24,66 @@ interface JsDirectiveEntry {
block: string;
}

type VariableContext = Map<string, LiteralValue>;

interface CompileContext {
keywords: Map<string, Keyword>;
indent: string;
cssDirectives: Map<string, CssDirectiveEntry[]>;
jsDirectives: Map<string, JsDirectiveEntry[]>;
variables: VariableContext;
}

const DEFAULT_INDENT = " ";

function buildVariableContext(varsBlock?: VarsBlock): VariableContext {
const context = new Map<string, LiteralValue>();

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
Expand Down Expand Up @@ -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)}"`
Expand Down Expand Up @@ -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) {
Expand All @@ -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 };
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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":
Expand Down Expand Up @@ -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) {
Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/compiler/lexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export enum TokenType {
STRING = "STRING",
NUMBER = "NUMBER",
IDENTIFIER = "IDENTIFIER",
DOLLAR = "DOLLAR",
AT = "AT",
COLON = "COLON",
EQUALS = "EQUALS",
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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 = "";
Expand Down
Loading