From b8e2a6e910c39e1e13fae05d7fc38f5d92f50155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:59:33 +0200 Subject: [PATCH] Unify remote element style declarations (#23263) Front components run in a Web Worker with a fake DOM. Until now the worker had a hand-rolled `style` object for remote elements and the host had its own separate CSS-string parser: two implementations of the same parsing that kept drifting apart (several review rounds fixed edge cases in one copy but not the other). What changed: - One shared `createStyleProxy` now backs `element.style` in the worker, and one shared `parseCssDeclarations` feeds both the worker proxy and the host's `parseCssString`. Most of the diff is existing logic split out of `installStylePropertyOnRemoteElements` into small single-purpose utils (`splitCssDeclarations`, `stripImportantPriorityFromCssValue`, `normalizeCssPropertyName`, `formatCssValue`, ...), not new behavior. - `!important` is stripped from values instead of tracked. Nothing ever read priorities back, and the host applies styles through React inline styles, which cannot express `!important`. Rendering note: `color: red !important` used to reach React as an invalid value (property silently not applied); it now applies, without the priority. - Style writes flush to the host synchronously, exactly as on main. - The parser handles quotes, escapes and parentheses; CSS comments inside hand-written `cssText` are not supported. This shared proxy is also the base for the worker `getComputedStyle` stub in the geometry PR. Second of three PRs splitting the geometry mirror work. --- .../src/constants/UnitlessCssPropertyNames.ts | 60 +++++ .../utils/__tests__/parseCssString.test.ts | 28 ++- .../src/host/utils/parseCssString.ts | 35 ++- .../src/polyfills/dom/types/StyleProxy.ts | 11 + .../utils/__tests__/createStyleProxy.test.ts | 137 +++++++++++ .../utils/__tests__/formatCssValue.test.ts | 27 +++ .../parseCssTextIntoStyleDeclarations.test.ts | 33 +++ ...eCssPropertyNameFromJsPropertyName.test.ts | 26 ++ ...erializeStyleDeclarationsToCssText.test.ts | 13 + .../polyfills/dom/utils/createStyleProxy.ts | 126 ++++++++++ .../src/polyfills/dom/utils/formatCssValue.ts | 17 ++ .../dom/utils/isImportantPriorityKeyword.ts | 2 + .../dom/utils/isObjectPrototypeMember.ts | 3 + .../parseCssTextIntoStyleDeclarations.ts | 15 ++ ...esolveCssPropertyNameFromJsPropertyName.ts | 24 ++ .../serializeStyleDeclarationsToCssText.ts | 6 + .../installStylePropertyOnRemoteElements.ts | 222 +++--------------- .../src/types/CssDeclaration.ts | 4 + .../__tests__/isCssCustomPropertyName.test.ts | 11 + .../__tests__/parseCssDeclarations.test.ts | 77 ++++++ .../__tests__/splitCssDeclarations.test.ts | 54 +++++ ...stripImportantPriorityFromCssValue.test.ts | 23 ++ .../src/utils/isCssCustomPropertyName.ts | 2 + .../src/utils/normalizeCssPropertyName.ts | 6 + .../src/utils/parseCssDeclarations.ts | 49 ++++ .../src/utils/splitCssDeclarations.ts | 55 +++++ .../stripImportantPriorityFromCssValue.ts | 5 + 27 files changed, 853 insertions(+), 218 deletions(-) create mode 100644 packages/twenty-front-component-renderer/src/constants/UnitlessCssPropertyNames.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/types/StyleProxy.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/createStyleProxy.test.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/formatCssValue.test.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/parseCssTextIntoStyleDeclarations.test.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/resolveCssPropertyNameFromJsPropertyName.test.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/serializeStyleDeclarationsToCssText.test.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/utils/createStyleProxy.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/utils/formatCssValue.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/utils/isImportantPriorityKeyword.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/utils/isObjectPrototypeMember.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/utils/parseCssTextIntoStyleDeclarations.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/utils/resolveCssPropertyNameFromJsPropertyName.ts create mode 100644 packages/twenty-front-component-renderer/src/polyfills/dom/utils/serializeStyleDeclarationsToCssText.ts create mode 100644 packages/twenty-front-component-renderer/src/types/CssDeclaration.ts create mode 100644 packages/twenty-front-component-renderer/src/utils/__tests__/isCssCustomPropertyName.test.ts create mode 100644 packages/twenty-front-component-renderer/src/utils/__tests__/parseCssDeclarations.test.ts create mode 100644 packages/twenty-front-component-renderer/src/utils/__tests__/splitCssDeclarations.test.ts create mode 100644 packages/twenty-front-component-renderer/src/utils/__tests__/stripImportantPriorityFromCssValue.test.ts create mode 100644 packages/twenty-front-component-renderer/src/utils/isCssCustomPropertyName.ts create mode 100644 packages/twenty-front-component-renderer/src/utils/normalizeCssPropertyName.ts create mode 100644 packages/twenty-front-component-renderer/src/utils/parseCssDeclarations.ts create mode 100644 packages/twenty-front-component-renderer/src/utils/splitCssDeclarations.ts create mode 100644 packages/twenty-front-component-renderer/src/utils/stripImportantPriorityFromCssValue.ts diff --git a/packages/twenty-front-component-renderer/src/constants/UnitlessCssPropertyNames.ts b/packages/twenty-front-component-renderer/src/constants/UnitlessCssPropertyNames.ts new file mode 100644 index 0000000000..1e438d6d43 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/constants/UnitlessCssPropertyNames.ts @@ -0,0 +1,60 @@ +const UNITLESS_CSS_PROPERTY_BASE_NAMES = [ + 'animationIterationCount', + 'aspectRatio', + 'borderImageOutset', + 'borderImageSlice', + 'borderImageWidth', + 'boxFlex', + 'boxFlexGroup', + 'boxOrdinalGroup', + 'columnCount', + 'columns', + 'flex', + 'flexGrow', + 'flexPositive', + 'flexShrink', + 'flexNegative', + 'flexOrder', + 'gridArea', + 'gridRow', + 'gridRowEnd', + 'gridRowSpan', + 'gridRowStart', + 'gridColumn', + 'gridColumnEnd', + 'gridColumnSpan', + 'gridColumnStart', + 'fontWeight', + 'lineClamp', + 'lineHeight', + 'opacity', + 'order', + 'orphans', + 'scale', + 'tabSize', + 'widows', + 'zIndex', + 'zoom', + 'fillOpacity', + 'floodOpacity', + 'stopOpacity', + 'strokeDasharray', + 'strokeDashoffset', + 'strokeMiterlimit', + 'strokeOpacity', + 'strokeWidth', +]; + +const UNITLESS_CSS_PROPERTY_VENDOR_PREFIXES = ['Webkit', 'Moz', 'ms', 'O']; + +const withVendorPrefixedAliases = (propertyName: string): string[] => [ + propertyName, + ...UNITLESS_CSS_PROPERTY_VENDOR_PREFIXES.map( + (vendorPrefix) => + `${vendorPrefix}${propertyName[0].toUpperCase()}${propertyName.slice(1)}`, + ), +]; + +export const UNITLESS_CSS_PROPERTY_NAMES = new Set( + UNITLESS_CSS_PROPERTY_BASE_NAMES.flatMap(withVendorPrefixedAliases), +); diff --git a/packages/twenty-front-component-renderer/src/host/utils/__tests__/parseCssString.test.ts b/packages/twenty-front-component-renderer/src/host/utils/__tests__/parseCssString.test.ts index b474897812..81d25a22d7 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/__tests__/parseCssString.test.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/__tests__/parseCssString.test.ts @@ -1,9 +1,9 @@ import { parseCssString } from '../parseCssString'; describe('parseCssString', () => { - it('should return the input unchanged when it is not a non-empty string', () => { + it('should return undefined when the input is not a non-empty string', () => { expect(parseCssString(undefined)).toBeUndefined(); - expect(parseCssString('')).toBe(''); + expect(parseCssString('')).toBeUndefined(); }); it('should convert kebab-case properties to camelCase', () => { @@ -23,13 +23,27 @@ describe('parseCssString', () => { }); }); - it('should skip declarations without a colon', () => { - expect(parseCssString('color: red; invalid')).toEqual({ color: 'red' }); + it('should keep semicolons inside url() values', () => { + expect( + parseCssString( + 'background-image: url(data:image/png;base64,abc); color: red', + ), + ).toEqual({ + backgroundImage: 'url(data:image/png;base64,abc)', + color: 'red', + }); }); - it('should only split on the first colon so values may contain colons', () => { - expect(parseCssString('background: url(http://example.com)')).toEqual({ - background: 'url(http://example.com)', + it('should strip an important priority from values', () => { + expect(parseCssString('color: red !important; width: 10px')).toEqual({ + color: 'red', + width: '10px', + }); + }); + + it('should keep an earlier important declaration over a later normal duplicate', () => { + expect(parseCssString('color: red !important; color: blue')).toEqual({ + color: 'red', }); }); }); diff --git a/packages/twenty-front-component-renderer/src/host/utils/parseCssString.ts b/packages/twenty-front-component-renderer/src/host/utils/parseCssString.ts index 4e8aedf06c..368c50f276 100644 --- a/packages/twenty-front-component-renderer/src/host/utils/parseCssString.ts +++ b/packages/twenty-front-component-renderer/src/host/utils/parseCssString.ts @@ -1,35 +1,28 @@ import { isNonEmptyString } from '@sniptt/guards'; import { type CSSProperties } from 'react'; +import { kebabToCamelCase } from 'twenty-shared/utils'; + +import { isCssCustomPropertyName } from '@/utils/isCssCustomPropertyName'; +import { parseCssDeclarations } from '@/utils/parseCssDeclarations'; export const parseCssString = ( styleString: string | undefined, ): CSSProperties | undefined => { if (!isNonEmptyString(styleString)) { - return styleString as CSSProperties | undefined; + return undefined; } - const style: Record = {}; - const declarations = styleString.split(';').filter(Boolean); + const reactStyleProperties: Record = {}; - for (const declaration of declarations) { - const colonIndex = declaration.indexOf(':'); - if (colonIndex === -1) { - continue; - } + for (const { cssPropertyName, cssValue } of parseCssDeclarations( + styleString, + )) { + const reactStylePropertyName = isCssCustomPropertyName(cssPropertyName) + ? cssPropertyName + : kebabToCamelCase(cssPropertyName); - const property = declaration.slice(0, colonIndex).trim(); - const value = declaration.slice(colonIndex + 1).trim(); - - const isCssCustomProperty = property.startsWith('--'); - - const key = isCssCustomProperty - ? property - : property.replace(/-([a-z])/g, (_, letter: string) => - letter.toUpperCase(), - ); - - style[key] = value; + reactStyleProperties[reactStylePropertyName] = cssValue; } - return style; + return reactStyleProperties; }; diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/types/StyleProxy.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/types/StyleProxy.ts new file mode 100644 index 0000000000..1af00d561f --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/types/StyleProxy.ts @@ -0,0 +1,11 @@ +export type StyleProxy = { + cssText: string; + setProperty: ( + cssPropertyName: string, + value: string | null, + priority?: string, + ) => void; + removeProperty: (cssPropertyName: string) => string; + getPropertyValue: (cssPropertyName: string) => string; + [stylePropertyName: string]: unknown; +}; diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/createStyleProxy.test.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/createStyleProxy.test.ts new file mode 100644 index 0000000000..3ccbe2eb33 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/createStyleProxy.test.ts @@ -0,0 +1,137 @@ +import { createStyleProxy } from '../createStyleProxy'; + +describe('createStyleProxy', () => { + it('should round trip a property through setProperty and getPropertyValue', () => { + const style = createStyleProxy(() => {}); + + style.setProperty('color', 'red'); + + expect(style.getPropertyValue('color')).toBe('red'); + expect(style.cssText).toBe('color:red'); + }); + + it('should flush every mutation synchronously with the serialized cssText', () => { + const flush = jest.fn(); + const style = createStyleProxy(flush); + + style.setProperty('color', 'red'); + style.width = 10; + style.removeProperty('color'); + + expect( + flush.mock.calls.map(([serializedCssText]) => serializedCssText), + ).toEqual(['color:red', 'color:red;width:10px', 'width:10px']); + }); + + it('should accept an important priority and store the plain value', () => { + const style = createStyleProxy(() => {}); + + style.setProperty('color', 'red', 'IMPORTANT'); + + expect(style.getPropertyValue('color')).toBe('red'); + expect(style.cssText).toBe('color:red'); + }); + + it('should ignore setProperty with an invalid priority', () => { + const flush = jest.fn(); + const style = createStyleProxy(flush); + + style.setProperty('color', 'red', 'loud'); + + expect(style.getPropertyValue('color')).toBe(''); + expect(flush).not.toHaveBeenCalled(); + }); + + it('should ignore setProperty with a whitespace-padded important priority', () => { + const style = createStyleProxy(() => {}); + + style.setProperty('color', 'red', ' important '); + + expect(style.getPropertyValue('color')).toBe(''); + }); + + it('should remove the property when the value is empty even with an invalid priority', () => { + const style = createStyleProxy(() => {}); + + style.setProperty('color', 'red'); + style.setProperty('color', '', 'loud'); + + expect(style.getPropertyValue('color')).toBe(''); + expect(style.cssText).toBe(''); + }); + + it('should normalize the property name passed to setProperty and getPropertyValue', () => { + const style = createStyleProxy(() => {}); + + style.setProperty('COLOR', 'red'); + + expect(style.getPropertyValue('color')).toBe('red'); + expect(style.color).toBe('red'); + }); + + it('should replace all declarations when cssText is assigned', () => { + const style = createStyleProxy(() => {}); + + style.setProperty('color', 'red'); + style.cssText = 'width: 10px; height: 20px'; + + expect(style.getPropertyValue('color')).toBe(''); + expect(style.cssText).toBe('width:10px;height:20px'); + }); + + it('should resolve direct camelCase assignment to the css property name', () => { + const style = createStyleProxy(() => {}); + + style.backgroundColor = 'red'; + + expect(style.getPropertyValue('background-color')).toBe('red'); + expect(style.backgroundColor).toBe('red'); + }); + + it('should map the cssFloat alias to the float property', () => { + const style = createStyleProxy(() => {}); + + style.cssFloat = 'left'; + + expect(style.cssText).toBe('float:left'); + }); + + it('should remove a property on empty direct assignment', () => { + const style = createStyleProxy(() => {}); + + style.color = 'red'; + style.color = ''; + + expect(style.cssText).toBe(''); + }); + + it('should append px to numeric values while keeping unitless properties unitless', () => { + const style = createStyleProxy(() => {}); + + style.width = 10; + style.aspectRatio = 2; + + expect(style.getPropertyValue('width')).toBe('10px'); + expect(style.getPropertyValue('aspect-ratio')).toBe('2'); + }); + + it('should preserve the exact custom property key on direct assignment', () => { + const style = createStyleProxy(() => {}); + + style['--myVar'] = '2px'; + + expect(style.cssText).toBe('--myVar:2px'); + expect(style['--myVar']).toBe('2px'); + }); + + it('should keep Object.prototype methods callable', () => { + const style = createStyleProxy(() => {}); + + style.setProperty('color', 'red'); + + expect(style.hasOwnProperty('color')).toBe(true); + expect(style.hasOwnProperty('background')).toBe(false); + expect(() => `${style}`).not.toThrow(); + expect(String(style)).toBe('[object Object]'); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/formatCssValue.test.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/formatCssValue.test.ts new file mode 100644 index 0000000000..c81fa19307 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/formatCssValue.test.ts @@ -0,0 +1,27 @@ +import { formatCssValue } from '../formatCssValue'; + +describe('formatCssValue', () => { + it('should append px to a numeric length', () => { + expect(formatCssValue(10, 'width')).toBe('10px'); + }); + + it('should keep a unitless numeric property unitless', () => { + expect(formatCssValue(2, 'aspectRatio')).toBe('2'); + }); + + it('should keep a vendor-prefixed unitless property unitless', () => { + expect(formatCssValue(3, 'WebkitLineClamp')).toBe('3'); + }); + + it('should never append px to a custom property', () => { + expect(formatCssValue(4, '--gap')).toBe('4'); + }); + + it('should keep zero unitless', () => { + expect(formatCssValue(0, 'width')).toBe('0'); + }); + + it('should stringify non-numeric values', () => { + expect(formatCssValue('10px', 'width')).toBe('10px'); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/parseCssTextIntoStyleDeclarations.test.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/parseCssTextIntoStyleDeclarations.test.ts new file mode 100644 index 0000000000..1fc36919ad --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/parseCssTextIntoStyleDeclarations.test.ts @@ -0,0 +1,33 @@ +import { parseCssTextIntoStyleDeclarations } from '../parseCssTextIntoStyleDeclarations'; + +describe('parseCssTextIntoStyleDeclarations', () => { + it('should parse declarations into values keyed by css property name', () => { + expect( + parseCssTextIntoStyleDeclarations('color: red !important; width: 10px'), + ).toEqual({ color: 'red', width: '10px' }); + }); + + it('should let the last duplicate win', () => { + expect( + parseCssTextIntoStyleDeclarations('color: red; color: blue'), + ).toEqual({ color: 'blue' }); + }); + + it('should keep an earlier important declaration over a later normal duplicate', () => { + expect( + parseCssTextIntoStyleDeclarations('color: red !important; color: blue'), + ).toEqual({ color: 'red' }); + }); + + it('should lowercase standard property names while preserving custom ones', () => { + expect( + parseCssTextIntoStyleDeclarations('COLOR: red; --My-Var: 1px'), + ).toEqual({ color: 'red', '--My-Var': '1px' }); + }); + + it('should skip declarations without a property name or value', () => { + expect( + parseCssTextIntoStyleDeclarations(': red; color: ; width: 10px'), + ).toEqual({ width: '10px' }); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/resolveCssPropertyNameFromJsPropertyName.test.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/resolveCssPropertyNameFromJsPropertyName.test.ts new file mode 100644 index 0000000000..7b52f30cf6 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/resolveCssPropertyNameFromJsPropertyName.test.ts @@ -0,0 +1,26 @@ +import { resolveCssPropertyNameFromJsPropertyName } from '../resolveCssPropertyNameFromJsPropertyName'; + +describe('resolveCssPropertyNameFromJsPropertyName', () => { + it('should convert a camelCase property name to kebab-case', () => { + expect(resolveCssPropertyNameFromJsPropertyName('backgroundColor')).toBe( + 'background-color', + ); + }); + + it('should preserve a custom property name verbatim', () => { + expect(resolveCssPropertyNameFromJsPropertyName('--myVar')).toBe('--myVar'); + }); + + it('should map the cssFloat alias to the float property', () => { + expect(resolveCssPropertyNameFromJsPropertyName('cssFloat')).toBe('float'); + }); + + it('should not resolve Object.prototype keys to inherited values', () => { + expect(resolveCssPropertyNameFromJsPropertyName('constructor')).toBe( + 'constructor', + ); + expect(resolveCssPropertyNameFromJsPropertyName('__proto__')).toBe( + '__proto__', + ); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/serializeStyleDeclarationsToCssText.test.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/serializeStyleDeclarationsToCssText.test.ts new file mode 100644 index 0000000000..0d3da0d407 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/__tests__/serializeStyleDeclarationsToCssText.test.ts @@ -0,0 +1,13 @@ +import { serializeStyleDeclarationsToCssText } from '../serializeStyleDeclarationsToCssText'; + +describe('serializeStyleDeclarationsToCssText', () => { + it('should join declarations with semicolons', () => { + expect( + serializeStyleDeclarationsToCssText({ color: 'red', width: '10px' }), + ).toBe('color:red;width:10px'); + }); + + it('should return an empty string for no declarations', () => { + expect(serializeStyleDeclarationsToCssText({})).toBe(''); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/utils/createStyleProxy.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/createStyleProxy.ts new file mode 100644 index 0000000000..fd7e0d2643 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/createStyleProxy.ts @@ -0,0 +1,126 @@ +import { isNonEmptyString, isString } from '@sniptt/guards'; + +import { type StyleProxy } from '@/polyfills/dom/types/StyleProxy'; +import { formatCssValue } from '@/polyfills/dom/utils/formatCssValue'; +import { isImportantPriorityKeyword } from '@/polyfills/dom/utils/isImportantPriorityKeyword'; +import { isObjectPrototypeMember } from '@/polyfills/dom/utils/isObjectPrototypeMember'; +import { normalizeCssPropertyName } from '@/utils/normalizeCssPropertyName'; +import { parseCssTextIntoStyleDeclarations } from '@/polyfills/dom/utils/parseCssTextIntoStyleDeclarations'; +import { resolveCssPropertyNameFromJsPropertyName } from '@/polyfills/dom/utils/resolveCssPropertyNameFromJsPropertyName'; +import { serializeStyleDeclarationsToCssText } from '@/polyfills/dom/utils/serializeStyleDeclarationsToCssText'; + +export const createStyleProxy = ( + flushSerializedCssTextToHost: (serializedCssText: string) => void, +): StyleProxy => { + const cssValueByCssPropertyName: Record = {}; + + const flushToHost = (): void => { + flushSerializedCssTextToHost( + serializeStyleDeclarationsToCssText(cssValueByCssPropertyName), + ); + }; + + const replaceAllDeclarationsFromCssText = (cssText: string): void => { + for (const cssPropertyName of Object.keys(cssValueByCssPropertyName)) { + delete cssValueByCssPropertyName[cssPropertyName]; + } + + Object.assign( + cssValueByCssPropertyName, + parseCssTextIntoStyleDeclarations(cssText), + ); + }; + + const setPropertyValue = ( + cssPropertyName: string, + value: string | null, + priority?: string, + ): void => { + const normalizedCssPropertyName = normalizeCssPropertyName(cssPropertyName); + + if (value === null || value === '') { + delete cssValueByCssPropertyName[normalizedCssPropertyName]; + flushToHost(); + + return; + } + + if (isNonEmptyString(priority) && !isImportantPriorityKeyword(priority)) { + return; + } + + cssValueByCssPropertyName[normalizedCssPropertyName] = String(value); + flushToHost(); + }; + + const removePropertyValue = (cssPropertyName: string): string => { + const normalizedCssPropertyName = normalizeCssPropertyName(cssPropertyName); + const previousValue = + cssValueByCssPropertyName[normalizedCssPropertyName] ?? ''; + + delete cssValueByCssPropertyName[normalizedCssPropertyName]; + flushToHost(); + + return previousValue; + }; + + const readPropertyValue = (cssPropertyName: string): string => + cssValueByCssPropertyName[normalizeCssPropertyName(cssPropertyName)] ?? ''; + + return new Proxy(cssValueByCssPropertyName, { + get: (target, property) => { + if (property === 'cssText') { + return serializeStyleDeclarationsToCssText(target); + } + + if (property === 'setProperty') { + return setPropertyValue; + } + + if (property === 'removeProperty') { + return removePropertyValue; + } + + if (property === 'getPropertyValue') { + return readPropertyValue; + } + + if (isObjectPrototypeMember(property)) { + return Reflect.get(Object.prototype, property); + } + + if (isString(property)) { + return target[resolveCssPropertyNameFromJsPropertyName(property)] ?? ''; + } + + return undefined; + }, + set: (target, property, value) => { + if (!isString(property)) { + return true; + } + + if (property === 'cssText') { + replaceAllDeclarationsFromCssText(String(value)); + flushToHost(); + + return true; + } + + const cssPropertyName = + resolveCssPropertyNameFromJsPropertyName(property); + + if (value === null || value === undefined || value === '') { + delete target[cssPropertyName]; + flushToHost(); + + return true; + } + + target[cssPropertyName] = formatCssValue(value, property); + flushToHost(); + + return true; + }, + }) as unknown as StyleProxy; +}; diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/utils/formatCssValue.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/formatCssValue.ts new file mode 100644 index 0000000000..b2ba8fd5b8 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/formatCssValue.ts @@ -0,0 +1,17 @@ +import { isNumber } from '@sniptt/guards'; + +import { UNITLESS_CSS_PROPERTY_NAMES } from '@/constants/UnitlessCssPropertyNames'; +import { isCssCustomPropertyName } from '@/utils/isCssCustomPropertyName'; + +export const formatCssValue = ( + value: unknown, + stylePropertyName: string, +): string => { + const shouldAppendPixelUnitToNumber = + isNumber(value) && + value !== 0 && + !isCssCustomPropertyName(stylePropertyName) && + !UNITLESS_CSS_PROPERTY_NAMES.has(stylePropertyName); + + return shouldAppendPixelUnitToNumber ? `${value}px` : String(value); +}; diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/utils/isImportantPriorityKeyword.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/isImportantPriorityKeyword.ts new file mode 100644 index 0000000000..dd1bc80fc3 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/isImportantPriorityKeyword.ts @@ -0,0 +1,2 @@ +export const isImportantPriorityKeyword = (priority: string): boolean => + priority.toLowerCase() === 'important'; diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/utils/isObjectPrototypeMember.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/isObjectPrototypeMember.ts new file mode 100644 index 0000000000..57f576900d --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/isObjectPrototypeMember.ts @@ -0,0 +1,3 @@ +export const isObjectPrototypeMember = ( + propertyName: string | symbol, +): boolean => propertyName in Object.prototype; diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/utils/parseCssTextIntoStyleDeclarations.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/parseCssTextIntoStyleDeclarations.ts new file mode 100644 index 0000000000..28003b63fa --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/parseCssTextIntoStyleDeclarations.ts @@ -0,0 +1,15 @@ +import { normalizeCssPropertyName } from '@/utils/normalizeCssPropertyName'; +import { parseCssDeclarations } from '@/utils/parseCssDeclarations'; + +export const parseCssTextIntoStyleDeclarations = ( + cssText: string, +): Record => { + const cssValueByCssPropertyName: Record = {}; + + for (const { cssPropertyName, cssValue } of parseCssDeclarations(cssText)) { + cssValueByCssPropertyName[normalizeCssPropertyName(cssPropertyName)] = + cssValue; + } + + return cssValueByCssPropertyName; +}; diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/utils/resolveCssPropertyNameFromJsPropertyName.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/resolveCssPropertyNameFromJsPropertyName.ts new file mode 100644 index 0000000000..789c07a676 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/resolveCssPropertyNameFromJsPropertyName.ts @@ -0,0 +1,24 @@ +import { camelToKebab } from 'twenty-shared/utils'; + +import { isCssCustomPropertyName } from '@/utils/isCssCustomPropertyName'; + +const CSS_PROPERTY_NAME_BY_CSSOM_ALIAS = new Map([ + ['cssFloat', 'float'], +]); + +export const resolveCssPropertyNameFromJsPropertyName = ( + jsPropertyName: string, +): string => { + if (isCssCustomPropertyName(jsPropertyName)) { + return jsPropertyName; + } + + const aliasedCssPropertyName = + CSS_PROPERTY_NAME_BY_CSSOM_ALIAS.get(jsPropertyName); + + if (aliasedCssPropertyName !== undefined) { + return aliasedCssPropertyName; + } + + return camelToKebab(jsPropertyName); +}; diff --git a/packages/twenty-front-component-renderer/src/polyfills/dom/utils/serializeStyleDeclarationsToCssText.ts b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/serializeStyleDeclarationsToCssText.ts new file mode 100644 index 0000000000..1a935ab54a --- /dev/null +++ b/packages/twenty-front-component-renderer/src/polyfills/dom/utils/serializeStyleDeclarationsToCssText.ts @@ -0,0 +1,6 @@ +export const serializeStyleDeclarationsToCssText = ( + cssValueByCssPropertyName: Record, +): string => + Object.entries(cssValueByCssPropertyName) + .map(([cssPropertyName, cssValue]) => `${cssPropertyName}:${cssValue}`) + .join(';'); diff --git a/packages/twenty-front-component-renderer/src/remote/utils/installStylePropertyOnRemoteElements.ts b/packages/twenty-front-component-renderer/src/remote/utils/installStylePropertyOnRemoteElements.ts index 13df5eedce..c22097f9a4 100644 --- a/packages/twenty-front-component-renderer/src/remote/utils/installStylePropertyOnRemoteElements.ts +++ b/packages/twenty-front-component-renderer/src/remote/utils/installStylePropertyOnRemoteElements.ts @@ -1,210 +1,52 @@ +import { isNonEmptyString, isString } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; + import { ALLOWED_HTML_ELEMENTS } from '@/constants/AllowedHtmlElements'; - -const camelToKebab = (property: string): string => - property.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); - -const UNITLESS_CSS_PROPERTIES = new Set([ - 'animationIterationCount', - 'borderImageOutset', - 'borderImageSlice', - 'borderImageWidth', - 'boxFlex', - 'boxFlexGroup', - 'boxOrdinalGroup', - 'columnCount', - 'columns', - 'flex', - 'flexGrow', - 'flexPositive', - 'flexShrink', - 'flexNegative', - 'flexOrder', - 'gridArea', - 'gridRow', - 'gridRowEnd', - 'gridRowSpan', - 'gridRowStart', - 'gridColumn', - 'gridColumnEnd', - 'gridColumnSpan', - 'gridColumnStart', - 'fontWeight', - 'lineClamp', - 'lineHeight', - 'opacity', - 'order', - 'orphans', - 'tabSize', - 'widows', - 'zIndex', - 'zoom', - 'fillOpacity', - 'floodOpacity', - 'stopOpacity', - 'strokeDasharray', - 'strokeDashoffset', - 'strokeMiterlimit', - 'strokeOpacity', - 'strokeWidth', -]); - -type FlushFn = (cssText: string) => void; - -const createStyleProxy = (flush: FlushFn): Record => { - const styleStore: Record = {}; - - const flushToRemoteProperty = (): void => { - const cssText = Object.entries(styleStore) - .map(([key, value]) => `${key}:${value}`) - .join(';'); - - flush(cssText); - }; - - return new Proxy(styleStore, { - get: (target, property) => { - if (property === 'cssText') { - return Object.entries(target) - .map(([key, value]) => `${key}:${value}`) - .join(';'); - } - - if (property === 'setProperty') { - return (name: string, value: string | null) => { - if (value === null || value === '') { - delete target[name]; - } else { - target[name] = String(value); - } - - flushToRemoteProperty(); - }; - } - - if (property === 'removeProperty') { - return (name: string): string => { - const oldValue = target[name] ?? ''; - - delete target[name]; - flushToRemoteProperty(); - - return oldValue; - }; - } - - if (property === 'getPropertyValue') { - return (name: string): string => target[name] ?? ''; - } - - if (typeof property === 'string') { - const kebabKey = camelToKebab(property); - - return target[kebabKey] ?? ''; - } - - return undefined; - }, - set: (target, property, value) => { - if (property === 'cssText') { - for (const key of Object.keys(target)) { - delete target[key]; - } - - String(value) - .split(';') - .forEach((pair) => { - const colonIndex = pair.indexOf(':'); - - if (colonIndex > 0) { - const key = pair.slice(0, colonIndex).trim(); - const val = pair.slice(colonIndex + 1).trim(); - - if (key && val) { - target[key] = val; - } - } - }); - - flushToRemoteProperty(); - - return true; - } - - if (typeof property === 'string') { - const kebabKey = camelToKebab(property); - - if (value === null || value === undefined || value === '') { - delete target[kebabKey]; - } else { - let stringValue = String(value); - - if ( - typeof value === 'number' && - value !== 0 && - !UNITLESS_CSS_PROPERTIES.has(property) - ) { - stringValue = `${value}px`; - } - - target[kebabKey] = stringValue; - } - - flushToRemoteProperty(); - - return true; - } - - return true; - }, - }); -}; +import { type StyleProxy } from '@/polyfills/dom/types/StyleProxy'; +import { createStyleProxy } from '@/polyfills/dom/utils/createStyleProxy'; type RemoteElementLike = Element & { updateRemoteProperty: (name: string, value: unknown) => void; }; +const createRemoteStyleProxy = (element: RemoteElementLike): StyleProxy => + createStyleProxy((serializedCssText) => { + element.updateRemoteProperty( + 'style', + isNonEmptyString(serializedCssText) ? serializedCssText : undefined, + ); + }); + export const installStylePropertyOnRemoteElements = (): void => { + const styleProxies = new WeakMap(); + + const resolveStyleProxy = (element: RemoteElementLike): StyleProxy => { + const existingProxy = styleProxies.get(element); + + if (isDefined(existingProxy)) { + return existingProxy; + } + + const createdProxy = createRemoteStyleProxy(element); + styleProxies.set(element, createdProxy); + + return createdProxy; + }; + for (const elementConfig of ALLOWED_HTML_ELEMENTS) { const elementConstructor = customElements.get(elementConfig.tag); - if (!elementConstructor) { + if (!isDefined(elementConstructor)) { continue; } - const styleProxies = new WeakMap>(); - Object.defineProperty(elementConstructor.prototype, 'style', { get(this: RemoteElementLike) { - let proxy = styleProxies.get(this); - - if (!proxy) { - const element = this; - - const flush: FlushFn = (cssText: string) => { - element.updateRemoteProperty('style', cssText || undefined); - }; - - proxy = createStyleProxy(flush); - styleProxies.set(this, proxy); - } - - return proxy; + return resolveStyleProxy(this); }, set(this: RemoteElementLike, value: unknown) { - let proxy = styleProxies.get(this); - - if (!proxy) { - const element = this; - const flush: FlushFn = (cssText: string) => { - element.updateRemoteProperty('style', cssText || undefined); - }; - - proxy = createStyleProxy(flush); - styleProxies.set(this, proxy); - } - - if (typeof value === 'string') { - (proxy as Record).cssText = value; + if (isString(value)) { + resolveStyleProxy(this).cssText = value; } }, configurable: true, diff --git a/packages/twenty-front-component-renderer/src/types/CssDeclaration.ts b/packages/twenty-front-component-renderer/src/types/CssDeclaration.ts new file mode 100644 index 0000000000..8f5c46bf65 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/types/CssDeclaration.ts @@ -0,0 +1,4 @@ +export type CssDeclaration = { + cssPropertyName: string; + cssValue: string; +}; diff --git a/packages/twenty-front-component-renderer/src/utils/__tests__/isCssCustomPropertyName.test.ts b/packages/twenty-front-component-renderer/src/utils/__tests__/isCssCustomPropertyName.test.ts new file mode 100644 index 0000000000..27d9ee4596 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/__tests__/isCssCustomPropertyName.test.ts @@ -0,0 +1,11 @@ +import { isCssCustomPropertyName } from '../isCssCustomPropertyName'; + +describe('isCssCustomPropertyName', () => { + it('should detect a custom property name', () => { + expect(isCssCustomPropertyName('--gap')).toBe(true); + }); + + it('should reject a standard property name', () => { + expect(isCssCustomPropertyName('color')).toBe(false); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/utils/__tests__/parseCssDeclarations.test.ts b/packages/twenty-front-component-renderer/src/utils/__tests__/parseCssDeclarations.test.ts new file mode 100644 index 0000000000..db004d12d0 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/__tests__/parseCssDeclarations.test.ts @@ -0,0 +1,77 @@ +import { parseCssDeclarations } from '../parseCssDeclarations'; + +describe('parseCssDeclarations', () => { + it('should parse declarations into property names and values', () => { + expect(parseCssDeclarations('color: red; width: 10px')).toEqual([ + { cssPropertyName: 'color', cssValue: 'red' }, + { cssPropertyName: 'width', cssValue: '10px' }, + ]); + }); + + it('should strip an important priority from values', () => { + expect(parseCssDeclarations('color: red !important')).toEqual([ + { cssPropertyName: 'color', cssValue: 'red' }, + ]); + }); + + it('should let the last duplicate win among normal declarations', () => { + expect(parseCssDeclarations('color: red; color: blue')).toEqual([ + { cssPropertyName: 'color', cssValue: 'blue' }, + ]); + }); + + it('should keep an earlier important declaration over a later normal duplicate', () => { + expect(parseCssDeclarations('color: red !important; color: blue')).toEqual([ + { cssPropertyName: 'color', cssValue: 'red' }, + ]); + }); + + it('should let a later important duplicate replace an earlier important one', () => { + expect( + parseCssDeclarations('color: red !important; color: blue !important'), + ).toEqual([{ cssPropertyName: 'color', cssValue: 'blue' }]); + }); + + it('should share priority state between case variants of a property name', () => { + expect(parseCssDeclarations('COLOR: red !important; color: blue')).toEqual([ + { cssPropertyName: 'COLOR', cssValue: 'red' }, + ]); + }); + + it('should keep case-sensitive custom properties as separate declarations', () => { + expect(parseCssDeclarations('--My-Var: 1px; --my-var: 2px')).toEqual([ + { cssPropertyName: '--My-Var', cssValue: '1px' }, + { cssPropertyName: '--my-var', cssValue: '2px' }, + ]); + }); + + it('should skip declarations without a colon', () => { + expect(parseCssDeclarations('color: red; invalid')).toEqual([ + { cssPropertyName: 'color', cssValue: 'red' }, + ]); + }); + + it('should skip a declaration starting with a colon', () => { + expect(parseCssDeclarations(': red; color: blue')).toEqual([ + { cssPropertyName: 'color', cssValue: 'blue' }, + ]); + }); + + it('should skip declarations whose value is empty', () => { + expect( + parseCssDeclarations('color: !important; width: ; height: 10px'), + ).toEqual([{ cssPropertyName: 'height', cssValue: '10px' }]); + }); + + it('should only split on the first colon so values may contain colons', () => { + expect(parseCssDeclarations('background: url(http://example.com)')).toEqual( + [{ cssPropertyName: 'background', cssValue: 'url(http://example.com)' }], + ); + }); + + it('should tolerate a trailing semicolon', () => { + expect(parseCssDeclarations('color: red;')).toEqual([ + { cssPropertyName: 'color', cssValue: 'red' }, + ]); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/utils/__tests__/splitCssDeclarations.test.ts b/packages/twenty-front-component-renderer/src/utils/__tests__/splitCssDeclarations.test.ts new file mode 100644 index 0000000000..9a1c38ae44 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/__tests__/splitCssDeclarations.test.ts @@ -0,0 +1,54 @@ +import { splitCssDeclarations } from '../splitCssDeclarations'; + +describe('splitCssDeclarations', () => { + it('should split plain declarations on semicolons', () => { + expect(splitCssDeclarations('color: red; font-size: 14px')).toEqual([ + 'color: red', + ' font-size: 14px', + ]); + }); + + it('should keep semicolons inside quoted strings', () => { + expect(splitCssDeclarations('content: "a;b"; color: red')).toEqual([ + 'content: "a;b"', + ' color: red', + ]); + }); + + it('should keep semicolons inside single quoted strings', () => { + expect(splitCssDeclarations("content: 'a;b'")).toEqual(["content: 'a;b'"]); + }); + + it('should keep semicolons after an escaped quote inside a string', () => { + expect(splitCssDeclarations('content: "a\\";b"; color: red')).toEqual([ + 'content: "a\\";b"', + ' color: red', + ]); + }); + + it('should keep semicolons inside url parentheses', () => { + expect( + splitCssDeclarations( + 'background: url(data:image/png;base64,abc); color: red', + ), + ).toEqual(['background: url(data:image/png;base64,abc)', ' color: red']); + }); + + it('should keep slash star sequences as plain characters', () => { + expect( + splitCssDeclarations( + 'background: url(http://example.com/a/*/b.png); color: red', + ), + ).toEqual(['background: url(http://example.com/a/*/b.png)', ' color: red']); + }); + + it('should handle nested parentheses', () => { + expect( + splitCssDeclarations('width: calc(min(10px; 2px)); color: red'), + ).toEqual(['width: calc(min(10px; 2px))', ' color: red']); + }); + + it('should return the whole text when there is no top level semicolon', () => { + expect(splitCssDeclarations('color: red')).toEqual(['color: red']); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/utils/__tests__/stripImportantPriorityFromCssValue.test.ts b/packages/twenty-front-component-renderer/src/utils/__tests__/stripImportantPriorityFromCssValue.test.ts new file mode 100644 index 0000000000..900875b1e5 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/__tests__/stripImportantPriorityFromCssValue.test.ts @@ -0,0 +1,23 @@ +import { stripImportantPriorityFromCssValue } from '../stripImportantPriorityFromCssValue'; + +describe('stripImportantPriorityFromCssValue', () => { + it('should strip a trailing important priority', () => { + expect(stripImportantPriorityFromCssValue('red !important')).toBe('red'); + }); + + it('should keep a value without an important priority unchanged', () => { + expect(stripImportantPriorityFromCssValue('red')).toBe('red'); + }); + + it('should ignore the word important inside a quoted value', () => { + expect(stripImportantPriorityFromCssValue('"hello !important"')).toBe( + '"hello !important"', + ); + }); + + it('should strip a real priority following a quoted value', () => { + expect(stripImportantPriorityFromCssValue('"hello" !important')).toBe( + '"hello"', + ); + }); +}); diff --git a/packages/twenty-front-component-renderer/src/utils/isCssCustomPropertyName.ts b/packages/twenty-front-component-renderer/src/utils/isCssCustomPropertyName.ts new file mode 100644 index 0000000000..61e84901b5 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/isCssCustomPropertyName.ts @@ -0,0 +1,2 @@ +export const isCssCustomPropertyName = (propertyName: string): boolean => + propertyName.startsWith('--'); diff --git a/packages/twenty-front-component-renderer/src/utils/normalizeCssPropertyName.ts b/packages/twenty-front-component-renderer/src/utils/normalizeCssPropertyName.ts new file mode 100644 index 0000000000..2486b89c3f --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/normalizeCssPropertyName.ts @@ -0,0 +1,6 @@ +import { isCssCustomPropertyName } from '@/utils/isCssCustomPropertyName'; + +export const normalizeCssPropertyName = (cssPropertyName: string): string => + isCssCustomPropertyName(cssPropertyName) + ? cssPropertyName + : cssPropertyName.toLowerCase(); diff --git a/packages/twenty-front-component-renderer/src/utils/parseCssDeclarations.ts b/packages/twenty-front-component-renderer/src/utils/parseCssDeclarations.ts new file mode 100644 index 0000000000..fe050608aa --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/parseCssDeclarations.ts @@ -0,0 +1,49 @@ +import { type CssDeclaration } from '@/types/CssDeclaration'; +import { normalizeCssPropertyName } from '@/utils/normalizeCssPropertyName'; +import { splitCssDeclarations } from '@/utils/splitCssDeclarations'; +import { stripImportantPriorityFromCssValue } from '@/utils/stripImportantPriorityFromCssValue'; + +export const parseCssDeclarations = (cssText: string): CssDeclaration[] => { + const declarationsByNormalizedCssPropertyName = new Map< + string, + CssDeclaration + >(); + const importantNormalizedCssPropertyNames = new Set(); + + for (const declaration of splitCssDeclarations(cssText)) { + const propertyNameEndIndex = declaration.indexOf(':'); + + if (propertyNameEndIndex <= 0) { + continue; + } + + const cssPropertyName = declaration.slice(0, propertyNameEndIndex).trim(); + const rawCssValue = declaration.slice(propertyNameEndIndex + 1).trim(); + const cssValue = stripImportantPriorityFromCssValue(rawCssValue); + + if (cssPropertyName === '' || cssValue === '') { + continue; + } + + const normalizedCssPropertyName = normalizeCssPropertyName(cssPropertyName); + const hasImportantPriority = cssValue !== rawCssValue; + + if ( + !hasImportantPriority && + importantNormalizedCssPropertyNames.has(normalizedCssPropertyName) + ) { + continue; + } + + if (hasImportantPriority) { + importantNormalizedCssPropertyNames.add(normalizedCssPropertyName); + } + + declarationsByNormalizedCssPropertyName.set(normalizedCssPropertyName, { + cssPropertyName, + cssValue, + }); + } + + return [...declarationsByNormalizedCssPropertyName.values()]; +}; diff --git a/packages/twenty-front-component-renderer/src/utils/splitCssDeclarations.ts b/packages/twenty-front-component-renderer/src/utils/splitCssDeclarations.ts new file mode 100644 index 0000000000..b5bc051141 --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/splitCssDeclarations.ts @@ -0,0 +1,55 @@ +export const splitCssDeclarations = (cssText: string): string[] => { + const declarations: string[] = []; + + let currentDeclaration = ''; + let quoteCharacter: string | null = null; + let isEscaped = false; + let parenthesisDepth = 0; + + for (const character of cssText) { + if (quoteCharacter !== null) { + currentDeclaration += character; + + if (isEscaped) { + isEscaped = false; + } else if (character === '\\') { + isEscaped = true; + } else if (character === quoteCharacter) { + quoteCharacter = null; + } + continue; + } + + if (character === '"' || character === "'") { + quoteCharacter = character; + currentDeclaration += character; + continue; + } + + if (character === '(') { + parenthesisDepth += 1; + currentDeclaration += character; + continue; + } + + if (character === ')') { + if (parenthesisDepth > 0) { + parenthesisDepth -= 1; + } + currentDeclaration += character; + continue; + } + + if (character === ';' && parenthesisDepth === 0) { + declarations.push(currentDeclaration); + currentDeclaration = ''; + continue; + } + + currentDeclaration += character; + } + + declarations.push(currentDeclaration); + + return declarations; +}; diff --git a/packages/twenty-front-component-renderer/src/utils/stripImportantPriorityFromCssValue.ts b/packages/twenty-front-component-renderer/src/utils/stripImportantPriorityFromCssValue.ts new file mode 100644 index 0000000000..546a33922c --- /dev/null +++ b/packages/twenty-front-component-renderer/src/utils/stripImportantPriorityFromCssValue.ts @@ -0,0 +1,5 @@ +const CSS_IMPORTANT_PRIORITY_PATTERN = /\s*!\s*important\s*$/i; + +export const stripImportantPriorityFromCssValue = ( + rawCssValue: string, +): string => rawCssValue.replace(CSS_IMPORTANT_PRIORITY_PATTERN, '');