@db-ux/core-postcss-plugin 0.0.0 → 4.5.4-postcss-a42fe67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -0
- package/README.md +299 -0
- package/build/index.d.ts +3 -0
- package/build/index.js +4 -0
- package/build/plugins/flatten/data.d.ts +17 -0
- package/build/plugins/flatten/data.js +1 -0
- package/build/plugins/flatten/helpers/collect.d.ts +71 -0
- package/build/plugins/flatten/helpers/collect.js +175 -0
- package/build/plugins/flatten/helpers/css-parser.d.ts +28 -0
- package/build/plugins/flatten/helpers/css-parser.js +59 -0
- package/build/plugins/flatten/helpers/resolve.d.ts +32 -0
- package/build/plugins/flatten/helpers/resolve.js +333 -0
- package/build/plugins/flatten/helpers/transform.d.ts +22 -0
- package/build/plugins/flatten/helpers/transform.js +85 -0
- package/build/plugins/flatten/index.d.ts +16 -0
- package/build/plugins/flatten/index.js +52 -0
- package/package.json +36 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# @db-ux/core-postcss-plugin
|
package/README.md
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# @db-ux/core-postcss-plugin
|
|
2
|
+
|
|
3
|
+
PostCSS plugins for the DB UX Design System.
|
|
4
|
+
|
|
5
|
+
## Plugins
|
|
6
|
+
|
|
7
|
+
| Plugin | Description |
|
|
8
|
+
| ------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
9
|
+
| `dbUxFlatten` | Flatten CSS custom properties by resolving `var()`, `@property`, `calc()`, `color-mix()`, and `light-dark()` |
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```shell
|
|
14
|
+
npm install @db-ux/core-postcss-plugin --save-dev
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Imports
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
// Named import (recommended)
|
|
21
|
+
import { dbUxFlatten } from "@db-ux/core-postcss-plugin";
|
|
22
|
+
|
|
23
|
+
// Default import (backward compatible — exports dbUxFlatten)
|
|
24
|
+
import dbUxFlatten from "@db-ux/core-postcss-plugin";
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## CSS Setup
|
|
28
|
+
|
|
29
|
+
Use `@layer` to ensure the theme overrides base component styles. The plugin detects `@import ... layer()` rules to assign the correct layer to each file:
|
|
30
|
+
|
|
31
|
+
```css
|
|
32
|
+
/* styles.css */
|
|
33
|
+
@layer db-ux, db-theme;
|
|
34
|
+
@import "@db-ux/db-theme/build/styles/rollup.css" layer(db-theme);
|
|
35
|
+
@import "@db-ux/core-components/build/styles/rollup.css" layer(db-ux);
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Framework Integration
|
|
39
|
+
|
|
40
|
+
### Vite (React, Vue, Svelte, etc.)
|
|
41
|
+
|
|
42
|
+
Configure the plugin directly in `vite.config.ts`:
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// vite.config.ts
|
|
46
|
+
import { defineConfig } from "vite";
|
|
47
|
+
import { dbUxFlatten } from "@db-ux/core-postcss-plugin";
|
|
48
|
+
|
|
49
|
+
export default defineConfig({
|
|
50
|
+
css: {
|
|
51
|
+
transformer: "postcss", // required for Vite 8+ (default: 'lightningcss')
|
|
52
|
+
postcss: {
|
|
53
|
+
plugins: [dbUxFlatten()]
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
> **Note**: Vite 8+ uses `lightningcss` as the default CSS transformer, which does not run PostCSS plugins. Set `css.transformer: 'postcss'` to enable PostCSS processing. Vite 7 and earlier use PostCSS by default and do not need this option.
|
|
60
|
+
|
|
61
|
+
Works in both dev and build mode.
|
|
62
|
+
|
|
63
|
+
### Angular
|
|
64
|
+
|
|
65
|
+
Create a `postcss.config.json` in your project root:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"plugins": {
|
|
70
|
+
"@db-ux/core-postcss-plugin": {}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Angular CLI (`@angular/build:application`) only supports JSON-based PostCSS configs and loads plugins by name via `require()`. Works in both `ng build` and `ng serve`.
|
|
76
|
+
|
|
77
|
+
### Next.js
|
|
78
|
+
|
|
79
|
+
Create a `postcss.config.mjs` in your project root:
|
|
80
|
+
|
|
81
|
+
```js
|
|
82
|
+
// postcss.config.mjs
|
|
83
|
+
import { dbUxFlatten } from "@db-ux/core-postcss-plugin";
|
|
84
|
+
|
|
85
|
+
export default {
|
|
86
|
+
plugins: [dbUxFlatten()]
|
|
87
|
+
};
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Works with both webpack and turbopack.
|
|
91
|
+
|
|
92
|
+
> **Note**: For CommonJS (`postcss.config.js`):
|
|
93
|
+
>
|
|
94
|
+
> ```js
|
|
95
|
+
> const { dbUxFlatten } = require("@db-ux/core-postcss-plugin");
|
|
96
|
+
> module.exports = { plugins: [dbUxFlatten()] };
|
|
97
|
+
> ```
|
|
98
|
+
|
|
99
|
+
### Webpack (standalone)
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
// webpack.config.js
|
|
103
|
+
const { dbUxFlatten } = require("@db-ux/core-postcss-plugin");
|
|
104
|
+
|
|
105
|
+
module.exports = {
|
|
106
|
+
module: {
|
|
107
|
+
rules: [
|
|
108
|
+
{
|
|
109
|
+
test: /\.css$/,
|
|
110
|
+
use: [
|
|
111
|
+
"style-loader",
|
|
112
|
+
"css-loader",
|
|
113
|
+
{
|
|
114
|
+
loader: "postcss-loader",
|
|
115
|
+
options: {
|
|
116
|
+
postcssOptions: {
|
|
117
|
+
plugins: [dbUxFlatten()]
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
]
|
|
122
|
+
}
|
|
123
|
+
]
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## `dbUxFlatten`
|
|
131
|
+
|
|
132
|
+
Flattens DB UX Design System CSS custom properties by resolving `var()`, `@property`, `calc()`, `color-mix()`, and `light-dark()`.
|
|
133
|
+
|
|
134
|
+
### What it does
|
|
135
|
+
|
|
136
|
+
1. **Collects** all `@property` declarations and their `initial-value` as a resolution cache
|
|
137
|
+
2. **Detects `@layer` priority** from `@layer` order declarations and `@import ... layer()` rules — theme values override base values regardless of processing order
|
|
138
|
+
3. **Detects dynamic variables** that must stay as `var()` references:
|
|
139
|
+
- Variables re-declared in non-`:root`/`:host` selectors (e.g. `[data-density=functional]`)
|
|
140
|
+
- Variables re-declared inside `@media` queries
|
|
141
|
+
- Variables matching `dynamicPrefixes` (default: `--db-adaptive-*`)
|
|
142
|
+
4. **Resolves** all static `var()` references recursively — including nested `var()` with fallbacks like `var(--a, var(--b))`
|
|
143
|
+
5. **Evaluates** `calc()` expressions when all values are static
|
|
144
|
+
6. **Evaluates** `color-mix(in srgb, ...)` when all colors are resolved
|
|
145
|
+
7. **Collapses** `light-dark(x, y)` to `x` when both arguments are identical after resolution
|
|
146
|
+
8. **Removes** `@property` rules and unused intermediate declarations
|
|
147
|
+
|
|
148
|
+
### Options
|
|
149
|
+
|
|
150
|
+
| Option | Type | Default | Description |
|
|
151
|
+
| ------------------ | ---------- | -------------------- | ----------------------------------------------------------------------- |
|
|
152
|
+
| `removeAtProperty` | `boolean` | `true` | Remove `@property` rules after resolving |
|
|
153
|
+
| `removeResolved` | `boolean` | `true` | Remove declarations from `@property` that are no longer referenced |
|
|
154
|
+
| `dynamicPrefixes` | `string[]` | `['--db-adaptive-']` | Variable prefixes that are always treated as dynamic and never resolved |
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
dbUxFlatten({
|
|
158
|
+
removeAtProperty: true,
|
|
159
|
+
removeResolved: true,
|
|
160
|
+
dynamicPrefixes: ["--db-adaptive-", "--my-custom-dynamic-"]
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### How it works
|
|
165
|
+
|
|
166
|
+
#### Static vs. dynamic variables
|
|
167
|
+
|
|
168
|
+
- **Static**: Only declared in `:root`/`:host` and `@property` — safe to inline everywhere
|
|
169
|
+
- **Dynamic**: Re-declared in class selectors, data attributes, `@media` queries, or matching `dynamicPrefixes` — must stay as `var()` references
|
|
170
|
+
|
|
171
|
+
```css
|
|
172
|
+
/* Static — will be resolved */
|
|
173
|
+
@property --db-neutral-0 {
|
|
174
|
+
syntax: "<color>";
|
|
175
|
+
initial-value: #0d0e10;
|
|
176
|
+
inherits: true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* Dynamic — stays as var() */
|
|
180
|
+
:root {
|
|
181
|
+
--db-spacing-fixed-sm: 0.75rem;
|
|
182
|
+
}
|
|
183
|
+
[data-density="functional"] {
|
|
184
|
+
--db-spacing-fixed-sm: 0.5rem; /* re-declared → dynamic */
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
#### `@layer` and `@import layer()` support
|
|
189
|
+
|
|
190
|
+
The plugin detects layer priority from two sources:
|
|
191
|
+
|
|
192
|
+
1. **`@layer` order declarations**: `@layer db-ux, db-theme;` — later in the list = higher priority
|
|
193
|
+
2. **`@import ... layer()` rules**: `@import "file.css" layer(db-theme);` — maps each imported file to its layer
|
|
194
|
+
|
|
195
|
+
This works even when the bundler processes each imported file independently (e.g. Angular), because the plugin sees the `@import` rules in the entry CSS file first and builds a file-to-layer mapping before the imported files are processed.
|
|
196
|
+
|
|
197
|
+
```css
|
|
198
|
+
@layer db-ux, db-theme;
|
|
199
|
+
@import "@db-ux/db-theme/build/styles/rollup.css" layer(db-theme);
|
|
200
|
+
@import "@db-ux/core-components/build/styles/rollup.css" layer(db-ux);
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Unlayered CSS always wins over all layers, matching the CSS spec.
|
|
204
|
+
|
|
205
|
+
#### Nested `var()` with fallbacks
|
|
206
|
+
|
|
207
|
+
```css
|
|
208
|
+
/* Input */
|
|
209
|
+
font-family: var(--db-icon-font-family, var(--db-icon-default-font-family));
|
|
210
|
+
|
|
211
|
+
/* Output (--db-icon-font-family unknown, --db-icon-default-font-family resolved) */
|
|
212
|
+
font-family: var(--db-icon-font-family, "db-default", icon-font-fallback);
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
#### `light-dark()` collapsing
|
|
216
|
+
|
|
217
|
+
When both arguments resolve to the same value, the function is collapsed:
|
|
218
|
+
|
|
219
|
+
```css
|
|
220
|
+
/* Input */
|
|
221
|
+
--db-color: light-dark(
|
|
222
|
+
var(--db-neutral-origin-light-default),
|
|
223
|
+
var(--db-neutral-origin-dark-default)
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
/* Output (both resolve to #232529) */
|
|
227
|
+
--db-color: #232529;
|
|
228
|
+
|
|
229
|
+
/* Output (different values — kept) */
|
|
230
|
+
--db-color: light-dark(#232529, #f0f0f0);
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### Example
|
|
234
|
+
|
|
235
|
+
**Input:**
|
|
236
|
+
|
|
237
|
+
```css
|
|
238
|
+
@layer db-ux, db-theme;
|
|
239
|
+
|
|
240
|
+
@layer db-theme {
|
|
241
|
+
@property --db-brand-origin-light-default {
|
|
242
|
+
syntax: "<color>";
|
|
243
|
+
initial-value: #ec0016;
|
|
244
|
+
inherits: true;
|
|
245
|
+
}
|
|
246
|
+
@property --db-brand-origin-dark-default {
|
|
247
|
+
syntax: "<color>";
|
|
248
|
+
initial-value: #ec0016;
|
|
249
|
+
inherits: true;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
@layer db-ux {
|
|
254
|
+
:root {
|
|
255
|
+
--db-brand-origin-default: light-dark(
|
|
256
|
+
var(--db-brand-origin-light-default),
|
|
257
|
+
var(--db-brand-origin-dark-default)
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
.button {
|
|
261
|
+
color: var(--db-adaptive-on-bg-basic-emphasis-100-default);
|
|
262
|
+
border-radius: var(--db-border-radius-xs);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
**Output:**
|
|
268
|
+
|
|
269
|
+
```css
|
|
270
|
+
:root {
|
|
271
|
+
--db-brand-origin-default: #ec0016;
|
|
272
|
+
}
|
|
273
|
+
.button {
|
|
274
|
+
color: var(--db-adaptive-on-bg-basic-emphasis-100-default);
|
|
275
|
+
border-radius: 0.25rem;
|
|
276
|
+
}
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
- `--db-brand-origin-default`: both light/dark resolved to `#ec0016` (theme value) → `light-dark()` collapsed
|
|
280
|
+
- `--db-adaptive-*`: kept as `var()` (dynamic prefix)
|
|
281
|
+
- `--db-border-radius-xs`: resolved from `@property` (static)
|
|
282
|
+
|
|
283
|
+
---
|
|
284
|
+
|
|
285
|
+
## Package Structure
|
|
286
|
+
|
|
287
|
+
```text
|
|
288
|
+
src/
|
|
289
|
+
├── index.ts # barrel export
|
|
290
|
+
└── plugins/
|
|
291
|
+
└── flatten/
|
|
292
|
+
├── index.ts # dbUxFlatten plugin
|
|
293
|
+
├── data.ts # types & constants
|
|
294
|
+
└── helpers/
|
|
295
|
+
├── css-parser.ts # generic CSS string parsing
|
|
296
|
+
├── resolve.ts # var(), calc(), color-mix() resolution
|
|
297
|
+
├── collect.ts # PostCSS AST collection (layers, vars, imports)
|
|
298
|
+
└── transform.ts # transformRoot + collapseLightDark
|
|
299
|
+
```
|
package/build/index.d.ts
ADDED
package/build/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type FlattenOptions = {
|
|
2
|
+
/** Remove @property rules after resolving (default: true) */
|
|
3
|
+
removeAtProperty?: boolean;
|
|
4
|
+
/** Remove declarations whose variables came from @property and are no longer referenced (default: true) */
|
|
5
|
+
removeResolved?: boolean;
|
|
6
|
+
/**
|
|
7
|
+
* Variable prefixes that are always treated as dynamic (never resolved).
|
|
8
|
+
* Default: ['--db-adaptive-']
|
|
9
|
+
*/
|
|
10
|
+
dynamicPrefixes?: string[];
|
|
11
|
+
};
|
|
12
|
+
export type VarEntry = {
|
|
13
|
+
value: string;
|
|
14
|
+
layer: string | null;
|
|
15
|
+
file: string;
|
|
16
|
+
};
|
|
17
|
+
export declare const DEFAULT_DYNAMIC_PREFIXES: string[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const DEFAULT_DYNAMIC_PREFIXES = ['--db-adaptive-'];
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ChildNode, Root } from 'postcss';
|
|
2
|
+
import type { VarEntry } from '../data.js';
|
|
3
|
+
/**
|
|
4
|
+
* Check whether a CSS selector targets only `:root` and/or `:host`.
|
|
5
|
+
* @param selector - The CSS selector string to check
|
|
6
|
+
* @returns True if the selector only contains `:root` and/or `:host`
|
|
7
|
+
*/
|
|
8
|
+
export declare const isRootSelector: (selector: string) => boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Walk up the PostCSS AST from a node to find the enclosing `@layer` name.
|
|
11
|
+
* @param node - The PostCSS child node to start from
|
|
12
|
+
* @returns The layer name, or null if not inside any `@layer`
|
|
13
|
+
*/
|
|
14
|
+
export declare const getLayerName: (node: ChildNode) => string | null;
|
|
15
|
+
/**
|
|
16
|
+
* Parse `@layer` order declarations (e.g. `@layer db-ux, db-theme;`) from a root.
|
|
17
|
+
* Later names in the list get higher priority indices.
|
|
18
|
+
* @param root - The PostCSS root to scan
|
|
19
|
+
* @returns A map of layer name to priority index
|
|
20
|
+
*/
|
|
21
|
+
export declare const collectLayerOrder: (root: Root) => Map<string, number>;
|
|
22
|
+
/**
|
|
23
|
+
* Parse `@import` rules to extract file-specifier to layer name mappings.
|
|
24
|
+
* @param root - The PostCSS root to scan
|
|
25
|
+
* @returns A map of import specifier to layer name
|
|
26
|
+
*/
|
|
27
|
+
export declare const collectImportLayers: (root: Root) => Map<string, string>;
|
|
28
|
+
/**
|
|
29
|
+
* Get the numeric priority for a layer. Unlayered CSS (null) gets the highest
|
|
30
|
+
* priority (`MAX_SAFE_INTEGER`), matching the CSS spec.
|
|
31
|
+
* @param layer - The layer name, or null for unlayered CSS
|
|
32
|
+
* @param layerOrder - The layer priority map
|
|
33
|
+
* @returns The numeric priority (higher = wins)
|
|
34
|
+
*/
|
|
35
|
+
export declare const getLayerPriority: (layer: string | null, layerOrder: Map<string, number>) => number;
|
|
36
|
+
/**
|
|
37
|
+
* Pick the best (highest-priority) value from multiple `VarEntry` entries
|
|
38
|
+
* for the same variable, based on `@layer` priority.
|
|
39
|
+
* @param entries - All collected entries for a single variable
|
|
40
|
+
* @param layerOrder - The layer priority map
|
|
41
|
+
* @returns The value string from the highest-priority entry
|
|
42
|
+
*/
|
|
43
|
+
export declare const pickBestVar: (entries: VarEntry[], layerOrder: Map<string, number>) => string;
|
|
44
|
+
/**
|
|
45
|
+
* Look up the `@layer` name for a file by matching its path against
|
|
46
|
+
* known `@import ... layer()` specifiers.
|
|
47
|
+
* @param filePath - The absolute file path to look up
|
|
48
|
+
* @param importLayerMap - Map of import specifiers to layer names
|
|
49
|
+
* @returns The layer name, or null if not associated with any layer
|
|
50
|
+
*/
|
|
51
|
+
export declare const getFileLayer: (filePath: string, importLayerMap: Map<string, string>) => string | null;
|
|
52
|
+
/**
|
|
53
|
+
* Collect all CSS custom property declarations and `@property` initial-values
|
|
54
|
+
* from a PostCSS root, assigning each a layer and detecting dynamic variables.
|
|
55
|
+
*
|
|
56
|
+
* A variable is marked as dynamic if:
|
|
57
|
+
* - Its name matches one of the `prefixes`
|
|
58
|
+
* - It is declared inside a non-`:root`/`:host` selector
|
|
59
|
+
* - It is declared inside `@media` within `:root`/`:host`
|
|
60
|
+
*
|
|
61
|
+
* Duplicate entries (same prop + file + layer) are skipped.
|
|
62
|
+
*
|
|
63
|
+
* @param root - The PostCSS root to scan
|
|
64
|
+
* @param varMap - Shared map to accumulate variable entries into
|
|
65
|
+
* @param propertyNames - Shared set to track `@property` variable names
|
|
66
|
+
* @param dynamicVars - Shared set to track dynamic variable names
|
|
67
|
+
* @param prefixes - Variable prefixes that are always treated as dynamic
|
|
68
|
+
* @param forceLayer - If set, overrides the detected layer for all entries
|
|
69
|
+
* @param file - The source file path for deduplication
|
|
70
|
+
*/
|
|
71
|
+
export declare const collectVarsWithLayer: (root: Root, varMap: Map<string, VarEntry[]>, propertyNames: Set<string>, dynamicVars: Set<string>, prefixes: string[], forceLayer: string | null, file: string) => void;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check whether a CSS selector targets only `:root` and/or `:host`.
|
|
3
|
+
* @param selector - The CSS selector string to check
|
|
4
|
+
* @returns True if the selector only contains `:root` and/or `:host`
|
|
5
|
+
*/
|
|
6
|
+
export const isRootSelector = (selector) => {
|
|
7
|
+
const trimmed = selector.trim();
|
|
8
|
+
return /^(:root|:host)(\s*,\s*(:root|:host))*$/.test(trimmed);
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Walk up the PostCSS AST from a node to find the enclosing `@layer` name.
|
|
12
|
+
* @param node - The PostCSS child node to start from
|
|
13
|
+
* @returns The layer name, or null if not inside any `@layer`
|
|
14
|
+
*/
|
|
15
|
+
export const getLayerName = (node) => {
|
|
16
|
+
let current = node.parent;
|
|
17
|
+
while (current) {
|
|
18
|
+
if (current.type === 'atrule' &&
|
|
19
|
+
current.name === 'layer' &&
|
|
20
|
+
current.params) {
|
|
21
|
+
return current.params.trim();
|
|
22
|
+
}
|
|
23
|
+
current = current.parent;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Parse `@layer` order declarations (e.g. `@layer db-ux, db-theme;`) from a root.
|
|
29
|
+
* Later names in the list get higher priority indices.
|
|
30
|
+
* @param root - The PostCSS root to scan
|
|
31
|
+
* @returns A map of layer name to priority index
|
|
32
|
+
*/
|
|
33
|
+
export const collectLayerOrder = (root) => {
|
|
34
|
+
const order = new Map();
|
|
35
|
+
root.walkAtRules('layer', (atRule) => {
|
|
36
|
+
if (atRule.nodes && atRule.nodes.length > 0)
|
|
37
|
+
return;
|
|
38
|
+
const names = atRule.params
|
|
39
|
+
.split(',')
|
|
40
|
+
.map((n) => n.trim())
|
|
41
|
+
.filter(Boolean);
|
|
42
|
+
for (let i = 0; i < names.length; i++) {
|
|
43
|
+
order.set(names[i], i);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
return order;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Parse `@import` rules to extract file-specifier to layer name mappings.
|
|
50
|
+
* @param root - The PostCSS root to scan
|
|
51
|
+
* @returns A map of import specifier to layer name
|
|
52
|
+
*/
|
|
53
|
+
export const collectImportLayers = (root) => {
|
|
54
|
+
const importLayers = new Map();
|
|
55
|
+
root.walkAtRules('import', (atRule) => {
|
|
56
|
+
const params = atRule.params;
|
|
57
|
+
const layerMatch = params.match(/layer\(([^)]+)\)/);
|
|
58
|
+
if (!layerMatch)
|
|
59
|
+
return;
|
|
60
|
+
const layerName = layerMatch[1].trim();
|
|
61
|
+
const fileMatch = params.match(/(?:url\(\s*)?["']([^"']+)["'](?:\s*\))?/);
|
|
62
|
+
if (!fileMatch)
|
|
63
|
+
return;
|
|
64
|
+
importLayers.set(fileMatch[1], layerName);
|
|
65
|
+
});
|
|
66
|
+
return importLayers;
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Get the numeric priority for a layer. Unlayered CSS (null) gets the highest
|
|
70
|
+
* priority (`MAX_SAFE_INTEGER`), matching the CSS spec.
|
|
71
|
+
* @param layer - The layer name, or null for unlayered CSS
|
|
72
|
+
* @param layerOrder - The layer priority map
|
|
73
|
+
* @returns The numeric priority (higher = wins)
|
|
74
|
+
*/
|
|
75
|
+
export const getLayerPriority = (layer, layerOrder) => {
|
|
76
|
+
if (layer === null)
|
|
77
|
+
return Number.MAX_SAFE_INTEGER;
|
|
78
|
+
return layerOrder.get(layer) ?? -1;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Pick the best (highest-priority) value from multiple `VarEntry` entries
|
|
82
|
+
* for the same variable, based on `@layer` priority.
|
|
83
|
+
* @param entries - All collected entries for a single variable
|
|
84
|
+
* @param layerOrder - The layer priority map
|
|
85
|
+
* @returns The value string from the highest-priority entry
|
|
86
|
+
*/
|
|
87
|
+
export const pickBestVar = (entries, layerOrder) => {
|
|
88
|
+
let best = entries[0];
|
|
89
|
+
for (let i = 1; i < entries.length; i++) {
|
|
90
|
+
if (getLayerPriority(entries[i].layer, layerOrder) >=
|
|
91
|
+
getLayerPriority(best.layer, layerOrder)) {
|
|
92
|
+
best = entries[i];
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return best.value;
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* Look up the `@layer` name for a file by matching its path against
|
|
99
|
+
* known `@import ... layer()` specifiers.
|
|
100
|
+
* @param filePath - The absolute file path to look up
|
|
101
|
+
* @param importLayerMap - Map of import specifiers to layer names
|
|
102
|
+
* @returns The layer name, or null if not associated with any layer
|
|
103
|
+
*/
|
|
104
|
+
export const getFileLayer = (filePath, importLayerMap) => {
|
|
105
|
+
for (const [specifier, layer] of importLayerMap) {
|
|
106
|
+
if (filePath.replace(/\\/g, '/').endsWith(specifier.replace(/\\/g, '/'))) {
|
|
107
|
+
return layer;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
};
|
|
112
|
+
/**
|
|
113
|
+
* Collect all CSS custom property declarations and `@property` initial-values
|
|
114
|
+
* from a PostCSS root, assigning each a layer and detecting dynamic variables.
|
|
115
|
+
*
|
|
116
|
+
* A variable is marked as dynamic if:
|
|
117
|
+
* - Its name matches one of the `prefixes`
|
|
118
|
+
* - It is declared inside a non-`:root`/`:host` selector
|
|
119
|
+
* - It is declared inside `@media` within `:root`/`:host`
|
|
120
|
+
*
|
|
121
|
+
* Duplicate entries (same prop + file + layer) are skipped.
|
|
122
|
+
*
|
|
123
|
+
* @param root - The PostCSS root to scan
|
|
124
|
+
* @param varMap - Shared map to accumulate variable entries into
|
|
125
|
+
* @param propertyNames - Shared set to track `@property` variable names
|
|
126
|
+
* @param dynamicVars - Shared set to track dynamic variable names
|
|
127
|
+
* @param prefixes - Variable prefixes that are always treated as dynamic
|
|
128
|
+
* @param forceLayer - If set, overrides the detected layer for all entries
|
|
129
|
+
* @param file - The source file path for deduplication
|
|
130
|
+
*/
|
|
131
|
+
export const collectVarsWithLayer = (root, varMap, propertyNames, dynamicVars, prefixes, forceLayer, file) => {
|
|
132
|
+
const addVar = (prop, value, layer) => {
|
|
133
|
+
const entries = varMap.get(prop);
|
|
134
|
+
if (entries) {
|
|
135
|
+
if (entries.some((e) => e.file === file && e.layer === layer))
|
|
136
|
+
return;
|
|
137
|
+
entries.push({ value, layer, file });
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
varMap.set(prop, [{ value, layer, file }]);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
root.walkAtRules('property', (atRule) => {
|
|
144
|
+
const propName = atRule.params.trim();
|
|
145
|
+
propertyNames.add(propName);
|
|
146
|
+
const layer = forceLayer ?? getLayerName(atRule);
|
|
147
|
+
atRule.walkDecls('initial-value', (decl) => {
|
|
148
|
+
addVar(propName, decl.value, layer);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
root.walkDecls(/^--/, (decl) => {
|
|
152
|
+
const parent = decl.parent;
|
|
153
|
+
if (prefixes.some((p) => decl.prop.startsWith(p))) {
|
|
154
|
+
dynamicVars.add(decl.prop);
|
|
155
|
+
}
|
|
156
|
+
if (parent && parent.type === 'rule') {
|
|
157
|
+
const rule = parent;
|
|
158
|
+
if (!isRootSelector(rule.selector)) {
|
|
159
|
+
dynamicVars.add(decl.prop);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (parent && parent.type === 'rule') {
|
|
163
|
+
const rule = parent;
|
|
164
|
+
if (isRootSelector(rule.selector) &&
|
|
165
|
+
rule.parent &&
|
|
166
|
+
rule.parent.type === 'atrule') {
|
|
167
|
+
const atRule = rule.parent;
|
|
168
|
+
if (atRule.name === 'media') {
|
|
169
|
+
dynamicVars.add(decl.prop);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
addVar(decl.prop, decl.value, forceLayer ?? getLayerName(decl));
|
|
174
|
+
});
|
|
175
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find the matching closing paren for a CSS function call starting at a given position.
|
|
3
|
+
* Assumes the opening `(` has already been consumed (depth starts at 1).
|
|
4
|
+
* @param value - The full CSS value string
|
|
5
|
+
* @param openIndex - The index right after the opening `(`
|
|
6
|
+
* @returns The index right after the matching `)`, or -1 if unbalanced
|
|
7
|
+
*/
|
|
8
|
+
export declare const findMatchingParenthesis: (value: string, openIndex: number) => number;
|
|
9
|
+
/**
|
|
10
|
+
* Find the index of the first top-level comma in a string,
|
|
11
|
+
* skipping commas inside nested parentheses.
|
|
12
|
+
* @param value - The string to search
|
|
13
|
+
* @returns The index of the first top-level comma, or -1 if none found
|
|
14
|
+
*/
|
|
15
|
+
export declare const findTopLevelComma: (value: string) => number;
|
|
16
|
+
/**
|
|
17
|
+
* Find the next occurrence of a CSS function by name and extract its span and inner content.
|
|
18
|
+
* Uses paren counting to handle nested parentheses.
|
|
19
|
+
* @param value - The CSS value string to search
|
|
20
|
+
* @param funcName - The function name (e.g. "var", "calc", "light-dark")
|
|
21
|
+
* @param fromIndex - The index to start searching from
|
|
22
|
+
* @returns An object with `start`, `end`, and `inner` content, or null if not found
|
|
23
|
+
*/
|
|
24
|
+
export declare const findCssFunction: (value: string, funcName: string, fromIndex?: number) => {
|
|
25
|
+
start: number;
|
|
26
|
+
end: number;
|
|
27
|
+
inner: string;
|
|
28
|
+
} | null;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find the matching closing paren for a CSS function call starting at a given position.
|
|
3
|
+
* Assumes the opening `(` has already been consumed (depth starts at 1).
|
|
4
|
+
* @param value - The full CSS value string
|
|
5
|
+
* @param openIndex - The index right after the opening `(`
|
|
6
|
+
* @returns The index right after the matching `)`, or -1 if unbalanced
|
|
7
|
+
*/
|
|
8
|
+
export const findMatchingParenthesis = (value, openIndex) => {
|
|
9
|
+
let depth = 1;
|
|
10
|
+
let i = openIndex;
|
|
11
|
+
while (i < value.length && depth > 0) {
|
|
12
|
+
if (value[i] === '(')
|
|
13
|
+
depth++;
|
|
14
|
+
if (value[i] === ')')
|
|
15
|
+
depth--;
|
|
16
|
+
i++;
|
|
17
|
+
}
|
|
18
|
+
return depth === 0 ? i : -1;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* Find the index of the first top-level comma in a string,
|
|
22
|
+
* skipping commas inside nested parentheses.
|
|
23
|
+
* @param value - The string to search
|
|
24
|
+
* @returns The index of the first top-level comma, or -1 if none found
|
|
25
|
+
*/
|
|
26
|
+
export const findTopLevelComma = (value) => {
|
|
27
|
+
let depth = 0;
|
|
28
|
+
for (let i = 0; i < value.length; i++) {
|
|
29
|
+
if (value[i] === '(')
|
|
30
|
+
depth++;
|
|
31
|
+
else if (value[i] === ')')
|
|
32
|
+
depth--;
|
|
33
|
+
else if (value[i] === ',' && depth === 0)
|
|
34
|
+
return i;
|
|
35
|
+
}
|
|
36
|
+
return -1;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Find the next occurrence of a CSS function by name and extract its span and inner content.
|
|
40
|
+
* Uses paren counting to handle nested parentheses.
|
|
41
|
+
* @param value - The CSS value string to search
|
|
42
|
+
* @param funcName - The function name (e.g. "var", "calc", "light-dark")
|
|
43
|
+
* @param fromIndex - The index to start searching from
|
|
44
|
+
* @returns An object with `start`, `end`, and `inner` content, or null if not found
|
|
45
|
+
*/
|
|
46
|
+
export const findCssFunction = (value, funcName, fromIndex = 0) => {
|
|
47
|
+
const prefix = `${funcName}(`;
|
|
48
|
+
const idx = value.indexOf(prefix, fromIndex);
|
|
49
|
+
if (idx === -1)
|
|
50
|
+
return null;
|
|
51
|
+
const end = findMatchingParenthesis(value, idx + prefix.length);
|
|
52
|
+
if (end === -1)
|
|
53
|
+
return null;
|
|
54
|
+
return {
|
|
55
|
+
start: idx,
|
|
56
|
+
end,
|
|
57
|
+
inner: value.slice(idx + prefix.length, end - 1)
|
|
58
|
+
};
|
|
59
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursively resolve `var()` references in a CSS value string.
|
|
3
|
+
* Uses a `seen` set per resolution chain to prevent circular references.
|
|
4
|
+
* If a var is known, it replaces the entire `var(...)` with the resolved value.
|
|
5
|
+
* If unknown but has a fallback, it resolves vars inside the fallback.
|
|
6
|
+
* @param value - The CSS value string containing `var()` references
|
|
7
|
+
* @param varMap - Map of variable names to their resolved values
|
|
8
|
+
* @param seen - Set of variable names already visited in the current chain
|
|
9
|
+
* @returns The value with all resolvable `var()` references inlined
|
|
10
|
+
*/
|
|
11
|
+
export declare const resolveVars: (value: string, varMap: Map<string, string>, seen?: Set<string>) => string;
|
|
12
|
+
/**
|
|
13
|
+
* Collect all CSS variable names referenced in a value, including
|
|
14
|
+
* names inside nested `var()` fallbacks.
|
|
15
|
+
* @param value - The CSS value string to scan
|
|
16
|
+
* @param refs - Set to add discovered variable names to
|
|
17
|
+
*/
|
|
18
|
+
export declare const collectVarReferences: (value: string, refs: Set<string>) => void;
|
|
19
|
+
/**
|
|
20
|
+
* Resolve all fully-static `calc()` expressions in a CSS value.
|
|
21
|
+
* Skips expressions that still contain unresolved `var()` or nested CSS functions.
|
|
22
|
+
* @param value - The CSS value string potentially containing `calc()` expressions
|
|
23
|
+
* @returns The value with all evaluable `calc()` expressions replaced
|
|
24
|
+
*/
|
|
25
|
+
export declare const resolveCalc: (value: string) => string;
|
|
26
|
+
/**
|
|
27
|
+
* Resolve all fully-static `color-mix()` expressions in a CSS value.
|
|
28
|
+
* Skips expressions that still contain unresolved `var()` references.
|
|
29
|
+
* @param value - The CSS value string potentially containing `color-mix()` expressions
|
|
30
|
+
* @returns The value with all evaluable `color-mix()` expressions replaced
|
|
31
|
+
*/
|
|
32
|
+
export declare const resolveColorMix: (value: string) => string;
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import { findCssFunction, findTopLevelComma } from './css-parser.js';
|
|
2
|
+
// ── var() ───────────────────────────────────────────────────────────────
|
|
3
|
+
/**
|
|
4
|
+
* Find the next `var(` occurrence and parse it into name and optional fallback.
|
|
5
|
+
* @param value - The CSS value string to search
|
|
6
|
+
* @param fromIndex - The index to start searching from
|
|
7
|
+
* @returns The parsed var reference, or null if not found
|
|
8
|
+
*/
|
|
9
|
+
const findNextVar = (value, fromIndex) => {
|
|
10
|
+
const found = findCssFunction(value, 'var', fromIndex);
|
|
11
|
+
if (!found)
|
|
12
|
+
return null;
|
|
13
|
+
const commaIdx = findTopLevelComma(found.inner);
|
|
14
|
+
if (commaIdx === -1) {
|
|
15
|
+
return {
|
|
16
|
+
start: found.start,
|
|
17
|
+
end: found.end,
|
|
18
|
+
name: found.inner.trim(),
|
|
19
|
+
fallback: null
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
start: found.start,
|
|
24
|
+
end: found.end,
|
|
25
|
+
name: found.inner.slice(0, commaIdx).trim(),
|
|
26
|
+
fallback: found.inner.slice(commaIdx + 1).trim()
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Recursively resolve `var()` references in a CSS value string.
|
|
31
|
+
* Uses a `seen` set per resolution chain to prevent circular references.
|
|
32
|
+
* If a var is known, it replaces the entire `var(...)` with the resolved value.
|
|
33
|
+
* If unknown but has a fallback, it resolves vars inside the fallback.
|
|
34
|
+
* @param value - The CSS value string containing `var()` references
|
|
35
|
+
* @param varMap - Map of variable names to their resolved values
|
|
36
|
+
* @param seen - Set of variable names already visited in the current chain
|
|
37
|
+
* @returns The value with all resolvable `var()` references inlined
|
|
38
|
+
*/
|
|
39
|
+
export const resolveVars = (value, varMap, seen = new Set()) => {
|
|
40
|
+
let result = value;
|
|
41
|
+
let searchFrom = 0;
|
|
42
|
+
while (searchFrom < result.length) {
|
|
43
|
+
const found = findNextVar(result, searchFrom);
|
|
44
|
+
if (!found)
|
|
45
|
+
break;
|
|
46
|
+
const { start, end, name, fallback } = found;
|
|
47
|
+
if (seen.has(name)) {
|
|
48
|
+
searchFrom = end;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const resolved = varMap.get(name);
|
|
52
|
+
if (resolved !== undefined) {
|
|
53
|
+
const childSeen = new Set(seen);
|
|
54
|
+
childSeen.add(name);
|
|
55
|
+
const resolvedValue = resolveVars(resolved, varMap, childSeen);
|
|
56
|
+
result = result.slice(0, start) + resolvedValue + result.slice(end);
|
|
57
|
+
searchFrom = start + resolvedValue.length;
|
|
58
|
+
}
|
|
59
|
+
else if (fallback !== null) {
|
|
60
|
+
const resolvedFallback = resolveVars(fallback, varMap, seen);
|
|
61
|
+
const replacement = `var(${name}, ${resolvedFallback})`;
|
|
62
|
+
result = result.slice(0, start) + replacement + result.slice(end);
|
|
63
|
+
searchFrom = start + replacement.length;
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
searchFrom = end;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Collect all CSS variable names referenced in a value, including
|
|
73
|
+
* names inside nested `var()` fallbacks.
|
|
74
|
+
* @param value - The CSS value string to scan
|
|
75
|
+
* @param refs - Set to add discovered variable names to
|
|
76
|
+
*/
|
|
77
|
+
export const collectVarReferences = (value, refs) => {
|
|
78
|
+
let searchFrom = 0;
|
|
79
|
+
while (searchFrom < value.length) {
|
|
80
|
+
const found = findNextVar(value, searchFrom);
|
|
81
|
+
if (!found)
|
|
82
|
+
break;
|
|
83
|
+
refs.add(found.name);
|
|
84
|
+
if (found.fallback) {
|
|
85
|
+
collectVarReferences(found.fallback, refs);
|
|
86
|
+
}
|
|
87
|
+
searchFrom = found.end;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
// ── Generic CSS function resolver ───────────────────────────────────────
|
|
91
|
+
/**
|
|
92
|
+
* Find and evaluate all occurrences of a CSS function in a value string.
|
|
93
|
+
* Skips occurrences that still contain unresolved `var()` references.
|
|
94
|
+
* @param value - The CSS value string to process
|
|
95
|
+
* @param funcName - The CSS function name to match (e.g. "calc", "color-mix")
|
|
96
|
+
* @param evaluate - Callback that receives the inner content and returns the
|
|
97
|
+
* evaluated result, or null to skip
|
|
98
|
+
* @param skipNestedFunctions - If true, skip when inner content contains other CSS functions
|
|
99
|
+
* @returns The value with all evaluable occurrences replaced
|
|
100
|
+
*/
|
|
101
|
+
const resolveCssFunction = (value, funcName, evaluate, skipNestedFunctions = false) => {
|
|
102
|
+
let result = value;
|
|
103
|
+
let searchFrom = 0;
|
|
104
|
+
while (searchFrom < result.length) {
|
|
105
|
+
const found = findCssFunction(result, funcName, searchFrom);
|
|
106
|
+
if (!found)
|
|
107
|
+
break;
|
|
108
|
+
if (found.inner.includes('var(')) {
|
|
109
|
+
searchFrom = found.end;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (skipNestedFunctions && /[a-z-]+\(/i.test(found.inner)) {
|
|
113
|
+
searchFrom = found.end;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const evaluated = evaluate(found.inner);
|
|
117
|
+
if (evaluated !== null) {
|
|
118
|
+
result =
|
|
119
|
+
result.slice(0, found.start) +
|
|
120
|
+
evaluated +
|
|
121
|
+
result.slice(found.end);
|
|
122
|
+
searchFrom = found.start + evaluated.length;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
searchFrom = found.end;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return result;
|
|
129
|
+
};
|
|
130
|
+
// ── calc() ──────────────────────────────────────────────────────────────
|
|
131
|
+
/**
|
|
132
|
+
* Parse a CSS unit value like "0.75rem" into its numeric value and unit.
|
|
133
|
+
* @param str - The string to parse (e.g. "0.75rem", "100%", "2")
|
|
134
|
+
* @returns An object with `value` and `unit`, or null if not parseable
|
|
135
|
+
*/
|
|
136
|
+
const parseUnit = (str) => {
|
|
137
|
+
const match = str.trim().match(/^(-?[\d.]+)\s*(%|[a-z]*)$/i);
|
|
138
|
+
if (!match)
|
|
139
|
+
return null;
|
|
140
|
+
return { value: Number.parseFloat(match[1]), unit: match[2] || '' };
|
|
141
|
+
};
|
|
142
|
+
/**
|
|
143
|
+
* Evaluate a simple `calc()` expression with static values.
|
|
144
|
+
* Supports `+`, `-`, `*`, `/` with a single unit type (e.g. `calc(2 * 0.75rem)`).
|
|
145
|
+
* @param expr - The inner content of a `calc()` expression
|
|
146
|
+
* @returns The evaluated result as a string (e.g. "1.5rem"), or null if not evaluable
|
|
147
|
+
*/
|
|
148
|
+
const evaluateCalc = (expr) => {
|
|
149
|
+
const tokens = expr.trim().split(/\s+/).filter(Boolean);
|
|
150
|
+
if (tokens.length === 0)
|
|
151
|
+
return null;
|
|
152
|
+
let result = 0;
|
|
153
|
+
let resultUnit = '';
|
|
154
|
+
let operator = '+';
|
|
155
|
+
for (const token of tokens) {
|
|
156
|
+
if (['+', '-', '*', '/'].includes(token)) {
|
|
157
|
+
operator = token;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const parsed = parseUnit(token);
|
|
161
|
+
if (!parsed)
|
|
162
|
+
return null;
|
|
163
|
+
if (parsed.unit && !resultUnit) {
|
|
164
|
+
resultUnit = parsed.unit;
|
|
165
|
+
}
|
|
166
|
+
else if (parsed.unit && parsed.unit !== resultUnit && resultUnit) {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
switch (operator) {
|
|
170
|
+
case '+':
|
|
171
|
+
result += parsed.value;
|
|
172
|
+
break;
|
|
173
|
+
case '-':
|
|
174
|
+
result -= parsed.value;
|
|
175
|
+
break;
|
|
176
|
+
case '*':
|
|
177
|
+
result *= parsed.value;
|
|
178
|
+
break;
|
|
179
|
+
case '/':
|
|
180
|
+
if (parsed.value === 0)
|
|
181
|
+
return null;
|
|
182
|
+
result /= parsed.value;
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const rounded = Math.abs(result) < 1e-10 ? 0 : Math.round(result * 1e6) / 1e6;
|
|
187
|
+
return `${rounded}${resultUnit}`;
|
|
188
|
+
};
|
|
189
|
+
/**
|
|
190
|
+
* Resolve all fully-static `calc()` expressions in a CSS value.
|
|
191
|
+
* Skips expressions that still contain unresolved `var()` or nested CSS functions.
|
|
192
|
+
* @param value - The CSS value string potentially containing `calc()` expressions
|
|
193
|
+
* @returns The value with all evaluable `calc()` expressions replaced
|
|
194
|
+
*/
|
|
195
|
+
export const resolveCalc = (value) => resolveCssFunction(value, 'calc', evaluateCalc, true);
|
|
196
|
+
// ── color-mix() ─────────────────────────────────────────────────────────
|
|
197
|
+
/**
|
|
198
|
+
* Parse a hex color string to an RGBA tuple.
|
|
199
|
+
* Supports `#rgb`, `#rrggbb`, `#rgba`, and `#rrggbbaa`.
|
|
200
|
+
* @param hex - The hex color string
|
|
201
|
+
* @returns An [r, g, b, a] tuple (0-255 for rgb, 0-1 for alpha), or null if not parseable
|
|
202
|
+
*/
|
|
203
|
+
const parseHexColor = (hex) => {
|
|
204
|
+
const h = hex.replace('#', '');
|
|
205
|
+
let r, g, b;
|
|
206
|
+
let a = 1;
|
|
207
|
+
if (h.length === 3) {
|
|
208
|
+
r = Number.parseInt(h[0] + h[0], 16);
|
|
209
|
+
g = Number.parseInt(h[1] + h[1], 16);
|
|
210
|
+
b = Number.parseInt(h[2] + h[2], 16);
|
|
211
|
+
}
|
|
212
|
+
else if (h.length === 4) {
|
|
213
|
+
r = Number.parseInt(h[0] + h[0], 16);
|
|
214
|
+
g = Number.parseInt(h[1] + h[1], 16);
|
|
215
|
+
b = Number.parseInt(h[2] + h[2], 16);
|
|
216
|
+
a = Number.parseInt(h[3] + h[3], 16) / 255;
|
|
217
|
+
}
|
|
218
|
+
else if (h.length === 6) {
|
|
219
|
+
r = Number.parseInt(h.slice(0, 2), 16);
|
|
220
|
+
g = Number.parseInt(h.slice(2, 4), 16);
|
|
221
|
+
b = Number.parseInt(h.slice(4, 6), 16);
|
|
222
|
+
}
|
|
223
|
+
else if (h.length === 8) {
|
|
224
|
+
r = Number.parseInt(h.slice(0, 2), 16);
|
|
225
|
+
g = Number.parseInt(h.slice(2, 4), 16);
|
|
226
|
+
b = Number.parseInt(h.slice(4, 6), 16);
|
|
227
|
+
a = Number.parseInt(h.slice(6, 8), 16) / 255;
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
if (Number.isNaN(r) ||
|
|
233
|
+
Number.isNaN(g) ||
|
|
234
|
+
Number.isNaN(b) ||
|
|
235
|
+
Number.isNaN(a))
|
|
236
|
+
return null;
|
|
237
|
+
return [r, g, b, a];
|
|
238
|
+
};
|
|
239
|
+
/**
|
|
240
|
+
* Convert a number (0-255) to a two-digit hex string.
|
|
241
|
+
* @param n - The number to convert
|
|
242
|
+
* @returns A zero-padded hex string (e.g. "0a", "ff")
|
|
243
|
+
*/
|
|
244
|
+
const toHex = (n) => Math.round(Math.max(0, Math.min(255, n)))
|
|
245
|
+
.toString(16)
|
|
246
|
+
.padStart(2, '0');
|
|
247
|
+
/**
|
|
248
|
+
* Evaluate a `color-mix(in srgb, ...)` expression with two color arguments.
|
|
249
|
+
* Supports hex colors and `transparent`. Handles percentage-based mixing
|
|
250
|
+
* with premultiplied alpha blending.
|
|
251
|
+
* @param args - The inner arguments of `color-mix()`
|
|
252
|
+
* @returns The mixed color as a hex string, "transparent", or null if not evaluable
|
|
253
|
+
*/
|
|
254
|
+
const evaluateColorMix = (args) => {
|
|
255
|
+
const srgbMatch = args.match(/^in\s+srgb\s*,\s*([\s\S]+?)\s*,\s*([\s\S]+?)\s*$/);
|
|
256
|
+
if (!srgbMatch)
|
|
257
|
+
return null;
|
|
258
|
+
const parseColorArg = (arg) => {
|
|
259
|
+
const parts = arg.trim().split(/\s+/);
|
|
260
|
+
if (parts.length === 1)
|
|
261
|
+
return { color: parts[0], percentage: null };
|
|
262
|
+
if (parts.length === 2) {
|
|
263
|
+
const pctMatch = parts[1].match(/^([\d.]+)%$/);
|
|
264
|
+
if (!pctMatch)
|
|
265
|
+
return null;
|
|
266
|
+
return {
|
|
267
|
+
color: parts[0],
|
|
268
|
+
percentage: Number.parseFloat(pctMatch[1])
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
return null;
|
|
272
|
+
};
|
|
273
|
+
const arg1 = parseColorArg(srgbMatch[1]);
|
|
274
|
+
const arg2 = parseColorArg(srgbMatch[2]);
|
|
275
|
+
if (!arg1 || !arg2)
|
|
276
|
+
return null;
|
|
277
|
+
let p1 = arg1.percentage;
|
|
278
|
+
let p2 = arg2.percentage;
|
|
279
|
+
if (p1 === null && p2 === null) {
|
|
280
|
+
p1 = 50;
|
|
281
|
+
p2 = 50;
|
|
282
|
+
}
|
|
283
|
+
else if (p1 !== null && p2 === null) {
|
|
284
|
+
p2 = 100 - p1;
|
|
285
|
+
}
|
|
286
|
+
else if (p1 === null && p2 !== null) {
|
|
287
|
+
p1 = 100 - p2;
|
|
288
|
+
}
|
|
289
|
+
const pct1 = p1 / 100;
|
|
290
|
+
const pct2 = p2 / 100;
|
|
291
|
+
const isTransparent = (c) => c === 'transparent';
|
|
292
|
+
let rgb1, alpha1 = 1;
|
|
293
|
+
if (isTransparent(arg1.color)) {
|
|
294
|
+
rgb1 = [0, 0, 0];
|
|
295
|
+
alpha1 = 0;
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
const parsed = parseHexColor(arg1.color);
|
|
299
|
+
if (!parsed)
|
|
300
|
+
return null;
|
|
301
|
+
rgb1 = [parsed[0], parsed[1], parsed[2]];
|
|
302
|
+
alpha1 = parsed[3];
|
|
303
|
+
}
|
|
304
|
+
let rgb2, alpha2 = 1;
|
|
305
|
+
if (isTransparent(arg2.color)) {
|
|
306
|
+
rgb2 = [0, 0, 0];
|
|
307
|
+
alpha2 = 0;
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
const parsed = parseHexColor(arg2.color);
|
|
311
|
+
if (!parsed)
|
|
312
|
+
return null;
|
|
313
|
+
rgb2 = [parsed[0], parsed[1], parsed[2]];
|
|
314
|
+
alpha2 = parsed[3];
|
|
315
|
+
}
|
|
316
|
+
const mixedAlpha = alpha1 * pct1 + alpha2 * pct2;
|
|
317
|
+
if (mixedAlpha === 0)
|
|
318
|
+
return 'transparent';
|
|
319
|
+
const mix = (c1, c2) => (c1 * alpha1 * pct1 + c2 * alpha2 * pct2) / mixedAlpha;
|
|
320
|
+
const r = mix(rgb1[0], rgb2[0]);
|
|
321
|
+
const g = mix(rgb1[1], rgb2[1]);
|
|
322
|
+
const b = mix(rgb1[2], rgb2[2]);
|
|
323
|
+
if (mixedAlpha >= 0.995)
|
|
324
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
325
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(mixedAlpha * 255)}`;
|
|
326
|
+
};
|
|
327
|
+
/**
|
|
328
|
+
* Resolve all fully-static `color-mix()` expressions in a CSS value.
|
|
329
|
+
* Skips expressions that still contain unresolved `var()` references.
|
|
330
|
+
* @param value - The CSS value string potentially containing `color-mix()` expressions
|
|
331
|
+
* @returns The value with all evaluable `color-mix()` expressions replaced
|
|
332
|
+
*/
|
|
333
|
+
export const resolveColorMix = (value) => resolveCssFunction(value, 'color-mix', evaluateColorMix);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Root } from 'postcss';
|
|
2
|
+
/**
|
|
3
|
+
* Collapse `light-dark(x, y)` to just `x` when both arguments are identical
|
|
4
|
+
* after trimming whitespace. Handles nested parentheses correctly.
|
|
5
|
+
* @param value - The CSS value string potentially containing `light-dark()` calls
|
|
6
|
+
* @returns The value with identical-argument `light-dark()` calls collapsed
|
|
7
|
+
*/
|
|
8
|
+
export declare const collapseLightDark: (value: string) => string;
|
|
9
|
+
/**
|
|
10
|
+
* Transform all declarations in a PostCSS root by resolving static `var()`,
|
|
11
|
+
* evaluating `calc()` and `color-mix()`, collapsing identical `light-dark()`,
|
|
12
|
+
* and optionally removing `@property` rules and unused declarations.
|
|
13
|
+
*
|
|
14
|
+
* @param root - The PostCSS root to transform
|
|
15
|
+
* @param staticVarMap - Map of static variable names to their resolved values
|
|
16
|
+
* @param referencedVars - Set to track which variables are still referenced after resolution
|
|
17
|
+
* @param propertyNames - Set of variable names that came from `@property`
|
|
18
|
+
* @param dynamicVars - Set of dynamic variable names (never removed)
|
|
19
|
+
* @param removeAtProperty - Whether to remove `@property` rules
|
|
20
|
+
* @param removeResolved - Whether to remove unused `@property`-sourced declarations
|
|
21
|
+
*/
|
|
22
|
+
export declare const transformRoot: (root: Root, staticVarMap: Map<string, string>, referencedVars: Set<string>, propertyNames: Set<string>, dynamicVars: Set<string>, removeAtProperty: boolean, removeResolved: boolean) => void;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { findCssFunction, findTopLevelComma } from './css-parser.js';
|
|
2
|
+
import { collectVarReferences, resolveCalc, resolveColorMix, resolveVars } from './resolve.js';
|
|
3
|
+
/**
|
|
4
|
+
* Collapse `light-dark(x, y)` to just `x` when both arguments are identical
|
|
5
|
+
* after trimming whitespace. Handles nested parentheses correctly.
|
|
6
|
+
* @param value - The CSS value string potentially containing `light-dark()` calls
|
|
7
|
+
* @returns The value with identical-argument `light-dark()` calls collapsed
|
|
8
|
+
*/
|
|
9
|
+
export const collapseLightDark = (value) => {
|
|
10
|
+
let result = value;
|
|
11
|
+
let searchFrom = 0;
|
|
12
|
+
while (searchFrom < result.length) {
|
|
13
|
+
const found = findCssFunction(result, 'light-dark', searchFrom);
|
|
14
|
+
if (!found)
|
|
15
|
+
break;
|
|
16
|
+
const commaIdx = findTopLevelComma(found.inner);
|
|
17
|
+
if (commaIdx === -1) {
|
|
18
|
+
searchFrom = found.end;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const light = found.inner.slice(0, commaIdx).trim();
|
|
22
|
+
const dark = found.inner.slice(commaIdx + 1).trim();
|
|
23
|
+
if (light === dark) {
|
|
24
|
+
result =
|
|
25
|
+
result.slice(0, found.start) + light + result.slice(found.end);
|
|
26
|
+
searchFrom = found.start + light.length;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
searchFrom = found.end;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Transform all declarations in a PostCSS root by resolving static `var()`,
|
|
36
|
+
* evaluating `calc()` and `color-mix()`, collapsing identical `light-dark()`,
|
|
37
|
+
* and optionally removing `@property` rules and unused declarations.
|
|
38
|
+
*
|
|
39
|
+
* @param root - The PostCSS root to transform
|
|
40
|
+
* @param staticVarMap - Map of static variable names to their resolved values
|
|
41
|
+
* @param referencedVars - Set to track which variables are still referenced after resolution
|
|
42
|
+
* @param propertyNames - Set of variable names that came from `@property`
|
|
43
|
+
* @param dynamicVars - Set of dynamic variable names (never removed)
|
|
44
|
+
* @param removeAtProperty - Whether to remove `@property` rules
|
|
45
|
+
* @param removeResolved - Whether to remove unused `@property`-sourced declarations
|
|
46
|
+
*/
|
|
47
|
+
export const transformRoot = (root, staticVarMap, referencedVars, propertyNames, dynamicVars, removeAtProperty, removeResolved) => {
|
|
48
|
+
root.walkDecls((decl) => {
|
|
49
|
+
const hasVar = decl.value.includes('var(');
|
|
50
|
+
const hasCalc = decl.value.includes('calc(');
|
|
51
|
+
const hasColorMix = decl.value.includes('color-mix(');
|
|
52
|
+
const hasLightDark = decl.value.includes('light-dark(');
|
|
53
|
+
if (!hasVar && !hasCalc && !hasColorMix && !hasLightDark)
|
|
54
|
+
return;
|
|
55
|
+
let resolved = decl.value;
|
|
56
|
+
if (hasVar) {
|
|
57
|
+
resolved = resolveVars(resolved, staticVarMap);
|
|
58
|
+
}
|
|
59
|
+
if (resolved.includes('calc(')) {
|
|
60
|
+
resolved = resolveCalc(resolved);
|
|
61
|
+
}
|
|
62
|
+
if (resolved.includes('color-mix(')) {
|
|
63
|
+
resolved = resolveColorMix(resolved);
|
|
64
|
+
}
|
|
65
|
+
if (resolved.includes('light-dark(')) {
|
|
66
|
+
resolved = collapseLightDark(resolved);
|
|
67
|
+
}
|
|
68
|
+
collectVarReferences(resolved, referencedVars);
|
|
69
|
+
decl.value = resolved;
|
|
70
|
+
});
|
|
71
|
+
if (removeAtProperty) {
|
|
72
|
+
root.walkAtRules('property', (atRule) => {
|
|
73
|
+
atRule.remove();
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
if (removeResolved) {
|
|
77
|
+
root.walkDecls(/^--/, (decl) => {
|
|
78
|
+
if (propertyNames.has(decl.prop) &&
|
|
79
|
+
!referencedVars.has(decl.prop) &&
|
|
80
|
+
!dynamicVars.has(decl.prop)) {
|
|
81
|
+
decl.remove();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { PluginCreator } from 'postcss';
|
|
2
|
+
import type { FlattenOptions } from './data.js';
|
|
3
|
+
/**
|
|
4
|
+
* PostCSS plugin that flattens DB UX Design System CSS custom properties
|
|
5
|
+
* by resolving `var()`, `@property`, `calc()`, `color-mix()`, and `light-dark()`.
|
|
6
|
+
*
|
|
7
|
+
* Detects dynamic variables (re-declared in non-`:root` selectors, `@media`,
|
|
8
|
+
* or matching `dynamicPrefixes`) and leaves them as `var()` references.
|
|
9
|
+
* Respects `@layer` priority via `@layer` order declarations and `@import ... layer()` rules.
|
|
10
|
+
*
|
|
11
|
+
* @param opts - Plugin options
|
|
12
|
+
* @returns A PostCSS plugin instance
|
|
13
|
+
*/
|
|
14
|
+
declare const dbUxFlatten: PluginCreator<FlattenOptions>;
|
|
15
|
+
export { dbUxFlatten };
|
|
16
|
+
export type { FlattenOptions };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { DEFAULT_DYNAMIC_PREFIXES } from './data.js';
|
|
2
|
+
import { collectImportLayers, collectLayerOrder, collectVarsWithLayer, getFileLayer, pickBestVar } from './helpers/collect.js';
|
|
3
|
+
import { transformRoot } from './helpers/transform.js';
|
|
4
|
+
/**
|
|
5
|
+
* PostCSS plugin that flattens DB UX Design System CSS custom properties
|
|
6
|
+
* by resolving `var()`, `@property`, `calc()`, `color-mix()`, and `light-dark()`.
|
|
7
|
+
*
|
|
8
|
+
* Detects dynamic variables (re-declared in non-`:root` selectors, `@media`,
|
|
9
|
+
* or matching `dynamicPrefixes`) and leaves them as `var()` references.
|
|
10
|
+
* Respects `@layer` priority via `@layer` order declarations and `@import ... layer()` rules.
|
|
11
|
+
*
|
|
12
|
+
* @param opts - Plugin options
|
|
13
|
+
* @returns A PostCSS plugin instance
|
|
14
|
+
*/
|
|
15
|
+
const dbUxFlatten = (opts = {}) => {
|
|
16
|
+
const removeAtProperty = opts.removeAtProperty ?? true;
|
|
17
|
+
const removeResolved = opts.removeResolved ?? true;
|
|
18
|
+
const dynamicPrefixes = opts.dynamicPrefixes ?? DEFAULT_DYNAMIC_PREFIXES;
|
|
19
|
+
const varMap = new Map();
|
|
20
|
+
const propertyNames = new Set();
|
|
21
|
+
const dynamicVars = new Set();
|
|
22
|
+
const referencedVars = new Set();
|
|
23
|
+
const layerOrder = new Map();
|
|
24
|
+
const importLayerMap = new Map();
|
|
25
|
+
return {
|
|
26
|
+
postcssPlugin: 'postcss-flatten-db-variables',
|
|
27
|
+
Once(root) {
|
|
28
|
+
const filePath = root.source?.input?.file ?? '';
|
|
29
|
+
const fileLayerOrder = collectLayerOrder(root);
|
|
30
|
+
for (const [name, priority] of fileLayerOrder) {
|
|
31
|
+
layerOrder.set(name, priority);
|
|
32
|
+
}
|
|
33
|
+
const fileImportLayers = collectImportLayers(root);
|
|
34
|
+
for (const [specifier, layer] of fileImportLayers) {
|
|
35
|
+
importLayerMap.set(specifier, layer);
|
|
36
|
+
}
|
|
37
|
+
const fileLayer = getFileLayer(filePath, importLayerMap);
|
|
38
|
+
collectVarsWithLayer(root, varMap, propertyNames, dynamicVars, dynamicPrefixes, fileLayer, filePath);
|
|
39
|
+
},
|
|
40
|
+
OnceExit(root) {
|
|
41
|
+
const staticVarMap = new Map();
|
|
42
|
+
for (const [key, entries] of varMap) {
|
|
43
|
+
if (!dynamicVars.has(key)) {
|
|
44
|
+
staticVarMap.set(key, pickBestVar(entries, layerOrder));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
transformRoot(root, staticVarMap, referencedVars, propertyNames, dynamicVars, removeAtProperty, removeResolved);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
dbUxFlatten.postcss = true;
|
|
52
|
+
export { dbUxFlatten };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@db-ux/core-postcss-plugin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.5.4-postcss-a42fe67",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "PostCSS plugins for DB UX Design System",
|
|
6
6
|
"repository": {
|
|
@@ -8,6 +8,41 @@
|
|
|
8
8
|
"url": "git+https://github.com/db-ux-design-system/core-web.git"
|
|
9
9
|
},
|
|
10
10
|
"license": "Apache-2.0",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./build/index.d.ts",
|
|
14
|
+
"default": "./build/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"CHANGELOG.md",
|
|
19
|
+
"build"
|
|
20
|
+
],
|
|
21
|
+
"keywords": [
|
|
22
|
+
"postcss",
|
|
23
|
+
"postcss-plugin",
|
|
24
|
+
"db-ux",
|
|
25
|
+
"css-variables",
|
|
26
|
+
"light-dark"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc",
|
|
30
|
+
"copy-output": "npm-run-all copy:*",
|
|
31
|
+
"copy:changelog": "cpr CHANGELOG.md ../../build-outputs/postcss-plugin/CHANGELOG.md --overwrite",
|
|
32
|
+
"copy:outputs": "cpr build ../../build-outputs/postcss-plugin/build -o",
|
|
33
|
+
"copy:package.json": "cpr package.json ../../build-outputs/postcss-plugin/package.json -o",
|
|
34
|
+
"copy:readme": "cpr README.md ../../build-outputs/postcss-plugin/README.md -o",
|
|
35
|
+
"test": "vitest run --config vitest.config.ts",
|
|
36
|
+
"test:update": "vitest run --config vitest.config.ts --update"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"postcss": "^8.0.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"postcss": "8.5.8",
|
|
43
|
+
"typescript": "5.9.3",
|
|
44
|
+
"vitest": "3.2.4"
|
|
45
|
+
},
|
|
11
46
|
"publishConfig": {
|
|
12
47
|
"registry": "https://registry.npmjs.org/",
|
|
13
48
|
"access": "public"
|