diff --git a/.changeset/core-compound-ref-validation.md b/.changeset/core-compound-ref-validation.md new file mode 100644 index 00000000..9dbef25e --- /dev/null +++ b/.changeset/core-compound-ref-validation.md @@ -0,0 +1,6 @@ +--- +"@styleframe/core": patch +"styleframe": patch +--- + +Validate variable references inside compound `@`-shorthand values. `resolvePropertyValue` already threw `Variable "X" is not defined` for a single exact ref (`padding: "@0.5"`), but the embedded/compound path (`padding: "@0.25 @0.5"`, `border: "1px solid @undefined"`) only recorded usage and skipped `validateReference`, so a compound ref to an undefined variable silently emitted a dead `var(--…)`. The embedded branch now calls `validateReference` for each ref part, matching the single-ref path. Compound refs whose variables are all defined are unchanged (byte-for-byte identical output); only compound refs to undefined variables change — from a silent dead `var(--…)` to a thrown error. diff --git a/engine/core/src/tokens/atRule.test.ts b/engine/core/src/tokens/atRule.test.ts index d2b8df1e..2df5272f 100644 --- a/engine/core/src/tokens/atRule.test.ts +++ b/engine/core/src/tokens/atRule.test.ts @@ -668,7 +668,7 @@ describe("createMediaFunction", () => { const result = media( "(-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi)", { - backgroundImage: 'url("image@2x.png")', + backgroundImage: 'url("image-2x.png")', }, ); diff --git a/engine/core/src/tokens/css.test.ts b/engine/core/src/tokens/css.test.ts index 87e42a1d..dd1032d7 100644 --- a/engine/core/src/tokens/css.test.ts +++ b/engine/core/src/tokens/css.test.ts @@ -618,6 +618,13 @@ describe("createCSSFunction", () => { }); it("should resolve embedded @reference to a CSS object", () => { + root.variables.push({ + type: "variable", + id: "color-primary", + name: "color.primary", + value: "#006cff", + }); + const result = resolvePropertyValue("1px solid @color.primary"); expect(result).toEqual({ @@ -630,6 +637,21 @@ describe("createCSSFunction", () => { }); it("should resolve multiple embedded @references to a CSS object", () => { + root.variables.push( + { + type: "variable", + id: "spacing-sm", + name: "spacing.sm", + value: "0.5rem", + }, + { + type: "variable", + id: "spacing-md", + name: "spacing.md", + value: "1rem", + }, + ); + const result = resolvePropertyValue("@spacing.sm @spacing.md"); expect(result).toEqual({ diff --git a/engine/core/src/tokens/declarations.test.ts b/engine/core/src/tokens/declarations.test.ts index b2333ed0..4047eb91 100644 --- a/engine/core/src/tokens/declarations.test.ts +++ b/engine/core/src/tokens/declarations.test.ts @@ -378,6 +378,13 @@ describe("parseDeclarationsBlock", () => { }); it("should resolve embedded @references to a CSS object", () => { + root.variables.push({ + type: "variable", + id: "color-primary", + name: "color.primary", + value: "#006cff", + }); + const declarations: any = { border: "1px solid @color.primary", }; @@ -391,6 +398,21 @@ describe("parseDeclarationsBlock", () => { }); it("should resolve multiple embedded @references to a CSS object", () => { + root.variables.push( + { + type: "variable", + id: "spacing-sm", + name: "spacing.sm", + value: "0.5rem", + }, + { + type: "variable", + id: "spacing-md", + name: "spacing.md", + value: "1rem", + }, + ); + const declarations: any = { padding: "@spacing.sm @spacing.md", }; diff --git a/engine/core/src/tokens/resolve.test.ts b/engine/core/src/tokens/resolve.test.ts index 2a3b2295..e1a6b9a6 100644 --- a/engine/core/src/tokens/resolve.test.ts +++ b/engine/core/src/tokens/resolve.test.ts @@ -313,6 +313,13 @@ describe("createPropertyValueResolver", () => { describe("embedded @ references", () => { it("should resolve embedded @variable to a CSS object", () => { + root.variables.push({ + type: "variable", + id: "color-primary", + name: "color.primary", + value: "#006cff", + }); + const result = resolvePropertyValue("1px solid @color.primary"); expect(result).toEqual({ @@ -329,6 +336,21 @@ describe("createPropertyValueResolver", () => { }); it("should resolve multiple embedded @variables to a CSS object", () => { + root.variables.push( + { + type: "variable", + id: "border-width", + name: "border.width", + value: "1px", + }, + { + type: "variable", + id: "color-primary", + name: "color.primary", + value: "#006cff", + }, + ); + const result = resolvePropertyValue("@border.width solid @color.primary"); expect(result).toEqual({ @@ -348,5 +370,60 @@ describe("createPropertyValueResolver", () => { ], }); }); + + it("should throw when an embedded @variable is not defined", () => { + expect(() => resolvePropertyValue("@0.25 @0.5")).toThrow( + 'Variable "0.25" is not defined', + ); + }); + + it("should throw when only one of several embedded refs is undefined", () => { + root.variables.push({ + type: "variable", + id: "border-width", + name: "border.width", + value: "1px", + }); + + expect(() => + resolvePropertyValue("@border.width solid @color.primary"), + ).toThrow('Variable "color.primary" is not defined'); + }); + + it("should still compile embedded refs when every variable is defined", () => { + root.variables.push( + { + type: "variable", + id: "spacing-sm", + name: "spacing.sm", + value: "0.5rem", + }, + { + type: "variable", + id: "spacing-md", + name: "spacing.md", + value: "1rem", + }, + ); + + const result = resolvePropertyValue("@spacing.sm @spacing.md"); + + expect(result).toEqual({ + type: "css", + value: [ + { + type: "reference", + name: "spacing.sm", + fallback: undefined, + }, + " ", + { + type: "reference", + name: "spacing.md", + fallback: undefined, + }, + ], + }); + }); }); }); diff --git a/engine/core/src/tokens/resolve.ts b/engine/core/src/tokens/resolve.ts index 80561a70..5d3b36cf 100644 --- a/engine/core/src/tokens/resolve.ts +++ b/engine/core/src/tokens/resolve.ts @@ -111,6 +111,7 @@ export function createPropertyValueResolver(parent: Container, root: Root) { if (hasReferences) { for (const part of parts) { if (isRef(part)) { + validateReference(part.name, parent, root); root._usage.variables.add(part.name); } }