feat: add vite-plugin to resolve CSS automatically#6055
feat: add vite-plugin to resolve CSS automatically#6055
Conversation
…components and styles
🦋 Changeset detectedLatest commit: 95b044b The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
| const importMatches = code.matchAll(IMPORT_PATTERN); | ||
| for (const match of importMatches) { | ||
| const componentName = match[1] | ||
| .toLowerCase() |
There was a problem hiding this comment.
| .toLowerCase() |
we have to remove the first .toLowerCase(), otherwise the regex will fail.
| const usageMatches = code.matchAll(COMPONENT_USAGE_PATTERN); | ||
| for (const match of usageMatches) { | ||
| const componentName = match[1] | ||
| .toLowerCase() |
There was a problem hiding this comment.
| .toLowerCase() |
we have to remove the first .toLowerCase(), otherwise the regex will fail.
|
|
||
| const IMPORT_PATTERN = | ||
| /import\s+\{[^}]*\bDB(\w+)\b[^}]*\}\s+from\s+['"]@db-ux\/(?:react|ngx|v|wc)-core-components['"]/g; | ||
| const COMPONENT_USAGE_PATTERN = /<DB(\w+)[\s>]/g; |
There was a problem hiding this comment.
This only works for JSX but won't work for e.g. <db-button>
| forceInclude: string[] | ||
| ): Promise<Set<string>> { | ||
| const components = new Set<string>(forceInclude); | ||
| const moduleIds = Array.from(context.getModuleIds()); |
There was a problem hiding this comment.
Using context.getModuleIds() here might cause issues with the dev-server. Since Vite loads files on-demand, the modules will be mostly empty when the initial CSS file is requested.
To fix this, we could scan the file system directly (e.g., using fast-glob). This is the same approach Tailwind CSS uses.
import fg from 'fast-glob';
const files = await fg(['src/**/*.{vue,jsx,tsx,html}', 'index.html'], { absolute: true });
for (const file of files) {
const code = readFileSync(file, 'utf-8');
// run detection regex...
}
|
|
||
| const distDir = resolve(process.cwd(), outDir); | ||
| try { | ||
| const files = await readdir(distDir, { recursive: true }); |
There was a problem hiding this comment.
closeBundle is not meant to read and write directly to the dist folder. Changing files on disk behind Vite's back can break source maps or other plugins.
We should use the generateBundle hook instead. It allows to iterate over the bundle object and modify the CSS directly before Vite writes it to the disk.
| const fullPath = | ||
| key === 'animations' | ||
| ? `build/styles/${path}` | ||
| : `build/styles/${path}`; |
There was a problem hiding this comment.
| const fullPath = | |
| key === 'animations' | |
| ? `build/styles/${path}` | |
| : `build/styles/${path}`; | |
| const fullPath = `build/styles/${path}`; |
Both cases for fullPath return the exact same string.
| css = css.replace( | ||
| new RegExp(`\\[data-color=${color}\\],\\.db-color-${color}`, 'g'), | ||
| `.db-color-${color}` |
There was a problem hiding this comment.
The pattern searches for the exact string [data-color=blue] (without quotes).
If selectors with quotes are used (e.g. [data-color="blue"]), this regex will fail and won't remove the unused styles.
| const mod = server.moduleGraph.getModuleById(cssModuleId); | ||
| if (mod) { | ||
| server.moduleGraph.invalidateModule(mod); | ||
| server.ws.send({ type: 'full-reload' }); |
There was a problem hiding this comment.
| server.ws.send({ type: 'full-reload' }); |
Sending a full-reload event here breaks Vite's hot module replacement. It forces a full page refresh in the browser every time a developer saves a .vue or .tsx file, which disturbs the fast developer experience Vite is known for.
Using server.moduleGraph.invalidateModule(mod) should be enough for Vite to update CSS without refreshing the page.
| ]; | ||
|
|
||
| const COLOR_PATTERNS = [ | ||
| /\.db-color-(neutral|brand|blue|burgundy|critical|cyan|green|informational|light-green|orange|pink|red|successful|turquoise|violet|warning|yellow)/g, |
There was a problem hiding this comment.
The first pattern in each const array looks for a dot (e.g., /\.db-color-...). In the relevant files, developers will write class="db-color-blue". The regex will fail to match and all class-based usages will be ignored and falsely removed.
| import { readFileSync } from 'fs'; | ||
| import type { PluginContext } from 'rollup'; | ||
|
|
||
| const COMPONENT_PATTERNS = [ |
There was a problem hiding this comment.
Every time a new component is created or a color/token is renamed/added, developers must remember to manually update these regex strings, the VALID_COMPONENTS array and the hardcoded lists in optimizer.ts and types.ts. Otherwise, the Vite plugin will strip the CSS for the new component.
Instead of maintaining manual lists, we can use a generic regex to capture anything relevant and dynamically check if it's a valid component by looking them up in the file system.
| /["']data-font-size["']:\s*["'](body|headline)-(3xs|2xs|xs|sm|md|lg|xl|2xl|3xl)["']/g | ||
| ]; | ||
|
|
||
| const IMPORT_PATTERN = |
There was a problem hiding this comment.
If a user imports multiple components in a single statement (e.g. import { DBButton, DBCard } from '@db-ux/react-core-components'), \bDB(\w+)\b only catches the first one (Button). DBCard will be ignored.
| const componentMatch = | ||
| match[0].match(/db-(\S+?)(?:[\s"]|$)/); |
There was a problem hiding this comment.
The class detection misses components if multiple db- classes are used in the same attribute (e.g., <div class="db-navigation-item db-infotext">).
The regex only returns the first hit, meaning db-infotext will be ignored. I am not sure if this really occurs (two components sharing the same wrapper)...
| '' | ||
| ); | ||
| css = css.replace( | ||
| new RegExp(`--db-${color}-[a-z0-9-]+:[^;]+;`, 'g'), |
There was a problem hiding this comment.
Vite strips the trailing semicolon of the last property in a rule block (.class { color: red; --db-blue: #000 }). Because the regex strictly requires a semicolon, it will fail to remove any unused variables that happen to be at the end of a block.
We should make the semicolon optional and ensure we don't match past the closing }
| ); | ||
| // Remove entire rule block | ||
| css = css.replace( | ||
| new RegExp(`\\.db-color-${color}\\{[^}]+\\}`, 'g'), |
There was a problem hiding this comment.
The regex for removing entire unused rule blocks is too rigid. It assumes the opening { comes exactly after the class name.
Therefore, it completely ignores pseudo-classes (like .db-color-blue:hover { ... }), leaving those as dead code in the bundle.
| "exports": { | ||
| ".": { | ||
| "types": "./build/src/index.d.ts", | ||
| "default": "./build/src/index.js" | ||
| }, |
There was a problem hiding this comment.
Since the tsconfig.json uses "outDir": "build" and the code is inside src, TypeScript will handle src as the root directory. This means src/index.ts will be compiled directly to build/index.js, not build/src/index.js. If we publish it like this, users will get an error.
|
|
||
| describe('generateCSS', () => { | ||
| it('should include default theme when no theme is detected', () => { | ||
| const css = generateCSS({ |
There was a problem hiding this comment.
The unit tests are using an outdated signature for generateCSS. The tests pass the configuration as a flat object, but generateCSS expects them to be inside include and exclude objects (see GenerateOptions type)
There was a problem hiding this comment.
Just tested the unit tests. When I change the call to this, the tests are working.
const css = generateCSS({
include: {
foundations: ['icons']
},
exclude: {},
hasTailwind: false
});
| "@components": ["../../../../output/react/src"], | ||
| "@components/src/*": ["../../../../output/react/src/*"], | ||
| "@components/components/*": [ | ||
| "../../../../output/react/src/components/*" |
There was a problem hiding this comment.
| "@components": ["../../../../output/react/src"], | |
| "@components/src/*": ["../../../../output/react/src/*"], | |
| "@components/components/*": [ | |
| "../../../../output/react/src/components/*" | |
| "@components": ["../../../../../output/react/src"], | |
| "@components/src/*": ["../../../../../output/react/src/*"], | |
| "@components/components/*": [ | |
| "../../../../../output/react/src/components/*" |
I think we have to go up one more level. Here, we go up only 4 levels to the output directory, but in the vite.config.ts 5 (which is correct).
| "@components": ["../../../../output/vue/src"], | ||
| "@components/src/*": ["../../../../output/vue/src/*"], | ||
| "@components/components/*": [ | ||
| "../../../../output/vue/src/components/*" |
There was a problem hiding this comment.
| "@components": ["../../../../output/vue/src"], | |
| "@components/src/*": ["../../../../output/vue/src/*"], | |
| "@components/components/*": [ | |
| "../../../../output/vue/src/components/*" | |
| "@components": ["../../../../../output/vue/src"], | |
| "@components/src/*": ["../../../../../output/vue/src/*"], | |
| "@components/components/*": [ | |
| "../../../../../output/vue/src/components/*" |
I think we have to go up one more level. Here, we go up only 4 levels to the output directory, but in the vite.config.ts 5 (which is correct).
| (d) => !detectedDensities.has(d) | ||
| ); | ||
|
|
||
| const allFontSizes = [ |
There was a problem hiding this comment.
The type FontSize in the types.ts has more entries than allFontSizes.
If a user doesn't use headline-md in their code, the optimizer looks for it in the allFontSizes array. Since it is missing there, it never gets added to unusedFontSizes. As a result, the plugin will never remove the unused CSS variables for the missing font sizes.
| PluginConfig | ||
| } from './types.js'; | ||
|
|
||
| export default function dbUxPlugin(config: PluginConfig = {}): Plugin { |
There was a problem hiding this comment.
If a user passes a custom config to the plugin
dbUxPlugin({ include: { components: ['button'] } })
the default value for the include object is completely overridden because the objec exists and the foundations array inside the include object becomes undefined. Users will lose their default icons, helper etc. styles because they explicitly wanted to include a single component.
Proposed changes
add vite-plugin to resolve CSS automatically based on detected components and styles
Types of changes
Further comments
🔭🐙🐈 Test this branch here: https://design-system.deutschebahn.com/core-web/review/feat-vite-plugin