@symbiote-native/css-parser 0.2.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  //
@@ -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
- // 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. This is an accepted
30
- // limitation of the regex-based approach — complex selectors may not be detected correctly.
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.
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');
@@ -48,17 +46,19 @@ function isCssModuleFile(fileName) {
48
46
  return CSS_MODULE_RE.test(fileName);
49
47
  }
50
48
 
51
- function kebabToCamel(value) {
52
- return value.replace(/-([a-z0-9])/gi, (_match, char) => char.toUpperCase());
53
- }
54
-
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.
55
55
  function extractClassNames(css) {
56
56
  const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
57
57
  const names = new Set();
58
58
  const classRe = /\.([a-zA-Z_][\w-]*)/g;
59
59
  let match;
60
60
  while ((match = classRe.exec(withoutComments))) {
61
- names.add(kebabToCamel(match[1]));
61
+ names.add(match[1]);
62
62
  }
63
63
  return [...names];
64
64
  }
@@ -70,7 +70,7 @@ function generateDts(classNames) {
70
70
 
71
71
  const fields = [...classNames]
72
72
  .sort()
73
- .map((name) => {
73
+ .map(name => {
74
74
  const key = IDENTIFIER_RE.test(name) ? name : JSON.stringify(name);
75
75
  return ` readonly ${key}: string;`;
76
76
  })
@@ -114,16 +114,20 @@ function init(modules) {
114
114
  return dts;
115
115
  }
116
116
 
117
- const originalGetScriptKind = host.getScriptKind ? host.getScriptKind.bind(host) : undefined;
117
+ const originalGetScriptKind = host.getScriptKind
118
+ ? host.getScriptKind.bind(host)
119
+ : undefined;
118
120
  const originalGetScriptSnapshot = host.getScriptSnapshot.bind(host);
119
121
  const originalResolveModuleNameLiterals = host.resolveModuleNameLiterals;
120
122
 
121
- host.getScriptKind = (fileName) =>
123
+ host.getScriptKind = fileName =>
122
124
  isCssModuleFile(fileName)
123
125
  ? typescript.ScriptKind.TS
124
- : (originalGetScriptKind ? originalGetScriptKind(fileName) : typescript.ScriptKind.Unknown);
126
+ : originalGetScriptKind
127
+ ? originalGetScriptKind(fileName)
128
+ : typescript.ScriptKind.Unknown;
125
129
 
126
- host.getScriptSnapshot = (fileName) =>
130
+ host.getScriptSnapshot = fileName =>
127
131
  isCssModuleFile(fileName)
128
132
  ? typescript.ScriptSnapshot.fromString(getDtsForCssFile(fileName))
129
133
  : originalGetScriptSnapshot(fileName);
@@ -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 +0,0 @@
1
- export declare function globalClassNamesIn(css: string): Set<string>;
@@ -1,22 +0,0 @@
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]));
20
- }
21
- return names;
22
- }
@@ -1,22 +0,0 @@
1
- export type ICssParserOptions = {
2
- filename?: string;
3
- };
4
- export declare function kebabToCamel(value: string): string;
5
- /**
6
- * Extract a camelCase class name from a CSS selector, or `null` if the selector has no RN
7
- * equivalent (pseudo-classes/-elements, bare element selectors, the universal selector — RN has
8
- * no element-selector concept, so those would just pollute the output).
9
- *
10
- * - `.card` → `'card'`
11
- * - `#header` → `'header'`
12
- * - `.btn.primary` → `'btnPrimary'` (compound)
13
- * - `.card .title` / `.card > .title` → `'cardTitle'` (descendant/child, flattened)
14
- * - `[data-theme]` → `'dataTheme'` (attribute)
15
- * - `.my-class-name` → `'myClassName'` (kebab → camel)
16
- */
17
- export declare function extractClassName(selector: string): string | null;
18
- /**
19
- * Parse a plain CSS string into a `{ className: RNStyleObject }` map. Build-time only — never
20
- * ship this in the app's native JS bundle; it is meant to run inside a Metro transformer.
21
- */
22
- export declare function parseCSS(css: string, options?: ICssParserOptions): Record<string, Record<string, unknown>>;
@@ -1,221 +0,0 @@
1
- // CSS → React Native style-object compiler. `extractClassName` and the CSS-custom-property/
2
- // `var()` resolution machinery are framework/target-agnostic. `evaluateCalc` treats `px` as
3
- // identity (RN has no cell grid to scale against) and `rem`/`em` as scaled by the same
4
- // {@link REM_TO_PX} constant as a bare value (see values.ts). `mapCSSProperty` (properties.ts)
5
- // targets RN's `ViewStyle`/`TextStyle`.
6
- import postcss from 'postcss';
7
- import valueParser from 'postcss-value-parser';
8
- import { mapCSSProperty } from "../properties.js";
9
- import { REM_TO_PX } from "../values.js";
10
- //#region Selector utilities
11
- // Exported: the SFC style compiler (metro-vue-transformer.js) reuses this exact conversion to
12
- // normalize a template's kebab-case class="section-label" authoring to the camelCase key this
13
- // module already registers CSS selectors under, so both spellings resolve to the same style.
14
- export function kebabToCamel(value) {
15
- return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
16
- }
17
- function capitalize(value) {
18
- return value.charAt(0).toUpperCase() + value.slice(1);
19
- }
20
- function unescapeIdentifier(value) {
21
- return value.replace(/\\(.)/g, '$1');
22
- }
23
- /**
24
- * Extract a camelCase class name from a CSS selector, or `null` if the selector has no RN
25
- * equivalent (pseudo-classes/-elements, bare element selectors, the universal selector — RN has
26
- * no element-selector concept, so those would just pollute the output).
27
- *
28
- * - `.card` → `'card'`
29
- * - `#header` → `'header'`
30
- * - `.btn.primary` → `'btnPrimary'` (compound)
31
- * - `.card .title` / `.card > .title` → `'cardTitle'` (descendant/child, flattened)
32
- * - `[data-theme]` → `'dataTheme'` (attribute)
33
- * - `.my-class-name` → `'myClassName'` (kebab → camel)
34
- */
35
- export function extractClassName(selector) {
36
- const trimmed = selector.trim();
37
- if (/^[a-z]+$/i.test(trimmed))
38
- return null;
39
- if (trimmed === '*')
40
- 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
- if (trimmed.startsWith(':'))
52
- return null;
53
- // A pseudo-class/-element trailing a class/id selector (`.card:hover`, `.card::before`) has
54
- // no RN equivalent — RN has no hover/focus/nth-child style variants — so the WHOLE rule is
55
- // dropped, same as a bare `:hover`. Stripping just the pseudo suffix and keeping `.card`'s
56
- // other declarations would be wrong: it'd silently merge hover-only styles into the
57
- // always-applied base style (a real gap an earlier version of this fix had — found by
58
- // manually running the parser on `.card:hover { opacity: 0.5 }` and seeing `opacity` leak
59
- // into `card`'s permanent style). `[...]` is excluded first since an attribute selector's
60
- // value may legitimately contain a colon (`[data-x="a:b"]`).
61
- if (trimmed.replace(/\[[^\]]*\]/g, '').includes(':'))
62
- return null;
63
- // Compound selector (`.btn.primary`, `div.card`) — split on unescaped dots.
64
- if (trimmed.includes('.') && !trimmed.includes(' ') && !trimmed.includes('>')) {
65
- const parts = trimmed.split(/(?<!\\)\./).filter(Boolean);
66
- if (parts.length > 0) {
67
- const startsWithElement = !trimmed.startsWith('.');
68
- const startIndex = startsWithElement ? 1 : 0;
69
- if (startIndex >= parts.length)
70
- 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('');
78
- }
79
- }
80
- // Descendant/child selector (`.card .title`, `.card > .title`) — flattened into one name.
81
- if (trimmed.includes(' ')) {
82
- const parts = trimmed.split(/\s+(?:>\s*)?/).filter(Boolean);
83
- const classNames = [];
84
- for (const part of parts) {
85
- const classMatch = part.match(/\.((?:[a-zA-Z0-9_-]|\\.)+)/);
86
- if (classMatch?.[1]) {
87
- classNames.push(unescapeIdentifier(classMatch[1]));
88
- continue;
89
- }
90
- const idMatch = part.match(/#((?:[a-zA-Z0-9_-]|\\.)+)/);
91
- if (idMatch?.[1])
92
- classNames.push(unescapeIdentifier(idMatch[1]));
93
- }
94
- if (classNames.length === 0)
95
- return null;
96
- return classNames
97
- .map((name, i) => {
98
- const camelName = kebabToCamel(name);
99
- return i === 0 ? camelName : capitalize(camelName);
100
- })
101
- .join('');
102
- }
103
- // Single class selector (`.card`).
104
- const classMatch = trimmed.match(/^\.((?:[a-zA-Z0-9_-]|\\.)+)/);
105
- if (classMatch?.[1])
106
- return kebabToCamel(unescapeIdentifier(classMatch[1]));
107
- // ID selector (`#header`).
108
- const idMatch = trimmed.match(/^#((?:[a-zA-Z0-9_-]|\\.)+)/);
109
- if (idMatch?.[1])
110
- return kebabToCamel(unescapeIdentifier(idMatch[1]));
111
- // Attribute selector (`[data-theme]`).
112
- const attrMatch = trimmed.match(/^\[([a-zA-Z0-9_-]+)(?:=[^\]]+)?\]/);
113
- if (attrMatch?.[1])
114
- return kebabToCamel(attrMatch[1]);
115
- return null;
116
- }
117
- //#endregion Selector utilities
118
- //#region var() resolution
119
- function resolveVariables(value, variables) {
120
- if (!value.includes('var('))
121
- return value;
122
- const parsed = valueParser(value);
123
- parsed.walk((node, index, nodes) => {
124
- if (node.type !== 'function' || node.value !== 'var' || node.nodes.length === 0)
125
- return;
126
- const varName = node.nodes[0]?.value;
127
- if (!varName)
128
- return;
129
- const fallbackNode = node.nodes.length > 2 ? node.nodes[2] : undefined;
130
- const resolved = variables.get(varName) ?? fallbackNode?.value ?? '';
131
- if (!resolved)
132
- return;
133
- // Replace the `var(...)` function node in its containing array with a plain word node
134
- // holding the resolved text, instead of mutating `node`'s discriminated `type` in place.
135
- nodes[index] = {
136
- type: 'word',
137
- value: resolveVariables(resolved, variables),
138
- sourceIndex: node.sourceIndex,
139
- sourceEndIndex: node.sourceEndIndex,
140
- };
141
- });
142
- return parsed.toString();
143
- }
144
- //#endregion var() resolution
145
- //#region calc() evaluation
146
- const CALC_TERM_PATTERN = /calc\(([^)]+)\)/g;
147
- const NUMBER_WITH_UNIT_PATTERN = /(-?\d+(?:\.\d+)?)(rem|em|px)?/g;
148
- /**
149
- * Evaluates a narrow shape of `calc()`: a single multiplication, or the first numeric term
150
- * as a fallback. `px` is identity; `rem`/`em` scale by {@link REM_TO_PX}, matching a bare
151
- * dimension value.
152
- */
153
- function evaluateCalc(value) {
154
- if (!value.includes('calc('))
155
- return value;
156
- return value.replace(CALC_TERM_PATTERN, (_, expr) => {
157
- const matches = expr.match(NUMBER_WITH_UNIT_PATTERN) ?? [];
158
- const values = [];
159
- for (const term of matches) {
160
- const numMatch = term.match(/(-?\d+(?:\.\d+)?)(rem|em|px)?/);
161
- if (!numMatch)
162
- continue;
163
- const amount = parseFloat(numMatch[1]);
164
- const unit = numMatch[2];
165
- values.push(unit === 'rem' || unit === 'em' ? amount * REM_TO_PX : amount);
166
- }
167
- if (expr.includes('*')) {
168
- const parts = expr.split('*').map(part => part.trim());
169
- if (parts.length === 2) {
170
- const a = values[0] ?? parseFloat(parts[0]) ?? 0;
171
- const b = parseFloat(parts[1]) || 1;
172
- return String(Math.round(a * b));
173
- }
174
- }
175
- return String(Math.round(values[0] ?? 0));
176
- });
177
- }
178
- //#endregion calc() evaluation
179
- /**
180
- * Parse a plain CSS string into a `{ className: RNStyleObject }` map. Build-time only — never
181
- * ship this in the app's native JS bundle; it is meant to run inside a Metro transformer.
182
- */
183
- export function parseCSS(css, options) {
184
- if (!css || typeof css !== 'string')
185
- return {};
186
- const root = postcss.parse(css, { from: options?.filename });
187
- const styles = {};
188
- const warnedProperties = new Set();
189
- // `@media` (and any other at-rule) is unsupported; drop it before the rule walk below so its
190
- // nested rules never leak into the output.
191
- root.walkAtRules(atRule => {
192
- console.warn(`[@symbiote-native/css-parser] "@${atRule.name}" at-rules are not supported, "@${atRule.name} ${atRule.params}" skipped`);
193
- atRule.remove();
194
- });
195
- const variables = new Map();
196
- root.walkDecls(decl => {
197
- if (decl.prop.startsWith('--'))
198
- variables.set(decl.prop, decl.value);
199
- });
200
- root.walkRules(rule => {
201
- const selectors = rule.selector.split(',').map(selector => selector.trim());
202
- for (const selector of selectors) {
203
- const className = extractClassName(selector);
204
- if (!className)
205
- continue;
206
- const style = {};
207
- rule.walkDecls(decl => {
208
- if (decl.prop.startsWith('--'))
209
- return;
210
- const resolvedValue = evaluateCalc(resolveVariables(decl.value, variables));
211
- const mapped = mapCSSProperty(decl.prop.toLowerCase(), resolvedValue, warnedProperties);
212
- if (mapped)
213
- Object.assign(style, mapped);
214
- });
215
- if (Object.keys(style).length === 0)
216
- continue;
217
- styles[className] = { ...styles[className], ...style };
218
- }
219
- });
220
- return styles;
221
- }