@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.
@@ -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 };