From 31f57da41cddeaaf51b6a3d88aebc1d230759b06 Mon Sep 17 00:00:00 2001 From: Paul Golmann Date: Fri, 26 Jun 2026 13:57:38 +0200 Subject: [PATCH 1/2] feat(vector-style-compiler): add literal prop path prefixes for selectors and attr() Support prop| and env-prop| for flat GeoJSON keys such as stroke-width, fix selector tokenization for |js expressions containing brackets, and include selector paths in allowedProps for cache invalidation. --- .changeset/vector-style-prop-paths.md | 5 + packages/vector-style-compiler/README.md | 128 +++++- .../__snapshots__/compiler.test.ts.snap | 158 +++++-- .../__tests__/featurePropPath.test.ts | 392 ++++++++++++++++++ .../__tests__/mapValue.test.ts | 16 + .../__tests__/parseAttrParameter.test.ts | 46 ++ .../__tests__/parseFeaturePropPath.test.ts | 51 +++ .../__tests__/tokenizeSelector.test.ts | 45 ++ .../mapsight-vector-style.web-types.json | 2 +- .../src/js/cssToRules/mapSelector.ts | 13 +- .../src/js/cssToRules/mapSelectorPart.ts | 42 +- .../src/js/cssToRules/mapValue.ts | 14 +- .../src/js/helpers/parseAttrParameter.ts | 26 ++ .../src/js/helpers/parseFeaturePropPath.ts | 99 +++++ .../src/js/helpers/tokenizeSelector.ts | 154 +++++++ 15 files changed, 1090 insertions(+), 101 deletions(-) create mode 100644 .changeset/vector-style-prop-paths.md create mode 100644 packages/vector-style-compiler/__tests__/featurePropPath.test.ts create mode 100644 packages/vector-style-compiler/__tests__/parseAttrParameter.test.ts create mode 100644 packages/vector-style-compiler/__tests__/parseFeaturePropPath.test.ts create mode 100644 packages/vector-style-compiler/__tests__/tokenizeSelector.test.ts create mode 100644 packages/vector-style-compiler/src/js/helpers/parseAttrParameter.ts create mode 100644 packages/vector-style-compiler/src/js/helpers/parseFeaturePropPath.ts create mode 100644 packages/vector-style-compiler/src/js/helpers/tokenizeSelector.ts diff --git a/.changeset/vector-style-prop-paths.md b/.changeset/vector-style-prop-paths.md new file mode 100644 index 00000000..47534f88 --- /dev/null +++ b/.changeset/vector-style-prop-paths.md @@ -0,0 +1,5 @@ +--- +"@mapsight/vector-style-compiler": minor +--- + +Add `prop|` and `env-prop|` prefixes for literal feature and env property keys in selectors and `attr()`, fix string-aware selector tokenization for `|js` expressions, and register selector paths in `allowedProps`. diff --git a/packages/vector-style-compiler/README.md b/packages/vector-style-compiler/README.md index a7964384..1114710d 100644 --- a/packages/vector-style-compiler/README.md +++ b/packages/vector-style-compiler/README.md @@ -1,15 +1,21 @@ # Mapsight vector styles -> **Package:** `@mapsight/vector-style-compiler` · **Hub:** [Documentation index](https://github.com/open-mapsight/mapsight/blob/main/docs/README.md) +> **Package:** `@mapsight/vector-style-compiler` · **Hub: +> ** [Documentation index](https://github.com/open-mapsight/mapsight/blob/main/docs/README.md) Creates JavaScript code that can be used as a `styleFunction` in Mapsight by transforming a subset of CSS. -**Deep dive:** [ARCHITECTURE_DEEP_DIVE.md](https://github.com/open-mapsight/mapsight/blob/main/packages/vector-style-compiler/docs/ARCHITECTURE_DEEP_DIVE.md) · **Consumer:** [`@mapsight/traffic-style`](https://github.com/open-mapsight/mapsight/blob/main/packages/traffic-style/README.md), [`@mapsight/core`](https://github.com/open-mapsight/mapsight/blob/main/packages/core/README.md) +**Deep dive: +** [ARCHITECTURE_DEEP_DIVE.md](https://github.com/open-mapsight/mapsight/blob/main/packages/vector-style-compiler/docs/ARCHITECTURE_DEEP_DIVE.md) · +**Consumer:** [ +`@mapsight/traffic-style`](https://github.com/open-mapsight/mapsight/blob/main/packages/traffic-style/README.md), [ +`@mapsight/core`](https://github.com/open-mapsight/mapsight/blob/main/packages/core/README.md) ## How it Works -The package compiles a CSS subset into a JavaScript module providing an efficient OpenLayers `styleFunction` with built-in caching. +The package compiles a CSS subset into a JavaScript module providing an efficient OpenLayers `styleFunction` with +built-in caching. ``` ┌─────────────────────────────────────────────────────────────────┐ @@ -75,6 +81,7 @@ The package compiles a CSS subset into a JavaScript module providing an efficien ### Supported Selector Types ```css +/* @formatter:off */ /* Universal selector (any feature) */ * { ... } @@ -82,39 +89,110 @@ The package compiles a CSS subset into a JavaScript module providing an efficien #myStyleName { ... } /* State selector (pseudo-class) */ -::selected { ... } -::highlighted { ... } +:selected { ... } +:highlighted { ... } /* Props selector (attribute) */ -[state="hover"] { ... } /* Simple equality */ -[props|name="Road"] { ... } /* Access nested props */ -[geometry|type="Point"] { ... } /* Check geometry type */ -[env|zoom="5"] { ... } /* Access environment */ -[|js="props['id'] > 100"] { ... } /* JavaScript expression */ +[state="hover"] { ... } /* simple equality */ +[props|name="Road"] { ... } /* nested props path */ +[prop|stroke-width="2"] { ... } /* literal props key */ +[env|zoom="5"] { ... } /* nested env path */ +[env-prop|primaryColor="1"] { ... } /* literal env key */ +[geometry|type="Point"] { ... } /* geometry type */ +[|js="props['id'] > 100"] { ... } /* JavaScript expression */ /* Negation */ :not([state="hover"]) { ... } :not([geometry|type="LineString"]) { ... } /* Combinations (space = AND) */ -#myStyle [state="hover"] { ... } /* myStyle AND state==hover */ -[geometry|type="Point"] .icon { ... } /* Geometry AND group */ +#myStyle [state="hover"] { ... } +[geometry|type="Point"] .icon { ... } +/* @formatter:on */ +``` + +### Feature property paths + +Mapsight resolves **feature properties** (`props`) and **style environment** (`env`) separately from +**style declaration names** (custom CSS properties such as `stroke-width:` on the left-hand side of +a rule). + +Unquoted kebab-case in selectors and `attr()` arguments is split on `-` into **nested paths** +(accessed with optional chaining in generated code): + +| Syntax | Reads | +| -------------------- | -------------------------- | +| `attr(stroke-width)` | `props.stroke?.width` | +| `[stroke-width="2"]` | `props.stroke?.width == 2` | +| `attr(path-to-test)` | `props.path?.to?.test` | + +That differs from GeoJSON keys such as Simplestyle's flat `"stroke-width"` property. Use a **literal +key** form when the feature property name contains hyphens but is not nested: + +- **Nested props path** — selector: `[stroke-width="2"]`, value: `attr(stroke-width)` → + `props.stroke?.width` +- **Literal props key** — selector: `[prop|stroke-width="2"]`, value: `attr("prop|stroke-width")` or + `attr('stroke-width')` → `props['stroke-width']` +- **Nested env path** — selector: `[env|zoom="10"]`, value: `attr(--env-zoom)` → `env.zoom` +- **Literal env key** — selector: `[env-prop|stroke-width="2"]`, value: + `attr("env-prop|stroke-width")` or `attr(--env-'stroke-width')` → `env['stroke-width']` + +Literal selector prefixes register the key in `allowedProps` and style-cache hashing. Prefer +`prop|` / `env-prop|` over `|js` for property checks. + +**Style declaration names** (left-hand side) always use hyphen nesting for the OpenLayers style +object — `stroke-width: 2` compiles to `{ stroke: { width: 2 } }` regardless of how feature props +are read on the right-hand side. + +#### SCSS / Sass notes + +The CLI compiles `.scss` before the style compiler. Keep these constraints in mind: + +- **Quote `attr()` arguments that contain `|`** — Sass treats `|` specially inside function calls: + `attr("prop|stroke-width")`, not `attr(prop|stroke-width)`. +- **Use `env-prop|`, not `env|prop|`** — only one `|` is valid in Sass attribute selectors; chained + pipes fail SCSS compilation. +- **Selector prefixes work unquoted in SCSS** — `[prop|stroke-width]` and `[env-prop|zoom]` are fine. +- **Quoted selector attribute names** (`['stroke-width']`) are not valid Sass; use `[prop|stroke-width]` + instead. + +Example (Simplestyle-style flat keys): + +```scss +/* @formatter:off */ +#features { + [prop|stroke-width] { + stroke-width: attr("prop|stroke-width"); + } + + [prop|stroke-opacity] { + stroke-opacity: attr("prop|stroke-opacity"); + } +} +/* @formatter:on */ ``` ### Property Examples + + ```css +/* @formatter:off */ /* MapBox-like custom properties */ fill-color: red; -fill-color: attr(color); /* From props['color'] */ -fill-color: attr(--env-primaryColor); /* From env['primaryColor'] */ +fill-color: attr(color); /* props['color'] */ +fill-color: attr(--env-primaryColor); /* env['primaryColor'] */ +stroke-width: attr( + "prop|stroke-width" +); /* props['stroke-width'] — quote in SCSS */ circle-radius: 5; circle-radius: calc(zoom * 2 + 3); /* JavaScript expression */ -stroke-color: replace("pattern", "X", "attr(id)"); /* String replace */ +stroke-color: replace("pattern", "X", "attr(id)"); /* string replace */ -text-text: attr(--env-title); /* Dynamic text */ +text-text: attr(--env-title); /* dynamic text */ +text-text: attr("env-prop|title"); /* literal env key — quote in SCSS */ /* Complex nested properties */ icon-src: "path/to/icon.png"; @@ -124,8 +202,11 @@ icon-offsetx: attr(offsetX); /* Runtime icons (async-loaded, cache-aware) */ icon-src: calc(mapsightRuntimeIcon(attr(mapsightIconId), "default")); +/* @formatter:on */ ``` + + See full list of supported properties in [Custom CSS properties](#custom-css-properties). ### Volatile `calc()` helpers @@ -213,8 +294,10 @@ circle-fill-color,circle-radius,circle-stroke-color,circle-stroke-width,fill-col ## Benchmarking - `pnpm bench` - - Runs the canonical workload simulation benchmark across `nodejs`, `chromium`, `firefox`, and `webkit` via headless Playwright. - - Includes memory output for all engines (`nodejs` heap usage; browser memory via OS RSS sampling on the Playwright browser process using `pidusage`). + - Runs the canonical workload simulation benchmark across `nodejs`, `chromium`, `firefox`, and `webkit` via headless + Playwright. + - Includes memory output for all engines (`nodejs` heap usage; browser memory via OS RSS sampling on the Playwright + browser process using `pidusage`). - Browser RSS is process-level memory (not JS-heap-only), so use it for trend/comparison signals. - For stronger GC signal in Node, run with exposed GC: - `NODE_OPTIONS=--expose-gc pnpm bench` @@ -427,7 +510,8 @@ Since version: v0.0.0 #### Adding the custom properties to WebStorm/PHPStorm/IntelliJ -Go to `Settings -> Editor -> Inspections` and search for `Unknown CSS property`. Then add the following string under `Options -> Custom CSS Properties`: +Go to `Settings -> Editor -> Inspections` and search for `Unknown CSS property`. Then add the following string under +`Options -> Custom CSS Properties`: @@ -439,8 +523,10 @@ circle-fill-color,circle-radius,circle-stroke-color,circle-stroke-width,fill-col ## Benchmarking - `pnpm bench` - - Runs the canonical workload simulation benchmark across `nodejs`, `chromium`, `firefox`, and `webkit` via headless Playwright. - - Includes memory output for all engines (`nodejs` heap usage; browser memory via OS RSS sampling on the Playwright browser process using `pidusage`). + - Runs the canonical workload simulation benchmark across `nodejs`, `chromium`, `firefox`, and `webkit` via headless + Playwright. + - Includes memory output for all engines (`nodejs` heap usage; browser memory via OS RSS sampling on the Playwright + browser process using `pidusage`). - Browser RSS is process-level memory (not JS-heap-only), so use it for trend/comparison signals. - For stronger GC signal in Node, run with exposed GC: - `NODE_OPTIONS=--expose-gc pnpm bench` diff --git a/packages/vector-style-compiler/__tests__/__snapshots__/compiler.test.ts.snap b/packages/vector-style-compiler/__tests__/__snapshots__/compiler.test.ts.snap index 827d5fa5..56f54b94 100644 --- a/packages/vector-style-compiler/__tests__/__snapshots__/compiler.test.ts.snap +++ b/packages/vector-style-compiler/__tests__/__snapshots__/compiler.test.ts.snap @@ -119,30 +119,39 @@ style: __vectorStyle_style,}, h('@' + createHash($b)); if ($d) { h(2); + h('@' + createHash($c)); } if ($e) { h(3); + h('@' + createHash($c)); } if ($g) { h(4); + h('@' + createHash($f)); } if (!$g) { h(5); + h('@' + createHash($f)); } if ($i) { h(6); + h('@' + createHash($h)); } if ($j) { h(7); + h('@' + createHash($f)); } if ($k) { h(8); + h('@' + createHash($f)); } if (!$k) { h(9); + h('@' + createHash($f)); } if ($m) { h(10); + h('@' + createHash($l)); } if ($o) { h(11); @@ -176,6 +185,7 @@ style: __vectorStyle_style,}, } if (!$u) { h(21); + h('@' + createHash($f)); } } switch (state) { @@ -201,6 +211,7 @@ style: __vectorStyle_style,}, h(26); if ($b) { h(27); + h('@' + createHash($a)); } } break; @@ -666,30 +677,39 @@ h('@' + createHash($a)); h('@' + createHash($b)); if ($d) { h(2); + h('@' + createHash($c)); } if ($e) { h(3); + h('@' + createHash($c)); } if ($g) { h(4); + h('@' + createHash($f)); } if (!$g) { h(5); + h('@' + createHash($f)); } if ($i) { h(6); + h('@' + createHash($h)); } if ($j) { h(7); + h('@' + createHash($f)); } if ($k) { h(8); + h('@' + createHash($f)); } if (!$k) { h(9); + h('@' + createHash($f)); } if ($m) { h(10); + h('@' + createHash($l)); } if ($o) { h(11); @@ -723,6 +743,7 @@ if ($q) { } if (!$u) { h(21); + h('@' + createHash($f)); } } switch (state) { @@ -748,6 +769,7 @@ switch (style) { h(26); if ($b) { h(27); + h('@' + createHash($a)); } } break; @@ -808,6 +830,10 @@ exports[`parses full css to rules correctly 1`] = ` "stylePropExpressions": [ "props['test']", "get(props, ['path', 'to', 'test'])", + "props['state']", + "props['attr']", + "props['hasAttrTest']", + "get(props, ['parking', 'capacity'])", ], "styleProps": [ "test", @@ -1016,7 +1042,9 @@ exports[`parses full css to rules correctly 1`] = ` "someState", ], "styleNames": [], - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['state']", + ], "styleProps": [ "state", ], @@ -1028,7 +1056,9 @@ exports[`parses full css to rules correctly 1`] = ` "stateNames": [ "someState", ], - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['state']", + ], "styleProps": [ "state", ], @@ -1069,7 +1099,9 @@ exports[`parses full css to rules correctly 1`] = ` "anotherState", ], "styleNames": [], - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['state']", + ], "styleProps": [ "state", ], @@ -1081,7 +1113,9 @@ exports[`parses full css to rules correctly 1`] = ` "stateNames": [ "anotherState", ], - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['state']", + ], "styleProps": [ "state", ], @@ -1291,7 +1325,9 @@ exports[`parses full css to rules correctly 1`] = ` ], "stateNames": [], "styleNames": [], - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['attr']", + ], "styleProps": [ "attr", ], @@ -1301,7 +1337,9 @@ exports[`parses full css to rules correctly 1`] = ` { "__meta": { "stateNames": [], - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['attr']", + ], "styleProps": [ "attr", ], @@ -1338,7 +1376,9 @@ exports[`parses full css to rules correctly 1`] = ` ], "stateNames": [], "styleNames": [], - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['attr']", + ], "styleProps": [ "attr", ], @@ -1348,7 +1388,9 @@ exports[`parses full css to rules correctly 1`] = ` { "__meta": { "stateNames": [], - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['attr']", + ], "styleProps": [ "attr", ], @@ -1385,7 +1427,9 @@ exports[`parses full css to rules correctly 1`] = ` ], "stateNames": [], "styleNames": [], - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['hasAttrTest']", + ], "styleProps": [ "hasAttrTest", ], @@ -1395,7 +1439,9 @@ exports[`parses full css to rules correctly 1`] = ` { "__meta": { "stateNames": [], - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['hasAttrTest']", + ], "styleProps": [ "hasAttrTest", ], @@ -1431,7 +1477,9 @@ exports[`parses full css to rules correctly 1`] = ` ], "stateNames": [], "styleNames": [], - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['attr']", + ], "styleProps": [ "attr", ], @@ -1441,7 +1489,9 @@ exports[`parses full css to rules correctly 1`] = ` { "__meta": { "stateNames": [], - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['attr']", + ], "styleProps": [ "attr", ], @@ -1478,7 +1528,9 @@ exports[`parses full css to rules correctly 1`] = ` ], "stateNames": [], "styleNames": [], - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['attr']", + ], "styleProps": [ "attr", ], @@ -1488,7 +1540,9 @@ exports[`parses full css to rules correctly 1`] = ` { "__meta": { "stateNames": [], - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['attr']", + ], "styleProps": [ "attr", ], @@ -1525,7 +1579,9 @@ exports[`parses full css to rules correctly 1`] = ` ], "stateNames": [], "styleNames": [], - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['attr']", + ], "styleProps": [ "attr", ], @@ -1535,7 +1591,9 @@ exports[`parses full css to rules correctly 1`] = ` { "__meta": { "stateNames": [], - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['attr']", + ], "styleProps": [ "attr", ], @@ -1572,7 +1630,9 @@ exports[`parses full css to rules correctly 1`] = ` ], "stateNames": [], "styleNames": [], - "stylePropExpressions": [], + "stylePropExpressions": [ + "get(props, ['parking', 'capacity'])", + ], "styleProps": [ "parking", ], @@ -1582,7 +1642,9 @@ exports[`parses full css to rules correctly 1`] = ` { "__meta": { "stateNames": [], - "stylePropExpressions": [], + "stylePropExpressions": [ + "get(props, ['parking', 'capacity'])", + ], "styleProps": [ "parking", ], @@ -1879,7 +1941,9 @@ exports[`parses full css to rules correctly 1`] = ` "styleNames": [ "features", ], - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['attr']", + ], "styleProps": [ "attr", ], @@ -1889,7 +1953,9 @@ exports[`parses full css to rules correctly 1`] = ` { "__meta": { "stateNames": [], - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['attr']", + ], "styleProps": [ "attr", ], @@ -2143,7 +2209,9 @@ exports[`parses full css to rules correctly 1`] = ` ], "stateNames": [], "styleNames": [], - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['attr']", + ], "styleProps": [ "attr", ], @@ -2153,7 +2221,9 @@ exports[`parses full css to rules correctly 1`] = ` { "__meta": { "stateNames": [], - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['attr']", + ], "styleProps": [ "attr", ], @@ -2211,7 +2281,9 @@ exports[`parses full css to tree correctly 1`] = ` }, }, }, - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['state']", + ], "volatileCalcExpressions": [], }, { @@ -2237,7 +2309,9 @@ exports[`parses full css to tree correctly 1`] = ` }, }, }, - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['state']", + ], "volatileCalcExpressions": [], }, { @@ -2261,7 +2335,9 @@ exports[`parses full css to tree correctly 1`] = ` }, }, }, - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['attr']", + ], "volatileCalcExpressions": [], }, { @@ -2285,7 +2361,9 @@ exports[`parses full css to tree correctly 1`] = ` }, }, }, - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['attr']", + ], "volatileCalcExpressions": [], }, { @@ -2308,7 +2386,9 @@ exports[`parses full css to tree correctly 1`] = ` }, }, }, - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['hasAttrTest']", + ], "volatileCalcExpressions": [], }, { @@ -2332,7 +2412,9 @@ exports[`parses full css to tree correctly 1`] = ` }, }, }, - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['attr']", + ], "volatileCalcExpressions": [], }, { @@ -2356,7 +2438,9 @@ exports[`parses full css to tree correctly 1`] = ` }, }, }, - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['attr']", + ], "volatileCalcExpressions": [], }, { @@ -2380,7 +2464,9 @@ exports[`parses full css to tree correctly 1`] = ` }, }, }, - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['attr']", + ], "volatileCalcExpressions": [], }, { @@ -2405,7 +2491,9 @@ exports[`parses full css to tree correctly 1`] = ` }, }, }, - "stylePropExpressions": [], + "stylePropExpressions": [ + "get(props, ['parking', 'capacity'])", + ], "volatileCalcExpressions": [], }, { @@ -2643,7 +2731,9 @@ exports[`parses full css to tree correctly 1`] = ` }, }, }, - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['attr']", + ], "volatileCalcExpressions": [], }, ], @@ -2779,7 +2869,9 @@ exports[`parses full css to tree correctly 1`] = ` }, }, }, - "stylePropExpressions": [], + "stylePropExpressions": [ + "props['attr']", + ], "volatileCalcExpressions": [], }, ], diff --git a/packages/vector-style-compiler/__tests__/featurePropPath.test.ts b/packages/vector-style-compiler/__tests__/featurePropPath.test.ts new file mode 100644 index 00000000..017876e4 --- /dev/null +++ b/packages/vector-style-compiler/__tests__/featurePropPath.test.ts @@ -0,0 +1,392 @@ +import * as sass from "sass"; +import {describe, expect, it} from "vitest"; + +import cssToRules from "../src/js/cssToRules.ts"; +import mapSelectorPart from "../src/js/cssToRules/mapSelectorPart.ts"; +import mapValue from "../src/js/cssToRules/mapValue.ts"; +import pathToExpression from "../src/js/helpers/pathToExpression.ts"; +import compile from "../src/js/index.ts"; +import rulesToTree from "../src/js/rulesToTree.ts"; +import treeToProgram from "../src/js/treeToProgram.ts"; + +function compileScss(source: string): string { + return sass + .compileString(source, { + style: "expanded", + quietDeps: true, + silenceDeprecations: ["legacy-js-api"], + }) + .css.toString(); +} + +describe("Sass pre-processing", () => { + it("preserves quoted attr() literal keys through Sass", () => { + const css = compileScss(` + #features { + stroke-width: attr('stroke-width'); + } + `); + + expect(css).toContain('attr("stroke-width")'); + + const program = compile(css); + expect(program).toContain("props['stroke-width']"); + expect(program).not.toContain("get(props, ['stroke', 'width'])"); + }); + + it("preserves unquoted attr() nested paths through Sass", () => { + const css = compileScss(` + #features { + stroke-width: attr(stroke-width); + } + `); + + expect(css).toContain("attr(stroke-width)"); + + const program = compile(css); + expect(program).toContain("get(props, ['stroke', 'width'])"); + expect(program).not.toContain("props['stroke-width']"); + }); + + it("does not allow quoted attribute selector names in Sass source", () => { + expect(() => + compileScss(`['stroke-width'="3"] { fill-color: red; }`), + ).toThrow(); + }); + + it("treats interpolated selector names as unquoted nested paths", () => { + const css = compileScss(`[#{"stroke-width"}="3"] { fill-color: red; }`); + + expect(css).toContain('[stroke-width="3"]'); + + const program = treeToProgram(rulesToTree(cssToRules(css).rules)); + expect(program).toContain("get(props, ['stroke', 'width'])"); + expect(program).not.toContain("props['stroke-width']"); + }); + + it("allows |js selectors through Sass when the expression has no ] characters", () => { + const css = compileScss(` + [|js="get(props, 'stroke-width') == 3"] { + fill-color: red; + } + `); + + const program = treeToProgram(rulesToTree(cssToRules(css).rules)); + expect(program).toContain("get(props, 'stroke-width') == 3"); + }); + + it("preserves bracket notation inside |js selectors through Sass", () => { + const css = compileScss(` + [|js="props['stroke-width'] == 3"] { + fill-color: red; + } + `); + + const program = treeToProgram(rulesToTree(cssToRules(css).rules)); + expect(program).toContain("props['stroke-width'] == 3"); + }); + + it("supports prop-prefixed attr() in Sass when the argument is quoted", () => { + const css = compileScss(` + #features { + stroke-width: attr("prop|stroke-width"); + fill-color: attr("env-prop|primaryColor"); + } + `); + + expect(css).toContain('attr("prop|stroke-width")'); + expect(css).toContain('attr("env-prop|primaryColor")'); + + const program = compile(css); + expect(program).toContain("props['stroke-width']"); + expect(program).toContain("env['primaryColor']"); + expect(program).toContain('allowedProps: ["stroke-width"]'); + }); + + it("supports unquoted prop-prefixed attr() in plain CSS", () => { + const program = compile(` + #features { + stroke-width: attr(prop|stroke-width); + } + `); + + expect(program).toContain("props['stroke-width']"); + }); + + it("supports prop-prefixed selectors in Sass", () => { + const css = compileScss(` + #features { + [prop|stroke-width] { + stroke-width: attr('stroke-width'); + } + } + `); + + const rules = cssToRules(css); + expect(rules.__meta.styleProps).toContain("stroke-width"); + expect(rules.__meta.stylePropExpressions).toContain( + "props['stroke-width']", + ); + + const program = compile(css); + expect(program).toContain("props['stroke-width']"); + expect(program).not.toContain("get(props, ['stroke', 'width'])"); + }); + + it("supports env-prop-prefixed selectors in Sass", () => { + expect(() => + compileScss(`[env|prop|stroke-width="3"] { fill-color: red; }`), + ).toThrow(); + + const css = compileScss(` + [env-prop|stroke-width="3"] { + fill-color: red; + } + `); + + const program = treeToProgram(rulesToTree(cssToRules(css).rules)); + expect(program).toContain("env['stroke-width']"); + expect(program).toContain("== 3"); + }); +}); + +describe("pathToExpression", () => { + it("emits bracket access for single segments and get() for nested paths", () => { + expect(pathToExpression("props", ["stroke-width"])).toBe( + "props['stroke-width']", + ); + expect(pathToExpression("props", ["stroke", "width"])).toBe( + "get(props, ['stroke', 'width'])", + ); + }); +}); + +describe("attr() feature property paths", () => { + it("maps unquoted kebab-case to nested props paths", () => { + expect(mapValue("attr(stroke-width)")).toMatchObject({ + value: "'' + get(props, ['stroke', 'width']) + ''", + __meta: { + styleProps: ["stroke"], + stylePropExpressions: ["get(props, ['stroke', 'width'])"], + }, + }); + }); + + it("maps quoted names to literal props keys", () => { + expect(mapValue("attr('stroke-width')")).toMatchObject({ + value: "'' + props['stroke-width'] + ''", + __meta: { + styleProps: ["stroke-width"], + stylePropExpressions: ["props['stroke-width']"], + }, + }); + + expect(mapValue('attr("stroke-width")')).toMatchObject({ + value: "'' + props['stroke-width'] + ''", + }); + }); + + it("maps prop-prefixed names to literal props and env keys", () => { + expect(mapValue("attr(prop|stroke-width)")).toMatchObject({ + value: "'' + props['stroke-width'] + ''", + __meta: { + styleProps: ["stroke-width"], + stylePropExpressions: ["props['stroke-width']"], + }, + }); + + expect(mapValue("attr(env-prop|stroke-width)")).toMatchObject({ + value: "'' + env['stroke-width'] + ''", + }); + }); + + it("maps unquoted env paths to nested env keys", () => { + expect(mapValue("attr(--env-stroke-width)")).toMatchObject({ + value: "'' + get(env, ['stroke', 'width']) + ''", + }); + }); + + it("maps quoted env paths to literal env keys", () => { + expect(mapValue("attr(--env-'stroke-width')")).toMatchObject({ + value: "'' + env['stroke-width'] + ''", + }); + }); +}); + +describe("[attribute] selector feature property paths", () => { + it("maps unquoted kebab-case to nested props paths", () => { + expect(mapSelectorPart("[stroke-width]")).toMatchObject({ + check: { + type: "value", + target: "props", + path: ["stroke", "width"], + negate: false, + }, + __meta: { + styleProps: ["stroke"], + stylePropExpressions: ["get(props, ['stroke', 'width'])"], + }, + }); + + expect(mapSelectorPart('[stroke-width="3"]')).toMatchObject({ + check: { + type: "value", + target: "props", + path: ["stroke", "width"], + value: 3, + }, + __meta: { + stylePropExpressions: ["get(props, ['stroke', 'width'])"], + }, + }); + }); + + it("maps prop-prefixed names to literal props keys", () => { + expect(mapSelectorPart("[prop|stroke-width]")).toMatchObject({ + check: { + type: "value", + target: "props", + path: ["stroke-width"], + }, + __meta: { + styleProps: ["stroke-width"], + stylePropExpressions: ["props['stroke-width']"], + }, + }); + + expect(mapSelectorPart('[prop|stroke-width="3"]')).toMatchObject({ + check: { + type: "value", + target: "props", + path: ["stroke-width"], + value: 3, + }, + __meta: { + styleProps: ["stroke-width"], + stylePropExpressions: ["props['stroke-width']"], + }, + }); + + expect(mapSelectorPart('[env-prop|stroke-width="3"]')).toMatchObject({ + check: { + type: "value", + target: "env", + path: ["stroke-width"], + value: 3, + }, + __meta: { + styleProps: [], + stylePropExpressions: [], + }, + }); + }); + + it("maps quoted names to literal props keys", () => { + expect(mapSelectorPart("['stroke-width']")).toMatchObject({ + check: { + type: "value", + target: "props", + path: ["stroke-width"], + }, + __meta: { + styleProps: ["stroke-width"], + stylePropExpressions: ["props['stroke-width']"], + }, + }); + + expect(mapSelectorPart('[props|"stroke-width"="3"]')).toMatchObject({ + check: { + type: "value", + target: "props", + path: ["stroke-width"], + value: 3, + }, + }); + }); + + it("compiles nested vs literal selector checks differently", () => { + const nested = treeToProgram( + rulesToTree( + cssToRules(` + [stroke-width="3"] { + fill-color: red; + } + `).rules, + ), + ); + const literal = treeToProgram( + rulesToTree( + cssToRules(` + ['stroke-width'="3"] { + fill-color: red; + } + `).rules, + ), + ); + + expect(nested).toContain("get(props, ['stroke', 'width'])"); + expect(nested).toContain("== 3"); + expect(literal).toContain("props['stroke-width']"); + expect(literal).toContain("== 3"); + expect(nested).not.toContain("props['stroke-width']"); + expect(literal).not.toContain("get(props, ['stroke', 'width'])"); + }); + + it("includes selector paths in allowedProps metadata", () => { + const rules = cssToRules(` + [prop|stroke-width="3"] { + fill-color: red; + } + `); + + expect(rules.__meta.styleProps).toEqual(["stroke-width"]); + expect(rules.__meta.stylePropExpressions).toEqual([ + "props['stroke-width']", + ]); + + const program = compile(` + [prop|stroke-width="3"] { + fill-color: red; + } + `); + expect(program).toContain('allowedProps: ["stroke-width"]'); + }); +}); + +describe("CSS declaration property names vs feature props", () => { + it("still maps stroke-width declarations to nested style object paths", () => { + const rules = cssToRules(` + #features { + stroke-width: attr(stroke-width); + } + `); + + expect(rules.rules[0]?.declarations).toEqual({ + default: { + stroke: { + width: { + value: "'' + get(props, ['stroke', 'width']) + ''", + }, + }, + }, + }); + }); + + it("can read a literal stroke-width feature prop into a style declaration", () => { + const rules = cssToRules(` + #features { + stroke-width: attr('stroke-width'); + } + `); + + expect(rules.rules[0]?.declarations).toEqual({ + default: { + stroke: { + width: { + value: "'' + props['stroke-width'] + ''", + }, + }, + }, + }); + }); +}); diff --git a/packages/vector-style-compiler/__tests__/mapValue.test.ts b/packages/vector-style-compiler/__tests__/mapValue.test.ts index 3ce346c4..bd8ef7e1 100644 --- a/packages/vector-style-compiler/__tests__/mapValue.test.ts +++ b/packages/vector-style-compiler/__tests__/mapValue.test.ts @@ -35,6 +35,22 @@ it("mapValue", () => { }, value: "'' + get(props, ['path', 'to', 'test']) + ''", }); + expect(mapValue("attr(prop|stroke-width)")).toStrictEqual({ + __meta: { + stylePropExpressions: ["props['stroke-width']"], + styleProps: ["stroke-width"], + volatileCalcExpressions: [], + }, + value: "'' + props['stroke-width'] + ''", + }); + expect(mapValue("attr(env-prop|stroke-width)")).toStrictEqual({ + __meta: { + stylePropExpressions: [], + styleProps: [], + volatileCalcExpressions: [], + }, + value: "'' + env['stroke-width'] + ''", + }); expect(mapValue("calc(10 + 50)")).toStrictEqual({ __meta: { stylePropExpressions: [], diff --git a/packages/vector-style-compiler/__tests__/parseAttrParameter.test.ts b/packages/vector-style-compiler/__tests__/parseAttrParameter.test.ts new file mode 100644 index 00000000..a1d0b3fd --- /dev/null +++ b/packages/vector-style-compiler/__tests__/parseAttrParameter.test.ts @@ -0,0 +1,46 @@ +import {describe, expect, it} from "vitest"; + +import parseAttrParameter from "../src/js/helpers/parseAttrParameter.ts"; + +describe("parseAttrParameter", () => { + it("parses nested and literal prop paths", () => { + expect(parseAttrParameter("stroke-width")).toEqual({ + target: "props", + path: ["stroke", "width"], + }); + expect(parseAttrParameter("prop|stroke-width")).toEqual({ + target: "props", + path: ["stroke-width"], + }); + expect(parseAttrParameter("'stroke-width'")).toEqual({ + target: "props", + path: ["stroke-width"], + }); + }); + + it("parses nested and literal env paths", () => { + expect(parseAttrParameter("--env-stroke-width")).toEqual({ + target: "env", + path: ["stroke", "width"], + }); + expect(parseAttrParameter("--env-'stroke-width'")).toEqual({ + target: "env", + path: ["stroke-width"], + }); + expect(parseAttrParameter("env-prop|stroke-width")).toEqual({ + target: "env", + path: ["stroke-width"], + }); + }); + + it("parses quoted prop-prefixed arguments", () => { + expect(parseAttrParameter('"prop|stroke-width"')).toEqual({ + target: "props", + path: ["stroke-width"], + }); + expect(parseAttrParameter('"env-prop|stroke-width"')).toEqual({ + target: "env", + path: ["stroke-width"], + }); + }); +}); diff --git a/packages/vector-style-compiler/__tests__/parseFeaturePropPath.test.ts b/packages/vector-style-compiler/__tests__/parseFeaturePropPath.test.ts new file mode 100644 index 00000000..ec27d8f1 --- /dev/null +++ b/packages/vector-style-compiler/__tests__/parseFeaturePropPath.test.ts @@ -0,0 +1,51 @@ +import {describe, expect, it} from "vitest"; + +import { + parseFeaturePropPath, + parsePropPathSegment, +} from "../src/js/helpers/parseFeaturePropPath.ts"; + +describe("parsePropPathSegment", () => { + it("splits unquoted kebab-case into nested path segments", () => { + expect(parsePropPathSegment("stroke-width")).toEqual([ + "stroke", + "width", + ]); + }); + + it("preserves hyphens in quoted literal keys", () => { + expect(parsePropPathSegment("'stroke-width'")).toEqual([ + "stroke-width", + ]); + }); +}); + +describe("parseFeaturePropPath", () => { + it("parses selector attribute names", () => { + expect(parseFeaturePropPath("stroke-width", "selector")).toEqual({ + target: "props", + path: ["stroke", "width"], + }); + expect(parseFeaturePropPath("prop|stroke-width", "selector")).toEqual({ + target: "props", + path: ["stroke-width"], + }); + expect( + parseFeaturePropPath("env-prop|stroke-width", "selector"), + ).toEqual({ + target: "env", + path: ["stroke-width"], + }); + }); + + it("parses attr arguments", () => { + expect(parseFeaturePropPath("--env-stroke-width", "attr")).toEqual({ + target: "env", + path: ["stroke", "width"], + }); + expect(parseFeaturePropPath("prop|stroke-width", "attr")).toEqual({ + target: "props", + path: ["stroke-width"], + }); + }); +}); diff --git a/packages/vector-style-compiler/__tests__/tokenizeSelector.test.ts b/packages/vector-style-compiler/__tests__/tokenizeSelector.test.ts new file mode 100644 index 00000000..872c22b8 --- /dev/null +++ b/packages/vector-style-compiler/__tests__/tokenizeSelector.test.ts @@ -0,0 +1,45 @@ +import {describe, expect, it} from "vitest"; + +import tokenizeSelector, { + splitAttributeSelectorContent, +} from "../src/js/helpers/tokenizeSelector.ts"; + +describe("tokenizeSelector", () => { + it("tokenizes attribute selectors with bracket notation inside quoted |js values", () => { + expect( + tokenizeSelector( + "[|js=\"props['stroke-width'] == 3\"] .group #features", + ), + ).toEqual([ + "[|js=\"props['stroke-width'] == 3\"]", + ".group", + "#features", + ]); + }); + + it("tokenizes quoted attribute names", () => { + expect(tokenizeSelector("['stroke-width'=\"3\"]")).toEqual([ + "['stroke-width'=\"3\"]", + ]); + }); + + it("tokenizes prop-prefixed attribute selectors", () => { + expect(tokenizeSelector('[prop|stroke-width="3"]')).toEqual([ + '[prop|stroke-width="3"]', + ]); + }); + + it("tokenizes :not() with nested attribute selectors", () => { + expect(tokenizeSelector(':not( [attr = "test"] ) .group')).toEqual( + [':not( [attr = "test"] )', ".group"], + ); + }); +}); + +describe("splitAttributeSelectorContent", () => { + it("splits on the first unquoted equals sign", () => { + expect( + splitAttributeSelectorContent("|js=\"props['stroke-width'] == 3\""), + ).toEqual(["|js", "\"props['stroke-width'] == 3\""]); + }); +}); diff --git a/packages/vector-style-compiler/src/data/generated/mapsight-vector-style.web-types.json b/packages/vector-style-compiler/src/data/generated/mapsight-vector-style.web-types.json index c59a04c3..f1a42535 100644 --- a/packages/vector-style-compiler/src/data/generated/mapsight-vector-style.web-types.json +++ b/packages/vector-style-compiler/src/data/generated/mapsight-vector-style.web-types.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/web-types", "name": "@mapsight/vector-style-compiler", - "version": "11.0.0", + "version": "12.0.0", "description": "Mapsight Vector Style Compiler", "contributions": { "css": { diff --git a/packages/vector-style-compiler/src/js/cssToRules/mapSelector.ts b/packages/vector-style-compiler/src/js/cssToRules/mapSelector.ts index 6e0467a0..1a7b86a0 100644 --- a/packages/vector-style-compiler/src/js/cssToRules/mapSelector.ts +++ b/packages/vector-style-compiler/src/js/cssToRules/mapSelector.ts @@ -1,22 +1,13 @@ import unique from "@mapsight/lib-js/array/unique"; import {isTruthy} from "@mapsight/lib-js/boolean"; -import {ensureNonNullable} from "@mapsight/lib-js/nonNullable"; +import tokenizeSelector from "../helpers/tokenizeSelector.ts"; import mapSelectorPart from "./mapSelectorPart.ts"; -/** - * Finds - * A) Words incl. whitespace enclosed by matching single quotes ('), square brackets ([,]), not (:not(,)) or matching double quotes (") and - * B) Words (groups of non-whitespace characters) - * - * @type {RegExp} - */ -const REGEX_SELECTOR_PART: RegExp = /('.*?'|\[.*?]|:not\(.*?\)|".*?"|\S+)/g; - export type Selector = ReturnType; export default function mapSelector(selector: string) { - const selectorParts = ensureNonNullable(selector.match(REGEX_SELECTOR_PART)) + const selectorParts = tokenizeSelector(selector) .map((part) => mapSelectorPart(part)) .filter(isTruthy); const checks = unique( diff --git a/packages/vector-style-compiler/src/js/cssToRules/mapSelectorPart.ts b/packages/vector-style-compiler/src/js/cssToRules/mapSelectorPart.ts index 4a6bd129..32ff7568 100644 --- a/packages/vector-style-compiler/src/js/cssToRules/mapSelectorPart.ts +++ b/packages/vector-style-compiler/src/js/cssToRules/mapSelectorPart.ts @@ -1,5 +1,10 @@ import trimQuotes from "@mapsight/lib-js/string/trimQuotes"; +import { + featurePropPathMeta, + parseFeaturePropPath, +} from "../helpers/parseFeaturePropPath.ts"; +import {splitAttributeSelectorContent} from "../helpers/tokenizeSelector.ts"; import mapValue from "./mapValue.ts"; type JsCheck = { @@ -35,13 +40,13 @@ function mapAttributeSelectorPart( stateNames?: string[]; }; } { - const operands = part - .slice(1, -1) // remove square brackets - .split("="); // split by first equal sign - let leftHandOperand = operands?.shift()?.trim() || ""; - const rightHandOperand = operands.length - ? trimQuotes(operands.join("=").trim()) - : undefined; + const [leftHandOperand, rawRightHandOperand] = splitAttributeSelectorContent( + part.slice(1, -1), + ); + const rightHandOperand = + rawRightHandOperand !== undefined + ? trimQuotes(rawRightHandOperand) + : undefined; // special case: js expression if (leftHandOperand.startsWith("|js")) { @@ -69,28 +74,11 @@ function mapAttributeSelectorPart( }; } - let target: "props" | "env" = "props"; - - // env target - if (leftHandOperand.startsWith("env|")) { - target = "env"; - leftHandOperand = leftHandOperand.slice(4); - } else if (leftHandOperand.startsWith("props|")) { - // trim optional prefix - leftHandOperand = leftHandOperand.slice(6); - } - - // kebab case to dot separated string - const path = leftHandOperand.split("-"); + const {target, path} = parseFeaturePropPath(leftHandOperand, "selector"); // keep track of props used for styling let stateNames: string[] = []; - let styleProps: string[] = []; - let stylePropExpressions: string[] = []; - - if (target === "props") { - styleProps.push(path[0]!); - } + let {styleProps, stylePropExpressions} = featurePropPathMeta(target, path); let value = undefined; if (rightHandOperand !== undefined) { @@ -101,7 +89,7 @@ function mapAttributeSelectorPart( mappedValue.__meta.stylePropExpressions, ); - if (leftHandOperand === "state") { + if (path.length === 1 && path[0] === "state") { stateNames = [rightHandOperand]; } } diff --git a/packages/vector-style-compiler/src/js/cssToRules/mapValue.ts b/packages/vector-style-compiler/src/js/cssToRules/mapValue.ts index 50ec12c2..e1d96bda 100644 --- a/packages/vector-style-compiler/src/js/cssToRules/mapValue.ts +++ b/packages/vector-style-compiler/src/js/cssToRules/mapValue.ts @@ -6,6 +6,7 @@ import isNumberLike from "@mapsight/lib-js/types/isNumberLike"; import type {ReplacerFn} from "../helpers/Replacer.ts"; import Replacer from "../helpers/Replacer.ts"; +import parseAttrParameter from "../helpers/parseAttrParameter.ts"; import pathToExpression from "../helpers/pathToExpression.ts"; import {containsVolatileCalcHelper} from "../helpers/volatileCalcHelpers.ts"; @@ -57,17 +58,14 @@ export default function mapValue( [ "attr", (_match, parameter) => { - const isEnv = parameter.startsWith("--env-"); - const parameters = parameter.split("-"); + const {target, path} = parseAttrParameter(parameter); + const expression = pathToExpression(target, path); - if (isEnv) { - const restParameters = parameters.slice(3); - return `' + ${pathToExpression("env", restParameters)} + '`; + if (target === "props") { + meta.styleProps.push(ensureNonNullable(path[0])); + meta.stylePropExpressions.push(expression); } - const expression = pathToExpression("props", parameters); - meta.styleProps.push(ensureNonNullable(parameters[0])); - meta.stylePropExpressions.push(expression); return `' + ${expression} + '`; }, ], diff --git a/packages/vector-style-compiler/src/js/helpers/parseAttrParameter.ts b/packages/vector-style-compiler/src/js/helpers/parseAttrParameter.ts new file mode 100644 index 00000000..bbee58a4 --- /dev/null +++ b/packages/vector-style-compiler/src/js/helpers/parseAttrParameter.ts @@ -0,0 +1,26 @@ +import trimQuotes from "@mapsight/lib-js/string/trimQuotes"; + +import { + type FeaturePropPath, + hasExplicitAttrPrefix, + parseFeaturePropPath, +} from "./parseFeaturePropPath.ts"; + +export type {FeaturePropPath as AttrParameter}; + +/** + * Parse the argument of attr(...). + * + * In SCSS, quote arguments that contain `|`, e.g. attr("prop|stroke-width"). + */ +export default function parseAttrParameter(parameter: string): FeaturePropPath { + const trimmed = parameter.trim(); + const unquoted = trimQuotes(trimmed); + const wasQuoted = unquoted !== trimmed; + + if (wasQuoted && !hasExplicitAttrPrefix(unquoted)) { + return {target: "props", path: [unquoted]}; + } + + return parseFeaturePropPath(unquoted, "attr"); +} diff --git a/packages/vector-style-compiler/src/js/helpers/parseFeaturePropPath.ts b/packages/vector-style-compiler/src/js/helpers/parseFeaturePropPath.ts new file mode 100644 index 00000000..aa5ba8d1 --- /dev/null +++ b/packages/vector-style-compiler/src/js/helpers/parseFeaturePropPath.ts @@ -0,0 +1,99 @@ +import trimQuotes from "@mapsight/lib-js/string/trimQuotes"; + +import pathToExpression from "./pathToExpression.ts"; + +export type FeaturePropTarget = "props" | "env"; + +export type FeaturePropPath = { + target: FeaturePropTarget; + path: string[]; +}; + +export type ParseFeaturePropPathMode = "selector" | "attr"; + +const ENV_PROP_LITERAL_PREFIX = "env-prop|"; +const ENV_SELECTOR_PREFIX = "env|"; +const ENV_ATTR_PREFIX = "--env-"; +const PROPS_SELECTOR_PREFIX = "props|"; +const PROP_LITERAL_PREFIX = "prop|"; + +/** Whether an attr(...) operand uses an explicit prefix (not plain quoted text). */ +export function hasExplicitAttrPrefix(operand: string): boolean { + return ( + operand.startsWith(ENV_PROP_LITERAL_PREFIX) || + operand.startsWith(ENV_ATTR_PREFIX) || + operand.startsWith(PROP_LITERAL_PREFIX) + ); +} + +/** + * Split a segment on hyphens unless it is a quoted literal key. + */ +export function parsePropPathSegment(segment: string): string[] { + const trimmed = segment.trim(); + const unquoted = trimQuotes(trimmed); + + if (unquoted !== trimmed) { + return [unquoted]; + } + + return trimmed.split("-"); +} + +/** + * Parse a feature or env property reference from a selector attribute name + * or an attr(...) argument (after any attr-specific quote handling). + */ +export function parseFeaturePropPath( + operand: string, + mode: ParseFeaturePropPathMode, +): FeaturePropPath { + let rest = operand.trim(); + let target: FeaturePropTarget = "props"; + + if (rest.startsWith(ENV_PROP_LITERAL_PREFIX)) { + return { + target: "env", + path: [rest.slice(ENV_PROP_LITERAL_PREFIX.length)], + }; + } + + if (mode === "selector" && rest.startsWith(ENV_SELECTOR_PREFIX)) { + target = "env"; + rest = rest.slice(ENV_SELECTOR_PREFIX.length); + } else if (mode === "attr" && rest.startsWith(ENV_ATTR_PREFIX)) { + return { + target: "env", + path: parsePropPathSegment(rest.slice(ENV_ATTR_PREFIX.length)), + }; + } + + if (mode === "selector" && rest.startsWith(PROPS_SELECTOR_PREFIX)) { + rest = rest.slice(PROPS_SELECTOR_PREFIX.length); + } + + if (rest.startsWith(PROP_LITERAL_PREFIX)) { + return {target, path: [rest.slice(PROP_LITERAL_PREFIX.length)]}; + } + + return {target, path: parsePropPathSegment(rest)}; +} + +/** Metadata for allowedProps and declaration hashing. */ +export function featurePropPathMeta( + target: FeaturePropTarget, + path: string[], +): { + styleProps: string[]; + stylePropExpressions: string[]; +} { + if (target !== "props" || !path[0]) { + return {styleProps: [], stylePropExpressions: []}; + } + + const expression = pathToExpression(target, path); + return { + styleProps: [path[0]], + stylePropExpressions: [expression], + }; +} diff --git a/packages/vector-style-compiler/src/js/helpers/tokenizeSelector.ts b/packages/vector-style-compiler/src/js/helpers/tokenizeSelector.ts new file mode 100644 index 00000000..d8dd111f --- /dev/null +++ b/packages/vector-style-compiler/src/js/helpers/tokenizeSelector.ts @@ -0,0 +1,154 @@ +function findClosingQuote(source: string, openIndex: number): number { + const stringChar = source[openIndex]!; + for (let i = openIndex + 1; i < source.length; i++) { + if (source[i] === stringChar && source[i - 1] !== "\\") { + return i; + } + } + return -1; +} + +function scanWithStringAwareness( + source: string, + openIndex: number, + openChar: string, + closeChar: string, +): number { + let depth = 0; + let inString = false; + let stringChar = ""; + + for (let i = openIndex; i < source.length; i++) { + const char = source[i]!; + + if (inString) { + if (char === stringChar && source[i - 1] !== "\\") { + inString = false; + } + continue; + } + + if (char === "'" || char === '"') { + inString = true; + stringChar = char; + continue; + } + + if (char === openChar) { + depth += 1; + continue; + } + + if (char === closeChar) { + depth -= 1; + if (depth === 0) { + return i; + } + } + } + + return -1; +} + +function findClosingBracket(source: string, openIndex: number): number { + return scanWithStringAwareness(source, openIndex, "[", "]"); +} + +function findClosingParen(source: string, openIndex: number): number { + return scanWithStringAwareness(source, openIndex, "(", ")"); +} + +/** + * Split `[name=value]` content on the first `=` outside of quoted strings. + */ +export function splitAttributeSelectorContent( + content: string, +): [string, string | undefined] { + let inString = false; + let stringChar = ""; + + for (let i = 0; i < content.length; i++) { + const char = content[i]!; + + if (inString) { + if (char === stringChar && content[i - 1] !== "\\") { + inString = false; + } + continue; + } + + if (char === "'" || char === '"') { + inString = true; + stringChar = char; + continue; + } + + if (char === "=") { + return [content.slice(0, i).trim(), content.slice(i + 1).trim()]; + } + } + + return [content.trim(), undefined]; +} + +/** + * Tokenize a Mapsight style selector into parts such as `#features`, + * `[state="hover"]`, `:not(...)`, and `.group`. + */ +export default function tokenizeSelector(selector: string): string[] { + const parts: string[] = []; + let index = 0; + + while (index < selector.length) { + if (/\s/.test(selector[index]!)) { + index += 1; + continue; + } + + const char = selector[index]!; + + if (char === "'" || char === '"') { + const end = findClosingQuote(selector, index); + if (end === -1) { + throw new Error(`Unterminated string in selector: ${selector}`); + } + parts.push(selector.slice(index, end + 1)); + index = end + 1; + continue; + } + + if (char === "[") { + const end = findClosingBracket(selector, index); + if (end === -1) { + throw new Error( + `Unterminated attribute selector in: ${selector}`, + ); + } + parts.push(selector.slice(index, end + 1)); + index = end + 1; + continue; + } + + if (char === ":" && selector.startsWith(":not(", index)) { + const openParenIndex = index + 4; + const end = findClosingParen(selector, openParenIndex); + if (end === -1) { + throw new Error(`Unterminated :not() in selector: ${selector}`); + } + parts.push(selector.slice(index, end + 1)); + index = end + 1; + continue; + } + + const match = /^\S+/.exec(selector.slice(index)); + if (match) { + parts.push(match[0]); + index += match[0].length; + continue; + } + + index += 1; + } + + return parts; +} From 46dd109d5719abe80e47b9c0583aafe0c2886451 Mon Sep 17 00:00:00 2001 From: Paul Golmann Date: Fri, 26 Jun 2026 14:02:41 +0200 Subject: [PATCH 2/2] style(vector-style-compiler): fix prettier formatting after eslint in mapSelectorPart --- .../src/js/cssToRules/mapSelectorPart.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/vector-style-compiler/src/js/cssToRules/mapSelectorPart.ts b/packages/vector-style-compiler/src/js/cssToRules/mapSelectorPart.ts index 32ff7568..1fe95bfd 100644 --- a/packages/vector-style-compiler/src/js/cssToRules/mapSelectorPart.ts +++ b/packages/vector-style-compiler/src/js/cssToRules/mapSelectorPart.ts @@ -40,9 +40,8 @@ function mapAttributeSelectorPart( stateNames?: string[]; }; } { - const [leftHandOperand, rawRightHandOperand] = splitAttributeSelectorContent( - part.slice(1, -1), - ); + const [leftHandOperand, rawRightHandOperand] = + splitAttributeSelectorContent(part.slice(1, -1)); const rightHandOperand = rawRightHandOperand !== undefined ? trimQuotes(rawRightHandOperand)