@symbiote-native/css-parser 0.2.2 → 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 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('./metro-css-transformer.js') },
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'; // CSS Modules — default export is a name→scopedName map
51
- import './theme.css'; // plain CSS — registers classes globally, no export
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} /> // React
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>.card { padding: 10px; }</style>
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, extractClassName, kebabToCamel, // core compiler
82
- compileCssFile, isCssModuleFile, // standalone .css/.module.css files
83
- createCssMetroTransformer, // Metro babelTransformerPath factory
84
- compileScss, compileSass, compileLess, compileStylus, compile, detectLanguage, isStyleFile,
85
- classNamesToDtsSource, generateModuleDts, // .d.ts generation for CSS Modules typing
86
- globalClassNamesIn, hashFilePath,
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
- export declare function globalClassNamesIn(css: string): Set<string>;
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
- // A light, independent scan for `:global(...)`-wrapped selectors inside a scoped CSS block's
2
- // raw text. parseCSS's extractClassName already UNWRAPS :global(...) (so `:global(.reset)`
3
- // parses to the same `reset` key a plain `.reset` selector would), but its output has no marker
4
- // for "this key came from inside :global()" — parseCSS's return shape is deliberately just
5
- // `{ className: style }`, no per-key metadata. A caller doing its own scope-suffixing (Vue's
6
- // <style scoped>/<style module>, or a standalone .module.css file) needs that distinction to
7
- // exempt these names, so it re-derives it here with its own minimal regex, independent of the
8
- // full CSS-to-style pipeline. Only the single-class form (`:global(.name)`) is recognized,
9
- // matching extractClassName's own documented narrower gap for partial/nested :global() wrapping.
10
- import { kebabToCamel } from "./parser/index.js";
11
- const GLOBAL_SELECTOR_PATTERN = /:global\(\s*\.([a-zA-Z0-9_-]+)\s*\)/g;
12
- export function globalClassNamesIn(css) {
13
- const names = new Set();
14
- let match;
15
- while ((match = GLOBAL_SELECTOR_PATTERN.exec(css)) !== null) {
16
- // Normalize kebab->camel: parseCSS's output is always camelCase-keyed, but the regex above
17
- // captures the CSS text verbatim — a kebab selector like `:global(.reset-btn)` would
18
- // otherwise never match its own `resetBtn` key.
19
- names.add(kebabToCamel(match[1]));
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
- // Sync vs async: `transform()` is async uniformly, for every recognized style extension
12
- // including plain `.css`. Metro's own `metro-transform-worker` already does
13
- // `await transformer.transform(...)` before touching the result (confirmed by reading the
14
- // installed metro-transform-worker source directly — `transformJSWithBabel` in its `index.js`),
15
- // so a babelTransformerPath module's `transform()` returning a Promise is a supported, exercised
16
- // shape, not a hack. SCSS/Less/Stylus compilation is inherently async in Node (Less ships no sync
17
- // render API at all; Stylus's callback-based render must be Promise-wrapped; Sass's
18
- // `compileString` does have a sync API, but the lazy `import('sass')` step itself is async
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 (not merely a
29
- // peer/dev dep), so it lands in css-parser's OWN resolvable node_modules under pnpm — no
30
- // hoisting or `paths`-anchored require.resolve trick needed, unlike the app-local workaround
31
- // this replaces (formerly duplicated in every adapter's example metro-css-transformer.js).
32
- // Exported (not just used internally) so a per-framework Metro transformer that ALSO needs to
33
- // delegate to the upstream RN transformer (the Vue SFC transformer, for its non-.vue passthrough
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');
@@ -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.
@@ -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 trimmed = selector.trim();
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
- const classMatch = part.match(/\.((?:[a-zA-Z0-9_-]|\\.)+)/);
86
- if (classMatch?.[1]) {
87
- classNames.push(unescapeIdentifier(classMatch[1]));
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) {
@@ -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 — no shape mismatch), just missing
39
- // from the initial table. `2 / 3` string ratios are not accepted here (`parseNumeric`
40
- // requires a plain number) — CSS `aspect-ratio: 0.667` works, `aspect-ratio: 2/3` doesn't yet.
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,7 +1,8 @@
1
1
  {
2
2
  "name": "@symbiote-native/css-parser",
3
- "version": "0.2.2",
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
+ "license": "MIT",
5
6
  "repository": {
6
7
  "type": "git",
7
8
  "url": "git+https://github.com/OneEyed1366/symbiote-native.git",
@@ -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. A real follow-up, not a silent gap: recorded here, not hidden.
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. This is an accepted
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
- // this package's shared tsconfig (`moduleResolution: "Bundler"`, needed for the rest of the
37
- // package) doesn't apply the classic .cts→CJS format-forcing TypeScript otherwise gives Node16/
38
- // NodeNext projects — carving out a second tsconfig/project reference just for one file was more
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((name) => {
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 = (fileName) =>
119
+ host.getScriptKind = fileName =>
122
120
  isCssModuleFile(fileName)
123
121
  ? typescript.ScriptKind.TS
124
- : (originalGetScriptKind ? originalGetScriptKind(fileName) : typescript.ScriptKind.Unknown);
122
+ : originalGetScriptKind
123
+ ? originalGetScriptKind(fileName)
124
+ : typescript.ScriptKind.Unknown;
125
125
 
126
- host.getScriptSnapshot = (fileName) =>
126
+ host.getScriptSnapshot = fileName =>
127
127
  isCssModuleFile(fileName)
128
128
  ? typescript.ScriptSnapshot.fromString(getDtsForCssFile(fileName))
129
129
  : originalGetScriptSnapshot(fileName);