Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix/ssr css variables #3055

Merged
merged 5 commits into from
Mar 4, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/ui/build/common-config.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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

Expand Down
178 changes: 178 additions & 0 deletions packages/ui/build/plugins/component-v-bind-fix.spec.ts
Original file line number Diff line number Diff line change
@@ -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 = '') => `
<template>
<button${attrs ? ' ' + attrs : ''}>
<span${nestedAttrs ? ' ' + nestedAttrs : ''}>
Hello
</span>
world!
</button>
</template>

<script setup>
const color = computed(() => 'blue')
const background = 'yellow'
</script>

<style>
button {
color: v-bind(color);
background: v-bind(background);
}
</style>
`

const expectedComponentCode = (attrs = '', nestedAttrs = '') => `
<template>
<button${attrs ? ' ' + attrs : ''}>
<span${nestedAttrs ? ' ' + nestedAttrs : ''}>
Hello
</span>
world!
</button>
</template>

<script setup>
const color = computed(() => 'blue')
const background = 'yellow'
</script>

<style>
button {
color: var(--va-0-color);
background: var(--va-1-background);
}
</style>
`

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()))
})
})
})
129 changes: 129 additions & 0 deletions packages/ui/build/plugins/component-v-bind-fix.ts
Original file line number Diff line number Diff line change
@@ -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)
m0ksem marked this conversation as resolved.
Show resolved Hide resolved
?.map((line) => line.match(/v-bind\(([^)]*)\)/)![1]) ?? []
}

/**
* @example
*
* ```html
* <template>
* <va-button>
* Hello
* </va-button>
* </template>
* ```
* Returns `<va-button>`
*/
const getRootNodeOpenTagCode = (code: string) => {
const template = code.match(/<template[^>]*>([\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(/<style[^>]*>([\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)
},
}
}
5 changes: 4 additions & 1 deletion packages/ui/src/composables/useCSSVariables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@ import { computed } from 'vue'
import kebab from 'lodash/kebabCase.js'

export const useCSSVariables = (prefix: string, cb: () => Record<string, string>) => {
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
m0ksem marked this conversation as resolved.
Show resolved Hide resolved
return acc
}, {} as Record<string, string>))
}