diff --git a/packages/ui/build/common-config.ts b/packages/ui/build/common-config.ts index 16707489e9..1a1b805cfa 100644 --- a/packages/ui/build/common-config.ts +++ b/packages/ui/build/common-config.ts @@ -1,3 +1,4 @@ +import { componentVBindFix } from './plugins/component-v-bind-fix'; import { readFileSync, lstatSync, readdirSync } from 'fs' import vue from '@vitejs/plugin-vue' import { resolve as resolver } from 'path' @@ -102,6 +103,7 @@ export default function createViteConfig (format: BuildFormat) { isEsm && config.plugins.push(chunkSplitPlugin({ strategy: 'unbundle' })) isEsm && !isNode && config.plugins.push(appendComponentCss()) isEsm && config.plugins.push(removeSideEffectedChunks()) + isEsm && config.plugins.push(componentVBindFix()) config.build.rollupOptions = isNode ? { ...external, ...rollupMjsBuildOptions } : external diff --git a/packages/ui/build/plugins/component-v-bind-fix.spec.ts b/packages/ui/build/plugins/component-v-bind-fix.spec.ts new file mode 100644 index 0000000000..f23471f530 --- /dev/null +++ b/packages/ui/build/plugins/component-v-bind-fix.spec.ts @@ -0,0 +1,178 @@ +import { describe, test, expect } from 'vitest' +import { transformVueComponent } from './component-v-bind-fix' + +describe('component-v-bind-fix', () => { + describe('transformVueComponent', () => { + // eslint-disable-next-line no-template-curly-in-string + const expectedStyleString = '--va-0-color: ${String(color)};--va-1-background: ${String(background)}' + + const expectedObjectStyleString = (styleContent: string) => `typeof ${styleContent} === 'object' ? (Array.isArray(${styleContent}) ? [...${styleContent}, \`${expectedStyleString}\`] : { ...${styleContent}, '--va-0-color': String(color),'--va-1-background': String(background) }) : ${styleContent} + \`;${expectedStyleString}\`` + + const componentCode = (attrs = '', nestedAttrs = '') => ` + + + + + +` + + const expectedComponentCode = (attrs = '', nestedAttrs = '') => ` + + + + + +` + + test('expectedObjectStyleString', () => { + expect(expectedObjectStyleString('style')).toBe(`typeof style === 'object' ? (Array.isArray(style) ? [...style, \`${expectedStyleString}\`] : { ...style, '--va-0-color': String(color),'--va-1-background': String(background) }) : style + \`;${expectedStyleString}\``) + }) + + test('replace v-bind() with var(--va-index-name)', () => { + const code = transformVueComponent(componentCode()) + + expect(code).toContain('var(--va-0-color)') + expect(code).toContain('var(--va-1-background)') + + expect(code).not.toContain('v-bind(color)') + expect(code).not.toContain('v-bind(background)') + }) + + test("if root node doesn't have a style attr, we should add a style attr with css variables ", () => { + const code = transformVueComponent(componentCode()) + + expect(code).toBe(expectedComponentCode( + `:style="\`${expectedStyleString}\`"`, + )) + }) + + test('if root node have a style attr we add to its value css variables', () => { + const code = transformVueComponent(componentCode( + 'style="color: red"', + )) + + expect(code).toBe(expectedComponentCode( + `:style="\`color: red;${expectedStyleString}\`"`, + )) + }) + + test('if root node have a style object vbind, but no style tag we add style attr with css variables', () => { + const code = transformVueComponent(componentCode( + ':style="{ background: yellow }"', + )) + + const obj = "{ ...{ background: yellow }, '--va-0-color': color,'--va-1-background': background }" + // eslint-disable-next-line no-template-curly-in-string + const str = '{ background: yellow } + `;--va-0-color: ${color};--va-1-background: ${background}`' + + expect(code).toBe(expectedComponentCode( + `:style="${expectedObjectStyleString('{ background: yellow }')}"`, + )) + }) + + // TODO: Maybe better just use style string + test('if root node have both style attr and vbind we add to style attr css variables', () => { + const code = transformVueComponent(componentCode( + 'style="color: blue" :style="{ background: yellow }"', + )) + + expect(code).toBe(expectedComponentCode( + `style="color: blue" :style="${expectedObjectStyleString('{ background: yellow }')}"`, + )) + }) + + test('if root node have vbind and nested element has style attr, we still add style attr on root node', () => { + const code = transformVueComponent(componentCode( + ':style="{ background: yellow }"', + 'style="color: blue"', + )) + + expect(code).toBe(expectedComponentCode( + `:style="${expectedObjectStyleString('{ background: yellow }')}"`, + 'style="color: blue"', + )) + }) + + test('if root node have vbind and nested element has style vbind, we add css variables only to root node', () => { + const code = transformVueComponent(componentCode( + ':style="{ background: yellow }"', + ':style="{ color: blue }"', + )) + + expect(code).toBe(expectedComponentCode( + `:style="${expectedObjectStyleString('{ background: yellow }')}"`, + ':style="{ color: blue }"', + )) + }) + + test('if root node doesn\'t have any style we add it as string and nested elements stay the same', () => { + const code = transformVueComponent(componentCode('', ':style="{ color: blue }"')) + + expect(code).toBe(expectedComponentCode(`:style="\`${expectedStyleString}\`"`, ':style="{ color: blue }"')) + }) + + test('if root node has a lot of attrs we still add style', () => { + const code = transformVueComponent(componentCode(` + role="button" + id="submit" + class="btn btn-primary" + :class="{ active: isActive }" + `.trim())) + + expect(code).toBe(expectedComponentCode(` + role="button" + id="submit" + class="btn btn-primary" + :class="{ active: isActive }" :style="\`${expectedStyleString}\`" + `.trim())) + }) + + test('if root node has a lot of attrs', () => { + const code = transformVueComponent(componentCode(` + role="button" + id="submit" + class="btn btn-primary" + style="cursor: pointer" + :class="{ active: isActive }" + `.trim())) + + expect(code).toBe(expectedComponentCode(` + role="button" + id="submit" + class="btn btn-primary" + :style="\`cursor: pointer;${expectedStyleString}\`" + :class="{ active: isActive }" + `.trim())) + }) + }) +}) diff --git a/packages/ui/build/plugins/component-v-bind-fix.ts b/packages/ui/build/plugins/component-v-bind-fix.ts new file mode 100644 index 0000000000..6611e31446 --- /dev/null +++ b/packages/ui/build/plugins/component-v-bind-fix.ts @@ -0,0 +1,129 @@ +import { Plugin } from 'vite' +import kebabCase from 'lodash/kebabCase' + +/** + * Parse css and extract all variable names used in `v-bind` + * + * @example + * + * ```css + * .va-button { + * color: v-bind(colorComputed); + * background-color: v-bind(getBg()); + * } + * ``` + * + * Returns`['colorComputed', 'getBg()']` + */ +const parseCssVBindCode = (style: string) => { + return style + .match(/v-bind\((.*)\)/g) + ?.map((line) => line.match(/v-bind\(([^)]*)\)/)![1]) ?? [] +} + +/** + * @example + * + * ```html + * + * ``` + * Returns `` + */ +const getRootNodeOpenTagCode = (code: string) => { + const template = code.match(/]*>([\s\S]*)<\/template>/)?.[1] + const rootNode = template?.match(/<[^>]*>/)?.[0] + return rootNode +} + +const renderCssVariablesAsStringCode = (vBinds: string[]) => { + return vBinds.map((vBind, index) => { + return `--va-${index}-${kebabCase(vBind)}: \${String(${vBind})}` + }, '').join(';') +} + +const renderCssVariablesAsObjectPropertiesCode = (vBinds: string[]) => { + return vBinds.map((vBind, index) => { + return `'--va-${index}-${kebabCase(vBind)}': String(${vBind})` + }, '').join(',') +} + +const renderObjectGuardCode = (existingContent: string, binds: string[]) => { + const renderedAsString = renderCssVariablesAsStringCode(binds) + const renderedAsObjectProperties = renderCssVariablesAsObjectPropertiesCode(binds) + + // Merge existing style with rendered css variables + const arrayStyle = `[...${existingContent}, \`${renderedAsString}\`]` + const objectStyle = `{ ...${existingContent}, ${renderedAsObjectProperties} }` + const stringStyle = `${existingContent} + \`;${renderedAsString}\`` + + // Handle if style is an object, array or string + return `typeof ${existingContent} === 'object' ? (Array.isArray(${existingContent}) ? ${arrayStyle} : ${objectStyle}) : ${stringStyle}` +} + +const addStyleToRootNode = (rootNode: string, vBinds: string[]) => { + const [vBindCode, vBindContent] = rootNode?.match(/:style="([^"]*)"/) || [] + const [attrCode, attrContent] = rootNode?.match(/[^:]style="([^"]*)"/) || [] + const cssVariablesString = renderCssVariablesAsStringCode(vBinds) + + // If style attr already exists add css variables to it + if (vBindContent) { + // If :style already exists add a object guard here, because we're not sure if it is string, object or array. + return rootNode.replace(/:style="([^"]*)"/, `:style="${renderObjectGuardCode(vBindContent, vBinds)}"`) + } + + if (attrContent) { + // If style already exists as string attribute, append css variables string to it + return rootNode.replace(/style="([^"]*)"/, `:style="\`$1;${cssVariablesString}\`"`) + } + + // Add :style to root node, because it doesn't exist yet + // Replace `/>` or `>` with :style="..."/> or :style="..."> respectively + return rootNode.replace(/(\/?>)$/, ` :style="\`${cssVariablesString}\`"$1`) +} + +/** Replace each v-bind() with var(--va-index-name) */ +const replaceVueVBindWithCssVariables = (code: string, vBinds: string[]) => { + vBinds.forEach((vBind, index) => { + try { + code = code.replace(new RegExp(`v-bind\\(${vBind}\\)`, 'gm'), `var(--va-${index}-${kebabCase(vBind)})`) + } catch (e) { + console.log(vBind) + throw e + } + }) + + return code +} + +export const transformVueComponent = (code: string) => { + const style = code.match(/]*>([\s\S]*)<\/style>/) + if (!style) { return } + + const vBinds = parseCssVBindCode(style[0]) + if (!vBinds.length) { return } + + const rootNode = getRootNodeOpenTagCode(code) + if (!rootNode) { throw new Error('Root node not found in template') } + + code = replaceVueVBindWithCssVariables(code, vBinds) + + return code.replace(rootNode, addStyleToRootNode(rootNode, vBinds)) +} + +export const componentVBindFix = (): Plugin => { + return { + name: 'vuestic:component-v-bind-fix', + enforce: 'pre', + transform (code, id) { + if (!/\.vue$/.test(id)) { + return + } + + return transformVueComponent(code) + }, + } +} diff --git a/packages/ui/src/composables/useCSSVariables.ts b/packages/ui/src/composables/useCSSVariables.ts index c95830ddc3..c0c152fb54 100644 --- a/packages/ui/src/composables/useCSSVariables.ts +++ b/packages/ui/src/composables/useCSSVariables.ts @@ -2,5 +2,8 @@ import { computed } from 'vue' import kebab from 'lodash/kebabCase.js' export const useCSSVariables = (prefix: string, cb: () => Record) => { - return computed(() => Object.entries(cb()).map(([key, value]) => ({ [`--${prefix}-${kebab(key)}`]: value }))) + return computed(() => Object.entries(cb()).reduce((acc, [key, value]) => { + acc[`--${prefix}-${kebab(key)}`] = value + return acc + }, {} as Record)) }