@symbiote-native/css-parser 0.2.3 → 0.3.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 +39 -17
- package/build/global-selectors.d.ts +25 -1
- package/build/global-selectors.js +95 -19
- package/build/index.d.ts +2 -2
- package/build/index.js +2 -2
- package/build/metro-transformer/index.js +14 -22
- package/build/parser/index.d.ts +37 -0
- package/build/parser/index.js +147 -30
- package/build/properties.js +3 -3
- package/package.json +1 -1
- package/typescript-plugin.cjs +12 -12
package/README.md
CHANGED
|
@@ -32,32 +32,35 @@ Node build machine — never shipped in the app's native JS bundle. Each adapter
|
|
|
32
32
|
as a regular dependency and re-exports it via its own `./metro-css-parser` subpath, so a consuming
|
|
33
33
|
app's `metro.config.js` wires:
|
|
34
34
|
|
|
35
|
-
```js
|
|
36
|
-
// metro-css-transformer.js, in the app
|
|
37
|
-
const { createCssMetroTransformer } = require('@symbiote-native/react/metro-css-parser');
|
|
38
|
-
module.exports = createCssMetroTransformer(require('@react-native/metro-babel-transformer'));
|
|
39
|
-
```
|
|
40
|
-
|
|
41
35
|
```js
|
|
42
36
|
// metro.config.js
|
|
43
37
|
resolver: { sourceExts: [...defaultSourceExts, 'css', 'scss', 'sass', 'less', 'styl'] },
|
|
44
|
-
transformer: { babelTransformerPath: require.resolve('
|
|
38
|
+
transformer: { babelTransformerPath: require.resolve('@symbiote-native/react/metro-css-parser') },
|
|
45
39
|
```
|
|
46
40
|
|
|
41
|
+
The subpath already calls `createCssMetroTransformer` and exports the finished transformer, so an
|
|
42
|
+
app writes no transformer file of its own — point `babelTransformerPath` straight at it. Reach for
|
|
43
|
+
`createCssMetroTransformer` only when building the subpath for a NEW adapter.
|
|
44
|
+
|
|
47
45
|
From there, a plain stylesheet import just works, from any adapter's own source file:
|
|
48
46
|
|
|
49
47
|
```ts
|
|
50
|
-
import styles from './Card.module.css';
|
|
51
|
-
import './theme.css';
|
|
48
|
+
import styles from './Card.module.css'; // CSS Modules — default export is a name→scopedName map
|
|
49
|
+
import './theme.css'; // plain CSS — registers classes globally, no export
|
|
52
50
|
```
|
|
53
51
|
|
|
54
52
|
```tsx
|
|
55
|
-
<View className="card" style={styles.highlight} />
|
|
53
|
+
<View className="card" style={styles.highlight} /> // React
|
|
56
54
|
```
|
|
55
|
+
|
|
57
56
|
```html
|
|
58
57
|
<!-- Vue SFC -->
|
|
59
58
|
<view :class="['card', { active: isActive }]" />
|
|
60
|
-
<style scoped
|
|
59
|
+
<style scoped>
|
|
60
|
+
.card {
|
|
61
|
+
padding: 10px;
|
|
62
|
+
}
|
|
63
|
+
</style>
|
|
61
64
|
```
|
|
62
65
|
|
|
63
66
|
## The pipeline
|
|
@@ -78,12 +81,24 @@ mechanism below runs identically regardless of source language.
|
|
|
78
81
|
|
|
79
82
|
```ts
|
|
80
83
|
import {
|
|
81
|
-
parseCSS,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
parseCSS,
|
|
85
|
+
extractClassName,
|
|
86
|
+
kebabToCamel, // core compiler
|
|
87
|
+
compileCssFile,
|
|
88
|
+
isCssModuleFile, // standalone .css/.module.css files
|
|
89
|
+
createCssMetroTransformer, // Metro babelTransformerPath factory
|
|
90
|
+
compileScss,
|
|
91
|
+
compileSass,
|
|
92
|
+
compileLess,
|
|
93
|
+
compileStylus,
|
|
94
|
+
compile,
|
|
95
|
+
detectLanguage,
|
|
96
|
+
isStyleFile,
|
|
97
|
+
classNamesToDtsSource,
|
|
98
|
+
generateModuleDts, // .d.ts generation for CSS Modules typing
|
|
99
|
+
globalClassNamesIn,
|
|
100
|
+
globalClassTokensIn,
|
|
101
|
+
hashFilePath,
|
|
87
102
|
} from '@symbiote-native/css-parser';
|
|
88
103
|
```
|
|
89
104
|
|
|
@@ -97,6 +112,13 @@ import {
|
|
|
97
112
|
`.css` file registers globally via a side-effect import.
|
|
98
113
|
- **`createCssMetroTransformer`** — wraps an upstream RN Babel transformer, detecting a stylesheet
|
|
99
114
|
extension and compiling it before delegating everything else unchanged.
|
|
115
|
+
- **`globalClassNamesIn` / `globalClassTokensIn`** — the two halves of `:global()`, and they answer
|
|
116
|
+
different questions. The first returns registered KEYS whose selector was global in full, so the
|
|
117
|
+
key itself skips scoping. The second returns MARKUP TOKENS that came out of a `:global(...)`
|
|
118
|
+
payload wherever it sat, including inside an otherwise-scoped selector — `.card :global(.reset)`
|
|
119
|
+
yields the key `cardReset` from neither, and the token `reset` from the second. A caller that
|
|
120
|
+
suffixes class names needs both: exempting only by key leaves a partial global's token
|
|
121
|
+
scope-mangled, exempting only by token leaves a fully global compound's rule dead.
|
|
100
122
|
- **Preprocessors** — `sass`/`less`/`stylus` are lazy, **optional** `devDependencies`: a project
|
|
101
123
|
that never authors `.scss`/`.less`/`.styl` never installs any of the three.
|
|
102
124
|
- **CSS Modules type safety** — `css-dts` (bin) walks a directory and writes a real `<file>.d.ts`
|
|
@@ -1 +1,25 @@
|
|
|
1
|
-
|
|
1
|
+
import { type ICssParserOptions } from './parser/index.ts';
|
|
2
|
+
/**
|
|
3
|
+
* The registered keys whose ENTIRE selector lived outside the file's scope — `:global(.reset)` →
|
|
4
|
+
* `reset`, `:global(.btn.primary)` → `btnPrimary`. These register under their plain name and get
|
|
5
|
+
* no scope suffix.
|
|
6
|
+
*
|
|
7
|
+
* A selector with a scoped part (`.card :global(.reset)` → `cardReset`) is deliberately absent:
|
|
8
|
+
* the rule still only applies where the file's own `.card` does, so its collapsed key belongs to
|
|
9
|
+
* this file. Only its `:global()` half escapes, which is {@link globalClassTokensIn}'s answer.
|
|
10
|
+
*/
|
|
11
|
+
export declare function globalClassNamesIn(css: string, options?: ICssParserOptions): Set<string>;
|
|
12
|
+
/**
|
|
13
|
+
* Every class token that came out of a `:global(...)` payload, wherever in a selector it sat —
|
|
14
|
+
* `.card :global(.legacy-widget) span` → `{ legacyWidget }`.
|
|
15
|
+
*
|
|
16
|
+
* This is the set a scope-suffixing caller subtracts from the tokens it owns. A token is in it
|
|
17
|
+
* whether or not the selector around it was scoped, which is the whole point: the author reached
|
|
18
|
+
* for `:global()` precisely because that name is spelled the same way in markup this file does
|
|
19
|
+
* not own, and a suffix would break the match it was reaching for.
|
|
20
|
+
*
|
|
21
|
+
* A selector the parser rejects contributes nothing — it registers no key, so it names nothing to
|
|
22
|
+
* exempt, and letting its payload leak in here would unscope a token some other rule legitimately
|
|
23
|
+
* owns.
|
|
24
|
+
*/
|
|
25
|
+
export declare function globalClassTokensIn(css: string, options?: ICssParserOptions): Set<string>;
|
|
@@ -1,22 +1,98 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// `{ className: style }`, no per-key metadata.
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
1
|
+
// What a caller doing its own scope-suffixing (Vue's <style scoped>/<style module>, Svelte's
|
|
2
|
+
// <style>, a standalone .module.css file) must leave alone because the author wrote `:global(...)`
|
|
3
|
+
// around it. parseCSS's output cannot answer that on its own: extractClassName UNWRAPS `:global()`
|
|
4
|
+
// (so `:global(.reset)` parses to the same `reset` key a plain `.reset` would) and its return
|
|
5
|
+
// shape is deliberately just `{ className: style }`, with no per-key metadata.
|
|
6
|
+
//
|
|
7
|
+
// The escape hatch is asked about at TWO levels, and they are genuinely different questions:
|
|
8
|
+
//
|
|
9
|
+
// :global(.reset) key `reset` — the whole selector is outside the scope
|
|
10
|
+
// .card :global(.reset) key `cardReset` — only the `reset` TOKEN is
|
|
11
|
+
//
|
|
12
|
+
// `globalClassNamesIn` answers the first (which registered KEY stays unsuffixed),
|
|
13
|
+
// `globalClassTokensIn` the second (which MARKUP token stays unsuffixed). A partial `:global()`
|
|
14
|
+
// needs both to disagree: the rule as a whole is scoped, because `.card` is the file's own, yet
|
|
15
|
+
// the `reset` half must reach the unscoped markup it was written for. Suffixing it anyway
|
|
16
|
+
// scope-mangles the author's escape hatch into something that matches nothing.
|
|
17
|
+
//
|
|
18
|
+
// Both walk the selectors through the parser's own tokenizer rather than a private regex — the
|
|
19
|
+
// names they hand back are compared against parseCSS's keys and against the markup tokens
|
|
20
|
+
// classTokensIn produces, so any independent spelling of "what class does this selector name" is
|
|
21
|
+
// one more chance to disagree with the pipeline it feeds.
|
|
22
|
+
import postcss from 'postcss';
|
|
23
|
+
import { extractClassName, extractClassTokens, globalPayloadsIn, } from "./parser/index.js";
|
|
24
|
+
function eachSelector(css, options, visit) {
|
|
25
|
+
if (!css || typeof css !== 'string')
|
|
26
|
+
return;
|
|
27
|
+
const root = postcss.parse(css, { from: options?.filename });
|
|
28
|
+
// Dropped ahead of the rule walk for the same reason parseCSS drops them, silently here since
|
|
29
|
+
// that pass already warned about each one.
|
|
30
|
+
root.walkAtRules(atRule => {
|
|
31
|
+
atRule.remove();
|
|
32
|
+
});
|
|
33
|
+
root.walkRules(rule => {
|
|
34
|
+
for (const selector of rule.selector.split(','))
|
|
35
|
+
visit(selector.trim());
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
// In source order, so a caller can compare this list against the selector's full token list
|
|
39
|
+
// position by position.
|
|
40
|
+
function globalTokensOf(selector) {
|
|
41
|
+
const tokens = [];
|
|
42
|
+
for (const payload of globalPayloadsIn(selector)) {
|
|
43
|
+
for (const token of extractClassTokens(payload) ?? [])
|
|
44
|
+
tokens.push(token);
|
|
20
45
|
}
|
|
46
|
+
return tokens;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* The registered keys whose ENTIRE selector lived outside the file's scope — `:global(.reset)` →
|
|
50
|
+
* `reset`, `:global(.btn.primary)` → `btnPrimary`. These register under their plain name and get
|
|
51
|
+
* no scope suffix.
|
|
52
|
+
*
|
|
53
|
+
* A selector with a scoped part (`.card :global(.reset)` → `cardReset`) is deliberately absent:
|
|
54
|
+
* the rule still only applies where the file's own `.card` does, so its collapsed key belongs to
|
|
55
|
+
* this file. Only its `:global()` half escapes, which is {@link globalClassTokensIn}'s answer.
|
|
56
|
+
*/
|
|
57
|
+
export function globalClassNamesIn(css, options) {
|
|
58
|
+
const names = new Set();
|
|
59
|
+
eachSelector(css, options, selector => {
|
|
60
|
+
const tokens = extractClassTokens(selector);
|
|
61
|
+
if (tokens === null)
|
|
62
|
+
return;
|
|
63
|
+
// Compared position by position rather than as a subset: `.card :global(.card)` repeats a
|
|
64
|
+
// token, and "every token appears somewhere in a payload" would read that as fully global.
|
|
65
|
+
const globalTokens = globalTokensOf(selector);
|
|
66
|
+
if (globalTokens.length !== tokens.length)
|
|
67
|
+
return;
|
|
68
|
+
if (!tokens.every((token, index) => token === globalTokens[index]))
|
|
69
|
+
return;
|
|
70
|
+
const name = extractClassName(selector);
|
|
71
|
+
if (name !== null)
|
|
72
|
+
names.add(name);
|
|
73
|
+
});
|
|
21
74
|
return names;
|
|
22
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Every class token that came out of a `:global(...)` payload, wherever in a selector it sat —
|
|
78
|
+
* `.card :global(.legacy-widget) span` → `{ legacyWidget }`.
|
|
79
|
+
*
|
|
80
|
+
* This is the set a scope-suffixing caller subtracts from the tokens it owns. A token is in it
|
|
81
|
+
* whether or not the selector around it was scoped, which is the whole point: the author reached
|
|
82
|
+
* for `:global()` precisely because that name is spelled the same way in markup this file does
|
|
83
|
+
* not own, and a suffix would break the match it was reaching for.
|
|
84
|
+
*
|
|
85
|
+
* A selector the parser rejects contributes nothing — it registers no key, so it names nothing to
|
|
86
|
+
* exempt, and letting its payload leak in here would unscope a token some other rule legitimately
|
|
87
|
+
* owns.
|
|
88
|
+
*/
|
|
89
|
+
export function globalClassTokensIn(css, options) {
|
|
90
|
+
const tokens = new Set();
|
|
91
|
+
eachSelector(css, options, selector => {
|
|
92
|
+
if (extractClassTokens(selector) === null)
|
|
93
|
+
return;
|
|
94
|
+
for (const token of globalTokensOf(selector))
|
|
95
|
+
tokens.add(token);
|
|
96
|
+
});
|
|
97
|
+
return tokens;
|
|
98
|
+
}
|
package/build/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export { parseCSS, extractClassName, kebabToCamel } from './parser/index.ts';
|
|
1
|
+
export { parseCSS, extractClassName, extractClassTokens, classTokensIn, kebabToCamel, } from './parser/index.ts';
|
|
2
2
|
export type { ICssParserOptions } from './parser/index.ts';
|
|
3
|
-
export { globalClassNamesIn } from './global-selectors.ts';
|
|
3
|
+
export { globalClassNamesIn, globalClassTokensIn } from './global-selectors.ts';
|
|
4
4
|
export { hashFilePath } from './file-scope-id.ts';
|
|
5
5
|
export { compileCssFile, isCssModuleFile } from './metro-css-module/index.ts';
|
|
6
6
|
export type { ICompiledCssFile } from './metro-css-module/index.ts';
|
package/build/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
export { parseCSS, extractClassName, kebabToCamel } from "./parser/index.js";
|
|
2
|
-
export { globalClassNamesIn } from "./global-selectors.js";
|
|
1
|
+
export { parseCSS, extractClassName, extractClassTokens, classTokensIn, kebabToCamel, } from "./parser/index.js";
|
|
2
|
+
export { globalClassNamesIn, globalClassTokensIn } from "./global-selectors.js";
|
|
3
3
|
export { hashFilePath } from "./file-scope-id.js";
|
|
4
4
|
export { compileCssFile, isCssModuleFile } from "./metro-css-module/index.js";
|
|
5
5
|
export { classNamesToDtsSource, generateModuleDts } from "./generate-dts/index.js";
|
|
@@ -8,31 +8,23 @@
|
|
|
8
8
|
// shamefully-hoist pnpm config (.npmrc) makes that resolvable without the app adding
|
|
9
9
|
// @symbiote-native/css-parser to its own package.json.
|
|
10
10
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// `
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// either way — see preprocessors.ts). A sync fast-path could still be kept for plain `.css`, but
|
|
20
|
-
// that forks this function into two shapes to save a single microtask on a call that only ever
|
|
21
|
-
// runs at Metro build time, content-hash-cached, never a runtime hot path — not worth the
|
|
22
|
-
// duplication. `return upstreamTransformer.transform(...)` as the last line of an async function
|
|
23
|
-
// forwards whatever it returns (Promise or not) as this function's own resolved value with no
|
|
24
|
-
// extra `await` needed; Metro awaits the whole chain regardless.
|
|
11
|
+
// `transform()` is async uniformly, even for plain `.css`: Metro's `metro-transform-worker`
|
|
12
|
+
// already awaits `transformer.transform(...)` before using the result (`transformJSWithBabel` in
|
|
13
|
+
// its `index.js`), so a babelTransformerPath module returning a Promise is a supported shape.
|
|
14
|
+
// SCSS/Less/Stylus compilation is inherently async in Node (Less ships no sync render API;
|
|
15
|
+
// Stylus's render is callback-based; Sass's sync `compileString` still needs an async
|
|
16
|
+
// `import('sass')` — see preprocessors.ts). No separate sync path for plain `.css`: this only
|
|
17
|
+
// runs at Metro build time, content-hash-cached, never a runtime hot path, so forking the
|
|
18
|
+
// function to save one microtask isn't worth the duplication.
|
|
25
19
|
import { createRequire } from 'node:module';
|
|
26
20
|
import { compileCssFile } from "../metro-css-module/index.js";
|
|
27
21
|
import { isStyleFile } from "../preprocessors/index.js";
|
|
28
|
-
// @react-native/metro-babel-transformer is a real `dependency` of this package
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// branch) can reuse this instead of its own fragile direct `require('@react-native/metro-babel-
|
|
35
|
-
// transformer')`, which would only resolve for an external install by accident of hoisting.
|
|
22
|
+
// @react-native/metro-babel-transformer is a real `dependency` of this package, so it resolves
|
|
23
|
+
// via css-parser's own node_modules under pnpm — no hoisting/`paths` trick needed, unlike the
|
|
24
|
+
// app-local workaround this replaces (formerly duplicated in every adapter's example
|
|
25
|
+
// metro-css-transformer.js). Exported so a per-framework transformer that also needs the
|
|
26
|
+
// upstream RN transformer (e.g. the Vue SFC transformer's non-.vue passthrough branch) can reuse
|
|
27
|
+
// this instead of its own fragile direct `require('@react-native/metro-babel-transformer')`.
|
|
36
28
|
export function resolveUpstreamTransformer() {
|
|
37
29
|
const require = createRequire(import.meta.url);
|
|
38
30
|
return require('@react-native/metro-babel-transformer');
|
package/build/parser/index.d.ts
CHANGED
|
@@ -2,6 +2,19 @@ export type ICssParserOptions = {
|
|
|
2
2
|
filename?: string;
|
|
3
3
|
};
|
|
4
4
|
export declare function kebabToCamel(value: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* The payload of every `:global(...)` in a selector, wrapper removed and in source order:
|
|
7
|
+
* `.card :global(.legacy) span` → `['.legacy']`, `:global(.a):global(.b)` → `['.a', '.b']`.
|
|
8
|
+
*
|
|
9
|
+
* The inverse view of {@link stripGlobalWrappers}: that one keeps everything BUT the wrappers,
|
|
10
|
+
* this one keeps only what they held. Both share {@link closingParenIndex}, so "where does this
|
|
11
|
+
* `:global(` end" has a single answer — the caller-side scope-suffix question needs to know which
|
|
12
|
+
* tokens came out of a payload, and re-finding them with a second regex is how the two would
|
|
13
|
+
* drift apart on `:global(.a:not(.b))`.
|
|
14
|
+
*
|
|
15
|
+
* A nested wrapper is left inside the payload it sits in; tokenizing the payload erases it.
|
|
16
|
+
*/
|
|
17
|
+
export declare function globalPayloadsIn(selector: string): string[];
|
|
5
18
|
/**
|
|
6
19
|
* Extract a camelCase class name from a CSS selector, or `null` if the selector has no RN
|
|
7
20
|
* equivalent (pseudo-classes/-elements, bare element selectors, the universal selector — RN has
|
|
@@ -13,8 +26,32 @@ export declare function kebabToCamel(value: string): string;
|
|
|
13
26
|
* - `.card .title` / `.card > .title` → `'cardTitle'` (descendant/child, flattened)
|
|
14
27
|
* - `[data-theme]` → `'dataTheme'` (attribute)
|
|
15
28
|
* - `.my-class-name` → `'myClassName'` (kebab → camel)
|
|
29
|
+
* - `.card :global(.reset)` → `'cardReset'` (the `:global()` wrapper is erased, its payload kept)
|
|
16
30
|
*/
|
|
17
31
|
export declare function extractClassName(selector: string): string | null;
|
|
32
|
+
/**
|
|
33
|
+
* The individual camelCase class tokens a selector is built from — the un-collapsed form of
|
|
34
|
+
* {@link extractClassName}. `.btn.primary` → `['btn', 'primary']`, `.card .title` →
|
|
35
|
+
* `['card', 'title']`, `.card` → `['card']`; `null` on the same selectors extractClassName
|
|
36
|
+
* rejects.
|
|
37
|
+
*
|
|
38
|
+
* Needed by every caller that scope-suffixes class names (Vue `<style scoped>`, a Svelte
|
|
39
|
+
* `<style>` block): the markup those callers rewrite says `class="btn primary"`, so `btn` and
|
|
40
|
+
* `primary` are the names they must recognize as locally defined — the collapsed `btnPrimary`
|
|
41
|
+
* key appears nowhere in the markup and would leave both tokens unscoped.
|
|
42
|
+
*
|
|
43
|
+
* Tokens from inside a `:global(...)` are included here too, since the rule still only matches an
|
|
44
|
+
* element carrying them. They are the ones a caller must NOT suffix, which is a distinction this
|
|
45
|
+
* list does not carry — `globalClassTokensIn` (../global-selectors.ts) is where it lives.
|
|
46
|
+
*/
|
|
47
|
+
export declare function extractClassTokens(selector: string): string[] | null;
|
|
48
|
+
/**
|
|
49
|
+
* Every registered class key in a stylesheet, mapped back to the class tokens it was built from
|
|
50
|
+
* (`.card.big { }` → `cardBig` → `['card', 'big']`). Build-time only, same as {@link parseCSS},
|
|
51
|
+
* whose rule walk this mirrors — at-rules are dropped first for the same reason, silently here
|
|
52
|
+
* since parseCSS already warns about them on its own pass.
|
|
53
|
+
*/
|
|
54
|
+
export declare function classTokensIn(css: string, options?: ICssParserOptions): Map<string, string[]>;
|
|
18
55
|
/**
|
|
19
56
|
* Parse a plain CSS string into a `{ className: RNStyleObject }` map. Build-time only — never
|
|
20
57
|
* ship this in the app's native JS bundle; it is meant to run inside a Metro transformer.
|
package/build/parser/index.js
CHANGED
|
@@ -20,6 +20,84 @@ function capitalize(value) {
|
|
|
20
20
|
function unescapeIdentifier(value) {
|
|
21
21
|
return value.replace(/\\(.)/g, '$1');
|
|
22
22
|
}
|
|
23
|
+
const GLOBAL_PSEUDO_OPEN = ':global(';
|
|
24
|
+
// Index of the `)` closing the `(` at `openIndex`, or -1 if the selector is unbalanced. Counting
|
|
25
|
+
// depth rather than reaching for the next `)` is what keeps `:global(.a:not(.b))` in one piece.
|
|
26
|
+
function closingParenIndex(value, openIndex) {
|
|
27
|
+
let depth = 0;
|
|
28
|
+
for (let index = openIndex; index < value.length; index++) {
|
|
29
|
+
if (value[index] === '(')
|
|
30
|
+
depth++;
|
|
31
|
+
else if (value[index] === ')' && --depth === 0)
|
|
32
|
+
return index;
|
|
33
|
+
}
|
|
34
|
+
return -1;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Erase every `:global(...)` wrapper, leaving its payload in place: `.card :global(.legacy) span`
|
|
38
|
+
* becomes `.card .legacy span`.
|
|
39
|
+
*
|
|
40
|
+
* `:global()` says a part of the selector lives outside the file's scope; it never changes which
|
|
41
|
+
* classes an element must carry for the rule to match. So the wrapper is gone before any selector
|
|
42
|
+
* shape is recognized below, and its payload participates exactly as if it had been written bare.
|
|
43
|
+
* Which of the resulting tokens then gets a scope suffix is a separate, caller-side question that
|
|
44
|
+
* `globalClassTokensIn` (../global-selectors.ts) answers, off the payloads {@link globalPayloadsIn}
|
|
45
|
+
* hands back.
|
|
46
|
+
*
|
|
47
|
+
* This follows SVELTE, not Vue, and the two genuinely disagree. Svelte erases the wrapper per
|
|
48
|
+
* relative selector and keeps the rest of the chain scoped (`.vendors/svelte-5.53.12-src/compiler/
|
|
49
|
+
* phases/3-transform/css/index.js`, `ComplexSelector`: a part flagged `is_global` keeps its inner
|
|
50
|
+
* selectors and only skips the scope class; `css-prune.js`'s `apply_selector` sets
|
|
51
|
+
* `metadata.scoped` on every part that is not an outer `:global`). Vue's `pluginScoped` instead
|
|
52
|
+
* does `selector.replaceWith(n.nodes[0])` on `:global` (`.vendors/vue/packages/compiler-sfc/src/
|
|
53
|
+
* style/pluginScoped.ts`), which throws the REST of the chain away — `.card :global(.reset)`
|
|
54
|
+
* degrades to a stylesheet-wide `.reset`. One registry serves React, Vue, Angular and Svelte
|
|
55
|
+
* alike, so the rule that silently widens a rule's reach beyond what the author wrote is the
|
|
56
|
+
* wrong one to standardize on; Vue itself steers the reach-into-a-child case to `:deep()`.
|
|
57
|
+
*/
|
|
58
|
+
function stripGlobalWrappers(selector) {
|
|
59
|
+
let result = selector;
|
|
60
|
+
let start = result.indexOf(GLOBAL_PSEUDO_OPEN);
|
|
61
|
+
while (start !== -1) {
|
|
62
|
+
const close = closingParenIndex(result, start + GLOBAL_PSEUDO_OPEN.length - 1);
|
|
63
|
+
// Unbalanced: leave the text alone and let the pseudo-class guard below drop the whole rule,
|
|
64
|
+
// the same answer any other unparseable selector gets.
|
|
65
|
+
if (close === -1)
|
|
66
|
+
return result;
|
|
67
|
+
const payload = result.slice(start + GLOBAL_PSEUDO_OPEN.length, close).trim();
|
|
68
|
+
result = result.slice(0, start) + payload + result.slice(close + 1);
|
|
69
|
+
// Re-search from the same offset: a payload may itself hold a `:global(...)`, and each pass
|
|
70
|
+
// removes one wrapper, so this terminates.
|
|
71
|
+
start = result.indexOf(GLOBAL_PSEUDO_OPEN, start);
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The payload of every `:global(...)` in a selector, wrapper removed and in source order:
|
|
77
|
+
* `.card :global(.legacy) span` → `['.legacy']`, `:global(.a):global(.b)` → `['.a', '.b']`.
|
|
78
|
+
*
|
|
79
|
+
* The inverse view of {@link stripGlobalWrappers}: that one keeps everything BUT the wrappers,
|
|
80
|
+
* this one keeps only what they held. Both share {@link closingParenIndex}, so "where does this
|
|
81
|
+
* `:global(` end" has a single answer — the caller-side scope-suffix question needs to know which
|
|
82
|
+
* tokens came out of a payload, and re-finding them with a second regex is how the two would
|
|
83
|
+
* drift apart on `:global(.a:not(.b))`.
|
|
84
|
+
*
|
|
85
|
+
* A nested wrapper is left inside the payload it sits in; tokenizing the payload erases it.
|
|
86
|
+
*/
|
|
87
|
+
export function globalPayloadsIn(selector) {
|
|
88
|
+
const payloads = [];
|
|
89
|
+
let start = selector.indexOf(GLOBAL_PSEUDO_OPEN);
|
|
90
|
+
while (start !== -1) {
|
|
91
|
+
const close = closingParenIndex(selector, start + GLOBAL_PSEUDO_OPEN.length - 1);
|
|
92
|
+
// Unbalanced: the same answer stripGlobalWrappers gives — stop, and let the selector reach
|
|
93
|
+
// the pseudo-class guard that drops the whole rule.
|
|
94
|
+
if (close === -1)
|
|
95
|
+
return payloads;
|
|
96
|
+
payloads.push(selector.slice(start + GLOBAL_PSEUDO_OPEN.length, close).trim());
|
|
97
|
+
start = selector.indexOf(GLOBAL_PSEUDO_OPEN, close + 1);
|
|
98
|
+
}
|
|
99
|
+
return payloads;
|
|
100
|
+
}
|
|
23
101
|
/**
|
|
24
102
|
* Extract a camelCase class name from a CSS selector, or `null` if the selector has no RN
|
|
25
103
|
* equivalent (pseudo-classes/-elements, bare element selectors, the universal selector — RN has
|
|
@@ -31,23 +109,42 @@ function unescapeIdentifier(value) {
|
|
|
31
109
|
* - `.card .title` / `.card > .title` → `'cardTitle'` (descendant/child, flattened)
|
|
32
110
|
* - `[data-theme]` → `'dataTheme'` (attribute)
|
|
33
111
|
* - `.my-class-name` → `'myClassName'` (kebab → camel)
|
|
112
|
+
* - `.card :global(.reset)` → `'cardReset'` (the `:global()` wrapper is erased, its payload kept)
|
|
34
113
|
*/
|
|
35
114
|
export function extractClassName(selector) {
|
|
36
|
-
const
|
|
115
|
+
const tokens = extractClassTokens(selector);
|
|
116
|
+
return tokens === null ? null : joinClassTokens(tokens);
|
|
117
|
+
}
|
|
118
|
+
// The collapsed key is nothing but its tokens concatenated, so both forms come from ONE walk of
|
|
119
|
+
// the selector — a caller that scope-suffixes names needs the tokens, everything else needs the
|
|
120
|
+
// key, and two separate parsers would be two chances to disagree about what `.a\.b.c` means.
|
|
121
|
+
function joinClassTokens(tokens) {
|
|
122
|
+
return tokens.map((token, index) => (index === 0 ? token : capitalize(token))).join('');
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The individual camelCase class tokens a selector is built from — the un-collapsed form of
|
|
126
|
+
* {@link extractClassName}. `.btn.primary` → `['btn', 'primary']`, `.card .title` →
|
|
127
|
+
* `['card', 'title']`, `.card` → `['card']`; `null` on the same selectors extractClassName
|
|
128
|
+
* rejects.
|
|
129
|
+
*
|
|
130
|
+
* Needed by every caller that scope-suffixes class names (Vue `<style scoped>`, a Svelte
|
|
131
|
+
* `<style>` block): the markup those callers rewrite says `class="btn primary"`, so `btn` and
|
|
132
|
+
* `primary` are the names they must recognize as locally defined — the collapsed `btnPrimary`
|
|
133
|
+
* key appears nowhere in the markup and would leave both tokens unscoped.
|
|
134
|
+
*
|
|
135
|
+
* Tokens from inside a `:global(...)` are included here too, since the rule still only matches an
|
|
136
|
+
* element carrying them. They are the ones a caller must NOT suffix, which is a distinction this
|
|
137
|
+
* list does not carry — `globalClassTokensIn` (../global-selectors.ts) is where it lives.
|
|
138
|
+
*/
|
|
139
|
+
export function extractClassTokens(selector) {
|
|
140
|
+
// Erased first, ahead of every guard below: `:global(...)` legitimately carries a colon that
|
|
141
|
+
// the pseudo-class guards would otherwise trip over, and its payload has to reach the shape
|
|
142
|
+
// checks as ordinary selector text.
|
|
143
|
+
const trimmed = stripGlobalWrappers(selector.trim()).trim();
|
|
37
144
|
if (/^[a-z]+$/i.test(trimmed))
|
|
38
145
|
return null;
|
|
39
146
|
if (trimmed === '*')
|
|
40
147
|
return null;
|
|
41
|
-
// `:global(...)` (Vue `<style scoped>` escape hatch) opts a selector out of scope-suffixing —
|
|
42
|
-
// a caller concern outside this package. Here it just needs unwrapping: when the WHOLE trimmed
|
|
43
|
-
// selector is one `:global(...)` wrapper, recurse on its inner text and return whatever that
|
|
44
|
-
// resolves to, reusing every selector shape below instead of duplicating it. Checked before the
|
|
45
|
-
// "starts with :" / "any colon anywhere" guards, since `:global(...)` legitimately contains a
|
|
46
|
-
// colon that must not trigger them. Known gap: a `:global(...)` wrapping only PART of a larger
|
|
47
|
-
// compound/descendant selector (e.g. `.card :global(.reset)`) is NOT unwrapped by this check.
|
|
48
|
-
const globalMatch = trimmed.match(/^:global\(\s*(.+?)\s*\)$/);
|
|
49
|
-
if (globalMatch?.[1])
|
|
50
|
-
return extractClassName(globalMatch[1]);
|
|
51
148
|
if (trimmed.startsWith(':'))
|
|
52
149
|
return null;
|
|
53
150
|
// A pseudo-class/-element trailing a class/id selector (`.card:hover`, `.card::before`) has
|
|
@@ -68,13 +165,7 @@ export function extractClassName(selector) {
|
|
|
68
165
|
const startIndex = startsWithElement ? 1 : 0;
|
|
69
166
|
if (startIndex >= parts.length)
|
|
70
167
|
return null;
|
|
71
|
-
return parts
|
|
72
|
-
.slice(startIndex)
|
|
73
|
-
.map((part, i) => {
|
|
74
|
-
const camelPart = kebabToCamel(unescapeIdentifier(part));
|
|
75
|
-
return i === 0 ? camelPart : capitalize(camelPart);
|
|
76
|
-
})
|
|
77
|
-
.join('');
|
|
168
|
+
return parts.slice(startIndex).map(part => kebabToCamel(unescapeIdentifier(part)));
|
|
78
169
|
}
|
|
79
170
|
}
|
|
80
171
|
// Descendant/child selector (`.card .title`, `.card > .title`) — flattened into one name.
|
|
@@ -82,9 +173,16 @@ export function extractClassName(selector) {
|
|
|
82
173
|
const parts = trimmed.split(/\s+(?:>\s*)?/).filter(Boolean);
|
|
83
174
|
const classNames = [];
|
|
84
175
|
for (const part of parts) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
176
|
+
// Every class of the part, not just the first: a chain link may itself be compound
|
|
177
|
+
// (`.card .btn.primary`, and now `.card :global(.btn.primary)` after the erase above), and
|
|
178
|
+
// an element has to carry BOTH names for the rule to apply. Taking only `.btn` would
|
|
179
|
+
// register the rule under a key that a `class="btn primary"` element never resolves to.
|
|
180
|
+
const classMatches = [...part.matchAll(/\.((?:[a-zA-Z0-9_-]|\\.)+)/g)];
|
|
181
|
+
if (classMatches.length > 0) {
|
|
182
|
+
for (const match of classMatches) {
|
|
183
|
+
if (match[1])
|
|
184
|
+
classNames.push(unescapeIdentifier(match[1]));
|
|
185
|
+
}
|
|
88
186
|
continue;
|
|
89
187
|
}
|
|
90
188
|
const idMatch = part.match(/#((?:[a-zA-Z0-9_-]|\\.)+)/);
|
|
@@ -93,27 +191,46 @@ export function extractClassName(selector) {
|
|
|
93
191
|
}
|
|
94
192
|
if (classNames.length === 0)
|
|
95
193
|
return null;
|
|
96
|
-
return classNames
|
|
97
|
-
.map((name, i) => {
|
|
98
|
-
const camelName = kebabToCamel(name);
|
|
99
|
-
return i === 0 ? camelName : capitalize(camelName);
|
|
100
|
-
})
|
|
101
|
-
.join('');
|
|
194
|
+
return classNames.map(name => kebabToCamel(name));
|
|
102
195
|
}
|
|
103
196
|
// Single class selector (`.card`).
|
|
104
197
|
const classMatch = trimmed.match(/^\.((?:[a-zA-Z0-9_-]|\\.)+)/);
|
|
105
198
|
if (classMatch?.[1])
|
|
106
|
-
return kebabToCamel(unescapeIdentifier(classMatch[1]));
|
|
199
|
+
return [kebabToCamel(unescapeIdentifier(classMatch[1]))];
|
|
107
200
|
// ID selector (`#header`).
|
|
108
201
|
const idMatch = trimmed.match(/^#((?:[a-zA-Z0-9_-]|\\.)+)/);
|
|
109
202
|
if (idMatch?.[1])
|
|
110
|
-
return kebabToCamel(unescapeIdentifier(idMatch[1]));
|
|
203
|
+
return [kebabToCamel(unescapeIdentifier(idMatch[1]))];
|
|
111
204
|
// Attribute selector (`[data-theme]`).
|
|
112
205
|
const attrMatch = trimmed.match(/^\[([a-zA-Z0-9_-]+)(?:=[^\]]+)?\]/);
|
|
113
206
|
if (attrMatch?.[1])
|
|
114
|
-
return kebabToCamel(attrMatch[1]);
|
|
207
|
+
return [kebabToCamel(attrMatch[1])];
|
|
115
208
|
return null;
|
|
116
209
|
}
|
|
210
|
+
/**
|
|
211
|
+
* Every registered class key in a stylesheet, mapped back to the class tokens it was built from
|
|
212
|
+
* (`.card.big { }` → `cardBig` → `['card', 'big']`). Build-time only, same as {@link parseCSS},
|
|
213
|
+
* whose rule walk this mirrors — at-rules are dropped first for the same reason, silently here
|
|
214
|
+
* since parseCSS already warns about them on its own pass.
|
|
215
|
+
*/
|
|
216
|
+
export function classTokensIn(css, options) {
|
|
217
|
+
const tokensByName = new Map();
|
|
218
|
+
if (!css || typeof css !== 'string')
|
|
219
|
+
return tokensByName;
|
|
220
|
+
const root = postcss.parse(css, { from: options?.filename });
|
|
221
|
+
root.walkAtRules(atRule => {
|
|
222
|
+
atRule.remove();
|
|
223
|
+
});
|
|
224
|
+
root.walkRules(rule => {
|
|
225
|
+
for (const selector of rule.selector.split(',')) {
|
|
226
|
+
const tokens = extractClassTokens(selector.trim());
|
|
227
|
+
if (tokens === null || tokens.length === 0)
|
|
228
|
+
continue;
|
|
229
|
+
tokensByName.set(joinClassTokens(tokens), tokens);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
return tokensByName;
|
|
233
|
+
}
|
|
117
234
|
//#endregion Selector utilities
|
|
118
235
|
//#region var() resolution
|
|
119
236
|
function resolveVariables(value, variables) {
|
package/build/properties.js
CHANGED
|
@@ -35,9 +35,9 @@ export const PROPERTY_TABLE = {
|
|
|
35
35
|
overflow: { rnProperty: 'overflow', kind: 'raw' },
|
|
36
36
|
// Only `flex`/`none` are valid RN values; passed through unvalidated per spec.
|
|
37
37
|
display: { rnProperty: 'display', kind: 'raw' },
|
|
38
|
-
// A genuine 1:1 CSS property (unlike transform/shadow
|
|
39
|
-
//
|
|
40
|
-
//
|
|
38
|
+
// A genuine 1:1 CSS property (unlike transform/shadow, no shape mismatch). `2 / 3` string
|
|
39
|
+
// ratios are not accepted here (`parseNumeric` requires a plain number) — CSS
|
|
40
|
+
// `aspect-ratio: 0.667` works, `aspect-ratio: 2/3` doesn't yet.
|
|
41
41
|
'aspect-ratio': { rnProperty: 'aspectRatio', kind: 'number' },
|
|
42
42
|
gap: { rnProperty: 'gap', kind: 'dimension' },
|
|
43
43
|
'row-gap': { rnProperty: 'rowGap', kind: 'dimension' },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@symbiote-native/css-parser",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Build-time CSS/SCSS/Less/Stylus compiler for SymbioteNative — compiles stylesheets to React Native style objects, resolved at runtime via a cross-adapter class-name registry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/typescript-plugin.cjs
CHANGED
|
@@ -21,22 +21,20 @@
|
|
|
21
21
|
// a correct, non-approximated preprocessor pipeline can't run here today. Those files still get
|
|
22
22
|
// basic (non-literal) type coverage from the project's ambient `.css` fallback declaration and
|
|
23
23
|
// from `css-dts`'s on-disk generation at pretypecheck time — just without live per-class
|
|
24
|
-
// completion in the plugin.
|
|
24
|
+
// completion in the plugin.
|
|
25
25
|
//
|
|
26
26
|
// SCOPE, second cut: only a SIMPLE `.foo { ... }` class selector is recognized correctly — a
|
|
27
27
|
// compound (`.btn.primary`) or descendant (`.card .title`) selector, which the real
|
|
28
28
|
// src/parser.ts's extractClassName merges into ONE key (`btnPrimary`/`cardTitle`), gets
|
|
29
|
-
// extracted here as TWO separate (wrong, non-existent) keys instead.
|
|
30
|
-
// limitation of the regex-based approach — complex selectors may not be detected correctly.
|
|
29
|
+
// extracted here as TWO separate (wrong, non-existent) keys instead.
|
|
31
30
|
//
|
|
32
31
|
// Hand-written plain CommonJS, NOT compiled from a `.ts`/`.cts` source — same convention already
|
|
33
32
|
// used for each adapter's metro-css-parser.cjs shim. tsserver loads a plugin via a synchronous
|
|
34
33
|
// `require()`, which cannot load this package's own ESM build output; a `.cts` source was tried
|
|
35
|
-
// first and rejected because
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
// machinery than a ~150-line, dependency-free plugin warrants.
|
|
34
|
+
// first and rejected because this package's shared tsconfig (`moduleResolution: "Bundler"`,
|
|
35
|
+
// needed for the rest of the package) doesn't apply the classic .cts→CJS format-forcing
|
|
36
|
+
// TypeScript otherwise gives Node16/NodeNext projects — carving out a second tsconfig/project
|
|
37
|
+
// reference just for one file was more machinery than a ~150-line, dependency-free plugin warrants.
|
|
40
38
|
'use strict';
|
|
41
39
|
|
|
42
40
|
const fs = require('node:fs');
|
|
@@ -70,7 +68,7 @@ function generateDts(classNames) {
|
|
|
70
68
|
|
|
71
69
|
const fields = [...classNames]
|
|
72
70
|
.sort()
|
|
73
|
-
.map(
|
|
71
|
+
.map(name => {
|
|
74
72
|
const key = IDENTIFIER_RE.test(name) ? name : JSON.stringify(name);
|
|
75
73
|
return ` readonly ${key}: string;`;
|
|
76
74
|
})
|
|
@@ -118,12 +116,14 @@ function init(modules) {
|
|
|
118
116
|
const originalGetScriptSnapshot = host.getScriptSnapshot.bind(host);
|
|
119
117
|
const originalResolveModuleNameLiterals = host.resolveModuleNameLiterals;
|
|
120
118
|
|
|
121
|
-
host.getScriptKind =
|
|
119
|
+
host.getScriptKind = fileName =>
|
|
122
120
|
isCssModuleFile(fileName)
|
|
123
121
|
? typescript.ScriptKind.TS
|
|
124
|
-
:
|
|
122
|
+
: originalGetScriptKind
|
|
123
|
+
? originalGetScriptKind(fileName)
|
|
124
|
+
: typescript.ScriptKind.Unknown;
|
|
125
125
|
|
|
126
|
-
host.getScriptSnapshot =
|
|
126
|
+
host.getScriptSnapshot = fileName =>
|
|
127
127
|
isCssModuleFile(fileName)
|
|
128
128
|
? typescript.ScriptSnapshot.fromString(getDtsForCssFile(fileName))
|
|
129
129
|
: originalGetScriptSnapshot(fileName);
|