Skip to content

Commit a03a29b

Browse files
authored
fix(core): boundary-guard @-reference parsing for mid-token literals (#323)
AT_VARIABLE_REGEX matched @ anywhere in a string, so a retina URL like url("image@2x.png") parsed @2x.png as a variable reference — silently emitting a dead var(--2x--png) before SF-13, and throwing after SF-13 tightened ref validation. Require a non-word boundary before @ so it only starts a reference when not preceded by a word character. Implements SF-33.
1 parent e5e1a84 commit a03a29b

4 files changed

Lines changed: 45 additions & 2 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@styleframe/core": patch
3+
"styleframe": patch
4+
---
5+
6+
Boundary-guard `@`-reference parsing so a `@` mid-token is a literal, not a reference. `AT_VARIABLE_REGEX` matched `@` anywhere in a string, so a retina URL like `url("image@2x.png")` parsed `@2x.png` as a variable reference — silently emitting a dead `var(--2x--png)` before SF-13, and *throwing* `Variable "2x.png" is not defined` after SF-13 tightened ref validation. The regex now requires a non-word boundary before `@` (`(?<!\w)`), so `@` only starts a reference when not preceded by a word character. Genuine leading-`@` refs (`@color.primary`, `1px solid @spacing.sm`, `@border.width solid @color.primary`) are unchanged, byte for byte; only `@`-mid-token literals change — from broken/throwing to passthrough.

‎engine/core/src/tokens/atRule.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -668,13 +668,16 @@ describe("createMediaFunction", () => {
668668
const result = media(
669669
"(-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi)",
670670
{
671-
backgroundImage: 'url("image-2x.png")',
671+
backgroundImage: 'url("image@2x.png")',
672672
},
673673
);
674674

675675
expect(result.rule).toBe(
676676
"(-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi)",
677677
);
678+
// A retina URL's mid-token @ must stay a literal string, not parse as
679+
// a reference (no throw, no dead var(--2x--png)). See SF-33.
680+
expect(result.declarations.backgroundImage).toBe('url("image@2x.png")');
678681
});
679682

680683
it("should handle empty query string", () => {

‎engine/core/src/tokens/resolve.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,27 @@ describe("parseAtReferences", () => {
6363

6464
expect(result).toEqual([]);
6565
});
66+
67+
it("should not treat a mid-token @ as a reference (retina URL)", () => {
68+
const result = parseAtReferences('url("image@2x.png")');
69+
70+
expect(result).toEqual(['url("image@2x.png")']);
71+
});
72+
73+
it("should not treat a @ preceded by a word character as a reference", () => {
74+
const result = parseAtReferences("foo@bar");
75+
76+
expect(result).toEqual(["foo@bar"]);
77+
});
78+
79+
it("should still parse a leading @ reference after a non-word boundary", () => {
80+
const result = parseAtReferences('url("image@2x.png") @color.primary');
81+
82+
expect(result).toEqual([
83+
'url("image@2x.png") ',
84+
{ type: "reference", name: "color.primary", fallback: undefined },
85+
]);
86+
});
6687
});
6788

6889
describe("findVariableInScope", () => {
@@ -284,6 +305,16 @@ describe("createPropertyValueResolver", () => {
284305
it("should return empty strings unchanged", () => {
285306
expect(resolvePropertyValue("")).toBe("");
286307
});
308+
309+
it("should return a retina URL literal unchanged (no throw, no ref)", () => {
310+
expect(resolvePropertyValue('url("image@2x.png")')).toBe(
311+
'url("image@2x.png")',
312+
);
313+
});
314+
315+
it("should return a bare mid-token @ literal unchanged", () => {
316+
expect(resolvePropertyValue("foo@bar")).toBe("foo@bar");
317+
});
287318
});
288319

289320
describe("exact @ reference", () => {

‎engine/core/src/tokens/resolve.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ export function trackReferenceUsage(root: Root, value: TokenValue): void {
2323

2424
export type RefFunction = (variable: string, fallback?: string) => Reference;
2525

26-
const AT_VARIABLE_REGEX = /@([\w.-]+)/g;
26+
// `@` only starts a reference at a non-word boundary. Guarding with `(?<!\w)`
27+
// keeps mid-token literals like a retina URL `url("image@2x.png")` intact
28+
// instead of parsing `@2x.png` as a variable reference (SF-33).
29+
const AT_VARIABLE_REGEX = /(?<!\w)@([\w.-]+)/g;
2730

2831
export function parseAtReferences(str: string): TokenValue[] {
2932
const parts: TokenValue[] = [];

0 commit comments

Comments
 (0)