@symbiote-native/css-parser 0.3.0 → 0.5.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.
@@ -1,27 +1,24 @@
1
1
  // Compiles a standalone .css/.scss/.sass/.less/.styl (and their .module.* twins) file into a
2
2
  // plain JS module — the framework-agnostic twin of a Vue SFC's inline <style>/<style module>
3
- // block (examples/vue-sfc/metro-vue-transformer.js), usable from ANY adapter's own source file:
3
+ // block (adapters/vue/metro-vue-transformer.cjs), usable from ANY adapter's own source file:
4
4
  // a default import of `Card.module.scss` works the same from a React .tsx, a Vue <script>, or an
5
5
  // Angular .ts.
6
6
  //
7
7
  // A plain style file registers its classes globally, exactly like an unscoped Vue <style>
8
8
  // block — side-effect import only (`import './theme.scss'`), no default export. A `.module.*`
9
- // file is ALWAYS scoped (that's the entire point of the extension): each class is suffixed with
10
- // a hash of the file's own path (core/css-parser's fileScopeId, tagged `__module__` so it can
11
- // never collide with a Vue <style scoped> block's plain `__<scopeId>` suffix even if the two
12
- // ever shared a scope id), and the default export is a plain name->scopedName map, so
13
- // `resolveClassName(styles.card)` (React `style={resolveClassName(styles.card)}`) or a template
14
- // binding that hands the already-scoped string straight to resolveClassName's exact-match path
15
- // both just work, no registry changes needed. `:global(.name)` opts a selector out of scoping,
16
- // same as Vue's <style scoped>.
9
+ // file is ALWAYS scoped (that's the entire point of the extension): **lightningcss** owns the
10
+ // renaming, `composes`, and `:global()` handling, and hands back both the renamed CSS and the
11
+ // authored-name -> scoped-name `exports` map. We keep only what is genuinely ours — turning CSS
12
+ // declarations into RN style objects (`compileCssToRules`).
13
+ //
14
+ // lightningcss is MPL-2.0 and this package is MIT. Depending on it is fine (file-level copyleft
15
+ // does not reach our files); NEVER vendor, copy, or patch its sources — a copied file would
16
+ // carry MPL.
17
17
  //
18
18
  // A preprocessor source (SCSS/Sass/Less/Stylus) is reduced to plain CSS text via
19
- // preprocessors.ts's `compile()` BEFORE any of the CSS-Modules scoping logic below runs — that
20
- // logic is entirely language-agnostic, it only ever sees `parseCSS`'s plain-CSS output, same as
21
- // it always did for a `.css` file.
19
+ // preprocessors.ts's `compile()` BEFORE lightningcss runs — it reads plain CSS only.
22
20
  import * as path from 'node:path';
23
- import { parseCSS } from "../parser/index.js";
24
- import { globalClassNamesIn } from "../global-selectors.js";
21
+ import { compileCssToRules } from "../lightning/rules.js";
25
22
  import { hashFilePath } from "../file-scope-id.js";
26
23
  import { compile, detectLanguage } from "../preprocessors/index.js";
27
24
  export function isCssModuleFile(filename) {
@@ -30,41 +27,150 @@ export function isCssModuleFile(filename) {
30
27
  return false;
31
28
  return filename.slice(0, -ext.length).endsWith('.module');
32
29
  }
30
+ // A `composes` entry names the SCOPED class, so walking a chain (`.a composes .b`, `.b composes
31
+ // .c`) needs the way back to the composed class's own export — lightningcss reports one hop, not
32
+ // the flattened set.
33
+ function localsByScopedName(exports) {
34
+ const locals = new Map();
35
+ for (const [local, entry] of Object.entries(exports)) {
36
+ locals.set(entry.name, local);
37
+ }
38
+ return locals;
39
+ }
40
+ function referenceTokens(reference, exports, locals, visited, filename) {
41
+ // Resolving one would mean compiling the other file here, which this per-file transform cannot
42
+ // do. Warn-and-drop is what the declaration mapper already does with a property it cannot map.
43
+ if (reference.type === 'dependency') {
44
+ console.warn(`[@symbiote-native/css-parser] ${filename}: \`composes: ${reference.name} from ` +
45
+ `"${reference.specifier}"\` ` +
46
+ 'crosses files and is not supported — dropped.');
47
+ return [];
48
+ }
49
+ if (reference.type === 'global')
50
+ return [reference.name];
51
+ const local = locals.get(reference.name);
52
+ return local === undefined
53
+ ? [reference.name]
54
+ : tokensFor(local, exports, locals, visited, filename);
55
+ }
56
+ // Composed names come FIRST, the class's own name LAST. The runtime registry merges a
57
+ // space-separated class string left to right (core/engine/src/style-registry), so this is what
58
+ // makes the composing class override what it composes — the cascade order `composes` means.
59
+ function tokensFor(local, exports, locals, visited, filename) {
60
+ if (visited.has(local))
61
+ return [];
62
+ visited.add(local);
63
+ const entry = exports[local];
64
+ if (entry === undefined)
65
+ return [];
66
+ const tokens = [];
67
+ for (const reference of entry.composes) {
68
+ tokens.push(...referenceTokens(reference, exports, locals, visited, filename));
69
+ }
70
+ tokens.push(entry.name);
71
+ return tokens;
72
+ }
73
+ // `globals` is deliberately a SUPERSET of what CSS Modules specifies. lightningcss (like
74
+ // css-loader and every other CSS Modules implementation) exports only the names it renamed, and
75
+ // a `:global(...)` name is
76
+ // by definition not renamed — so upstream has nothing to map and omits it. For us that omission
77
+ // would force the author back to a bare string literal (`class="legacy-reset"`), throwing away the
78
+ // typed, discoverable `styles.x` access that is the entire reason this map exists. So a global
79
+ // name is exported as ITSELF: same key, no scope suffix.
80
+ function classMapFrom(exports, globals, filename) {
81
+ const locals = localsByScopedName(exports);
82
+ const classMap = {};
83
+ // Sorted, because `exports` is a Rust HashMap whose iteration order is randomized PER PROCESS
84
+ // — emitting it verbatim makes the generated module bytes differ between builds and churns
85
+ // Metro's content cache. Measured, not assumed: three runs over one file gave three orders.
86
+ const names = [...new Set([...Object.keys(exports), ...globals])].sort();
87
+ for (const name of names) {
88
+ // A name that is BOTH locally declared and `:global()`-wrapped elsewhere in the file resolves
89
+ // as local — the rename actually happened, so the scoped spelling is the one that matches.
90
+ const value = exports[name] === undefined
91
+ ? name
92
+ : tokensFor(name, exports, locals, new Set(), filename).join(' ');
93
+ // Keyed AS AUTHORED — `.legacy-reset` keys as `legacy-reset`, read as
94
+ // `styles['legacy-reset']`. This reverses an earlier camelCase decision (css-loader's
95
+ // `exportLocalsConvention`), which existed only to keep dot access legal and paid for it by
96
+ // merging `.legacy-reset` and `.legacyReset` into one key — a collision that needed a warning
97
+ // to be survivable. Nothing downstream normalizes any more: the registry key is the authored
98
+ // name too, and `classNamesToDtsSource`'s `formatKey` already quotes a non-identifier key, so
99
+ // `styles['legacy-reset']` type-checks.
100
+ classMap[name] = value;
101
+ }
102
+ return classMap;
103
+ }
104
+ /**
105
+ * One CSS source compiled as a CSS MODULE — a standalone `.module.*` file and a Vue
106
+ * `<style module>` block are the same thing and go through this, so neither can register a class
107
+ * under a name the other would not.
108
+ *
109
+ * The scope tail is our own `hashFilePath`, not lightningcss's `[hash]`: the runtime registry
110
+ * parses this tail to factor a scope back out (SCOPE_TAIL_PATTERN in
111
+ * core/engine/src/style-registry) and its alphabet is lowercase base36, which lightningcss's
112
+ * mixed-case hash does not fit — that would silently kill scoped-token base layering. Same hash
113
+ * the `<style scoped>` and Svelte scopers use, so all three scoping shapes stay one algorithm.
114
+ */
115
+ export function compileCssModule(css, filename) {
116
+ const compiled = compileCssToRules(css, {
117
+ filename,
118
+ pattern: `[local]__module__${hashFilePath(filename)}`,
119
+ });
120
+ return {
121
+ rules: compiled.rules,
122
+ classMap: classMapFrom(compiled.moduleExports, compiled.globals, filename),
123
+ };
124
+ }
125
+ async function toPlainCss(source, filename) {
126
+ const lang = detectLanguage(filename);
127
+ return lang === 'css' ? source : await compile(source, lang, filename);
128
+ }
129
+ /**
130
+ * The names a `.module.*` file's default export actually carries.
131
+ *
132
+ * The `.d.ts` generator MUST read them from here rather than re-deriving names off the raw
133
+ * source: what a rule MATCHES on and what the module EXPORTS are different sets. A compound
134
+ * rule `.card.big` matches on both its tokens, but only the names the author wrote as classes
135
+ * are exports. Typing off a re-derived set invents members that are `undefined` at runtime and
136
+ * hides valid ones behind a TS2339.
137
+ */
138
+ export async function moduleClassNames(source, filename) {
139
+ const css = await toPlainCss(source, filename);
140
+ return Object.keys(compileCssModule(css, filename).classMap);
141
+ }
33
142
  // Preprocessing (SCSS/Less/Stylus → plain CSS text) is inherently async in Node — Less has no
34
143
  // sync render API at all, and Stylus's callback-based render must be Promise-wrapped — so
35
144
  // compileCssFile is async uniformly, even for a plain `.css` file that needs no preprocessing.
36
145
  // See metro-transformer.ts for the fuller sync-vs-async writeup; the short version is that a
37
146
  // sync fast-path for `.css` would fork this function into two shapes for a build-time-only,
38
147
  // content-hash-cached call that is never a runtime hot path.
39
- export async function compileCssFile(source, filename, options) {
40
- const lang = detectLanguage(filename);
41
- const css = lang === 'css' ? source : await compile(source, lang, filename);
42
- const parsed = parseCSS(css, { filename, ...options });
148
+ export async function compileCssFile(source, filename) {
149
+ const css = await toPlainCss(source, filename);
43
150
  if (!isCssModuleFile(filename)) {
151
+ const { rules } = compileCssToRules(css, { filename });
44
152
  return {
45
- code: `import { registerStyles } from '@symbiote-native/engine';\nregisterStyles(${JSON.stringify(parsed)});\n`,
153
+ code: `import { registerRules } from '@symbiote-native/engine';\n` +
154
+ `registerRules(${serializeRules(rules)});\n`,
46
155
  };
47
156
  }
48
- const scopeId = hashFilePath(filename);
49
- // Scanned against the COMPILED css text, not the raw source: :global(...) isn't native SCSS/
50
- // Less/Stylus syntax (each preprocessor just passes an unrecognized selector through
51
- // unchanged), but scanning the compiler's actual output — rather than assuming source and
52
- // output stay textually identical for this token — is the one that can't drift under nesting/
53
- // interpolation.
54
- const exemptFromScope = globalClassNamesIn(css);
55
- const styles = {};
56
- const classMap = {};
57
- for (const [className, props] of Object.entries(parsed)) {
58
- const isExempt = exemptFromScope.has(className);
59
- const scopedName = isExempt ? className : `${className}__module__${scopeId}`;
60
- classMap[className] = scopedName;
61
- styles[scopedName] = props;
62
- }
157
+ const { rules, classMap } = compileCssModule(css, filename);
63
158
  return {
64
159
  code: [
65
- `import { registerStyles } from '@symbiote-native/engine';`,
66
- `registerStyles(${JSON.stringify(styles)});`,
160
+ `import { registerRules } from '@symbiote-native/engine';`,
161
+ `registerRules(${serializeRules(rules)});`,
67
162
  `export default ${JSON.stringify(classMap)};`,
68
163
  ].join('\n') + '\n',
69
164
  };
70
165
  }
166
+ // `combinators` is compile-time-only — the registry matches by token subset and never reads it —
167
+ // so it is stripped rather than shipped in every app bundle. Stage 4 is where it starts to mean
168
+ // something at runtime, and that is when it earns its bytes.
169
+ function serializeRules(rules) {
170
+ return JSON.stringify(rules.map(({ tokens, specificity, order, style }) => ({
171
+ tokens,
172
+ specificity,
173
+ order,
174
+ style,
175
+ })));
176
+ }
@@ -1,6 +1,7 @@
1
1
  // Optional SCSS/Sass, Less, and Stylus preprocessor support. Each compiler here only ever reduces
2
- // its own syntax down to plain CSS text — parser.ts's `parseCSS()` is the single, UNCHANGED
3
- // downstream consumer of that text, exactly as it always was for a plain `.css` file. Tailwind
2
+ // its own syntax down to plain CSS text — `lightning/rules.ts`'s `compileCssToRules()` is the
3
+ // single, UNCHANGED downstream consumer of that text, exactly as it is for a plain `.css`
4
+ // file. Tailwind
4
5
  // is a separate, out-of-scope concern and has no branch here.
5
6
  //
6
7
  // `sass`/`less`/`stylus` are lazy `import()`ed, never a top-level import, and are
@@ -29,7 +30,7 @@ export function isStyleFile(filename) {
29
30
  * (`compileScss` itself picks the concrete syntax off the extension); anything unrecognized is
30
31
  * treated as plain CSS, same as today. */
31
32
  export function detectLanguage(filename) {
32
- return RECOGNIZED_EXTENSIONS.get(path.extname(filename).toLowerCase()) ?? 'css';
33
+ return (RECOGNIZED_EXTENSIONS.get(path.extname(filename).toLowerCase()) ?? 'css');
33
34
  }
34
35
  let sassModule;
35
36
  let lessModule;
@@ -12,13 +12,12 @@ type IPropertyMapping = {
12
12
  export declare const PROPERTY_TABLE: Record<string, IPropertyMapping>;
13
13
  /**
14
14
  * Map one CSS declaration to its RN style entry. Returns `null` and warns once per unique
15
- * unsupported property name (deduped via the caller-owned `warnedProperties` set, so the
16
- * warning fires once per {@link parseCSS} call, not per occurrence).
15
+ * unsupported property name, deduped via the caller-owned `warnedProperties` set.
17
16
  *
18
- * `text-shadow` bypasses {@link PROPERTY_TABLE}: RN has no unified CSS-string `textShadow` prop
19
- * (unlike `transform`/`box-shadow` above) — only three separate legacy props
20
- * (`textShadowColor`/`Offset`/`Radius`) that take already-decomposed values, so this package is
21
- * the only place that CAN parse the CSS shorthand; there is no engine-level processor to defer to.
17
+ * `text-shadow` is absent from {@link PROPERTY_TABLE} and is NOT handled here: RN has no unified
18
+ * CSS-string `textShadow` prop (unlike `transform`/`box-shadow` above), only three decomposed
19
+ * legacy props, so it needs the typed AST rather than a value string —
20
+ * `lightning/declarations.ts`'s `textShadowToStyle` owns it.
22
21
  */
23
22
  export declare function mapCSSProperty(prop: string, value: string, warnedProperties: Set<string>): Record<string, unknown> | null;
24
23
  export {};
@@ -1,7 +1,7 @@
1
1
  // CSS property → React Native ViewStyle/TextStyle prop mapping. RN's own style props already
2
2
  // mirror CSS's shorthand model 1:1 (`margin`, `borderRadius`, `borderTopLeftRadius`, …), so this
3
3
  // table is a flat kebab→camel rename plus a value-conversion kind — no shorthand expansion is needed.
4
- import { parseNumeric, parseNumericOrPercent, parseRawValue, parseTextShadow, warnOnce, } from "./values.js";
4
+ import { parseNumeric, parseNumericOrPercent, parseRawValue, warnOnce, } from "./values.js";
5
5
  /**
6
6
  * kebab-case CSS property → { RN camelCase prop, value-conversion kind }.
7
7
  * `dimension` = number-or-percent (`parseNumericOrPercent`); `number` = always a plain
@@ -67,7 +67,10 @@ export const PROPERTY_TABLE = {
67
67
  // from RN's own `processBackgroundImage.js`. RN's own style prop is itself named with an
68
68
  // `experimental_` prefix (still evolving upstream), which is why the RN key doesn't just
69
69
  // match a kebab→camel rename of the CSS property the way every other entry here does.
70
- 'background-image': { rnProperty: 'experimental_backgroundImage', kind: 'raw' },
70
+ 'background-image': {
71
+ rnProperty: 'experimental_backgroundImage',
72
+ kind: 'raw',
73
+ },
71
74
  // Spacing
72
75
  margin: { rnProperty: 'margin', kind: 'dimension' },
73
76
  'margin-top': { rnProperty: 'marginTop', kind: 'dimension' },
@@ -91,10 +94,22 @@ export const PROPERTY_TABLE = {
91
94
  'border-bottom-color': { rnProperty: 'borderBottomColor', kind: 'raw' },
92
95
  'border-left-color': { rnProperty: 'borderLeftColor', kind: 'raw' },
93
96
  'border-radius': { rnProperty: 'borderRadius', kind: 'dimension' },
94
- 'border-top-left-radius': { rnProperty: 'borderTopLeftRadius', kind: 'dimension' },
95
- 'border-top-right-radius': { rnProperty: 'borderTopRightRadius', kind: 'dimension' },
96
- 'border-bottom-left-radius': { rnProperty: 'borderBottomLeftRadius', kind: 'dimension' },
97
- 'border-bottom-right-radius': { rnProperty: 'borderBottomRightRadius', kind: 'dimension' },
97
+ 'border-top-left-radius': {
98
+ rnProperty: 'borderTopLeftRadius',
99
+ kind: 'dimension',
100
+ },
101
+ 'border-top-right-radius': {
102
+ rnProperty: 'borderTopRightRadius',
103
+ kind: 'dimension',
104
+ },
105
+ 'border-bottom-left-radius': {
106
+ rnProperty: 'borderBottomLeftRadius',
107
+ kind: 'dimension',
108
+ },
109
+ 'border-bottom-right-radius': {
110
+ rnProperty: 'borderBottomRightRadius',
111
+ kind: 'dimension',
112
+ },
98
113
  'border-style': { rnProperty: 'borderStyle', kind: 'raw' },
99
114
  // Visual
100
115
  'background-color': { rnProperty: 'backgroundColor', kind: 'raw' },
@@ -122,17 +137,14 @@ function convertValue(kind, value) {
122
137
  }
123
138
  /**
124
139
  * Map one CSS declaration to its RN style entry. Returns `null` and warns once per unique
125
- * unsupported property name (deduped via the caller-owned `warnedProperties` set, so the
126
- * warning fires once per {@link parseCSS} call, not per occurrence).
140
+ * unsupported property name, deduped via the caller-owned `warnedProperties` set.
127
141
  *
128
- * `text-shadow` bypasses {@link PROPERTY_TABLE}: RN has no unified CSS-string `textShadow` prop
129
- * (unlike `transform`/`box-shadow` above) — only three separate legacy props
130
- * (`textShadowColor`/`Offset`/`Radius`) that take already-decomposed values, so this package is
131
- * the only place that CAN parse the CSS shorthand; there is no engine-level processor to defer to.
142
+ * `text-shadow` is absent from {@link PROPERTY_TABLE} and is NOT handled here: RN has no unified
143
+ * CSS-string `textShadow` prop (unlike `transform`/`box-shadow` above), only three decomposed
144
+ * legacy props, so it needs the typed AST rather than a value string —
145
+ * `lightning/declarations.ts`'s `textShadowToStyle` owns it.
132
146
  */
133
147
  export function mapCSSProperty(prop, value, warnedProperties) {
134
- if (prop === 'text-shadow')
135
- return parseTextShadow(value, warnedProperties);
136
148
  const mapping = PROPERTY_TABLE[prop];
137
149
  if (!mapping) {
138
150
  warnOnce(warnedProperties, prop, `[@symbiote-native/css-parser] unsupported CSS property "${prop}" dropped`);
@@ -0,0 +1,23 @@
1
+ import { type IStyleRule } from './lightning/rules.ts';
2
+ export type IScopedCss = {
3
+ readonly rules: readonly IStyleRule[];
4
+ /** Authored class name -> the name it registers under. */
5
+ readonly names: ReadonlyMap<string, string>;
6
+ };
7
+ export type IScopedCssOptions = {
8
+ readonly filename: string;
9
+ /** A lightningcss CSS-Modules pattern, `[local]` first — see the table above. */
10
+ readonly pattern: string;
11
+ };
12
+ /**
13
+ * A SCOPED style block (a Svelte `<style>`, a Vue `<style scoped>`) compiled for the registry:
14
+ * the rules keyed under their scoped names, and the name map its markup rewriter resolves every
15
+ * class token through.
16
+ *
17
+ * Both halves come out of ONE `compileCssToRules` call. The two-pass shape this used to have —
18
+ * rename, then re-parse the ORIGINAL text and re-key it per token — existed only because the old
19
+ * parser derived a key by camelCasing the selector, which mangled a scope tail whose base36 hash
20
+ * started with a letter (`card__svelte-p4np8c` registered as `card__svelteP4np8c`). Nothing
21
+ * camelCases any more, so there is nothing to work around.
22
+ */
23
+ export declare function compileScopedCss(css: string, options: IScopedCssOptions): IScopedCss;
@@ -0,0 +1,35 @@
1
+ // The ONE class-renaming call in this repo. Every scoping shape we have is the same lightningcss
2
+ // CSS-Modules rename with a different suffix, so none of them owns a loop of its own:
3
+ //
4
+ // standalone .module.* [local]__module__<hash> metro-css-module/index.ts
5
+ // Vue <style module> [local]__module__<hash>
6
+ // Vue <style scoped> [local]__data-v-<hash>
7
+ // Svelte <style> [local]__svelte-<hash> adapters/svelte/src/preprocessor
8
+ //
9
+ // The rename is not the point — the name MAP is. A scoping pass has two halves (the registered
10
+ // style rules and the markup rewriter) and they must name a class identically; each half deriving
11
+ // the name for itself is the bug class this file exists to close. Both halves read `names` below.
12
+ // lightningcss also decides `:global()` for free: a name it did NOT rename is global, surfaced as
13
+ // `ICompiledCss.globals`, so nothing has to re-walk selectors hunting for the escape hatch.
14
+ //
15
+ // lightningcss is MPL-2.0 and this package is MIT. Depending on it is fine (file-level copyleft
16
+ // does not reach our files); NEVER vendor, copy, or patch its sources.
17
+ import { compileCssToRules } from "./lightning/rules.js";
18
+ /**
19
+ * A SCOPED style block (a Svelte `<style>`, a Vue `<style scoped>`) compiled for the registry:
20
+ * the rules keyed under their scoped names, and the name map its markup rewriter resolves every
21
+ * class token through.
22
+ *
23
+ * Both halves come out of ONE `compileCssToRules` call. The two-pass shape this used to have —
24
+ * rename, then re-parse the ORIGINAL text and re-key it per token — existed only because the old
25
+ * parser derived a key by camelCasing the selector, which mangled a scope tail whose base36 hash
26
+ * started with a letter (`card__svelte-p4np8c` registered as `card__svelteP4np8c`). Nothing
27
+ * camelCases any more, so there is nothing to work around.
28
+ */
29
+ export function compileScopedCss(css, options) {
30
+ const compiled = compileCssToRules(css, options);
31
+ return {
32
+ rules: compiled.rules,
33
+ names: new Map(Object.entries(compiled.exports)),
34
+ };
35
+ }
package/build/values.d.ts CHANGED
@@ -15,13 +15,9 @@ export declare function parseNumericOrPercent(value: string): number | string;
15
15
  */
16
16
  export declare function parseRawValue(value: string): string;
17
17
  /**
18
- * Warn once per unique `key` across a {@link parseCSS} call (the caller-owned `warned` set is
19
- * shared with the plain-property drop warning in properties.ts, so every "unsupported X dropped"
20
- * message in this package dedupes the same way).
18
+ * Warn once per unique `key` against a caller-owned `warned` set. Every "unsupported X dropped"
19
+ * message in this package dedupes through here, so the set's lifetime is what picks the
20
+ * granularity: `compileCssToRules` keeps one per call, `lightning/declarations.ts` one per file.
21
21
  */
22
22
  export declare function warnOnce(warned: Set<string>, key: string, message: string): void;
23
- /** `text-shadow` → RN's `textShadowColor`/`textShadowOffset`/`textShadowRadius` (no Android
24
- * elevation equivalent — RN has no elevation concept for text, and no engine-level processor
25
- * to defer to the way `box-shadow` does — see the PROPERTY_TABLE comment in properties.ts). */
26
- export declare function parseTextShadow(value: string, warned: Set<string>): Record<string, unknown> | null;
27
23
  export { REM_TO_PX };
package/build/values.js CHANGED
@@ -1,6 +1,5 @@
1
1
  // CSS → React Native value conversion. RN style props take plain numbers for `px` / unitless
2
2
  // values, so there is no unit conversion needed here beyond scaling `rem`/`em` — `px` is identity.
3
- import valueParser from 'postcss-value-parser';
4
3
  // symbiote has no root-font-size registry (a DOM `<html>` element would own one); we pick CSS's
5
4
  // own default of a 16px root font size as the `rem` multiplier, so `2rem` reads as `32`.
6
5
  const REM_TO_PX = 16;
@@ -37,9 +36,9 @@ export function parseRawValue(value) {
37
36
  return value.trim();
38
37
  }
39
38
  /**
40
- * Warn once per unique `key` across a {@link parseCSS} call (the caller-owned `warned` set is
41
- * shared with the plain-property drop warning in properties.ts, so every "unsupported X dropped"
42
- * message in this package dedupes the same way).
39
+ * Warn once per unique `key` against a caller-owned `warned` set. Every "unsupported X dropped"
40
+ * message in this package dedupes through here, so the set's lifetime is what picks the
41
+ * granularity: `compileCssToRules` keeps one per call, `lightning/declarations.ts` one per file.
43
42
  */
44
43
  export function warnOnce(warned, key, message) {
45
44
  if (warned.has(key))
@@ -47,60 +46,4 @@ export function warnOnce(warned, key, message) {
47
46
  warned.add(key);
48
47
  console.warn(message);
49
48
  }
50
- // A length token always starts with a digit, `.`, or `-` (`0`, `2px`, `-4px`); a color token
51
- // never does, whether it's a keyword (`red`), a hex (`#fff`), or a function (`rgba(0,0,0,.3)`) —
52
- // so classifying by leading character separates them without needing a CSS color grammar.
53
- const LENGTH_TOKEN_PATTERN = /^-?[\d.]/;
54
- /** Split a `text-shadow` value on its TOP-LEVEL commas only — a comma inside a color function
55
- * (`rgba(0, 0, 0, .3)`) must not split a single shadow layer in two. */
56
- function splitShadowLayers(value) {
57
- const layers = [];
58
- let current = [];
59
- for (const node of valueParser(value.trim()).nodes) {
60
- if (node.type === 'div' && node.value === ',') {
61
- layers.push(valueParser.stringify(current).trim());
62
- current = [];
63
- continue;
64
- }
65
- current.push(node);
66
- }
67
- layers.push(valueParser.stringify(current).trim());
68
- return layers.filter(Boolean);
69
- }
70
- function parseShadowTokens(layer) {
71
- const rawTokens = valueParser(layer)
72
- .nodes.filter(node => node.type !== 'space' && node.type !== 'div')
73
- .map(node => valueParser.stringify(node));
74
- const lengths = rawTokens.filter(token => LENGTH_TOKEN_PATTERN.test(token));
75
- const colorToken = rawTokens.find(token => !LENGTH_TOKEN_PATTERN.test(token));
76
- if (lengths.length < 2)
77
- return null;
78
- return {
79
- offsetX: parseNumeric(lengths[0]),
80
- offsetY: parseNumeric(lengths[1]),
81
- blurRadius: lengths[2] !== undefined ? parseNumeric(lengths[2]) : 0,
82
- color: colorToken ?? '#000000',
83
- };
84
- }
85
- /** `text-shadow` → RN's `textShadowColor`/`textShadowOffset`/`textShadowRadius` (no Android
86
- * elevation equivalent — RN has no elevation concept for text, and no engine-level processor
87
- * to defer to the way `box-shadow` does — see the PROPERTY_TABLE comment in properties.ts). */
88
- export function parseTextShadow(value, warned) {
89
- const layers = splitShadowLayers(value);
90
- if (layers.length > 1) {
91
- warnOnce(warned, 'text-shadow:multiple', '[@symbiote-native/css-parser] multiple text-shadow layers are not supported, only the first is applied');
92
- }
93
- const first = layers[0];
94
- if (!first)
95
- return null;
96
- const tokens = parseShadowTokens(first);
97
- if (!tokens)
98
- return null;
99
- return {
100
- textShadowColor: tokens.color,
101
- textShadowOffset: { width: tokens.offsetX, height: tokens.offsetY },
102
- textShadowRadius: tokens.blurRadius,
103
- };
104
- }
105
- //#endregion text-shadow
106
49
  export { REM_TO_PX };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@symbiote-native/css-parser",
3
- "version": "0.3.0",
3
+ "version": "0.5.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": {
@@ -17,6 +17,17 @@
17
17
  "main": "./build/index.js",
18
18
  "module": "./build/index.js",
19
19
  "types": "./build/index.d.ts",
20
+ "keywords": [
21
+ "react-native",
22
+ "symbiote-native",
23
+ "css",
24
+ "scss",
25
+ "css-modules",
26
+ "postcss",
27
+ "lightningcss",
28
+ "metro",
29
+ "build-tool"
30
+ ],
20
31
  "exports": {
21
32
  ".": {
22
33
  "types": "./build/index.d.ts",
@@ -41,8 +52,7 @@
41
52
  },
42
53
  "dependencies": {
43
54
  "@react-native/metro-babel-transformer": "0.86.0",
44
- "postcss": "^8.4.49",
45
- "postcss-value-parser": "^4.2.0"
55
+ "lightningcss": "^1.32.0"
46
56
  },
47
57
  "devDependencies": {
48
58
  "sass": "^1.101.0",
@@ -8,10 +8,10 @@
8
8
  // separate, on-disk source of truth for `tsc`/`vue-tsc` CLI runs — plugins never load there.
9
9
  //
10
10
  // Core mechanism: override getScriptSnapshot + resolveModuleNameLiterals to synthesize a virtual
11
- // .d.ts for the import. Two things worth calling out: (1) the class extractor converts kebab-case
12
- // to camelCase, so a suggested key like `section-tight` matches the ACTUAL exported key our
13
- // runtime produces (@symbiote-native/css-parser's parseCSS always camelCases — see
14
- // src/generate-dts.ts's classNamesToDtsSource, which this plugin's dts shape mirrors); (2) the dts
11
+ // .d.ts for the import. Two things worth calling out: (1) the class extractor keeps the AUTHORED
12
+ // spelling, because that is the key the runtime export map carries — see
13
+ // src/generate-dts/index.ts's classNamesToDtsSource, which this plugin's dts shape mirrors, and
14
+ // which quotes a non-identifier key for the same reason; (2) the dts
15
15
  // cache is keyed on the file's mtime, so autocomplete doesn't go stale after editing the CSS file
16
16
  // and wait for the IDE to restart tsserver.
17
17
  //
@@ -24,9 +24,9 @@
24
24
  // completion in the plugin.
25
25
  //
26
26
  // SCOPE, second cut: only a SIMPLE `.foo { ... }` class selector is recognized correctly — a
27
- // compound (`.btn.primary`) or descendant (`.card .title`) selector, which the real
28
- // src/parser.ts's extractClassName merges into ONE key (`btnPrimary`/`cardTitle`), gets
29
- // extracted here as TWO separate (wrong, non-existent) keys instead.
27
+ // compound (`.btn.primary`) or descendant (`.card .title`) selector is extracted here as TWO
28
+ // separate keys, where the real compiler keeps the tokens together on one rule and exports only
29
+ // the names the author actually wrote.
30
30
  //
31
31
  // Hand-written plain CommonJS, NOT compiled from a `.ts`/`.cts` source — same convention already
32
32
  // used for each adapter's metro-css-parser.cjs shim. tsserver loads a plugin via a synchronous
@@ -46,17 +46,19 @@ function isCssModuleFile(fileName) {
46
46
  return CSS_MODULE_RE.test(fileName);
47
47
  }
48
48
 
49
- function kebabToCamel(value) {
50
- return value.replace(/-([a-z0-9])/gi, (_match, char) => char.toUpperCase());
51
- }
52
-
49
+ // Names come out AS AUTHORED — `.section-tight` stays `section-tight`, read as
50
+ // `styles['section-tight']`. This must track `compileCssModule`'s export map exactly: the editor
51
+ // offering a key the runtime does not carry is the one failure this plugin can produce, and it
52
+ // fails in both directions at once (a suggested `sectionTight` is `undefined` at runtime, while
53
+ // the real `styles['section-tight']` reads as a TS2339). `generateDts` quotes a key that is not a
54
+ // valid identifier, which is what makes the kebab spelling usable.
53
55
  function extractClassNames(css) {
54
56
  const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
55
57
  const names = new Set();
56
58
  const classRe = /\.([a-zA-Z_][\w-]*)/g;
57
59
  let match;
58
60
  while ((match = classRe.exec(withoutComments))) {
59
- names.add(kebabToCamel(match[1]));
61
+ names.add(match[1]);
60
62
  }
61
63
  return [...names];
62
64
  }
@@ -112,7 +114,9 @@ function init(modules) {
112
114
  return dts;
113
115
  }
114
116
 
115
- const originalGetScriptKind = host.getScriptKind ? host.getScriptKind.bind(host) : undefined;
117
+ const originalGetScriptKind = host.getScriptKind
118
+ ? host.getScriptKind.bind(host)
119
+ : undefined;
116
120
  const originalGetScriptSnapshot = host.getScriptSnapshot.bind(host);
117
121
  const originalResolveModuleNameLiterals = host.resolveModuleNameLiterals;
118
122
 
@@ -150,7 +154,10 @@ function init(modules) {
150
154
  return literals.map((literal, index) => {
151
155
  const moduleName = literal.text;
152
156
  if (isCssModuleFile(moduleName) && moduleName.startsWith('.')) {
153
- const resolvedPath = resolveRelativePath(moduleName, containingFile);
157
+ const resolvedPath = resolveRelativePath(
158
+ moduleName,
159
+ containingFile,
160
+ );
154
161
  if (host.fileExists && host.fileExists(resolvedPath)) {
155
162
  return {
156
163
  resolvedModule: {
@@ -1,25 +0,0 @@
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>;