@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 +21 -0
- package/README.md +126 -0
- package/build/file-scope-id.d.ts +1 -0
- package/build/file-scope-id.js +14 -0
- package/build/generate-dts-cli.d.ts +2 -0
- package/build/generate-dts-cli.js +101 -0
- package/build/generate-dts.d.ts +2 -0
- package/build/generate-dts.js +43 -0
- package/build/global-selectors.d.ts +1 -0
- package/build/global-selectors.js +22 -0
- package/build/index.d.ts +11 -0
- package/build/index.js +7 -0
- package/build/metro-css-module.d.ts +6 -0
- package/build/metro-css-module.js +70 -0
- package/build/metro-transformer.d.ts +10 -0
- package/build/metro-transformer.js +41 -0
- package/build/parser.d.ts +22 -0
- package/build/parser.js +223 -0
- package/build/preprocessors.d.ts +20 -0
- package/build/preprocessors.js +128 -0
- package/build/properties.d.ts +24 -0
- package/build/properties.js +144 -0
- package/build/values.d.ts +27 -0
- package/build/values.js +107 -0
- package/package.json +65 -0
- package/typescript-plugin.cjs +181 -0
- package/typescript-plugin.d.ts +8 -0
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,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,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
|
+
}
|
package/build/index.d.ts
ADDED
|
@@ -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>>;
|