@symbiote-native/css-parser 0.3.0 → 0.4.0
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/README.md +19 -21
- package/build/generate-dts/index.js +3 -8
- package/build/generate-dts-cli.js +5 -1
- package/build/golden-corpus/fixtures/ScopedGlobalDemo.svelte +25 -0
- package/build/golden-corpus/fixtures/ScopedGlobalDemo.svelte.d.ts +3 -0
- package/build/index.d.ts +8 -7
- package/build/index.js +4 -4
- package/build/lightning/declarations.d.ts +46 -0
- package/build/lightning/declarations.js +885 -0
- package/build/lightning/rules.d.ts +52 -0
- package/build/lightning/rules.js +157 -0
- package/build/lightning/selectors.d.ts +21 -0
- package/build/lightning/selectors.js +372 -0
- package/build/metro-css-module/index.d.ts +30 -2
- package/build/metro-css-module/index.js +142 -36
- package/build/preprocessors/index.js +4 -3
- package/build/properties.d.ts +5 -6
- package/build/properties.js +26 -14
- package/build/scoped-classes.d.ts +23 -0
- package/build/scoped-classes.js +35 -0
- package/build/values.d.ts +3 -7
- package/build/values.js +3 -60
- package/package.json +2 -3
- package/typescript-plugin.cjs +21 -14
- package/build/global-selectors.d.ts +0 -25
- package/build/global-selectors.js +0 -98
- package/build/parser/index.d.ts +0 -59
- package/build/parser/index.js +0 -338
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { CSSModuleExports } from 'lightningcss';
|
|
2
|
+
import { type IStyleObject } from './declarations.ts';
|
|
3
|
+
import { type ISelectorCombinator } from './selectors.ts';
|
|
4
|
+
export interface IStyleRule {
|
|
5
|
+
/** Class names as authored, then through {@link ICompiledCss.exports} when a pattern renamed them. */
|
|
6
|
+
readonly tokens: readonly string[];
|
|
7
|
+
readonly specificity: readonly [number, number, number];
|
|
8
|
+
/** Source order across the whole file, 0-based. Breaks a specificity tie, as the cascade does. */
|
|
9
|
+
readonly order: number;
|
|
10
|
+
readonly style: IStyleObject;
|
|
11
|
+
/**
|
|
12
|
+
* One per gap between tokens. Carried but NOT yet consumed: the registry matches a rule by token
|
|
13
|
+
* SUBSET, so `.a .b` still fires like `.a.b` today (trap 6). Stage 4 is where this starts to
|
|
14
|
+
* mean something — it needs parent pointers, which only exist after the engine's commit walk.
|
|
15
|
+
*/
|
|
16
|
+
readonly combinators: readonly ISelectorCombinator[];
|
|
17
|
+
}
|
|
18
|
+
export interface ICompileRulesOptions {
|
|
19
|
+
readonly filename: string;
|
|
20
|
+
/** Root font size for `rem`. */
|
|
21
|
+
readonly remToPx?: number;
|
|
22
|
+
/**
|
|
23
|
+
* A lightningcss CSS-Modules pattern, `[local]` first — `[local]__module__<hash>` for a
|
|
24
|
+
* `.module.*` file or a Vue `<style module>`, `[local]__data-v-<hash>` for a Vue `<style
|
|
25
|
+
* scoped>`, `[local]__svelte-<hash>` for a Svelte `<style>`. Omitted for a plain global `.css`.
|
|
26
|
+
*/
|
|
27
|
+
readonly pattern?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface ICompiledCss {
|
|
30
|
+
readonly rules: readonly IStyleRule[];
|
|
31
|
+
/**
|
|
32
|
+
* Authored class name -> the name it registers under, straight out of lightningcss. Empty
|
|
33
|
+
* without a `pattern`. The markup rewriter reads THIS — never its own recomputation of the
|
|
34
|
+
* name, which is the bug class the single-renamer design exists to close.
|
|
35
|
+
*/
|
|
36
|
+
readonly exports: Readonly<Record<string, string>>;
|
|
37
|
+
/**
|
|
38
|
+
* The same map unflattened, kept because CSS Modules needs more than the new name: `composes`
|
|
39
|
+
* lives on the entry, and resolving a chain (`.a composes .b composes .c`) walks it. Empty
|
|
40
|
+
* without a `pattern`.
|
|
41
|
+
*/
|
|
42
|
+
readonly moduleExports: CSSModuleExports;
|
|
43
|
+
/**
|
|
44
|
+
* Class tokens the rules use that lightningcss did NOT rename. Under a `pattern` that means
|
|
45
|
+
* exactly one thing — the name came out of a `:global()` — so the escape hatch is DERIVED here
|
|
46
|
+
* rather than re-detected by walking selectors a second time (which is all
|
|
47
|
+
* a retired second selector walk ever was). Without a pattern nothing is renamed, so this is
|
|
48
|
+
* simply every token in the file, and no caller has a use for it.
|
|
49
|
+
*/
|
|
50
|
+
readonly globals: ReadonlySet<string>;
|
|
51
|
+
}
|
|
52
|
+
export declare function compileCssToRules(css: string, options: ICompileRulesOptions): ICompiledCss;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// The glue of the lightningcss-typed pipeline: CSS text in, engine style RULES out. One
|
|
2
|
+
// `transform()` call feeds both halves — `./selectors.ts` for the selector, `./declarations.ts`
|
|
3
|
+
// for the values — and nothing in between re-derives a name from text.
|
|
4
|
+
//
|
|
5
|
+
// WHY a rule instead of a key. The retired text pipeline COLLAPSED a selector into one string
|
|
6
|
+
// key (`.a.b` -> `aB`, camelCased) and the runtime registry reversed that guess by
|
|
7
|
+
// permuting the element's class tokens. Every lossy step in the collapse mapped two different
|
|
8
|
+
// selectors onto one key and the later rule silently overwrote the earlier — the seventh trap in
|
|
9
|
+
// `.claude/rules/style-registry-collisions.md`. A rule carries the TOKEN SET the selector was
|
|
10
|
+
// written with, so the registry matches by subset and there is nothing to guess or reverse.
|
|
11
|
+
//
|
|
12
|
+
// Class names are emitted AS AUTHORED (`card-title` stays `card-title`, plus whatever the scoping
|
|
13
|
+
// rename appended). No camelCase anywhere in this file: normalization is what the trap was made of.
|
|
14
|
+
//
|
|
15
|
+
// Measured 2026-08-20 and the reason this is ONE pass, not two: with `cssModules` on, the visitor
|
|
16
|
+
// still sees the ORIGINAL class names — lightningcss renames AFTER the visitor walk. So the AST
|
|
17
|
+
// gives authored tokens and `exports` gives their renamed spelling, and the renamed CSS text never
|
|
18
|
+
// has to be parsed a second time (which is exactly what `../metro-css-module/index.ts` had to do,
|
|
19
|
+
// and what mangled a scope tail whose base36 hash began with a letter).
|
|
20
|
+
//
|
|
21
|
+
// `:global()` is the one thing the mode DOES change, and it changes shape rather than presence:
|
|
22
|
+
// with `cssModules` off it is a `custom-function` pseudo-class holding a raw token stream, with it
|
|
23
|
+
// on a `kind:'global'` pseudo-class holding a real parsed selector. `./selectors.ts` handles both;
|
|
24
|
+
// either way the tokens come out and `exports` not naming them is what marks them global.
|
|
25
|
+
import { transform } from 'lightningcss';
|
|
26
|
+
import { declarationToStyle, variablesIn, } from "./declarations.js";
|
|
27
|
+
import { selectorsToMatches } from "./selectors.js";
|
|
28
|
+
import { warnOnce } from "../values.js";
|
|
29
|
+
// A conditional at-rule is DROPPED WHOLE, its nested rules with it. There is no media-query engine
|
|
30
|
+
// in React Native, so applying `.responsive` from `@media (min-width: 900px)` would paint it on
|
|
31
|
+
// every phone — worse than not supporting the rule, because it looks supported. The retired text
|
|
32
|
+
// pass dropped these too, but silently: it simply never walked into an at-rule. Measured
|
|
33
|
+
// 2026-08-20: returning `[]` from the at-rule visitor removes it BEFORE the walk descends, so a
|
|
34
|
+
// nested style rule never reaches the collector; without it lightningcss hoists it out.
|
|
35
|
+
//
|
|
36
|
+
// `@keyframes` and `@font-face` need no entry here — neither emits a style rule to begin with.
|
|
37
|
+
const CONDITIONAL_AT_RULES = ['media', 'supports', 'container'];
|
|
38
|
+
// The wrappers `./selectors.ts` unwraps itself. lightningcss implements neither, so it reports both
|
|
39
|
+
// as an unsupported pseudo-class even though the rule survives.
|
|
40
|
+
const HANDLED_PSEUDO_CLASSES = new Set(['global', 'deep']);
|
|
41
|
+
function isHandledPseudoClass(value) {
|
|
42
|
+
if (typeof value !== 'object' || value === null)
|
|
43
|
+
return false;
|
|
44
|
+
const record = { ...value };
|
|
45
|
+
return (record.type === 'UnsupportedPseudoClass' &&
|
|
46
|
+
typeof record.value === 'string' &&
|
|
47
|
+
HANDLED_PSEUDO_CLASSES.has(record.value));
|
|
48
|
+
}
|
|
49
|
+
export function compileCssToRules(css, options) {
|
|
50
|
+
const { filename, pattern, remToPx } = options;
|
|
51
|
+
const collected = [];
|
|
52
|
+
const exports = {};
|
|
53
|
+
const warned = new Set();
|
|
54
|
+
const dropAtRule = (name) => () => {
|
|
55
|
+
warnOnce(warned, `at-rule:${name}`, `[@symbiote-native/css-parser] ${filename}: \`@${name}\` is not supported — the rules ` +
|
|
56
|
+
'inside it are dropped. React Native evaluates no CSS condition at all; branch in JS ' +
|
|
57
|
+
'(useWindowDimensions, Platform) instead.');
|
|
58
|
+
return [];
|
|
59
|
+
};
|
|
60
|
+
const result = transform({
|
|
61
|
+
filename,
|
|
62
|
+
code: Buffer.from(css),
|
|
63
|
+
// A rule lightningcss cannot use must not take the whole file down: the retired text pass
|
|
64
|
+
// never threw either, and a stylesheet is authored content, not our code.
|
|
65
|
+
errorRecovery: true,
|
|
66
|
+
// Without this `>>>` / `/deep/` die at parse as a dangling combinator, so the selector half
|
|
67
|
+
// never gets the chance to fold them. Inert until stage 4 consumes a combinator.
|
|
68
|
+
nonStandard: { deepSelectorCombinator: true },
|
|
69
|
+
...(pattern === undefined ? {} : { cssModules: { pattern } }),
|
|
70
|
+
visitor: {
|
|
71
|
+
Rule: {
|
|
72
|
+
...Object.fromEntries(CONDITIONAL_AT_RULES.map(name => [name, dropAtRule(name)])),
|
|
73
|
+
style(rule) {
|
|
74
|
+
// Every reason but `root` is announced by `selectorsToMatches` itself, with the
|
|
75
|
+
// explanation spelled out for the author — warning again from this level printed the
|
|
76
|
+
// same drop twice. `root` is the exception it hands back silently, because only here
|
|
77
|
+
// are the declarations in scope, and a `:root` block is worth a word ONLY when it
|
|
78
|
+
// carries something that would have painted. Custom properties are its whole purpose
|
|
79
|
+
// and are already collected by `collectCustomProperties`.
|
|
80
|
+
const { matches, dropped } = selectorsToMatches(rule.value.selectors, filename);
|
|
81
|
+
// `importantDeclarations` last: `!important` wins inside its own rule, and a later
|
|
82
|
+
// entry overwrites an earlier one when both name the same RN property.
|
|
83
|
+
const declarations = [
|
|
84
|
+
...rule.value.declarations.declarations,
|
|
85
|
+
...rule.value.declarations.importantDeclarations,
|
|
86
|
+
];
|
|
87
|
+
if (dropped.some(entry => entry.reason === 'root') &&
|
|
88
|
+
declarations.some(declaration => declaration.property !== 'custom')) {
|
|
89
|
+
warnOnce(warned, `root-paints:${filename}`, `[@symbiote-native/css-parser] ${filename}: a \`:root\` rule declares more than ` +
|
|
90
|
+
'custom properties — everything else in it is dropped, because `:root` matches no ' +
|
|
91
|
+
'node in React Native. Move those declarations onto a class.');
|
|
92
|
+
}
|
|
93
|
+
for (const match of matches) {
|
|
94
|
+
collected.push({ ...match, declarations });
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
// `errorRecovery` keeps a malformed rule from taking the file down, but silence is how a whole
|
|
101
|
+
// rule disappears unnoticed: `examples/angular/**/ApiPlaygroundScreen.css` had a comment closed
|
|
102
|
+
// early by a `.hero-*/` inside it, which swept the real class after it into a garbage selector.
|
|
103
|
+
// The retired text pass "recovered" by registering the garbage; this one drops it — either way
|
|
104
|
+
// the author has to be told.
|
|
105
|
+
//
|
|
106
|
+
// Two things this loop must NOT do. It must not claim an outcome — a SelectorError drops the
|
|
107
|
+
// rule but a value-level one drops only the declaration, and asserting the wrong one is worse
|
|
108
|
+
// than staying quiet about it. And it must not repeat a complaint we deliberately answer:
|
|
109
|
+
// lightningcss does not implement `:global()` or `:deep()`, so it warns "not recognized as a
|
|
110
|
+
// valid pseudo-class" for both while `./selectors.ts` unwraps them and keeps the rule. Passing
|
|
111
|
+
// that through told the author their working rule was thrown away.
|
|
112
|
+
for (const warning of result.warnings) {
|
|
113
|
+
if (isHandledPseudoClass(warning.value))
|
|
114
|
+
continue;
|
|
115
|
+
const where = `${warning.loc.line}:${warning.loc.column}`;
|
|
116
|
+
warnOnce(warned, `lightningcss:${warning.type}:${warning.message}`, `[@symbiote-native/css-parser] ${filename}:${where}: ${warning.message}`);
|
|
117
|
+
}
|
|
118
|
+
// Sorted: `exports` is a Rust HashMap whose iteration order is randomized PER PROCESS, and this
|
|
119
|
+
// map is emitted into a generated module — an unsorted one churns Metro's content cache.
|
|
120
|
+
for (const [local, entry] of Object.entries(result.exports ?? {}).sort(([left], [right]) => (left < right ? -1 : 1))) {
|
|
121
|
+
exports[local] = entry.name;
|
|
122
|
+
}
|
|
123
|
+
// Custom properties come from their own pass because nothing guarantees `:root` is declared
|
|
124
|
+
// before the rule that reads it — the retired text pass had the same two-phase shape.
|
|
125
|
+
const variables = variablesIn(css, filename);
|
|
126
|
+
const context = {
|
|
127
|
+
filename,
|
|
128
|
+
variables,
|
|
129
|
+
...(remToPx === undefined ? {} : { remToPx }),
|
|
130
|
+
};
|
|
131
|
+
const globals = new Set();
|
|
132
|
+
const rules = collected.map((rule, order) => ({
|
|
133
|
+
tokens: rule.tokens.map(token => {
|
|
134
|
+
const renamed = exports[token];
|
|
135
|
+
if (renamed === undefined)
|
|
136
|
+
globals.add(token);
|
|
137
|
+
return renamed ?? token;
|
|
138
|
+
}),
|
|
139
|
+
specificity: rule.specificity,
|
|
140
|
+
combinators: rule.combinators,
|
|
141
|
+
order,
|
|
142
|
+
style: rule.declarations.reduce((style, declaration) => ({
|
|
143
|
+
...style,
|
|
144
|
+
...declarationToStyle(declaration, context),
|
|
145
|
+
}), {}),
|
|
146
|
+
}));
|
|
147
|
+
// A rule whose every declaration was dropped — all unsupported, or all `var()`s declared in
|
|
148
|
+
// another file — has nothing to contribute, and keeping it only adds a candidate the registry
|
|
149
|
+
// must consider on every lookup of those tokens.
|
|
150
|
+
const painting = rules.filter(rule => Object.keys(rule.style).length > 0);
|
|
151
|
+
return {
|
|
152
|
+
rules: painting,
|
|
153
|
+
exports,
|
|
154
|
+
moduleExports: result.exports ?? {},
|
|
155
|
+
globals,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export type ISelectorCombinator = 'none' | 'descendant' | 'child' | 'next-sibling' | 'later-sibling' | 'deep';
|
|
2
|
+
export interface ISelectorMatch {
|
|
3
|
+
/** Class names AS AUTHORED — NO camelCase, NO collapsing, in source order. */
|
|
4
|
+
readonly tokens: readonly string[];
|
|
5
|
+
/** CSS specificity (a,b,c) = (id, class/attr/pseudo-class, element/pseudo-element). */
|
|
6
|
+
readonly specificity: readonly [number, number, number];
|
|
7
|
+
/** The combinators between the tokens, one per gap (length === tokens.length - 1). */
|
|
8
|
+
readonly combinators: readonly ISelectorCombinator[];
|
|
9
|
+
}
|
|
10
|
+
export interface IDroppedSelector {
|
|
11
|
+
readonly reason: 'root' | 'pseudo-class' | 'pseudo-element' | 'attribute' | 'element' | 'id' | 'universal' | 'unsupported';
|
|
12
|
+
/** e.g. 'hover', '[data-x]', 'div'. */
|
|
13
|
+
readonly detail: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ISelectorResult {
|
|
16
|
+
/** One per comma-separated selector that is usable. */
|
|
17
|
+
readonly matches: readonly ISelectorMatch[];
|
|
18
|
+
/** The rest, with a reason. */
|
|
19
|
+
readonly dropped: readonly IDroppedSelector[];
|
|
20
|
+
}
|
|
21
|
+
export declare function selectorsToMatches(selectors: unknown, filename: string): ISelectorResult;
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// Selector reading for the lightningcss pipeline: an AST walk that replaced the retired
|
|
2
|
+
// text-based `extractClassName` / `extractClassTokens` pair.
|
|
3
|
+
//
|
|
4
|
+
// That pair derived a registry key from the SELECTOR TEXT — it camelCased each class, collapsed
|
|
5
|
+
// every class in the selector into one concatenated key, and returned `null` for whatever its
|
|
6
|
+
// regexes failed to recognize. Each of those steps is lossy in a way that maps two different
|
|
7
|
+
// rules onto ONE key, and the later rule then overwrites the earlier per property, silently:
|
|
8
|
+
//
|
|
9
|
+
// .card-title{…} .cardTitle{…} -> both key `cardTitle` the first rule is GONE
|
|
10
|
+
// .card:hover{…} .card{…} -> both key `card` the :hover rule OVERWRITES the base
|
|
11
|
+
// .card[data-x]{…} -> key `card[dataX]` a key no element can ever carry
|
|
12
|
+
// .a.b / .a .b / .a>.b / .a+.b -> all key `aB` five selectors, one key, merged
|
|
13
|
+
//
|
|
14
|
+
// (Traps six and seven in `.claude/rules/style-registry-collisions.md`, measured 2026-08-20.)
|
|
15
|
+
//
|
|
16
|
+
// So this module reports what the selector ACTUALLY says and refuses to guess:
|
|
17
|
+
// - tokens stay AS AUTHORED — `card-title` is `card-title`. Casing is the caller's problem, and
|
|
18
|
+
// a caller that never re-cases can never collide two spellings onto one key.
|
|
19
|
+
// - the class list stays a LIST, with the combinator of every gap beside it, so a descendant
|
|
20
|
+
// rule is distinguishable from a compound one instead of both flattening to a concatenation.
|
|
21
|
+
// - a selector RN can never match (pseudo-class/-element, attribute, element, id, universal) is
|
|
22
|
+
// dropped WITH a reason and a warning, rather than silently degrading into a key that either
|
|
23
|
+
// collides with a real rule or matches nothing at all.
|
|
24
|
+
//
|
|
25
|
+
// The caller must pass `nonStandard: { deepSelectorCombinator: true }` to lightningcss's
|
|
26
|
+
// `transform`/`bundle` for a `deep` combinator to ever appear: without it `>>>` and `/deep/` are
|
|
27
|
+
// rejected at parse time as an invalid dangling combinator and the whole rule is lost before this
|
|
28
|
+
// walk sees it. That flag covers ONLY those two spellings — the other three deep forms are not
|
|
29
|
+
// lightningcss features at all and need no flag, so do not go re-reading its docs looking for
|
|
30
|
+
// them: `::v-deep` / `::ng-deep` arrive as an ordinary custom pseudo-element between two
|
|
31
|
+
// descendant combinators, and Vue's `:deep(X)` as a custom-function pseudo-class whose payload is
|
|
32
|
+
// a raw token stream — structurally the same thing `:global(X)` is under that same mode. All three
|
|
33
|
+
// are folded into `deep` here.
|
|
34
|
+
//
|
|
35
|
+
// `:global(X)` arrives in TWO DIFFERENT SHAPES and BOTH are live, so both are handled (measured
|
|
36
|
+
// 2026-08-20, lightningcss 1.32, same CSS through both modes):
|
|
37
|
+
//
|
|
38
|
+
// cssModules OFF {kind:'custom-function', name:'global', arguments:[…raw token stream…]}
|
|
39
|
+
// cssModules ON {kind:'global', selector:[…parsed SelectorComponent[]…]}
|
|
40
|
+
//
|
|
41
|
+
// A `.module.css` / scoped file runs WITH cssModules and gets the parsed form; the plain-`.css`
|
|
42
|
+
// pipeline runs WITHOUT it and gets the token stream. Handling only one of them silently drops the
|
|
43
|
+
// whole rule on the other — which is exactly how `:global(.reset)` in a `.module.css` came to
|
|
44
|
+
// contribute no rule and no export. `:deep()` does NOT have this split: lightningcss implements no
|
|
45
|
+
// `:deep()` at all, so it is the raw custom-function form under BOTH modes.
|
|
46
|
+
// Why the rule can never fire on a device, per reason — the half of the warning that tells an
|
|
47
|
+
// author what to do instead of just what was thrown away.
|
|
48
|
+
const DROP_EXPLANATION = {
|
|
49
|
+
root: 'a `:root` rule paints nothing — it exists to declare custom properties, which are collected by their own pass',
|
|
50
|
+
'pseudo-class': 'React Native has no pseudo-class state (no hover/focus/nth-child)',
|
|
51
|
+
'pseudo-element': 'React Native has no pseudo-elements',
|
|
52
|
+
attribute: 'React Native has no attribute selectors',
|
|
53
|
+
element: 'React Native has no element/tag selectors',
|
|
54
|
+
id: 'React Native has no id selectors',
|
|
55
|
+
universal: 'React Native has no universal selector',
|
|
56
|
+
unsupported: 'this selector shape has no React Native equivalent',
|
|
57
|
+
};
|
|
58
|
+
const DEEP_PSEUDO_ELEMENTS = new Set(['v-deep', 'ng-deep']);
|
|
59
|
+
// Every `SelectorComponent['type']`, so the guard below rejects a shape lightningcss does not
|
|
60
|
+
// produce instead of trusting any object that happens to carry a `type` string.
|
|
61
|
+
const SELECTOR_COMPONENT_TYPES = new Set([
|
|
62
|
+
'combinator',
|
|
63
|
+
'universal',
|
|
64
|
+
'namespace',
|
|
65
|
+
'type',
|
|
66
|
+
'id',
|
|
67
|
+
'class',
|
|
68
|
+
'attribute',
|
|
69
|
+
'pseudo-class',
|
|
70
|
+
'pseudo-element',
|
|
71
|
+
'nesting',
|
|
72
|
+
]);
|
|
73
|
+
function isRecord(value) {
|
|
74
|
+
return typeof value === 'object' && value !== null;
|
|
75
|
+
}
|
|
76
|
+
function isSelectorComponent(value) {
|
|
77
|
+
return (isRecord(value) &&
|
|
78
|
+
typeof value.type === 'string' &&
|
|
79
|
+
SELECTOR_COMPONENT_TYPES.has(value.type));
|
|
80
|
+
}
|
|
81
|
+
function isTokenOrValue(value) {
|
|
82
|
+
return isRecord(value) && typeof value.type === 'string';
|
|
83
|
+
}
|
|
84
|
+
/** The subset of lightningcss combinators this pipeline can act on; the rest drop the rule. */
|
|
85
|
+
function combinatorFor(value) {
|
|
86
|
+
switch (value) {
|
|
87
|
+
case 'descendant':
|
|
88
|
+
case 'child':
|
|
89
|
+
case 'next-sibling':
|
|
90
|
+
case 'later-sibling':
|
|
91
|
+
return value;
|
|
92
|
+
// `>>>` and `/deep/` differ only in spelling; a caller that treats them apart would be
|
|
93
|
+
// encoding CSS trivia, not a matching rule.
|
|
94
|
+
case 'deep-descendant':
|
|
95
|
+
case 'deep':
|
|
96
|
+
return 'deep';
|
|
97
|
+
default:
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function createBuilder() {
|
|
102
|
+
return {
|
|
103
|
+
tokens: [],
|
|
104
|
+
combinators: [],
|
|
105
|
+
specificity: [0, 0, 0],
|
|
106
|
+
pending: 'none',
|
|
107
|
+
drop: null,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function pushToken(builder, name) {
|
|
111
|
+
if (builder.tokens.length > 0)
|
|
112
|
+
builder.combinators.push(builder.pending);
|
|
113
|
+
builder.tokens.push(name);
|
|
114
|
+
builder.pending = 'none';
|
|
115
|
+
}
|
|
116
|
+
function setCombinator(builder, next) {
|
|
117
|
+
// A descendant never downgrades an explicit combinator already pending. lightningcss brackets
|
|
118
|
+
// `::v-deep` with a synthetic descendant on BOTH sides, and inside a `:global()` token stream
|
|
119
|
+
// `>` arrives surrounded by whitespace — in either case the second descendant would otherwise
|
|
120
|
+
// erase the real combinator the author wrote.
|
|
121
|
+
if (next === 'descendant' && builder.pending !== 'none')
|
|
122
|
+
return;
|
|
123
|
+
builder.pending = next;
|
|
124
|
+
}
|
|
125
|
+
function drop(builder, reason, detail) {
|
|
126
|
+
builder.drop ??= { reason, detail };
|
|
127
|
+
}
|
|
128
|
+
//#region custom-function payload token streams
|
|
129
|
+
/**
|
|
130
|
+
* Fold the payload of a custom-function pseudo-class into the builder as if its classes had been
|
|
131
|
+
* written bare. Serves both `:global(X)` and `:deep(X)` — lightningcss implements neither, so both
|
|
132
|
+
* arrive in the identical shape and one unwrapper covers them.
|
|
133
|
+
*
|
|
134
|
+
* This is the RAW-TOKEN half of `:global()`, which is the shape a PLAIN `.css` file produces —
|
|
135
|
+
* it is not run through cssModules, so `:global()` stays a `custom-function` pseudo-class whose
|
|
136
|
+
* arguments are an unparsed token stream (measured, lightningcss 1.32): `:global(.a > .b)` arrives
|
|
137
|
+
* as delim `.` · ident `a` · white-space · delim `>` · white-space · delim `.` · ident `b`.
|
|
138
|
+
* With cssModules ON the same source arrives as `kind:'global'` carrying a real parsed selector,
|
|
139
|
+
* handled where that kind is matched — `:global()` never disappears, it changes shape, and
|
|
140
|
+
* assuming the mode erased it is what silently killed the rule in every `.module.*` file.
|
|
141
|
+
*
|
|
142
|
+
* Neither wrapper changes which classes an element must carry — `:global()` only says a name lives
|
|
143
|
+
* outside the file's scope, `:deep()` only says the match may cross a scope boundary. So the
|
|
144
|
+
* payload participates exactly as if unwrapped, the rule `stripGlobalWrappers` follows in the text
|
|
145
|
+
* parser. `wrapper` is the authored spelling, used only so a drop warning names the right one.
|
|
146
|
+
*/
|
|
147
|
+
function consumePayload(builder, args, wrapper) {
|
|
148
|
+
for (let index = 0; index < args.length; index++) {
|
|
149
|
+
const argument = args[index];
|
|
150
|
+
if (!isTokenOrValue(argument) || argument.type !== 'token') {
|
|
151
|
+
drop(builder, 'unsupported', wrapper);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const token = argument.value;
|
|
155
|
+
switch (token.type) {
|
|
156
|
+
case 'white-space':
|
|
157
|
+
setCombinator(builder, 'descendant');
|
|
158
|
+
break;
|
|
159
|
+
case 'delim': {
|
|
160
|
+
if (token.value === '>') {
|
|
161
|
+
setCombinator(builder, 'child');
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
if (token.value === '+') {
|
|
165
|
+
setCombinator(builder, 'next-sibling');
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
if (token.value === '~') {
|
|
169
|
+
setCombinator(builder, 'later-sibling');
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
if (token.value === '*') {
|
|
173
|
+
drop(builder, 'universal', '*');
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
if (token.value !== '.') {
|
|
177
|
+
drop(builder, 'unsupported', `${wrapper.slice(0, -1)}${token.value})`);
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
// A class is two tokens — delim `.` then the ident. A `.` with no ident behind it means
|
|
181
|
+
// the payload is malformed, and nothing about it is then trustworthy.
|
|
182
|
+
const name = identAt(args, index + 1);
|
|
183
|
+
if (name === null) {
|
|
184
|
+
drop(builder, 'unsupported', wrapper);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
builder.specificity[1]++;
|
|
188
|
+
pushToken(builder, name);
|
|
189
|
+
index++;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
case 'colon': {
|
|
193
|
+
// One colon is a pseudo-class, two a pseudo-element; either way the name is the ident
|
|
194
|
+
// after them, and reporting it is the difference between a warning an author can act on
|
|
195
|
+
// and one that only says ":global()" / ":deep()".
|
|
196
|
+
const isPseudoElement = isColonAt(args, index + 1);
|
|
197
|
+
const nameIndex = index + (isPseudoElement ? 2 : 1);
|
|
198
|
+
const name = identAt(args, nameIndex) ?? wrapper;
|
|
199
|
+
builder.specificity[isPseudoElement ? 2 : 1]++;
|
|
200
|
+
drop(builder, isPseudoElement ? 'pseudo-element' : 'pseudo-class', name);
|
|
201
|
+
index = nameIndex;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
case 'ident':
|
|
205
|
+
builder.specificity[2]++;
|
|
206
|
+
drop(builder, 'element', token.value);
|
|
207
|
+
break;
|
|
208
|
+
case 'id-hash':
|
|
209
|
+
builder.specificity[0]++;
|
|
210
|
+
drop(builder, 'id', `#${token.value}`);
|
|
211
|
+
break;
|
|
212
|
+
case 'square-bracket-block': {
|
|
213
|
+
// The parsed shape reports `[data-x]` by name, so the token stream reads the name too —
|
|
214
|
+
// otherwise the same CSS would warn differently depending on the cssModules flag. The
|
|
215
|
+
// block's remaining tokens are left to fall through: they can only add a second drop, and
|
|
216
|
+
// the first one already recorded is the one reported.
|
|
217
|
+
const name = identAt(args, index + 1);
|
|
218
|
+
builder.specificity[1]++;
|
|
219
|
+
drop(builder, 'attribute', name === null ? '[…]' : `[${name}]`);
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
default:
|
|
223
|
+
drop(builder, 'unsupported', wrapper);
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function identAt(args, index) {
|
|
229
|
+
const argument = args[index];
|
|
230
|
+
if (!isTokenOrValue(argument) || argument.type !== 'token')
|
|
231
|
+
return null;
|
|
232
|
+
return argument.value.type === 'ident' ? argument.value.value : null;
|
|
233
|
+
}
|
|
234
|
+
function isColonAt(args, index) {
|
|
235
|
+
const argument = args[index];
|
|
236
|
+
return (isTokenOrValue(argument) &&
|
|
237
|
+
argument.type === 'token' &&
|
|
238
|
+
argument.value.type === 'colon');
|
|
239
|
+
}
|
|
240
|
+
//#endregion custom-function payload token streams
|
|
241
|
+
function consumeComponent(builder, component) {
|
|
242
|
+
switch (component.type) {
|
|
243
|
+
case 'class':
|
|
244
|
+
builder.specificity[1]++;
|
|
245
|
+
pushToken(builder, component.name);
|
|
246
|
+
return;
|
|
247
|
+
case 'combinator': {
|
|
248
|
+
const combinator = combinatorFor(component.value);
|
|
249
|
+
if (combinator === null) {
|
|
250
|
+
drop(builder, 'unsupported', component.value);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
setCombinator(builder, combinator);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
case 'id':
|
|
257
|
+
builder.specificity[0]++;
|
|
258
|
+
drop(builder, 'id', `#${component.name}`);
|
|
259
|
+
return;
|
|
260
|
+
case 'type':
|
|
261
|
+
builder.specificity[2]++;
|
|
262
|
+
drop(builder, 'element', component.name);
|
|
263
|
+
return;
|
|
264
|
+
case 'universal':
|
|
265
|
+
drop(builder, 'universal', '*');
|
|
266
|
+
return;
|
|
267
|
+
case 'attribute':
|
|
268
|
+
builder.specificity[1]++;
|
|
269
|
+
drop(builder, 'attribute', `[${component.name}]`);
|
|
270
|
+
return;
|
|
271
|
+
case 'pseudo-class':
|
|
272
|
+
// Neither wrapper contributes specificity of its own — the payload's classes carry it.
|
|
273
|
+
// The parsed `:global(X)` of cssModules mode. Its payload is an ordinary component array, so
|
|
274
|
+
// the SAME walk reads it — which is also why a non-class payload drops with the payload's
|
|
275
|
+
// own reason here for free, identically to the token-stream branch below.
|
|
276
|
+
if (component.kind === 'global') {
|
|
277
|
+
consumeSelector(builder, component.selector);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
// `:root` is not state — it is where an author is SUPPOSED to declare custom properties, and
|
|
281
|
+
// `collectCustomProperties` has already read them by the time this runs. Reported with its
|
|
282
|
+
// own reason so the caller can stay silent about the ordinary token sheet and speak up only
|
|
283
|
+
// when the rule also carries a declaration that would have painted. Warning unconditionally
|
|
284
|
+
// meant every stylesheet with a `:root { --token: … }` block printed "it can never match",
|
|
285
|
+
// about the one construct the docs tell people to write.
|
|
286
|
+
if (component.kind === 'root') {
|
|
287
|
+
drop(builder, 'root', 'root');
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (component.kind === 'custom-function') {
|
|
291
|
+
if (component.name === 'global') {
|
|
292
|
+
consumePayload(builder, component.arguments, ':global()');
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (component.name === 'deep') {
|
|
296
|
+
// `:deep(X)` reaches THROUGH a scope boundary into X — the same relation `>>>` and
|
|
297
|
+
// `::v-deep` express, so the gap into X's own tokens is `deep`, overriding the
|
|
298
|
+
// descendant lightningcss reports just before it.
|
|
299
|
+
setCombinator(builder, 'deep');
|
|
300
|
+
consumePayload(builder, component.arguments, ':deep()');
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// `:not()`/`:is()` take the specificity of their argument rather than a flat 1, but they are
|
|
305
|
+
// dropped here regardless, and only a KEPT selector's specificity is ever read.
|
|
306
|
+
builder.specificity[1]++;
|
|
307
|
+
drop(builder, 'pseudo-class', component.kind === 'custom' || component.kind === 'custom-function'
|
|
308
|
+
? component.name
|
|
309
|
+
: component.kind);
|
|
310
|
+
return;
|
|
311
|
+
case 'pseudo-element':
|
|
312
|
+
if (component.kind === 'custom' &&
|
|
313
|
+
DEEP_PSEUDO_ELEMENTS.has(component.name)) {
|
|
314
|
+
setCombinator(builder, 'deep');
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
builder.specificity[2]++;
|
|
318
|
+
drop(builder, 'pseudo-element', component.kind === 'custom' || component.kind === 'custom-function'
|
|
319
|
+
? component.name
|
|
320
|
+
: component.kind);
|
|
321
|
+
return;
|
|
322
|
+
// `namespace` (`ns|div`) and `nesting` (`&`) both need context this compiler does not have.
|
|
323
|
+
default:
|
|
324
|
+
drop(builder, 'unsupported', component.type);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* `selectors` is lightningcss's own `rule.value.selectors` (an array of selector-component
|
|
330
|
+
* arrays). Each entry is one comma-separated selector and is judged on its own: a list where some
|
|
331
|
+
* parts survive and some drop yields both a match and a drop.
|
|
332
|
+
*/
|
|
333
|
+
function consumeSelector(builder, selector) {
|
|
334
|
+
for (const component of selector) {
|
|
335
|
+
if (!isSelectorComponent(component)) {
|
|
336
|
+
drop(builder, 'unsupported', String(component));
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
consumeComponent(builder, component);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
export function selectorsToMatches(selectors, filename) {
|
|
343
|
+
if (!Array.isArray(selectors))
|
|
344
|
+
return { matches: [], dropped: [] };
|
|
345
|
+
const matches = [];
|
|
346
|
+
const dropped = [];
|
|
347
|
+
for (const selector of selectors) {
|
|
348
|
+
if (!Array.isArray(selector) || selector.length === 0)
|
|
349
|
+
continue;
|
|
350
|
+
const builder = createBuilder();
|
|
351
|
+
consumeSelector(builder, selector);
|
|
352
|
+
// No class survived and nothing explained why (a lone `::v-deep`, an empty compound) — there
|
|
353
|
+
// is still nothing to register, so say so rather than emitting a match with zero tokens.
|
|
354
|
+
if (builder.drop === null && builder.tokens.length === 0)
|
|
355
|
+
drop(builder, 'unsupported', 'no class selector');
|
|
356
|
+
const problem = builder.drop;
|
|
357
|
+
if (problem !== null) {
|
|
358
|
+
dropped.push(problem);
|
|
359
|
+
// `root` is returned, never announced from here — see DROP_EXPLANATION.root.
|
|
360
|
+
if (problem.reason === 'root')
|
|
361
|
+
continue;
|
|
362
|
+
console.warn(`[@symbiote-native/css-parser] ${filename}: dropped a rule on \`${problem.detail}\` — ${DROP_EXPLANATION[problem.reason]}, so it can never match in React Native.`);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
matches.push({
|
|
366
|
+
tokens: builder.tokens,
|
|
367
|
+
combinators: builder.combinators,
|
|
368
|
+
specificity: builder.specificity,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return { matches, dropped };
|
|
372
|
+
}
|
|
@@ -1,6 +1,34 @@
|
|
|
1
|
-
import { type
|
|
1
|
+
import { type IStyleRule } from '../lightning/rules.ts';
|
|
2
2
|
export interface ICompiledCssFile {
|
|
3
3
|
code: string;
|
|
4
4
|
}
|
|
5
5
|
export declare function isCssModuleFile(filename: string): boolean;
|
|
6
|
-
export
|
|
6
|
+
export type ICompiledCssModule = {
|
|
7
|
+
/** The rules, their tokens already carrying the scope. */
|
|
8
|
+
rules: readonly IStyleRule[];
|
|
9
|
+
/** The default export: authored name -> the token(s) to put in the markup. */
|
|
10
|
+
classMap: Record<string, string>;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* One CSS source compiled as a CSS MODULE — a standalone `.module.*` file and a Vue
|
|
14
|
+
* `<style module>` block are the same thing and go through this, so neither can register a class
|
|
15
|
+
* under a name the other would not.
|
|
16
|
+
*
|
|
17
|
+
* The scope tail is our own `hashFilePath`, not lightningcss's `[hash]`: the runtime registry
|
|
18
|
+
* parses this tail to factor a scope back out (SCOPE_TAIL_PATTERN in
|
|
19
|
+
* core/engine/src/style-registry) and its alphabet is lowercase base36, which lightningcss's
|
|
20
|
+
* mixed-case hash does not fit — that would silently kill scoped-token base layering. Same hash
|
|
21
|
+
* the `<style scoped>` and Svelte scopers use, so all three scoping shapes stay one algorithm.
|
|
22
|
+
*/
|
|
23
|
+
export declare function compileCssModule(css: string, filename: string): ICompiledCssModule;
|
|
24
|
+
/**
|
|
25
|
+
* The names a `.module.*` file's default export actually carries.
|
|
26
|
+
*
|
|
27
|
+
* The `.d.ts` generator MUST read them from here rather than re-deriving names off the raw
|
|
28
|
+
* source: what a rule MATCHES on and what the module EXPORTS are different sets. A compound
|
|
29
|
+
* rule `.card.big` matches on both its tokens, but only the names the author wrote as classes
|
|
30
|
+
* are exports. Typing off a re-derived set invents members that are `undefined` at runtime and
|
|
31
|
+
* hides valid ones behind a TS2339.
|
|
32
|
+
*/
|
|
33
|
+
export declare function moduleClassNames(source: string, filename: string): Promise<string[]>;
|
|
34
|
+
export declare function compileCssFile(source: string, filename: string): Promise<ICompiledCssFile>;
|