@symbiote-native/css-parser 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 A. Prokopenko
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # @symbiote-native/css-parser
2
+
3
+ The **build-time CSS compiler** of [SymbioteJS](../../README.md) — turns a Vue SFC `<style>` block,
4
+ or a standalone `.css`/`.module.css` file, into a React Native style object at build time, resolved
5
+ back at render time through a class-name registry shared by every adapter (React's `className`,
6
+ Vue's `class`/`:class`, Angular's `addClass`/`removeClass`). It also compiles SCSS/Sass, Less, and
7
+ Stylus sources down to plain CSS before the same pipeline runs, so scoped styles, `:global()`, and
8
+ CSS Modules all work identically regardless of source language.
9
+
10
+ > New to SymbioteJS? The [root README](../../README.md) has the architecture. Styling with CSS
11
+ > classes (instead of `StyleSheet.create`) is the supported convention across every example app —
12
+ > this package is what makes it work at build time; [`@symbiote-native/engine`](../engine)'s
13
+ > `style-registry` is what resolves it at runtime.
14
+
15
+ ---
16
+
17
+ ## Who calls this, and how
18
+
19
+ **An app never imports this package directly.** It runs only inside a Metro transformer, on the
20
+ Node build machine — never shipped in the app's native JS bundle. Each adapter package
21
+ (`@symbiote-native/react`, `@symbiote-native/vue`, `@symbiote-native/angular`) depends on `@symbiote-native/css-parser`
22
+ as a regular dependency and re-exports it via its own `./metro-css-parser` subpath, so a consuming
23
+ app's `metro.config.js` wires:
24
+
25
+ ```js
26
+ // metro-css-transformer.js, in the app
27
+ const { createCssMetroTransformer } = require('@symbiote-native/react/metro-css-parser');
28
+ module.exports = createCssMetroTransformer(require('@react-native/metro-babel-transformer'));
29
+ ```
30
+
31
+ ```js
32
+ // metro.config.js
33
+ resolver: { sourceExts: [...defaultSourceExts, 'css', 'scss', 'sass', 'less', 'styl'] },
34
+ transformer: { babelTransformerPath: require.resolve('./metro-css-transformer.js') },
35
+ ```
36
+
37
+ From there, a plain stylesheet import just works, from any adapter's own source file:
38
+
39
+ ```ts
40
+ import styles from './Card.module.css'; // CSS Modules — default export is a name→scopedName map
41
+ import './theme.css'; // plain CSS — registers classes globally, no export
42
+ ```
43
+
44
+ ```tsx
45
+ <View className="card" style={styles.highlight} /> // React
46
+ ```
47
+ ```html
48
+ <!-- Vue SFC -->
49
+ <view :class="['card', { active: isActive }]" />
50
+ <style scoped>.card { padding: 10px; }</style>
51
+ ```
52
+
53
+ ## The pipeline
54
+
55
+ ```
56
+ <style> CSS text / .css / .scss / .less / .styl class="foo" / className / addClass
57
+ │ (build time, Metro) │ (runtime, all adapters)
58
+ ▼ ▼
59
+ @symbiote-native/css-parser @symbiote-native/engine's style-registry
60
+ preprocessors.ts → parser.ts (parseCSS) registerStyles() / resolveClassName()
61
+ ```
62
+
63
+ A preprocessor source is reduced to plain CSS text first (`compileScss`/`compileSass`/
64
+ `compileLess`/`compileStylus`); `parseCSS()` is the single downstream consumer either way, so every
65
+ mechanism below runs identically regardless of source language.
66
+
67
+ ## API surface
68
+
69
+ ```ts
70
+ import {
71
+ parseCSS, extractClassName, kebabToCamel, // core compiler
72
+ compileCssFile, isCssModuleFile, // standalone .css/.module.css files
73
+ createCssMetroTransformer, // Metro babelTransformerPath factory
74
+ compileScss, compileSass, compileLess, compileStylus, compile, detectLanguage, isStyleFile,
75
+ classNamesToDtsSource, generateModuleDts, // .d.ts generation for CSS Modules typing
76
+ globalClassNamesIn, hashFilePath,
77
+ } from '@symbiote-native/css-parser';
78
+ ```
79
+
80
+ - **`parseCSS(css, { filename? })`** — the compiler core: postcss AST walk, `var()`/`calc()`
81
+ resolution, selector → camelCase key (`.card` → `card`, `.btn.primary` → `btnPrimary` compound,
82
+ `.card .title` → `cardTitle` descendant). A selector containing a pseudo-class (`:hover`, …) is
83
+ dropped whole — RN has no pseudo-class concept, so there is no partial-application semantics to
84
+ preserve.
85
+ - **`compileCssFile` / `isCssModuleFile`** — the standalone-file form: `Card.module.css`'s classes
86
+ are always scoped to a per-file hash and its default export is the name→scopedName map; a plain
87
+ `.css` file registers globally via a side-effect import.
88
+ - **`createCssMetroTransformer`** — wraps an upstream RN Babel transformer, detecting a stylesheet
89
+ extension and compiling it before delegating everything else unchanged.
90
+ - **Preprocessors** — `sass`/`less`/`stylus` are lazy, **optional** `devDependencies`: a project
91
+ that never authors `.scss`/`.less`/`.styl` never installs any of the three.
92
+ - **CSS Modules type safety** — `css-dts` (bin) walks a directory and writes a real `<file>.d.ts`
93
+ next to each `.module.css`/`.scss`/`.less`/`.styl` (no index signature, so a typo genuinely fails
94
+ `tsc`), wired as a `pretypecheck` script; `./typescript-plugin` is a language-service plugin for
95
+ live in-editor autocomplete. Both are needed — a `tsconfig.json` plugin is invisible to a
96
+ standalone `tsc`/CI run, and the on-disk `.d.ts` gives no live-while-typing feedback.
97
+
98
+ ## What it does NOT do
99
+
100
+ - It does not run at app runtime — it is a Node-only, Metro-build-time tool; the runtime half
101
+ (resolving a class name back into a style object) lives in `@symbiote-native/engine`'s
102
+ `style-registry`, not here.
103
+ - It does not implement Tailwind CSS — that needs whole-project class scanning and JIT utility
104
+ generation, a fundamentally different shape than "one source file reduces to CSS text", and is
105
+ being designed as a separate, future package.
106
+ - It supports `scoped` / `:global()` / CSS Modules and SCSS/Sass/Less/Stylus preprocessing; it does
107
+ not yet generate a typed `.d.ts` for an **inline** Vue `<style module>` block (only standalone
108
+ `.module.css` files get the strict, no-index-signature type — Vue's own Volar plugin gives inline
109
+ blocks a looser, typo-tolerant type for free) and has no Svelte support yet (no Svelte adapter
110
+ exists in SymbioteJS today).
111
+
112
+ ## Related packages
113
+
114
+ - [`@symbiote-native/engine`](../engine) — owns the runtime `style-registry` (`registerStyles` /
115
+ `resolveClassName`) this package's compiled output resolves against, and the class+style merge
116
+ used by every adapter.
117
+ - [`@symbiote-native/react`](../../adapters/react) / [`@symbiote-native/vue`](../../adapters/vue) /
118
+ [`@symbiote-native/angular`](../../adapters/angular) — each depends on this package directly and
119
+ re-exports it via its own `./metro-css-parser` subpath, so a consuming app needs no extra install
120
+ step.
121
+
122
+ ## Test it
123
+
124
+ ```bash
125
+ pnpm test # vitest, from the workspace root — parser, preprocessors, metro transformer
126
+ ```
@@ -0,0 +1 @@
1
+ export declare function hashFilePath(filePath: string): string;
@@ -0,0 +1,14 @@
1
+ // A short, stable id derived from a file path, used to scope CSS classes so two files can each
2
+ // define their own `.card` without colliding in the shared runtime registry (core/engine's
3
+ // style-registry). Deterministic so Metro's cache stays warm across rebuilds. Shared by both
4
+ // the Vue SFC compiler's own scope-id convention (examples/vue-sfc/metro-vue-transformer.js,
5
+ // which prefixes it `data-v-` to match Vue's own attribute-based scoping) and the
6
+ // framework-agnostic standalone `.module.css` file compiler (metro-css-module.ts) — same hash,
7
+ // different prefix per caller, so the algorithm lives once.
8
+ export function hashFilePath(filePath) {
9
+ let hash = 0;
10
+ for (let i = 0; i < filePath.length; i++) {
11
+ hash = (Math.imul(31, hash) + filePath.charCodeAt(i)) | 0;
12
+ }
13
+ return Math.abs(hash).toString(36).slice(0, 8);
14
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ // `css-dts <dir-or-file> [...more]` — walks the given paths, finds every `.module.css` (and
3
+ // `.module.scss`/`.module.less`/`.module.styl`) file, and writes a sibling `.d.ts` next to it via
4
+ // generateModuleDts. Kept separate from generate-dts.ts so the actual generation logic stays a
5
+ // pure, disk-free function (source text in, .d.ts text out) — this file is the one place that
6
+ // touches the filesystem, run at dev/build time only, never imported by app code.
7
+ //
8
+ // Deliberately NOT wired into the Metro transformer (metro-css-module.ts/metro-transformer.ts):
9
+ // Metro's transform is content-hash-cached and only ever touches a file that is actually reached
10
+ // by the bundle graph it's currently building — a `tsc`/`vue-tsc` typecheck run in CI has no
11
+ // Metro involved at all, so a Metro-coupled generator would leave `.d.ts` files missing or stale
12
+ // exactly where correctness matters most. The intended hook is `pretypecheck` (runs before every
13
+ // typecheck, local or CI, with zero dependency on a running Metro/dev server) plus an optional
14
+ // `--watch` mode for live autocomplete while actively editing styles.
15
+ import * as fs from 'node:fs/promises';
16
+ import * as path from 'node:path';
17
+ import { isStyleFile } from "./preprocessors.js";
18
+ import { isCssModuleFile } from "./metro-css-module.js";
19
+ import { generateModuleDts } from "./generate-dts.js";
20
+ const SKIPPED_DIR_NAMES = new Set(['node_modules', 'build', '.git']);
21
+ async function collectModuleStyleFiles(root) {
22
+ const stat = await fs.stat(root);
23
+ if (!stat.isDirectory()) {
24
+ return isStyleFile(root) && isCssModuleFile(root) ? [root] : [];
25
+ }
26
+ const entries = await fs.readdir(root, { withFileTypes: true });
27
+ const found = [];
28
+ for (const entry of entries) {
29
+ if (entry.name.startsWith('.') || SKIPPED_DIR_NAMES.has(entry.name))
30
+ continue;
31
+ const fullPath = path.join(root, entry.name);
32
+ if (entry.isDirectory()) {
33
+ found.push(...(await collectModuleStyleFiles(fullPath)));
34
+ }
35
+ else if (isStyleFile(fullPath) && isCssModuleFile(fullPath)) {
36
+ found.push(fullPath);
37
+ }
38
+ }
39
+ return found;
40
+ }
41
+ async function generateDtsForFile(filename) {
42
+ const source = await fs.readFile(filename, 'utf8');
43
+ const dts = await generateModuleDts(source, filename);
44
+ if (dts === null)
45
+ return null;
46
+ const dtsPath = `${filename}.d.ts`;
47
+ await fs.writeFile(dtsPath, dts, 'utf8');
48
+ return dtsPath;
49
+ }
50
+ async function generateAll(roots) {
51
+ const files = (await Promise.all(roots.map(collectModuleStyleFiles))).flat();
52
+ for (const file of files) {
53
+ const dtsPath = await generateDtsForFile(file);
54
+ if (dtsPath)
55
+ console.log(`css-dts: wrote ${dtsPath}`);
56
+ }
57
+ return files.length;
58
+ }
59
+ // Dev-convenience only, not part of the correctness story above — re-runs the full generation
60
+ // pass on any filesystem event under a root (a plain-CSS edit re-triggers it too, harmlessly; a
61
+ // short debounce collapses an editor's typical write+rename burst into one pass) so an open
62
+ // editor's autocomplete stays live while a `.module.css` file is being edited, without requiring
63
+ // Metro or any other dev server to be running.
64
+ async function watch(roots) {
65
+ console.log(`css-dts: watching ${roots.join(', ')} for changes (ctrl-c to stop)`);
66
+ let pending;
67
+ const regenerate = () => {
68
+ clearTimeout(pending);
69
+ pending = setTimeout(() => {
70
+ generateAll(roots).catch((error) => console.error(error));
71
+ }, 100);
72
+ };
73
+ regenerate();
74
+ await Promise.all(roots.map(async (root) => {
75
+ const watcher = fs.watch(root, { recursive: true });
76
+ for await (const _event of watcher)
77
+ regenerate();
78
+ }));
79
+ }
80
+ async function main() {
81
+ const args = process.argv.slice(2);
82
+ const isWatchMode = args.includes('--watch');
83
+ const targets = args.filter(arg => arg !== '--watch');
84
+ if (targets.length === 0) {
85
+ console.error('Usage: css-dts [--watch] <dir-or-file> [...more]');
86
+ process.exitCode = 1;
87
+ return;
88
+ }
89
+ if (isWatchMode) {
90
+ await watch(targets);
91
+ return;
92
+ }
93
+ const fileCount = await generateAll(targets);
94
+ if (fileCount === 0) {
95
+ console.log('css-dts: no .module.css (or .module.scss/.less/.styl) files found');
96
+ }
97
+ }
98
+ main().catch((error) => {
99
+ console.error(error);
100
+ process.exitCode = 1;
101
+ });
@@ -0,0 +1,2 @@
1
+ export declare function classNamesToDtsSource(classNames: readonly string[]): string;
2
+ export declare function generateModuleDts(source: string, filename: string): Promise<string | null>;
@@ -0,0 +1,43 @@
1
+ // Generates a real on-disk `.d.ts` for a `.module.css` (or `.module.scss`/`.module.less`/
2
+ // `.module.styl`) file — the follow-up the symbiote-sfc-style-compiler skill records as
3
+ // deliberately deferred when CSS Modules shipped. Unlike Volar's built-in `<style module>`
4
+ // typing for inline Vue SFC blocks (which synthesizes `Record<string, string> & {known keys}` —
5
+ // an index signature that still accepts any key, so it never catches a typo), the type emitted
6
+ // here has NO index signature: a typo in `styles.typo` is a genuine `error TS2339` under `tsc`/
7
+ // `vue-tsc`, not a silent runtime miss. Named `<file>.d.ts` (full original filename, extension
8
+ // appended rather than replaced — e.g. `Card.module.css.d.ts`) so TypeScript's own module
9
+ // resolution picks it up for `import styles from './Card.module.css'` without a separate
10
+ // registration step, the same convention typed-css-modules uses.
11
+ import { parseCSS } from "./parser.js";
12
+ import { compile, detectLanguage } from "./preprocessors.js";
13
+ import { isCssModuleFile } from "./metro-css-module.js";
14
+ const IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
15
+ function formatKey(className) {
16
+ return IDENTIFIER_RE.test(className) ? className : JSON.stringify(className);
17
+ }
18
+ export function classNamesToDtsSource(classNames) {
19
+ const fields = [...classNames]
20
+ .sort()
21
+ .map(className => ` readonly ${formatKey(className)}: string;`)
22
+ .join('\n');
23
+ return [
24
+ '// Auto-generated by @symbiote-native/css-parser from the matching CSS Modules file.',
25
+ '// Do not edit by hand — regenerate via `css-dts` instead.',
26
+ 'declare const styles: {',
27
+ fields,
28
+ '};',
29
+ 'export default styles;',
30
+ '',
31
+ ].join('\n');
32
+ }
33
+ // Mirrors compileCssFile's own module/non-module branch: a plain (non-`.module.*`) style file
34
+ // has no default export to type (registerStyles() runs as a side effect only), so it gets no
35
+ // `.d.ts` at all.
36
+ export async function generateModuleDts(source, filename) {
37
+ if (!isCssModuleFile(filename))
38
+ return null;
39
+ const lang = detectLanguage(filename);
40
+ const css = lang === 'css' ? source : await compile(source, lang, filename);
41
+ const parsed = parseCSS(css, { filename });
42
+ return classNamesToDtsSource(Object.keys(parsed));
43
+ }
@@ -0,0 +1 @@
1
+ export declare function globalClassNamesIn(css: string): Set<string>;
@@ -0,0 +1,22 @@
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.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
+ }
@@ -0,0 +1,11 @@
1
+ export { parseCSS, extractClassName, kebabToCamel } from './parser.ts';
2
+ export type { ICssParserOptions } from './parser.ts';
3
+ export { globalClassNamesIn } from './global-selectors.ts';
4
+ export { hashFilePath } from './file-scope-id.ts';
5
+ export { compileCssFile, isCssModuleFile } from './metro-css-module.ts';
6
+ export type { ICompiledCssFile } from './metro-css-module.ts';
7
+ export { classNamesToDtsSource, generateModuleDts } from './generate-dts.ts';
8
+ export { createCssMetroTransformer } from './metro-transformer.ts';
9
+ export type { IMetroTransformer, IMetroTransformParams } from './metro-transformer.ts';
10
+ export { compileScss, compileSass, compileLess, compileStylus, compile, detectLanguage, isStyleFile, } from './preprocessors.ts';
11
+ export type { IPreprocessorLanguage } from './preprocessors.ts';
package/build/index.js ADDED
@@ -0,0 +1,7 @@
1
+ export { parseCSS, extractClassName, kebabToCamel } from "./parser.js";
2
+ export { globalClassNamesIn } from "./global-selectors.js";
3
+ export { hashFilePath } from "./file-scope-id.js";
4
+ export { compileCssFile, isCssModuleFile } from "./metro-css-module.js";
5
+ export { classNamesToDtsSource, generateModuleDts } from "./generate-dts.js";
6
+ export { createCssMetroTransformer } from "./metro-transformer.js";
7
+ export { compileScss, compileSass, compileLess, compileStylus, compile, detectLanguage, isStyleFile, } from "./preprocessors.js";
@@ -0,0 +1,6 @@
1
+ import { type ICssParserOptions } from './parser.ts';
2
+ export interface ICompiledCssFile {
3
+ code: string;
4
+ }
5
+ export declare function isCssModuleFile(filename: string): boolean;
6
+ export declare function compileCssFile(source: string, filename: string, options?: ICssParserOptions): Promise<ICompiledCssFile>;
@@ -0,0 +1,70 @@
1
+ // Compiles a standalone .css/.scss/.sass/.less/.styl (and their .module.* twins) file into a
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:
4
+ // `import styles from './Card.module.scss'` works the same from a React .tsx, a Vue <script>, or
5
+ // an Angular .ts. See the symbiote-sfc-style-compiler skill.
6
+ //
7
+ // A plain style file registers its classes globally, exactly like an unscoped Vue <style>
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>.
17
+ //
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.
22
+ import * as path from 'node:path';
23
+ import { parseCSS } from "./parser.js";
24
+ import { globalClassNamesIn } from "./global-selectors.js";
25
+ import { hashFilePath } from "./file-scope-id.js";
26
+ import { compile, detectLanguage } from "./preprocessors.js";
27
+ export function isCssModuleFile(filename) {
28
+ const ext = path.extname(filename);
29
+ if (!ext)
30
+ return false;
31
+ return filename.slice(0, -ext.length).endsWith('.module');
32
+ }
33
+ // Preprocessing (SCSS/Less/Stylus → plain CSS text) is inherently async in Node — Less has no
34
+ // sync render API at all, and Stylus's callback-based render must be Promise-wrapped — so
35
+ // compileCssFile is async uniformly, even for a plain `.css` file that needs no preprocessing.
36
+ // See metro-transformer.ts for the fuller sync-vs-async writeup; the short version is that a
37
+ // sync fast-path for `.css` would fork this function into two shapes for a build-time-only,
38
+ // 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 });
43
+ if (!isCssModuleFile(filename)) {
44
+ return {
45
+ code: `import { registerStyles } from '@symbiote-native/engine';\nregisterStyles(${JSON.stringify(parsed)});\n`,
46
+ };
47
+ }
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
+ }
63
+ return {
64
+ code: [
65
+ `import { registerStyles } from '@symbiote-native/engine';`,
66
+ `registerStyles(${JSON.stringify(styles)});`,
67
+ `export default ${JSON.stringify(classMap)};`,
68
+ ].join('\n') + '\n',
69
+ };
70
+ }
@@ -0,0 +1,10 @@
1
+ export interface IMetroTransformParams {
2
+ filename: string;
3
+ src: string;
4
+ [key: string]: unknown;
5
+ }
6
+ export interface IMetroTransformer {
7
+ transform: (params: IMetroTransformParams) => unknown;
8
+ getCacheKey?: (...args: unknown[]) => string;
9
+ }
10
+ export declare function createCssMetroTransformer(upstreamTransformer: IMetroTransformer): IMetroTransformer;
@@ -0,0 +1,41 @@
1
+ // A ready-made Metro babel transformer wrapper for .css/.scss/.sass/.less/.styl (+ their
2
+ // .module.* twins) support, so a consuming app's own metro.config.js needs only a 3-line wiring
3
+ // file instead of hand-rolling the "compile a style file, delegate everything else to upstream"
4
+ // boilerplate three times (once per adapter's example, before this existed). @symbiote-native/css-parser
5
+ // is a regular `dependency` of every adapter package (@symbiote-native/react, @symbiote-native/vue,
6
+ // @symbiote-native/angular), so this is transitively resolvable from any app that already depends on one
7
+ // of them — this repo's shamefully-hoist pnpm config (.npmrc) makes that resolvable without the
8
+ // app adding @symbiote-native/css-parser to its own package.json. See the symbiote-sfc-style-compiler
9
+ // skill.
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.
25
+ import { compileCssFile } from "./metro-css-module.js";
26
+ import { isStyleFile } from "./preprocessors.js";
27
+ export function createCssMetroTransformer(upstreamTransformer) {
28
+ return {
29
+ async transform(params) {
30
+ if (!isStyleFile(params.filename))
31
+ return upstreamTransformer.transform(params);
32
+ const { code } = await compileCssFile(params.src, params.filename);
33
+ return upstreamTransformer.transform({
34
+ ...params,
35
+ src: code,
36
+ filename: `${params.filename}.js`,
37
+ });
38
+ },
39
+ getCacheKey: upstreamTransformer.getCacheKey,
40
+ };
41
+ }
@@ -0,0 +1,22 @@
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>>;