@webstudio-is/css-engine 0.95.0 → 0.96.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/lib/index.js CHANGED
@@ -1,3 +1,561 @@
1
- "use strict";
2
- export * from "./core";
3
- export * from "./schema";
1
+ // src/core/to-value.ts
2
+ import { captureError } from "@webstudio-is/error-utils";
3
+ import { DEFAULT_FONT_FALLBACK, SYSTEM_FONTS } from "@webstudio-is/fonts";
4
+ var fallbackTransform = (styleValue) => {
5
+ if (styleValue.type === "fontFamily") {
6
+ const firstFontFamily = styleValue.value[0];
7
+ const fallbacks = SYSTEM_FONTS.get(firstFontFamily);
8
+ const fontFamily = [...styleValue.value];
9
+ if (Array.isArray(fallbacks)) {
10
+ fontFamily.push(...fallbacks);
11
+ } else {
12
+ fontFamily.push(DEFAULT_FONT_FALLBACK);
13
+ }
14
+ return {
15
+ type: "fontFamily",
16
+ value: fontFamily
17
+ };
18
+ }
19
+ };
20
+ var toValue = (styleValue, transformValue) => {
21
+ if (styleValue === void 0) {
22
+ return "";
23
+ }
24
+ const transformedValue = transformValue?.(styleValue) ?? fallbackTransform(styleValue);
25
+ const value = transformedValue ?? styleValue;
26
+ if (value.type === "unit") {
27
+ return value.value + (value.unit === "number" ? "" : value.unit);
28
+ }
29
+ if (value.type === "fontFamily") {
30
+ return value.value.join(", ");
31
+ }
32
+ if (value.type === "var") {
33
+ const fallbacks = [];
34
+ for (const fallback of value.fallbacks) {
35
+ fallbacks.push(toValue(fallback, transformValue));
36
+ }
37
+ const fallbacksString = fallbacks.length > 0 ? `, ${fallbacks.join(", ")}` : "";
38
+ return `var(--${value.value}${fallbacksString})`;
39
+ }
40
+ if (value.type === "keyword") {
41
+ return value.value;
42
+ }
43
+ if (value.type === "invalid") {
44
+ return value.value;
45
+ }
46
+ if (value.type === "unset") {
47
+ return value.value;
48
+ }
49
+ if (value.type === "rgb") {
50
+ return `rgba(${value.r}, ${value.g}, ${value.b}, ${value.alpha})`;
51
+ }
52
+ if (value.type === "image") {
53
+ if (value.hidden || value.value.type !== "url") {
54
+ return "none";
55
+ }
56
+ return `url(${value.value.url})`;
57
+ }
58
+ if (value.type === "unparsed") {
59
+ if (value.hidden) {
60
+ return "none";
61
+ }
62
+ return value.value;
63
+ }
64
+ if (value.type === "layers") {
65
+ const valueString = value.value.filter(
66
+ (layer) => "hidden" in layer === false || "hidden" in layer && layer.hidden === false
67
+ ).map((layer) => {
68
+ return toValue(layer, transformValue);
69
+ }).join(", ");
70
+ return valueString === "" ? "none" : valueString;
71
+ }
72
+ if (value.type === "tuple") {
73
+ return value.value.map((value2) => toValue(value2, transformValue)).join(" ");
74
+ }
75
+ return captureError(new Error("Unknown value type"), value);
76
+ };
77
+
78
+ // src/core/to-property.ts
79
+ import hyphenate from "hyphenate-style-name";
80
+ var toProperty = (property) => {
81
+ if (property === "backgroundClip") {
82
+ return "-webkit-background-clip";
83
+ }
84
+ return hyphenate(property);
85
+ };
86
+
87
+ // src/core/rules.ts
88
+ var StylePropertyMap = class {
89
+ #styleMap = /* @__PURE__ */ new Map();
90
+ #isDirty = false;
91
+ #string = "";
92
+ #indent = 0;
93
+ #transformValue;
94
+ onChange;
95
+ constructor(transformValue) {
96
+ this.#transformValue = transformValue;
97
+ }
98
+ setTransformer(transformValue) {
99
+ this.#transformValue = transformValue;
100
+ }
101
+ set(property, value) {
102
+ this.#styleMap.set(property, value);
103
+ this.#isDirty = true;
104
+ this.onChange?.();
105
+ }
106
+ has(property) {
107
+ return this.#styleMap.has(property);
108
+ }
109
+ get size() {
110
+ return this.#styleMap.size;
111
+ }
112
+ keys() {
113
+ return this.#styleMap.keys();
114
+ }
115
+ delete(property) {
116
+ this.#styleMap.delete(property);
117
+ this.#isDirty = true;
118
+ this.onChange?.();
119
+ }
120
+ clear() {
121
+ this.#styleMap.clear();
122
+ this.#isDirty = true;
123
+ this.onChange?.();
124
+ }
125
+ toString({ indent = 0 } = {}) {
126
+ if (this.#isDirty === false && indent === this.#indent) {
127
+ return this.#string;
128
+ }
129
+ this.#indent = indent;
130
+ const block = [];
131
+ const spaces = " ".repeat(indent);
132
+ for (const [property, value] of this.#styleMap) {
133
+ if (value === void 0) {
134
+ continue;
135
+ }
136
+ block.push(
137
+ `${spaces}${toProperty(property)}: ${toValue(
138
+ value,
139
+ this.#transformValue
140
+ )}`
141
+ );
142
+ }
143
+ this.#string = block.join(";\n");
144
+ this.#isDirty = false;
145
+ return this.#string;
146
+ }
147
+ };
148
+ var StyleRule = class {
149
+ styleMap;
150
+ selectorText;
151
+ onChange;
152
+ constructor(selectorText, style, transformValue) {
153
+ this.styleMap = new StylePropertyMap(transformValue);
154
+ this.selectorText = selectorText;
155
+ let property;
156
+ for (property in style) {
157
+ this.styleMap.set(property, style[property]);
158
+ }
159
+ this.styleMap.onChange = this.#onChange;
160
+ }
161
+ #onChange = () => {
162
+ this.onChange?.();
163
+ };
164
+ get cssText() {
165
+ return this.toString();
166
+ }
167
+ toString(options = { indent: 0 }) {
168
+ const spaces = " ".repeat(options.indent);
169
+ return `${spaces}${this.selectorText} {
170
+ ${this.styleMap.toString({
171
+ indent: options.indent + 2
172
+ })}
173
+ ${spaces}}`;
174
+ }
175
+ };
176
+ var MediaRule = class {
177
+ options;
178
+ rules = [];
179
+ #mediaType;
180
+ constructor(options = {}) {
181
+ this.options = options;
182
+ this.#mediaType = options.mediaType ?? "all";
183
+ }
184
+ insertRule(rule) {
185
+ this.rules.push(rule);
186
+ return rule;
187
+ }
188
+ get cssText() {
189
+ return this.toString();
190
+ }
191
+ toString() {
192
+ if (this.rules.length === 0) {
193
+ return "";
194
+ }
195
+ const rules = [];
196
+ for (const rule of this.rules) {
197
+ rules.push(rule.toString({ indent: 2 }));
198
+ }
199
+ let conditionText = "";
200
+ const { minWidth, maxWidth } = this.options;
201
+ if (minWidth !== void 0) {
202
+ conditionText = ` and (min-width: ${minWidth}px)`;
203
+ }
204
+ if (maxWidth !== void 0) {
205
+ conditionText += ` and (max-width: ${maxWidth}px)`;
206
+ }
207
+ return `@media ${this.#mediaType}${conditionText} {
208
+ ${rules.join(
209
+ "\n"
210
+ )}
211
+ }`;
212
+ }
213
+ };
214
+ var PlaintextRule = class {
215
+ cssText;
216
+ styleMap = new StylePropertyMap();
217
+ constructor(cssText) {
218
+ this.cssText = cssText;
219
+ }
220
+ toString() {
221
+ return this.cssText;
222
+ }
223
+ };
224
+ var FontFaceRule = class {
225
+ options;
226
+ constructor(options) {
227
+ this.options = options;
228
+ }
229
+ get cssText() {
230
+ return this.toString();
231
+ }
232
+ toString() {
233
+ const decls = [];
234
+ const { fontFamily, fontStyle, fontWeight, fontDisplay, src } = this.options;
235
+ decls.push(
236
+ `font-family: ${/\s/.test(fontFamily) ? `"${fontFamily}"` : fontFamily}`
237
+ );
238
+ decls.push(`font-style: ${fontStyle}`);
239
+ decls.push(`font-weight: ${fontWeight}`);
240
+ decls.push(`font-display: ${fontDisplay}`);
241
+ decls.push(`src: ${src}`);
242
+ return `@font-face {
243
+ ${decls.join("; ")};
244
+ }`;
245
+ }
246
+ };
247
+
248
+ // src/core/compare-media.ts
249
+ var compareMedia = (optionA, optionB) => {
250
+ if (optionA.minWidth === void 0 && optionA.maxWidth === void 0) {
251
+ return -1;
252
+ }
253
+ if (optionB.minWidth === void 0 && optionB.maxWidth === void 0) {
254
+ return 1;
255
+ }
256
+ if (optionA.minWidth !== void 0 && optionB.minWidth !== void 0) {
257
+ return optionA.minWidth - optionB.minWidth;
258
+ }
259
+ if (optionA.maxWidth !== void 0 && optionB.maxWidth !== void 0) {
260
+ return optionB.maxWidth - optionA.maxWidth;
261
+ }
262
+ return "minWidth" in optionA ? 1 : -1;
263
+ };
264
+
265
+ // src/core/style-element.ts
266
+ var StyleElement = class {
267
+ #element;
268
+ #name;
269
+ constructor(name = "") {
270
+ this.#name = name;
271
+ }
272
+ get isMounted() {
273
+ return this.#element?.parentElement != null;
274
+ }
275
+ mount() {
276
+ if (this.isMounted === false) {
277
+ this.#element = document.createElement("style");
278
+ this.#element.setAttribute("data-webstudio", this.#name);
279
+ document.head.appendChild(this.#element);
280
+ }
281
+ }
282
+ unmount() {
283
+ if (this.isMounted) {
284
+ this.#element?.parentElement?.removeChild(this.#element);
285
+ this.#element = void 0;
286
+ }
287
+ }
288
+ render(cssText) {
289
+ if (this.#element) {
290
+ this.#element.textContent = cssText;
291
+ }
292
+ }
293
+ setAttribute(name, value) {
294
+ if (this.#element) {
295
+ this.#element.setAttribute(name, value);
296
+ }
297
+ }
298
+ getAttribute(name) {
299
+ if (this.#element) {
300
+ return this.#element.getAttribute(name);
301
+ }
302
+ }
303
+ };
304
+
305
+ // src/core/style-sheet.ts
306
+ var StyleSheet = class {
307
+ #cssText = "";
308
+ #element;
309
+ constructor(element) {
310
+ this.#element = element;
311
+ }
312
+ replaceSync(cssText) {
313
+ if (cssText !== this.#cssText) {
314
+ this.#cssText = cssText;
315
+ this.#element.render(cssText);
316
+ }
317
+ }
318
+ };
319
+
320
+ // src/core/css-engine.ts
321
+ var defaultMediaRuleId = "__default-media-rule__";
322
+ var CssEngine = class {
323
+ #element;
324
+ #mediaRules = /* @__PURE__ */ new Map();
325
+ #plainRules = /* @__PURE__ */ new Map();
326
+ #fontFaceRules = [];
327
+ #sheet;
328
+ #isDirty = false;
329
+ #cssText = "";
330
+ constructor({ name }) {
331
+ this.#element = new StyleElement(name);
332
+ this.#sheet = new StyleSheet(this.#element);
333
+ }
334
+ addMediaRule(id, options) {
335
+ let mediaRule = this.#mediaRules.get(id);
336
+ if (mediaRule === void 0) {
337
+ mediaRule = new MediaRule(options);
338
+ this.#mediaRules.set(id, mediaRule);
339
+ this.#isDirty = true;
340
+ return mediaRule;
341
+ }
342
+ if (options) {
343
+ mediaRule.options = options;
344
+ this.#isDirty = true;
345
+ }
346
+ return mediaRule;
347
+ }
348
+ addStyleRule(selectorText, rule, transformValue) {
349
+ const mediaRule = this.addMediaRule(rule.breakpoint || defaultMediaRuleId);
350
+ this.#isDirty = true;
351
+ const styleRule = new StyleRule(selectorText, rule.style, transformValue);
352
+ styleRule.onChange = this.#onChangeRule;
353
+ if (mediaRule === void 0) {
354
+ throw new Error("No media rule found");
355
+ }
356
+ mediaRule.insertRule(styleRule);
357
+ return styleRule;
358
+ }
359
+ addPlaintextRule(cssText) {
360
+ const rule = this.#plainRules.get(cssText);
361
+ if (rule !== void 0) {
362
+ return rule;
363
+ }
364
+ this.#isDirty = true;
365
+ return this.#plainRules.set(cssText, new PlaintextRule(cssText));
366
+ }
367
+ addFontFaceRule(options) {
368
+ this.#isDirty = true;
369
+ return this.#fontFaceRules.push(new FontFaceRule(options));
370
+ }
371
+ clear() {
372
+ this.#mediaRules.clear();
373
+ this.#plainRules.clear();
374
+ this.#fontFaceRules = [];
375
+ this.#isDirty = true;
376
+ }
377
+ render() {
378
+ this.#element.mount();
379
+ this.#sheet.replaceSync(this.cssText);
380
+ }
381
+ unmount() {
382
+ this.#element.unmount();
383
+ }
384
+ setAttribute(name, value) {
385
+ this.#element.setAttribute(name, value);
386
+ }
387
+ getAttribute(name) {
388
+ return this.#element.getAttribute(name);
389
+ }
390
+ get cssText() {
391
+ if (this.#isDirty === false) {
392
+ return this.#cssText;
393
+ }
394
+ this.#isDirty = false;
395
+ const css = [];
396
+ css.push(...this.#fontFaceRules.map((rule) => rule.cssText));
397
+ for (const plaintextRule of this.#plainRules.values()) {
398
+ css.push(plaintextRule.cssText);
399
+ }
400
+ const sortedMediaRules = Array.from(this.#mediaRules.values()).sort(
401
+ (ruleA, ruleB) => compareMedia(ruleA.options, ruleB.options)
402
+ );
403
+ for (const mediaRule of sortedMediaRules) {
404
+ const { cssText } = mediaRule;
405
+ if (cssText !== "") {
406
+ css.push(cssText);
407
+ }
408
+ }
409
+ this.#cssText = css.join("\n");
410
+ return this.#cssText;
411
+ }
412
+ #onChangeRule = () => {
413
+ this.#isDirty = true;
414
+ };
415
+ };
416
+
417
+ // src/core/create-css-engine.ts
418
+ var createCssEngine = (options = {}) => {
419
+ return new CssEngine(options);
420
+ };
421
+
422
+ // src/core/match-media.ts
423
+ var matchMedia = (options, width) => {
424
+ const minWidth = options.minWidth ?? Number.MIN_SAFE_INTEGER;
425
+ const maxWidth = options.maxWidth ?? Number.MAX_SAFE_INTEGER;
426
+ return width >= minWidth && width <= maxWidth;
427
+ };
428
+
429
+ // src/core/equal-media.ts
430
+ var equalMedia = (left, right) => {
431
+ return left.minWidth === right.minWidth && left.maxWidth === right.maxWidth;
432
+ };
433
+
434
+ // src/core/find-applicable-media.ts
435
+ var findApplicableMedia = (media, width) => {
436
+ const sortedMedia = [...media].sort(compareMedia).reverse();
437
+ for (const options of sortedMedia) {
438
+ if (matchMedia(options, width)) {
439
+ return options;
440
+ }
441
+ }
442
+ };
443
+
444
+ // src/schema.ts
445
+ import { z } from "zod";
446
+ var Unit = z.string();
447
+ var UnitValue = z.object({
448
+ type: z.literal("unit"),
449
+ unit: Unit,
450
+ value: z.number()
451
+ });
452
+ var KeywordValue = z.object({
453
+ type: z.literal("keyword"),
454
+ // @todo use exact type
455
+ value: z.string()
456
+ });
457
+ var UnparsedValue = z.object({
458
+ type: z.literal("unparsed"),
459
+ value: z.string(),
460
+ // For the builder we want to be able to hide background-image
461
+ hidden: z.boolean().optional()
462
+ });
463
+ var FontFamilyValue = z.object({
464
+ type: z.literal("fontFamily"),
465
+ value: z.array(z.string())
466
+ });
467
+ var RgbValue = z.object({
468
+ type: z.literal("rgb"),
469
+ r: z.number(),
470
+ g: z.number(),
471
+ b: z.number(),
472
+ alpha: z.number()
473
+ });
474
+ var ImageValue = z.object({
475
+ type: z.literal("image"),
476
+ value: z.union([
477
+ z.object({ type: z.literal("asset"), value: z.string() }),
478
+ // url is not stored in db and only used by css-engine transformValue
479
+ // to prepare image value for rendering
480
+ z.object({ type: z.literal("url"), url: z.string() })
481
+ ]),
482
+ // For the builder we want to be able to hide images
483
+ hidden: z.boolean().optional()
484
+ });
485
+ var InvalidValue = z.object({
486
+ type: z.literal("invalid"),
487
+ value: z.string()
488
+ });
489
+ var UnsetValue = z.object({
490
+ type: z.literal("unset"),
491
+ value: z.literal("")
492
+ });
493
+ var TupleValueItem = z.union([
494
+ UnitValue,
495
+ KeywordValue,
496
+ UnparsedValue,
497
+ RgbValue
498
+ ]);
499
+ var TupleValue = z.object({
500
+ type: z.literal("tuple"),
501
+ value: z.array(TupleValueItem),
502
+ hidden: z.boolean().optional()
503
+ });
504
+ var LayerValueItem = z.union([
505
+ UnitValue,
506
+ KeywordValue,
507
+ UnparsedValue,
508
+ ImageValue,
509
+ TupleValue,
510
+ InvalidValue
511
+ ]);
512
+ var LayersValue = z.object({
513
+ type: z.literal("layers"),
514
+ value: z.array(LayerValueItem)
515
+ });
516
+ var ValidStaticStyleValue = z.union([
517
+ ImageValue,
518
+ LayersValue,
519
+ UnitValue,
520
+ KeywordValue,
521
+ FontFamilyValue,
522
+ RgbValue,
523
+ UnparsedValue,
524
+ TupleValue
525
+ ]);
526
+ var isValidStaticStyleValue = (styleValue) => {
527
+ const staticStyleValue = styleValue;
528
+ return staticStyleValue.type === "image" || staticStyleValue.type === "layers" || staticStyleValue.type === "unit" || staticStyleValue.type === "keyword" || staticStyleValue.type === "fontFamily" || staticStyleValue.type === "rgb" || staticStyleValue.type === "unparsed" || staticStyleValue.type === "tuple";
529
+ };
530
+ var VarValue = z.object({
531
+ type: z.literal("var"),
532
+ value: z.string(),
533
+ fallbacks: z.array(ValidStaticStyleValue)
534
+ });
535
+ var StyleValue = z.union([
536
+ ValidStaticStyleValue,
537
+ InvalidValue,
538
+ UnsetValue,
539
+ VarValue
540
+ ]);
541
+ var Style = z.record(z.string(), StyleValue);
542
+ export {
543
+ CssEngine,
544
+ ImageValue,
545
+ InvalidValue,
546
+ KeywordValue,
547
+ LayersValue,
548
+ StyleValue,
549
+ TupleValue,
550
+ TupleValueItem,
551
+ UnitValue,
552
+ UnparsedValue,
553
+ compareMedia,
554
+ createCssEngine,
555
+ equalMedia,
556
+ findApplicableMedia,
557
+ isValidStaticStyleValue,
558
+ matchMedia,
559
+ toProperty,
560
+ toValue
561
+ };
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@webstudio-is/css-engine",
3
- "version": "0.95.0",
3
+ "version": "0.96.0",
4
4
  "description": "CSS Renderer for Webstudio",
5
5
  "author": "Webstudio <github@webstudio.is>",
6
6
  "homepage": "https://webstudio.is",
7
7
  "type": "module",
8
8
  "dependencies": {
9
9
  "hyphenate-style-name": "^1.0.4",
10
- "@webstudio-is/error-utils": "^0.95.0",
11
- "@webstudio-is/fonts": "^0.95.0"
10
+ "@webstudio-is/error-utils": "^0.96.0",
11
+ "@webstudio-is/fonts": "^0.96.0"
12
12
  },
13
13
  "devDependencies": {
14
14
  "@jest/globals": "^29.6.4",
@@ -39,8 +39,8 @@
39
39
  "scripts": {
40
40
  "typecheck": "tsc",
41
41
  "checks": "pnpm typecheck && pnpm test",
42
- "dev": "pnpm build --watch",
43
- "build": "rm -rf lib && esbuild 'src/**/*.ts' 'src/**/*.tsx' --outdir=lib",
42
+ "dev": "rm -rf lib && esbuild 'src/**/*.ts' 'src/**/*.tsx' --outdir=lib --watch",
43
+ "build": "rm -rf lib && esbuild src/index.ts --outdir=lib --bundle --format=esm --packages=external",
44
44
  "dts": "tsc --project tsconfig.dts.json",
45
45
  "test": "NODE_OPTIONS=--experimental-vm-modules jest",
46
46
  "storybook:dev": "storybook dev -p 6006",
@@ -1 +0,0 @@
1
- "use strict";
@@ -1,16 +0,0 @@
1
- "use strict";
2
- export const compareMedia = (optionA, optionB) => {
3
- if (optionA.minWidth === void 0 && optionA.maxWidth === void 0) {
4
- return -1;
5
- }
6
- if (optionB.minWidth === void 0 && optionB.maxWidth === void 0) {
7
- return 1;
8
- }
9
- if (optionA.minWidth !== void 0 && optionB.minWidth !== void 0) {
10
- return optionA.minWidth - optionB.minWidth;
11
- }
12
- if (optionA.maxWidth !== void 0 && optionB.maxWidth !== void 0) {
13
- return optionB.maxWidth - optionA.maxWidth;
14
- }
15
- return "minWidth" in optionA ? 1 : -1;
16
- };
@@ -1,63 +0,0 @@
1
- "use strict";
2
- import { describe, test, expect } from "@jest/globals";
3
- import { compareMedia } from "./compare-media";
4
- describe("Compare media", () => {
5
- test("min-width", () => {
6
- const initial = [
7
- {},
8
- { minWidth: 1280 },
9
- { minWidth: 0 },
10
- { minWidth: 1024 },
11
- { minWidth: 768 }
12
- ];
13
- const expected = [
14
- {},
15
- { minWidth: 0 },
16
- { minWidth: 768 },
17
- { minWidth: 1024 },
18
- { minWidth: 1280 }
19
- ];
20
- const sorted = initial.sort(compareMedia);
21
- expect(sorted).toStrictEqual(expected);
22
- });
23
- test("max-width", () => {
24
- const initial = [
25
- {},
26
- { maxWidth: 1280 },
27
- { maxWidth: 0 },
28
- { maxWidth: 1024 },
29
- { maxWidth: 768 }
30
- ];
31
- const expected = [
32
- {},
33
- { maxWidth: 1280 },
34
- { maxWidth: 1024 },
35
- { maxWidth: 768 },
36
- { maxWidth: 0 }
37
- ];
38
- const sorted = initial.sort(compareMedia);
39
- expect(sorted).toStrictEqual(expected);
40
- });
41
- test("mixed max and min", () => {
42
- const initial = [
43
- {},
44
- { maxWidth: 991 },
45
- { maxWidth: 479 },
46
- { maxWidth: 767 },
47
- { minWidth: 1440 },
48
- { minWidth: 1280 },
49
- { minWidth: 1920 }
50
- ];
51
- const expected = [
52
- {},
53
- { maxWidth: 991 },
54
- { maxWidth: 767 },
55
- { maxWidth: 479 },
56
- { minWidth: 1280 },
57
- { minWidth: 1440 },
58
- { minWidth: 1920 }
59
- ];
60
- const sorted = initial.sort(compareMedia);
61
- expect(sorted).toStrictEqual(expected);
62
- });
63
- });
@@ -1,5 +0,0 @@
1
- "use strict";
2
- import { CssEngine } from "./css-engine";
3
- export const createCssEngine = (options = {}) => {
4
- return new CssEngine(options);
5
- };