@webstudio-is/css-data 0.66.0 → 0.67.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.
Files changed (36) hide show
  1. package/lib/cjs/index.js +3 -0
  2. package/lib/cjs/parse-css-value.js +152 -0
  3. package/lib/cjs/parse-css.js +125 -0
  4. package/lib/cjs/property-parsers/background.js +105 -0
  5. package/lib/cjs/property-parsers/index.js +18 -0
  6. package/lib/cjs/property-parsers/parsers.js +24 -0
  7. package/lib/cjs/property-parsers/to-longhand.js +24 -0
  8. package/lib/index.js +3 -0
  9. package/lib/parse-css-value.js +122 -0
  10. package/lib/parse-css.js +95 -0
  11. package/lib/property-parsers/background.js +75 -0
  12. package/lib/property-parsers/index.js +1 -0
  13. package/lib/property-parsers/parsers.js +4 -0
  14. package/lib/property-parsers/to-longhand.js +4 -0
  15. package/lib/types/src/index.d.ts +3 -0
  16. package/lib/types/src/parse-css-value.d.ts +3 -0
  17. package/lib/types/src/parse-css-value.test.d.ts +1 -0
  18. package/lib/types/src/parse-css.d.ts +9 -0
  19. package/lib/types/src/parse-css.test.d.ts +1 -0
  20. package/lib/types/src/property-parsers/background.d.ts +11 -0
  21. package/lib/types/src/property-parsers/background.test.d.ts +1 -0
  22. package/lib/types/src/property-parsers/index.d.ts +1 -0
  23. package/lib/types/src/property-parsers/parsers.d.ts +1 -0
  24. package/lib/types/src/property-parsers/to-longhand.d.ts +1 -0
  25. package/package.json +12 -4
  26. package/src/index.ts +6 -0
  27. package/src/parse-css-value.test.ts +136 -0
  28. package/src/parse-css-value.ts +157 -0
  29. package/src/parse-css.test.ts +101 -0
  30. package/src/parse-css.ts +124 -0
  31. package/src/property-parsers/README.md +11 -0
  32. package/src/property-parsers/background.test.ts +184 -0
  33. package/src/property-parsers/background.ts +132 -0
  34. package/src/property-parsers/index.ts +1 -0
  35. package/src/property-parsers/parsers.ts +4 -0
  36. package/src/property-parsers/to-longhand.ts +4 -0
@@ -0,0 +1,124 @@
1
+ import * as csstree from "css-tree";
2
+ import { parseCssValue as parseCssValueLonghand } from "./parse-css-value";
3
+ import * as parsers from "./property-parsers/parsers";
4
+ import * as toLonghand from "./property-parsers/to-longhand";
5
+ import { StyleValue, type Style as S, type StyleProperty } from "./schema";
6
+
7
+ type Selector = string;
8
+ type Style = {
9
+ // @todo add support for states and media queries in addition to declarations
10
+ property: StyleProperty;
11
+ value: StyleValue;
12
+ };
13
+
14
+ export type Styles = Record<Selector, Style[]>;
15
+
16
+ type Longhand = keyof typeof toLonghand;
17
+
18
+ const parseCssValue = function parseCssValue(
19
+ property: Longhand | StyleProperty,
20
+ value: string
21
+ ): S {
22
+ const unwrap = toLonghand[property as Longhand];
23
+
24
+ if (typeof unwrap === "function") {
25
+ const longhands = unwrap(value);
26
+
27
+ return Object.fromEntries(
28
+ Object.entries(longhands).map(([property, value]) => {
29
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
30
+ // @ts-ignore @todo remove this ignore: property is a `keyof typeof longhands` which is a key in parsers but TS can't infer the link
31
+ const valueParser = parsers[property];
32
+
33
+ if (typeof valueParser === "function") {
34
+ return [property, valueParser(value)];
35
+ }
36
+
37
+ if (Array.isArray(value)) {
38
+ return [
39
+ property,
40
+ {
41
+ type: "invalid",
42
+ value: value.join(""),
43
+ },
44
+ ];
45
+ }
46
+
47
+ if (!value) {
48
+ return [property, { type: "invalid", value: "" }];
49
+ }
50
+
51
+ return [
52
+ property,
53
+ parseCssValueLonghand(property as StyleProperty, value),
54
+ ];
55
+ })
56
+ );
57
+ }
58
+
59
+ return {
60
+ [property]: parseCssValueLonghand(property as StyleProperty, value),
61
+ };
62
+ };
63
+
64
+ export const parseCss = function cssToWS(css: string) {
65
+ const ast = csstree.parse(css);
66
+
67
+ let selectors: Selector[] = [];
68
+ const styles: Styles = {};
69
+
70
+ csstree.walk(ast, (node, item) => {
71
+ if (node.type === "SelectorList") {
72
+ selectors = [];
73
+ }
74
+ if (node.type === "ClassSelector") {
75
+ if (!item.prev && !item.next) {
76
+ selectors.push(node.name);
77
+ }
78
+ return;
79
+ }
80
+
81
+ if (node.type === "Declaration") {
82
+ const stringValue = csstree.generate(node.value);
83
+
84
+ const parsedCss = parseCssValue(
85
+ node.property as Longhand | StyleProperty,
86
+ stringValue
87
+ );
88
+
89
+ (Object.entries(parsedCss) as [StyleProperty, StyleValue][]).forEach(
90
+ ([property, value]) => {
91
+ try {
92
+ StyleValue.parse(value);
93
+ selectors.forEach((selector) => {
94
+ if (Array.isArray(styles[selector])) {
95
+ styles[selector].push({
96
+ property,
97
+ value,
98
+ });
99
+ } else {
100
+ styles[selector] = [{ property, value }];
101
+ }
102
+ });
103
+ } catch (error) {
104
+ if (process.env.NODE_ENV !== "production") {
105
+ // eslint-disable-next-line no-console
106
+ console.warn(
107
+ true,
108
+ `Declaration parsing for \`${selectors.join(", ")}.${
109
+ node.property
110
+ }: ${stringValue}\` failed:\n\n${JSON.stringify(
111
+ parsedCss,
112
+ null,
113
+ 2
114
+ )}`
115
+ );
116
+ }
117
+ }
118
+ }
119
+ );
120
+ }
121
+ });
122
+
123
+ return styles;
124
+ };
@@ -0,0 +1,11 @@
1
+ ## Properties Parsers
2
+
3
+ This folder contains:
4
+
5
+ - `parsers.ts` individual properties parses
6
+ - `to-longhand.ts` shorthand forms unwrappers
7
+
8
+ The idea is that these two modules export a number of utilities whose name match the property that they parse or unwrap e.g.:
9
+
10
+ - `backgroundImage` is a property parser
11
+ - `background` is a "to-longhand" unwrapper
@@ -0,0 +1,184 @@
1
+ import { describe, expect, test } from "@jest/globals";
2
+
3
+ import { parseBackground } from "./background";
4
+
5
+ describe("parseBackground", () => {
6
+ test("parse background from figma", () => {
7
+ expect(
8
+ parseBackground(
9
+ "linear-gradient(180deg, #11181C 0%, rgba(17, 24, 28, 0) 36.09%), none, linear-gradient(180deg, rgba(230, 60, 254, 0.33) 0%, rgba(255, 174, 60, 0) 100%), radial-gradient(54.1% 95.83% at 100% 100%, #FFFFFF 0%, rgba(255, 255, 255, 0) 100%), linear-gradient(122.33deg, rgba(74, 78, 250, 0.2) 0%, rgba(0, 0, 0, 0) 69.38%), radial-gradient(92.26% 201.29% at 98.6% 10.65%, rgba(255, 174, 60, 0.3) 0%, rgba(227, 53, 255, 0) 100%) /* warning: gradient uses a rotation that is not supported by CSS and may not behave as expected */, radial-gradient(84.64% 267.51% at 10.07% 81.45%, rgba(53, 255, 182, 0.2) 0%, rgba(74, 78, 250, 0.2) 100%) /* warning: gradient uses a rotation that is not supported by CSS and may not behave as expected */, #EBFFFC;"
10
+ )
11
+ ).toMatchInlineSnapshot(`
12
+ {
13
+ "backgroundColor": {
14
+ "alpha": 1,
15
+ "b": 252,
16
+ "g": 255,
17
+ "r": 235,
18
+ "type": "rgb",
19
+ },
20
+ "backgroundImage": {
21
+ "type": "layers",
22
+ "value": [
23
+ {
24
+ "type": "unparsed",
25
+ "value": "linear-gradient(180deg,#11181C 0%,rgba(17,24,28,0) 36.09%)",
26
+ },
27
+ {
28
+ "type": "unparsed",
29
+ "value": "linear-gradient(180deg,rgba(230,60,254,0.33) 0%,rgba(255,174,60,0) 100%)",
30
+ },
31
+ {
32
+ "type": "unparsed",
33
+ "value": "radial-gradient(54.1% 95.83%at 100% 100%,#FFFFFF 0%,rgba(255,255,255,0) 100%)",
34
+ },
35
+ {
36
+ "type": "unparsed",
37
+ "value": "linear-gradient(122.33deg,rgba(74,78,250,0.2) 0%,rgba(0,0,0,0) 69.38%)",
38
+ },
39
+ {
40
+ "type": "unparsed",
41
+ "value": "radial-gradient(92.26% 201.29%at 98.6% 10.65%,rgba(255,174,60,0.3) 0%,rgba(227,53,255,0) 100%)",
42
+ },
43
+ {
44
+ "type": "unparsed",
45
+ "value": "radial-gradient(84.64% 267.51%at 10.07% 81.45%,rgba(53,255,182,0.2) 0%,rgba(74,78,250,0.2) 100%)",
46
+ },
47
+ ],
48
+ },
49
+ }
50
+ `);
51
+ });
52
+
53
+ test("parse background from figma without backgroundColor", () => {
54
+ expect(
55
+ parseBackground(
56
+ "linear-gradient(180deg, #11181C 0%, rgba(17, 24, 28, 0) 36.09%), linear-gradient(180deg, rgba(230, 60, 254, 0.33) 0%, rgba(255, 174, 60, 0) 100%), radial-gradient(54.1% 95.83% at 100% 100%, #FFFFFF 0%, rgba(255, 255, 255, 0) 100%), linear-gradient(122.33deg, rgba(74, 78, 250, 0.2) 0%, rgba(0, 0, 0, 0) 69.38%), radial-gradient(92.26% 201.29% at 98.6% 10.65%, rgba(255, 174, 60, 0.3) 0%, rgba(227, 53, 255, 0) 100%) /* warning: gradient uses a rotation that is not supported by CSS and may not behave as expected */, radial-gradient(84.64% 267.51% at 10.07% 81.45%, rgba(53, 255, 182, 0.2) 0%, rgba(74, 78, 250, 0.2) 100%) /* warning: gradient uses a rotation that is not supported by CSS and may not behave as expected */"
57
+ )
58
+ ).toMatchInlineSnapshot(`
59
+ {
60
+ "backgroundColor": undefined,
61
+ "backgroundImage": {
62
+ "type": "layers",
63
+ "value": [
64
+ {
65
+ "type": "unparsed",
66
+ "value": "linear-gradient(180deg,#11181C 0%,rgba(17,24,28,0) 36.09%)",
67
+ },
68
+ {
69
+ "type": "unparsed",
70
+ "value": "linear-gradient(180deg,rgba(230,60,254,0.33) 0%,rgba(255,174,60,0) 100%)",
71
+ },
72
+ {
73
+ "type": "unparsed",
74
+ "value": "radial-gradient(54.1% 95.83%at 100% 100%,#FFFFFF 0%,rgba(255,255,255,0) 100%)",
75
+ },
76
+ {
77
+ "type": "unparsed",
78
+ "value": "linear-gradient(122.33deg,rgba(74,78,250,0.2) 0%,rgba(0,0,0,0) 69.38%)",
79
+ },
80
+ {
81
+ "type": "unparsed",
82
+ "value": "radial-gradient(92.26% 201.29%at 98.6% 10.65%,rgba(255,174,60,0.3) 0%,rgba(227,53,255,0) 100%)",
83
+ },
84
+ {
85
+ "type": "unparsed",
86
+ "value": "radial-gradient(84.64% 267.51%at 10.07% 81.45%,rgba(53,255,182,0.2) 0%,rgba(74,78,250,0.2) 100%)",
87
+ },
88
+ ],
89
+ },
90
+ }
91
+ `);
92
+ });
93
+
94
+ test("parse background and skips url background", () => {
95
+ expect(
96
+ parseBackground(
97
+ "url(https://hello.world/some-image), linear-gradient(180deg, rgba(230, 60, 254, 0.33) 0%, rgba(255, 174, 60, 0) 100%), radial-gradient(54.1% 95.83% at 100% 100%, #FFFFFF 0%, rgba(255, 255, 255, 0) 100%), linear-gradient(122.33deg, rgba(74, 78, 250, 0.2) 0%, rgba(0, 0, 0, 0) 69.38%), radial-gradient(92.26% 201.29% at 98.6% 10.65%, rgba(255, 174, 60, 0.3) 0%, rgba(227, 53, 255, 0) 100%) /* warning: gradient uses a rotation that is not supported by CSS and may not behave as expected */, radial-gradient(84.64% 267.51% at 10.07% 81.45%, rgba(53, 255, 182, 0.2) 0%, rgba(74, 78, 250, 0.2) 100%) /* warning: gradient uses a rotation that is not supported by CSS and may not behave as expected */"
98
+ )
99
+ ).toMatchInlineSnapshot(`
100
+ {
101
+ "backgroundColor": undefined,
102
+ "backgroundImage": {
103
+ "type": "layers",
104
+ "value": [
105
+ {
106
+ "type": "unparsed",
107
+ "value": "linear-gradient(180deg,rgba(230,60,254,0.33) 0%,rgba(255,174,60,0) 100%)",
108
+ },
109
+ {
110
+ "type": "unparsed",
111
+ "value": "radial-gradient(54.1% 95.83%at 100% 100%,#FFFFFF 0%,rgba(255,255,255,0) 100%)",
112
+ },
113
+ {
114
+ "type": "unparsed",
115
+ "value": "linear-gradient(122.33deg,rgba(74,78,250,0.2) 0%,rgba(0,0,0,0) 69.38%)",
116
+ },
117
+ {
118
+ "type": "unparsed",
119
+ "value": "radial-gradient(92.26% 201.29%at 98.6% 10.65%,rgba(255,174,60,0.3) 0%,rgba(227,53,255,0) 100%)",
120
+ },
121
+ {
122
+ "type": "unparsed",
123
+ "value": "radial-gradient(84.64% 267.51%at 10.07% 81.45%,rgba(53,255,182,0.2) 0%,rgba(74,78,250,0.2) 100%)",
124
+ },
125
+ ],
126
+ },
127
+ }
128
+ `);
129
+ });
130
+
131
+ test("parse background partially copied background", () => {
132
+ expect(
133
+ parseBackground(
134
+ "linear-gradient(180deg, #11181C 0%, rgba(17, 24, 28, 0) 36.09%), linear-gradient(180deg, "
135
+ )
136
+ ).toMatchInlineSnapshot(`
137
+ {
138
+ "backgroundColor": undefined,
139
+ "backgroundImage": {
140
+ "type": "layers",
141
+ "value": [
142
+ {
143
+ "type": "unparsed",
144
+ "value": "linear-gradient(180deg,#11181C 0%,rgba(17,24,28,0) 36.09%)",
145
+ },
146
+ ],
147
+ },
148
+ }
149
+ `);
150
+ });
151
+
152
+ test("parse background partially commented", () => {
153
+ expect(
154
+ parseBackground(
155
+ "linear-gradient(180deg, rgba(230, 60, 254, 0.33) 0%, rgba(255, 174, 60, 0) 100%), /* radial-gradient(54.1% 95.83% at 100% 100%, #FFFFFF 0%, rgba(255, 255, 255, 0) 100%) */"
156
+ )
157
+ ).toMatchInlineSnapshot(`
158
+ {
159
+ "backgroundColor": undefined,
160
+ "backgroundImage": {
161
+ "type": "layers",
162
+ "value": [
163
+ {
164
+ "type": "unparsed",
165
+ "value": "linear-gradient(180deg,rgba(230,60,254,0.33) 0%,rgba(255,174,60,0) 100%)",
166
+ },
167
+ ],
168
+ },
169
+ }
170
+ `);
171
+ });
172
+
173
+ test("parse bad background", () => {
174
+ expect(parseBackground("linear-gradient(180deg,")).toMatchInlineSnapshot(`
175
+ {
176
+ "backgroundColor": undefined,
177
+ "backgroundImage": {
178
+ "type": "invalid",
179
+ "value": "linear-gradient(180deg,)",
180
+ },
181
+ }
182
+ `);
183
+ });
184
+ });
@@ -0,0 +1,132 @@
1
+ import * as csstree from "css-tree";
2
+ import { parseCssValue } from "../parse-css-value";
3
+ import type {
4
+ InvalidValue,
5
+ LayersValue,
6
+ RgbValue,
7
+ UnparsedValue,
8
+ } from "../schema";
9
+
10
+ export const gradientNames = [
11
+ "conic-gradient",
12
+ "linear-gradient",
13
+ "radial-gradient",
14
+ "repeating-conic-gradient",
15
+ "repeating-linear-gradient",
16
+ "repeating-radial-gradient",
17
+ ];
18
+
19
+ export const parseBackground = (
20
+ background: string
21
+ ): {
22
+ backgroundImage: LayersValue | InvalidValue;
23
+ backgroundColor: RgbValue | undefined;
24
+ } => {
25
+ const { backgroundImage, backgroundColor: backgroundColorRaw } =
26
+ backgroundToLonghand(background);
27
+
28
+ const backgroundColor = backgroundColorRaw
29
+ ? (parseCssValue("backgroundColor", backgroundColorRaw) as RgbValue)
30
+ : undefined;
31
+
32
+ return {
33
+ backgroundImage: parseBackgroundImage(backgroundImage),
34
+ backgroundColor:
35
+ backgroundColor && backgroundColor.type === "rgb"
36
+ ? backgroundColor
37
+ : undefined,
38
+ };
39
+ };
40
+
41
+ export const backgroundToLonghand = (
42
+ background: string
43
+ ): {
44
+ backgroundImage: string[];
45
+ backgroundColor: string | undefined;
46
+ } => {
47
+ const layers: string[] = [];
48
+
49
+ let tokenStream = background.trim();
50
+
51
+ tokenStream = tokenStream.endsWith(";")
52
+ ? tokenStream.slice(0, -1)
53
+ : tokenStream;
54
+
55
+ // The user can copy the whole style together with the name of the property from figma or any other tool.
56
+ // We need to remove the property name from the string.
57
+ const cleanupKeywords = ["background:", "background-image:"];
58
+
59
+ for (const cleanupKeyword of cleanupKeywords) {
60
+ tokenStream = tokenStream.startsWith(cleanupKeyword)
61
+ ? tokenStream.slice(cleanupKeyword.length).trim()
62
+ : tokenStream;
63
+ }
64
+
65
+ const cssAst = csstree.parse(tokenStream, { context: "value" });
66
+
67
+ let backgroundColorRaw: string | undefined;
68
+
69
+ let nestingLevel = 0;
70
+
71
+ csstree.walk(cssAst, {
72
+ enter: (node, item, list) => {
73
+ if (node.type === "Function") {
74
+ if (gradientNames.includes(node.name)) {
75
+ layers.push(csstree.generate(node));
76
+ }
77
+
78
+ // If the depth is at level 0 and the next item is null, it's likely that the backgroundColor
79
+ // is written as rgba(x,y,z,a) or similar format.
80
+ // nestingLevel is used as a fast way to check existance of parent Function node
81
+ if (item.next === null && nestingLevel === 0) {
82
+ backgroundColorRaw = csstree.generate(node);
83
+ }
84
+
85
+ nestingLevel++;
86
+ }
87
+
88
+ if (node.type === "Hash" && item.next === null && nestingLevel === 0) {
89
+ // If the depth is at level 0 and the next item is null, it's likely that the backgroundColor
90
+ // is written as hex #XYZFGH
91
+ backgroundColorRaw = csstree.generate(node);
92
+ }
93
+ },
94
+ leave: (node, item, list) => {
95
+ if (node.type === "Function") {
96
+ nestingLevel--;
97
+ }
98
+ },
99
+ });
100
+
101
+ return {
102
+ backgroundImage: layers,
103
+ backgroundColor: backgroundColorRaw,
104
+ };
105
+ };
106
+
107
+ export const parseBackgroundImage = (
108
+ layers: string[]
109
+ ): LayersValue | InvalidValue => {
110
+ const backgroundImages: UnparsedValue[] = [];
111
+
112
+ for (const layer of layers) {
113
+ if (
114
+ gradientNames.some((gradientName) => layer.startsWith(gradientName)) ===
115
+ false
116
+ ) {
117
+ break;
118
+ }
119
+
120
+ const layerStyle = parseCssValue("backgroundImage", layer);
121
+
122
+ if (layerStyle.type !== "unparsed") {
123
+ break;
124
+ }
125
+
126
+ backgroundImages.push(layerStyle);
127
+ }
128
+
129
+ return backgroundImages.length > 0
130
+ ? { type: "layers", value: backgroundImages }
131
+ : { type: "invalid", value: layers.join(",") };
132
+ };
@@ -0,0 +1 @@
1
+ export * from "./background";
@@ -0,0 +1,4 @@
1
+ /*
2
+ Longhand properties parsers. Each named export should match a property name.
3
+ */
4
+ export { parseBackgroundImage as backgroundImage } from "./background";
@@ -0,0 +1,4 @@
1
+ /*
2
+ Shorthand properties unwrappers. Each named export should match a property name.
3
+ */
4
+ export { backgroundToLonghand as background } from "./background";