@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/build/parser.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// CSS → React Native style-object compiler. Ported from wolf-tui's
|
|
2
|
+
// `internal/css-parser/src/parser.ts`: `extractClassName` and the CSS-custom-property/`var()`
|
|
3
|
+
// resolution machinery are framework/target-agnostic and carry over near-verbatim. `evaluateCalc`
|
|
4
|
+
// drops wolf-tui's terminal-cell scaling (`1rem = 4 cells`, `4px = 1 cell`) — RN has no cell grid,
|
|
5
|
+
// so `px` is identity and `rem`/`em` use the same {@link REM_TO_PX} constant as a bare value
|
|
6
|
+
// (see values.ts). `mapCSSProperty` (properties.ts) targets RN's `ViewStyle`/`TextStyle` instead
|
|
7
|
+
// of wolfie's `Styles`.
|
|
8
|
+
import postcss from 'postcss';
|
|
9
|
+
import valueParser from 'postcss-value-parser';
|
|
10
|
+
import { mapCSSProperty } from "./properties.js";
|
|
11
|
+
import { REM_TO_PX } from "./values.js";
|
|
12
|
+
//#region Selector utilities
|
|
13
|
+
// Exported: the SFC style compiler (metro-vue-transformer.js) reuses this exact conversion to
|
|
14
|
+
// normalize a template's kebab-case class="section-label" authoring to the camelCase key this
|
|
15
|
+
// module already registers CSS selectors under, so both spellings resolve to the same style.
|
|
16
|
+
export function kebabToCamel(value) {
|
|
17
|
+
return value.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
|
18
|
+
}
|
|
19
|
+
function capitalize(value) {
|
|
20
|
+
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
21
|
+
}
|
|
22
|
+
function unescapeIdentifier(value) {
|
|
23
|
+
return value.replace(/\\(.)/g, '$1');
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Extract a camelCase class name from a CSS selector, or `null` if the selector has no RN
|
|
27
|
+
* equivalent (pseudo-classes/-elements, bare element selectors, the universal selector — RN has
|
|
28
|
+
* no element-selector concept, so those would just pollute the output).
|
|
29
|
+
*
|
|
30
|
+
* - `.card` → `'card'`
|
|
31
|
+
* - `#header` → `'header'`
|
|
32
|
+
* - `.btn.primary` → `'btnPrimary'` (compound)
|
|
33
|
+
* - `.card .title` / `.card > .title` → `'cardTitle'` (descendant/child, flattened)
|
|
34
|
+
* - `[data-theme]` → `'dataTheme'` (attribute)
|
|
35
|
+
* - `.my-class-name` → `'myClassName'` (kebab → camel)
|
|
36
|
+
*/
|
|
37
|
+
export function extractClassName(selector) {
|
|
38
|
+
const trimmed = selector.trim();
|
|
39
|
+
if (/^[a-z]+$/i.test(trimmed))
|
|
40
|
+
return null;
|
|
41
|
+
if (trimmed === '*')
|
|
42
|
+
return null;
|
|
43
|
+
// `:global(...)` (Vue `<style scoped>` escape hatch) opts a selector out of scope-suffixing —
|
|
44
|
+
// a caller concern outside this package. Here it just needs unwrapping: when the WHOLE trimmed
|
|
45
|
+
// selector is one `:global(...)` wrapper, recurse on its inner text and return whatever that
|
|
46
|
+
// resolves to, reusing every selector shape below instead of duplicating it. Checked before the
|
|
47
|
+
// "starts with :" / "any colon anywhere" guards, since `:global(...)` legitimately contains a
|
|
48
|
+
// colon that must not trigger them. Known gap: a `:global(...)` wrapping only PART of a larger
|
|
49
|
+
// compound/descendant selector (e.g. `.card :global(.reset)`) is NOT unwrapped by this check.
|
|
50
|
+
const globalMatch = trimmed.match(/^:global\(\s*(.+?)\s*\)$/);
|
|
51
|
+
if (globalMatch?.[1])
|
|
52
|
+
return extractClassName(globalMatch[1]);
|
|
53
|
+
if (trimmed.startsWith(':'))
|
|
54
|
+
return null;
|
|
55
|
+
// A pseudo-class/-element trailing a class/id selector (`.card:hover`, `.card::before`) has
|
|
56
|
+
// no RN equivalent — RN has no hover/focus/nth-child style variants — so the WHOLE rule is
|
|
57
|
+
// dropped, same as a bare `:hover`. Stripping just the pseudo suffix and keeping `.card`'s
|
|
58
|
+
// other declarations would be wrong: it'd silently merge hover-only styles into the
|
|
59
|
+
// always-applied base style (a real gap an earlier version of this fix had — found by
|
|
60
|
+
// manually running the parser on `.card:hover { opacity: 0.5 }` and seeing `opacity` leak
|
|
61
|
+
// into `card`'s permanent style). `[...]` is excluded first since an attribute selector's
|
|
62
|
+
// value may legitimately contain a colon (`[data-x="a:b"]`).
|
|
63
|
+
if (trimmed.replace(/\[[^\]]*\]/g, '').includes(':'))
|
|
64
|
+
return null;
|
|
65
|
+
// Compound selector (`.btn.primary`, `div.card`) — split on unescaped dots.
|
|
66
|
+
if (trimmed.includes('.') && !trimmed.includes(' ') && !trimmed.includes('>')) {
|
|
67
|
+
const parts = trimmed.split(/(?<!\\)\./).filter(Boolean);
|
|
68
|
+
if (parts.length > 0) {
|
|
69
|
+
const startsWithElement = !trimmed.startsWith('.');
|
|
70
|
+
const startIndex = startsWithElement ? 1 : 0;
|
|
71
|
+
if (startIndex >= parts.length)
|
|
72
|
+
return null;
|
|
73
|
+
return parts
|
|
74
|
+
.slice(startIndex)
|
|
75
|
+
.map((part, i) => {
|
|
76
|
+
const camelPart = kebabToCamel(unescapeIdentifier(part));
|
|
77
|
+
return i === 0 ? camelPart : capitalize(camelPart);
|
|
78
|
+
})
|
|
79
|
+
.join('');
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Descendant/child selector (`.card .title`, `.card > .title`) — flattened into one name.
|
|
83
|
+
if (trimmed.includes(' ')) {
|
|
84
|
+
const parts = trimmed.split(/\s+(?:>\s*)?/).filter(Boolean);
|
|
85
|
+
const classNames = [];
|
|
86
|
+
for (const part of parts) {
|
|
87
|
+
const classMatch = part.match(/\.((?:[a-zA-Z0-9_-]|\\.)+)/);
|
|
88
|
+
if (classMatch?.[1]) {
|
|
89
|
+
classNames.push(unescapeIdentifier(classMatch[1]));
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const idMatch = part.match(/#((?:[a-zA-Z0-9_-]|\\.)+)/);
|
|
93
|
+
if (idMatch?.[1])
|
|
94
|
+
classNames.push(unescapeIdentifier(idMatch[1]));
|
|
95
|
+
}
|
|
96
|
+
if (classNames.length === 0)
|
|
97
|
+
return null;
|
|
98
|
+
return classNames
|
|
99
|
+
.map((name, i) => {
|
|
100
|
+
const camelName = kebabToCamel(name);
|
|
101
|
+
return i === 0 ? camelName : capitalize(camelName);
|
|
102
|
+
})
|
|
103
|
+
.join('');
|
|
104
|
+
}
|
|
105
|
+
// Single class selector (`.card`).
|
|
106
|
+
const classMatch = trimmed.match(/^\.((?:[a-zA-Z0-9_-]|\\.)+)/);
|
|
107
|
+
if (classMatch?.[1])
|
|
108
|
+
return kebabToCamel(unescapeIdentifier(classMatch[1]));
|
|
109
|
+
// ID selector (`#header`).
|
|
110
|
+
const idMatch = trimmed.match(/^#((?:[a-zA-Z0-9_-]|\\.)+)/);
|
|
111
|
+
if (idMatch?.[1])
|
|
112
|
+
return kebabToCamel(unescapeIdentifier(idMatch[1]));
|
|
113
|
+
// Attribute selector (`[data-theme]`).
|
|
114
|
+
const attrMatch = trimmed.match(/^\[([a-zA-Z0-9_-]+)(?:=[^\]]+)?\]/);
|
|
115
|
+
if (attrMatch?.[1])
|
|
116
|
+
return kebabToCamel(attrMatch[1]);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
//#endregion Selector utilities
|
|
120
|
+
//#region var() resolution
|
|
121
|
+
function resolveVariables(value, variables) {
|
|
122
|
+
if (!value.includes('var('))
|
|
123
|
+
return value;
|
|
124
|
+
const parsed = valueParser(value);
|
|
125
|
+
parsed.walk((node, index, nodes) => {
|
|
126
|
+
if (node.type !== 'function' || node.value !== 'var' || node.nodes.length === 0)
|
|
127
|
+
return;
|
|
128
|
+
const varName = node.nodes[0]?.value;
|
|
129
|
+
if (!varName)
|
|
130
|
+
return;
|
|
131
|
+
const fallbackNode = node.nodes.length > 2 ? node.nodes[2] : undefined;
|
|
132
|
+
const resolved = variables.get(varName) ?? fallbackNode?.value ?? '';
|
|
133
|
+
if (!resolved)
|
|
134
|
+
return;
|
|
135
|
+
// Replace the `var(...)` function node in its containing array with a plain word node
|
|
136
|
+
// holding the resolved text, instead of mutating `node`'s discriminated `type` in place.
|
|
137
|
+
nodes[index] = {
|
|
138
|
+
type: 'word',
|
|
139
|
+
value: resolveVariables(resolved, variables),
|
|
140
|
+
sourceIndex: node.sourceIndex,
|
|
141
|
+
sourceEndIndex: node.sourceEndIndex,
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
return parsed.toString();
|
|
145
|
+
}
|
|
146
|
+
//#endregion var() resolution
|
|
147
|
+
//#region calc() evaluation
|
|
148
|
+
const CALC_TERM_PATTERN = /calc\(([^)]+)\)/g;
|
|
149
|
+
const NUMBER_WITH_UNIT_PATTERN = /(-?\d+(?:\.\d+)?)(rem|em|px)?/g;
|
|
150
|
+
/**
|
|
151
|
+
* Evaluates the narrow shape of `calc()` wolf-tui supported (a single multiplication, or the
|
|
152
|
+
* first numeric term as a fallback) — ported verbatim minus the terminal-cell unit scale.
|
|
153
|
+
* `px` is identity; `rem`/`em` scale by {@link REM_TO_PX}, matching a bare dimension value.
|
|
154
|
+
*/
|
|
155
|
+
function evaluateCalc(value) {
|
|
156
|
+
if (!value.includes('calc('))
|
|
157
|
+
return value;
|
|
158
|
+
return value.replace(CALC_TERM_PATTERN, (_, expr) => {
|
|
159
|
+
const matches = expr.match(NUMBER_WITH_UNIT_PATTERN) ?? [];
|
|
160
|
+
const values = [];
|
|
161
|
+
for (const term of matches) {
|
|
162
|
+
const numMatch = term.match(/(-?\d+(?:\.\d+)?)(rem|em|px)?/);
|
|
163
|
+
if (!numMatch)
|
|
164
|
+
continue;
|
|
165
|
+
const amount = parseFloat(numMatch[1]);
|
|
166
|
+
const unit = numMatch[2];
|
|
167
|
+
values.push(unit === 'rem' || unit === 'em' ? amount * REM_TO_PX : amount);
|
|
168
|
+
}
|
|
169
|
+
if (expr.includes('*')) {
|
|
170
|
+
const parts = expr.split('*').map(part => part.trim());
|
|
171
|
+
if (parts.length === 2) {
|
|
172
|
+
const a = values[0] ?? parseFloat(parts[0]) ?? 0;
|
|
173
|
+
const b = parseFloat(parts[1]) || 1;
|
|
174
|
+
return String(Math.round(a * b));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return String(Math.round(values[0] ?? 0));
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
//#endregion calc() evaluation
|
|
181
|
+
/**
|
|
182
|
+
* Parse a plain CSS string into a `{ className: RNStyleObject }` map. Build-time only — never
|
|
183
|
+
* ship this in the app's native JS bundle; it is meant to run inside a Metro transformer.
|
|
184
|
+
*/
|
|
185
|
+
export function parseCSS(css, options) {
|
|
186
|
+
if (!css || typeof css !== 'string')
|
|
187
|
+
return {};
|
|
188
|
+
const root = postcss.parse(css, { from: options?.filename });
|
|
189
|
+
const styles = {};
|
|
190
|
+
const warnedProperties = new Set();
|
|
191
|
+
// `@media` (and any other at-rule) is unsupported; drop it before the rule walk below so its
|
|
192
|
+
// nested rules never leak into the output.
|
|
193
|
+
root.walkAtRules(atRule => {
|
|
194
|
+
console.warn(`[@symbiote-native/css-parser] "@${atRule.name}" at-rules are not supported, "@${atRule.name} ${atRule.params}" skipped`);
|
|
195
|
+
atRule.remove();
|
|
196
|
+
});
|
|
197
|
+
const variables = new Map();
|
|
198
|
+
root.walkDecls(decl => {
|
|
199
|
+
if (decl.prop.startsWith('--'))
|
|
200
|
+
variables.set(decl.prop, decl.value);
|
|
201
|
+
});
|
|
202
|
+
root.walkRules(rule => {
|
|
203
|
+
const selectors = rule.selector.split(',').map(selector => selector.trim());
|
|
204
|
+
for (const selector of selectors) {
|
|
205
|
+
const className = extractClassName(selector);
|
|
206
|
+
if (!className)
|
|
207
|
+
continue;
|
|
208
|
+
const style = {};
|
|
209
|
+
rule.walkDecls(decl => {
|
|
210
|
+
if (decl.prop.startsWith('--'))
|
|
211
|
+
return;
|
|
212
|
+
const resolvedValue = evaluateCalc(resolveVariables(decl.value, variables));
|
|
213
|
+
const mapped = mapCSSProperty(decl.prop.toLowerCase(), resolvedValue, warnedProperties);
|
|
214
|
+
if (mapped)
|
|
215
|
+
Object.assign(style, mapped);
|
|
216
|
+
});
|
|
217
|
+
if (Object.keys(style).length === 0)
|
|
218
|
+
continue;
|
|
219
|
+
styles[className] = { ...styles[className], ...style };
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
return styles;
|
|
223
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type IPreprocessorLanguage = 'css' | 'scss' | 'less' | 'stylus';
|
|
2
|
+
export declare function isStyleFile(filename: string): boolean;
|
|
3
|
+
/** Extension → preprocessor language. `.scss`/`.sass` both route through the SCSS/Sass compiler
|
|
4
|
+
* (`compileScss` itself picks the concrete syntax off the extension); anything unrecognized is
|
|
5
|
+
* treated as plain CSS, same as today. */
|
|
6
|
+
export declare function detectLanguage(filename: string): IPreprocessorLanguage;
|
|
7
|
+
/** Compiles SCSS, or the indented Sass syntax when `filePath` ends in `.sass`, down to plain CSS
|
|
8
|
+
* text. `loadPaths` points at the source file's own directory so a relative `@use`/`@import`
|
|
9
|
+
* resolves the way an author would expect. */
|
|
10
|
+
export declare function compileScss(source: string, filePath?: string): Promise<string>;
|
|
11
|
+
export declare const compileSass: typeof compileScss;
|
|
12
|
+
/** Compiles Less down to plain CSS text. Less has no synchronous render API. */
|
|
13
|
+
export declare function compileLess(source: string, filePath?: string): Promise<string>;
|
|
14
|
+
/** Compiles Stylus down to plain CSS text. Stylus's `render` is callback-based; wrapped in a
|
|
15
|
+
* Promise so it composes with the rest of this module's async API. */
|
|
16
|
+
export declare function compileStylus(source: string, filePath?: string): Promise<string>;
|
|
17
|
+
/** Unified entry point: reduces any recognized preprocessor language down to plain CSS text.
|
|
18
|
+
* `lang: 'css'` is a no-op passthrough — callers decide whether preprocessing is needed at all,
|
|
19
|
+
* typically via {@link detectLanguage}. */
|
|
20
|
+
export declare function compile(source: string, lang: IPreprocessorLanguage, filePath?: string): Promise<string>;
|
|
@@ -0,0 +1,128 @@
|
|
|
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. Ported from
|
|
4
|
+
// wolf-tui's `internal/css-parser/src/preprocessors.ts` (the SCSS/Less/Stylus shape only — that
|
|
5
|
+
// file's Tailwind branch is a separate, out-of-scope concern here, see the
|
|
6
|
+
// symbiote-sfc-style-compiler skill).
|
|
7
|
+
//
|
|
8
|
+
// `sass`/`less`/`stylus` are lazy `import()`ed, never a top-level import, and are
|
|
9
|
+
// devDependencies of THIS package ONLY (never a `dependency`, see package.json) — a project that
|
|
10
|
+
// never authors `.scss`/`.less`/`.styl` must never be forced to install any of the three. The
|
|
11
|
+
// loaders below throw an install-instruction error the first time a preprocessor is actually
|
|
12
|
+
// needed and its package turns out to be missing, instead of failing this package's whole module
|
|
13
|
+
// graph at import time.
|
|
14
|
+
import * as path from 'node:path';
|
|
15
|
+
// Every extension this module recognizes as "a style file, possibly needing preprocessing" —
|
|
16
|
+
// the one list `isStyleFile` (the Metro-transformer-facing "should I even look at this file?"
|
|
17
|
+
// check) and `detectLanguage` (the "which compiler?" check) both key off, so a new preprocessor
|
|
18
|
+
// extension is added in exactly one place.
|
|
19
|
+
const RECOGNIZED_EXTENSIONS = new Map([
|
|
20
|
+
['.css', 'css'],
|
|
21
|
+
['.scss', 'scss'],
|
|
22
|
+
['.sass', 'scss'],
|
|
23
|
+
['.less', 'less'],
|
|
24
|
+
['.styl', 'stylus'],
|
|
25
|
+
['.stylus', 'stylus'],
|
|
26
|
+
]);
|
|
27
|
+
export function isStyleFile(filename) {
|
|
28
|
+
return RECOGNIZED_EXTENSIONS.has(path.extname(filename).toLowerCase());
|
|
29
|
+
}
|
|
30
|
+
/** Extension → preprocessor language. `.scss`/`.sass` both route through the SCSS/Sass compiler
|
|
31
|
+
* (`compileScss` itself picks the concrete syntax off the extension); anything unrecognized is
|
|
32
|
+
* treated as plain CSS, same as today. */
|
|
33
|
+
export function detectLanguage(filename) {
|
|
34
|
+
return RECOGNIZED_EXTENSIONS.get(path.extname(filename).toLowerCase()) ?? 'css';
|
|
35
|
+
}
|
|
36
|
+
let sassModule;
|
|
37
|
+
let lessModule;
|
|
38
|
+
let stylusModule;
|
|
39
|
+
async function loadSass() {
|
|
40
|
+
if (!sassModule) {
|
|
41
|
+
try {
|
|
42
|
+
sassModule = await import('sass');
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
throw new Error('sass is required for .scss/.sass files. Install it: npm i -D sass');
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return sassModule;
|
|
49
|
+
}
|
|
50
|
+
async function loadLess() {
|
|
51
|
+
if (!lessModule) {
|
|
52
|
+
try {
|
|
53
|
+
const mod = await import('less');
|
|
54
|
+
lessModule = mod.default ?? mod;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
throw new Error('less is required for .less files. Install it: npm i -D less');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return lessModule;
|
|
61
|
+
}
|
|
62
|
+
async function loadStylus() {
|
|
63
|
+
if (!stylusModule) {
|
|
64
|
+
try {
|
|
65
|
+
const mod = await import('stylus');
|
|
66
|
+
stylusModule = mod.default ?? mod;
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
throw new Error('stylus is required for .styl files. Install it: npm i -D stylus');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return stylusModule;
|
|
73
|
+
}
|
|
74
|
+
/** Compiles SCSS, or the indented Sass syntax when `filePath` ends in `.sass`, down to plain CSS
|
|
75
|
+
* text. `loadPaths` points at the source file's own directory so a relative `@use`/`@import`
|
|
76
|
+
* resolves the way an author would expect. */
|
|
77
|
+
export async function compileScss(source, filePath) {
|
|
78
|
+
const sass = await loadSass();
|
|
79
|
+
const result = sass.compileString(source, {
|
|
80
|
+
loadPaths: filePath ? [path.dirname(filePath)] : [],
|
|
81
|
+
syntax: filePath?.endsWith('.sass') ? 'indented' : 'scss',
|
|
82
|
+
});
|
|
83
|
+
return result.css;
|
|
84
|
+
}
|
|
85
|
+
// The indented Sass syntax and SCSS syntax share the one compiler entry point (`compileScss`'s
|
|
86
|
+
// `syntax` option already picks between them off the file extension) — `compileSass` is just the
|
|
87
|
+
// `.sass`-reading alias for it.
|
|
88
|
+
export const compileSass = compileScss;
|
|
89
|
+
/** Compiles Less down to plain CSS text. Less has no synchronous render API. */
|
|
90
|
+
export async function compileLess(source, filePath) {
|
|
91
|
+
const less = await loadLess();
|
|
92
|
+
const result = await less.render(source, {
|
|
93
|
+
filename: filePath,
|
|
94
|
+
paths: filePath ? [path.dirname(filePath)] : [],
|
|
95
|
+
});
|
|
96
|
+
return result.css;
|
|
97
|
+
}
|
|
98
|
+
/** Compiles Stylus down to plain CSS text. Stylus's `render` is callback-based; wrapped in a
|
|
99
|
+
* Promise so it composes with the rest of this module's async API. */
|
|
100
|
+
export async function compileStylus(source, filePath) {
|
|
101
|
+
const stylus = await loadStylus();
|
|
102
|
+
const compiler = stylus(source);
|
|
103
|
+
if (filePath)
|
|
104
|
+
compiler.set('filename', filePath);
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
compiler.render((error, css) => {
|
|
107
|
+
if (error)
|
|
108
|
+
reject(error);
|
|
109
|
+
else
|
|
110
|
+
resolve(css ?? '');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/** Unified entry point: reduces any recognized preprocessor language down to plain CSS text.
|
|
115
|
+
* `lang: 'css'` is a no-op passthrough — callers decide whether preprocessing is needed at all,
|
|
116
|
+
* typically via {@link detectLanguage}. */
|
|
117
|
+
export async function compile(source, lang, filePath) {
|
|
118
|
+
switch (lang) {
|
|
119
|
+
case 'scss':
|
|
120
|
+
return compileScss(source, filePath);
|
|
121
|
+
case 'less':
|
|
122
|
+
return compileLess(source, filePath);
|
|
123
|
+
case 'stylus':
|
|
124
|
+
return compileStylus(source, filePath);
|
|
125
|
+
case 'css':
|
|
126
|
+
return source;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
type IPropertyValueKind = 'number' | 'dimension' | 'raw';
|
|
2
|
+
type IPropertyMapping = {
|
|
3
|
+
rnProperty: string;
|
|
4
|
+
kind: IPropertyValueKind;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* kebab-case CSS property → { RN camelCase prop, value-conversion kind }.
|
|
8
|
+
* `dimension` = number-or-percent (`parseNumericOrPercent`); `number` = always a plain
|
|
9
|
+
* number (`parseNumeric`, no percent, e.g. `flexGrow`); `raw` = passthrough string
|
|
10
|
+
* (colors, font family, enum keywords like `flexDirection`).
|
|
11
|
+
*/
|
|
12
|
+
export declare const PROPERTY_TABLE: Record<string, IPropertyMapping>;
|
|
13
|
+
/**
|
|
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).
|
|
17
|
+
*
|
|
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.
|
|
22
|
+
*/
|
|
23
|
+
export declare function mapCSSProperty(prop: string, value: string, warnedProperties: Set<string>): Record<string, unknown> | null;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// CSS property → React Native ViewStyle/TextStyle prop mapping. Unlike wolf-tui's
|
|
2
|
+
// `properties.ts` (which maps onto a terminal-cell `Styles` type with TUI-only concepts like
|
|
3
|
+
// `borderStyle: 'round'`), RN's own style props already mirror CSS's shorthand model 1:1
|
|
4
|
+
// (`margin`, `borderRadius`, `borderTopLeftRadius`, …), so this table is a flat kebab→camel
|
|
5
|
+
// rename plus a value-conversion kind — no shorthand expansion is needed.
|
|
6
|
+
import { parseNumeric, parseNumericOrPercent, parseRawValue, parseTextShadow, warnOnce, } from "./values.js";
|
|
7
|
+
/**
|
|
8
|
+
* kebab-case CSS property → { RN camelCase prop, value-conversion kind }.
|
|
9
|
+
* `dimension` = number-or-percent (`parseNumericOrPercent`); `number` = always a plain
|
|
10
|
+
* number (`parseNumeric`, no percent, e.g. `flexGrow`); `raw` = passthrough string
|
|
11
|
+
* (colors, font family, enum keywords like `flexDirection`).
|
|
12
|
+
*/
|
|
13
|
+
export const PROPERTY_TABLE = {
|
|
14
|
+
// Flexbox / layout
|
|
15
|
+
flex: { rnProperty: 'flex', kind: 'number' },
|
|
16
|
+
'flex-direction': { rnProperty: 'flexDirection', kind: 'raw' },
|
|
17
|
+
'flex-wrap': { rnProperty: 'flexWrap', kind: 'raw' },
|
|
18
|
+
'flex-grow': { rnProperty: 'flexGrow', kind: 'number' },
|
|
19
|
+
'flex-shrink': { rnProperty: 'flexShrink', kind: 'number' },
|
|
20
|
+
'flex-basis': { rnProperty: 'flexBasis', kind: 'dimension' },
|
|
21
|
+
'align-items': { rnProperty: 'alignItems', kind: 'raw' },
|
|
22
|
+
'align-self': { rnProperty: 'alignSelf', kind: 'raw' },
|
|
23
|
+
'align-content': { rnProperty: 'alignContent', kind: 'raw' },
|
|
24
|
+
'justify-content': { rnProperty: 'justifyContent', kind: 'raw' },
|
|
25
|
+
width: { rnProperty: 'width', kind: 'dimension' },
|
|
26
|
+
height: { rnProperty: 'height', kind: 'dimension' },
|
|
27
|
+
'min-width': { rnProperty: 'minWidth', kind: 'dimension' },
|
|
28
|
+
'min-height': { rnProperty: 'minHeight', kind: 'dimension' },
|
|
29
|
+
'max-width': { rnProperty: 'maxWidth', kind: 'dimension' },
|
|
30
|
+
'max-height': { rnProperty: 'maxHeight', kind: 'dimension' },
|
|
31
|
+
position: { rnProperty: 'position', kind: 'raw' },
|
|
32
|
+
top: { rnProperty: 'top', kind: 'dimension' },
|
|
33
|
+
right: { rnProperty: 'right', kind: 'dimension' },
|
|
34
|
+
bottom: { rnProperty: 'bottom', kind: 'dimension' },
|
|
35
|
+
left: { rnProperty: 'left', kind: 'dimension' },
|
|
36
|
+
'z-index': { rnProperty: 'zIndex', kind: 'number' },
|
|
37
|
+
overflow: { rnProperty: 'overflow', kind: 'raw' },
|
|
38
|
+
// Only `flex`/`none` are valid RN values; passed through unvalidated per spec.
|
|
39
|
+
display: { rnProperty: 'display', kind: 'raw' },
|
|
40
|
+
// A genuine 1:1 CSS property (unlike transform/shadow — no shape mismatch), just missing
|
|
41
|
+
// from the initial table. `2 / 3` string ratios are not accepted here (`parseNumeric`
|
|
42
|
+
// requires a plain number) — CSS `aspect-ratio: 0.667` works, `aspect-ratio: 2/3` doesn't yet.
|
|
43
|
+
'aspect-ratio': { rnProperty: 'aspectRatio', kind: 'number' },
|
|
44
|
+
gap: { rnProperty: 'gap', kind: 'dimension' },
|
|
45
|
+
'row-gap': { rnProperty: 'rowGap', kind: 'dimension' },
|
|
46
|
+
'column-gap': { rnProperty: 'columnGap', kind: 'dimension' },
|
|
47
|
+
// Passed through as raw CSS text, UNPARSED — RN's own JS pre-processors
|
|
48
|
+
// (`core/engine/src/process-transform`, ported from RN's `processTransform.js`) already parse
|
|
49
|
+
// this exact CSS-function-list syntax at commit time (`enableNativeCSSParsing()` defaults to
|
|
50
|
+
// `false`, so RN's stock path always runs this JS parse before native, regardless of what
|
|
51
|
+
// produced the string). Re-parsing it here would duplicate that logic and, being a narrower
|
|
52
|
+
// reimplementation, would regress real RN features (`matrix()`, `perspective()`,
|
|
53
|
+
// `translate3d()`) the engine's port already handles.
|
|
54
|
+
transform: { rnProperty: 'transform', kind: 'raw' },
|
|
55
|
+
// Same reasoning as `transform`: `core/engine/src/process-box-shadow` already parses this
|
|
56
|
+
// exact CSS syntax at commit time, including multi-shadow lists, `inset`, and spread-radius —
|
|
57
|
+
// all of which RN's native `boxShadow` prop genuinely supports (Fabric, both platforms).
|
|
58
|
+
'box-shadow': { rnProperty: 'boxShadow', kind: 'raw' },
|
|
59
|
+
// Same reasoning as `transform`/`box-shadow`: `core/engine/src/process-filter` already parses
|
|
60
|
+
// this exact CSS filter-function-list syntax (`brightness()`, `blur()`, `drop-shadow()`, …) at
|
|
61
|
+
// commit time, ported from RN's own `processFilter.js`.
|
|
62
|
+
filter: { rnProperty: 'filter', kind: 'raw' },
|
|
63
|
+
// Same reasoning again: `core/engine/src/process-transform-origin` already parses this exact
|
|
64
|
+
// CSS syntax (keyword/length/percentage pairs, e.g. `top left`, `50% 100%`) at commit time,
|
|
65
|
+
// ported from RN's own `processTransformOrigin.js`.
|
|
66
|
+
'transform-origin': { rnProperty: 'transformOrigin', kind: 'raw' },
|
|
67
|
+
// Same reasoning again: `core/engine/src/process-background-image` already parses this exact
|
|
68
|
+
// CSS gradient syntax (`linear-gradient(...)`/`radial-gradient(...)`) at commit time, ported
|
|
69
|
+
// from RN's own `processBackgroundImage.js`. RN's own style prop is itself named with an
|
|
70
|
+
// `experimental_` prefix (still evolving upstream), which is why the RN key doesn't just
|
|
71
|
+
// match a kebab→camel rename of the CSS property the way every other entry here does.
|
|
72
|
+
'background-image': { rnProperty: 'experimental_backgroundImage', kind: 'raw' },
|
|
73
|
+
// Spacing
|
|
74
|
+
margin: { rnProperty: 'margin', kind: 'dimension' },
|
|
75
|
+
'margin-top': { rnProperty: 'marginTop', kind: 'dimension' },
|
|
76
|
+
'margin-right': { rnProperty: 'marginRight', kind: 'dimension' },
|
|
77
|
+
'margin-bottom': { rnProperty: 'marginBottom', kind: 'dimension' },
|
|
78
|
+
'margin-left': { rnProperty: 'marginLeft', kind: 'dimension' },
|
|
79
|
+
padding: { rnProperty: 'padding', kind: 'dimension' },
|
|
80
|
+
'padding-top': { rnProperty: 'paddingTop', kind: 'dimension' },
|
|
81
|
+
'padding-right': { rnProperty: 'paddingRight', kind: 'dimension' },
|
|
82
|
+
'padding-bottom': { rnProperty: 'paddingBottom', kind: 'dimension' },
|
|
83
|
+
'padding-left': { rnProperty: 'paddingLeft', kind: 'dimension' },
|
|
84
|
+
// Border
|
|
85
|
+
'border-width': { rnProperty: 'borderWidth', kind: 'dimension' },
|
|
86
|
+
'border-top-width': { rnProperty: 'borderTopWidth', kind: 'dimension' },
|
|
87
|
+
'border-right-width': { rnProperty: 'borderRightWidth', kind: 'dimension' },
|
|
88
|
+
'border-bottom-width': { rnProperty: 'borderBottomWidth', kind: 'dimension' },
|
|
89
|
+
'border-left-width': { rnProperty: 'borderLeftWidth', kind: 'dimension' },
|
|
90
|
+
'border-color': { rnProperty: 'borderColor', kind: 'raw' },
|
|
91
|
+
'border-top-color': { rnProperty: 'borderTopColor', kind: 'raw' },
|
|
92
|
+
'border-right-color': { rnProperty: 'borderRightColor', kind: 'raw' },
|
|
93
|
+
'border-bottom-color': { rnProperty: 'borderBottomColor', kind: 'raw' },
|
|
94
|
+
'border-left-color': { rnProperty: 'borderLeftColor', kind: 'raw' },
|
|
95
|
+
'border-radius': { rnProperty: 'borderRadius', kind: 'dimension' },
|
|
96
|
+
'border-top-left-radius': { rnProperty: 'borderTopLeftRadius', kind: 'dimension' },
|
|
97
|
+
'border-top-right-radius': { rnProperty: 'borderTopRightRadius', kind: 'dimension' },
|
|
98
|
+
'border-bottom-left-radius': { rnProperty: 'borderBottomLeftRadius', kind: 'dimension' },
|
|
99
|
+
'border-bottom-right-radius': { rnProperty: 'borderBottomRightRadius', kind: 'dimension' },
|
|
100
|
+
'border-style': { rnProperty: 'borderStyle', kind: 'raw' },
|
|
101
|
+
// Visual
|
|
102
|
+
'background-color': { rnProperty: 'backgroundColor', kind: 'raw' },
|
|
103
|
+
opacity: { rnProperty: 'opacity', kind: 'number' },
|
|
104
|
+
// Text
|
|
105
|
+
color: { rnProperty: 'color', kind: 'raw' },
|
|
106
|
+
'font-size': { rnProperty: 'fontSize', kind: 'dimension' },
|
|
107
|
+
'font-weight': { rnProperty: 'fontWeight', kind: 'raw' },
|
|
108
|
+
'font-family': { rnProperty: 'fontFamily', kind: 'raw' },
|
|
109
|
+
'font-style': { rnProperty: 'fontStyle', kind: 'raw' },
|
|
110
|
+
'text-align': { rnProperty: 'textAlign', kind: 'raw' },
|
|
111
|
+
'text-decoration-line': { rnProperty: 'textDecorationLine', kind: 'raw' },
|
|
112
|
+
'line-height': { rnProperty: 'lineHeight', kind: 'dimension' },
|
|
113
|
+
'letter-spacing': { rnProperty: 'letterSpacing', kind: 'dimension' },
|
|
114
|
+
};
|
|
115
|
+
function convertValue(kind, value) {
|
|
116
|
+
switch (kind) {
|
|
117
|
+
case 'number':
|
|
118
|
+
return parseNumeric(value);
|
|
119
|
+
case 'dimension':
|
|
120
|
+
return parseNumericOrPercent(value);
|
|
121
|
+
case 'raw':
|
|
122
|
+
return parseRawValue(value);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Map one CSS declaration to its RN style entry. Returns `null` and warns once per unique
|
|
127
|
+
* unsupported property name (deduped via the caller-owned `warnedProperties` set, so the
|
|
128
|
+
* warning fires once per {@link parseCSS} call, not per occurrence).
|
|
129
|
+
*
|
|
130
|
+
* `text-shadow` bypasses {@link PROPERTY_TABLE}: RN has no unified CSS-string `textShadow` prop
|
|
131
|
+
* (unlike `transform`/`box-shadow` above) — only three separate legacy props
|
|
132
|
+
* (`textShadowColor`/`Offset`/`Radius`) that take already-decomposed values, so this package is
|
|
133
|
+
* the only place that CAN parse the CSS shorthand; there is no engine-level processor to defer to.
|
|
134
|
+
*/
|
|
135
|
+
export function mapCSSProperty(prop, value, warnedProperties) {
|
|
136
|
+
if (prop === 'text-shadow')
|
|
137
|
+
return parseTextShadow(value, warnedProperties);
|
|
138
|
+
const mapping = PROPERTY_TABLE[prop];
|
|
139
|
+
if (!mapping) {
|
|
140
|
+
warnOnce(warnedProperties, prop, `[@symbiote-native/css-parser] unsupported CSS property "${prop}" dropped`);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
return { [mapping.rnProperty]: convertValue(mapping.kind, value) };
|
|
144
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
declare const REM_TO_PX = 16;
|
|
2
|
+
/**
|
|
3
|
+
* Convert a CSS dimension to a plain number: strips `px`, scales `rem`/`em` by
|
|
4
|
+
* {@link REM_TO_PX}, and passes unitless numbers through untouched.
|
|
5
|
+
*/
|
|
6
|
+
export declare function parseNumeric(value: string): number;
|
|
7
|
+
/**
|
|
8
|
+
* Same as {@link parseNumeric}, except a percentage value is kept as a string
|
|
9
|
+
* (`'50%'`) — RN accepts percentage strings for most layout props.
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseNumericOrPercent(value: string): number | string;
|
|
12
|
+
/**
|
|
13
|
+
* Colors, font families, and CSS keyword values (`'bold'`, `'row'`, `'red'`) pass through
|
|
14
|
+
* unchanged — RN accepts the same raw strings CSS does for these props.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseRawValue(value: string): string;
|
|
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).
|
|
21
|
+
*/
|
|
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
|
+
export { REM_TO_PX };
|