vite icon indicating copy to clipboard operation
vite copied to clipboard

Support `additionalData` for the lightningcss css transformer

Open soluml opened this issue 1 year ago • 2 comments
trafficstars

Description

The additionalData option for other preprocessors is extremely valuable in sharing variables and other items to other files without explicit inclusion. While this largely isn't necessary for most things with Lightning CSS, it's still really useful for sharing @custom-media directives throughout your CSS.

Suggested solution

Offer the ability to prepend a string to all CSS files/segments when using the Lightning CSS transformer.

Alternative

No response

Additional context

No response

Validations

soluml avatar Jul 19 '24 19:07 soluml

I've been tinkering with this issue and it seems like CSS plugin skips preprocessing steps if the transformer is lightningCSS(1), thus it can't benefit from the additionalData option. additionalData might be better as a separate option rather than a sub-option under css.preprocessorOptions, since it isn’t originally a preprocessing option. It really is an additional field.

Would it make sense to move it up as css.additionalData so that it could also be available to non-preprocessed CSS?

(1): https://github.com/vitejs/vite/blob/bb2f8bb55fdd64e4f16831ff98921c221a5e734a/packages/vite/src/node/plugins/css.ts#L1215-L1223

log101 avatar Aug 05 '24 17:08 log101

As it's called out in doc https://vitejs.dev/config/shared-options.html#css-preprocessoroptions-extension-additionaldata, one of the intended usage is to inject a pre-processor specific variable since otherwise it would cause a duplication:

Note that if you include actual styles and not just variables, those styles will be duplicated in the final bundle.

For this reason, using additionalData for raw css files might not be recommended.

@soluml I'm not familiar with @custom-media. Can you elaborate how you intend to use additionalData and whether it would cause style duplication (or is it something lightningcss would transpile it away)?

hi-ogawa avatar Aug 06 '24 01:08 hi-ogawa

While it seems reasonable to support additionalData for lightningcss, I'm wondering if this can be easily implemented as Vite plugin.

I made an example here https://github.com/hi-ogawa/reproductions/tree/main/vite-17724-lightningcss-custom-media

import { defineConfig } from "vite";

export default defineConfig({
  css: {
    transformer: "lightningcss",
    lightningcss: {
      drafts: {
        customMedia: true,
      },
    },
  },
  plugins: [
    {
      name: "css-additional-data",
      enforce: "pre",
      transform(code, id) {
        if (id.endsWith(".css")) {
          // probably shouldn't inject `@custom-media` directly since it can cause an error such as:
          //   @import rules must precede all rules aside from @charset and @layer statements
          return `@import "/src/custom-media.css";` + code;
        }
      },
    },
  ],
});

hi-ogawa avatar Aug 28 '24 01:08 hi-ogawa

@hi-ogawa, thanks for the code snippet! I tested it and it works great. According to the LightningCSS documentation, imports should be relative only so I'm surprised this works. To handle the case of precedence for @charset and @layer which has to be before @import, I modified the snippet a little:

import { defineConfig } from 'vite';

export default defineConfig({
	build: {
		cssMinify: 'lightningcss',
	},
	css: {
		transformer: 'lightningcss',
		lightningcss: {
			targets,
			drafts: {
				customMedia: true,
			},
		},
	},
	plugins: [
		{
			name: 'css-additional-data',
			enforce: 'pre',
			transform(code, id) {
				if (id.endsWith('.css')) {
					const parts = code.split('\n');
					let insertIndex = 0;

					// Skip @charset and @layer statements
					for (const part of parts) {
						const trimmedPart = part.trim();
						if (
							trimmedPart.startsWith('@charset') ||
							trimmedPart.startsWith('@layer')
						) {
							insertIndex++;
						} else {
							break;
						}
					}

					// Insert the @import rule
					parts.splice(insertIndex, 0, '@import "/app/styles/_media.css";');

					return parts.join('\n');
				}
			},
		},
	],
});

This approach could be a problem when parsing minified code e.g. third party CSS but typically they shouldn't need @custom-media. On the other hand, splitting on semi-colons has a whole load of edge cases such as semi-colons in url() or content etc. To cover those cases, an actual CSS parser would make sense over simple JS string manipulation.


Alternatively, you can actually just simply import @custom-media directly because it will be transpiled away until the syntax is moved outside draft status and is widely supported. Bit of a ticking time bomb that might break builds in a few years, but imo it's a much safer approach than any of the other approaches above otherwise.

import fs from 'node:fs';
import { defineConfig } from 'vite';

const customMedia = fs.readFileSync('./app/styles/_media.css', 'utf-8');

export default defineConfig({
	build: {
		cssMinify: 'lightningcss',
	},
	css: {
		transformer: 'lightningcss',
		lightningcss: {
			targets,
			drafts: {
				customMedia: true,
			},
		},
	},
	plugins: [
		{
			name: 'css-custom-media',
			enforce: 'pre',
			transform(code, id) {
				if (id.endsWith('.css')) {
					return `${customMedia}\n${code}`;
				}
			},
		},
	],
});

ayuhito avatar Aug 28 '24 08:08 ayuhito

I iterated a little on the above also adding hot reload and using as a module

export function customMedia(path) {
	return {
		name: 'css-additional-data',
		enforce: 'pre',
		handleHotUpdate({ file, server }) {
			const tidyPath = path.replace(/^(\.\/|\.\.\/)+/, '')
			if (file.includes(tidyPath)) server.restart()
		},
		transform(code, id) {
			if (id.endsWith('.css')) {
				const parts = code.split('\n')
				let insertIndex = 0

				// Skip @charset and @layer statements
				for (const part of parts) {
					const trimmedPart = part.trim()
					if (trimmedPart.startsWith('@charset') || trimmedPart.startsWith('@layer')) {
						insertIndex++
					} else {
						break
					}
				}

				// Insert the @import rule
				parts.splice(insertIndex, 0, `@import '${path}';`)

				return parts.join('\n')
			}
		}
	}
}

Usage

import { defineConfig } from 'vite'
import { customMedia } from './src/library/customMedia'

export default defineConfig({
	build: { cssMinify: 'lightningcss' },
	css: {
		transformer: 'lightningcss',
		lightningcss: {
			drafts: { customMedia: true }
		}
	},
	plugins: [customMedia('./src/library/media.css')]
})

Would be cool if i could set that lightningcss custommedia flag from the plugin code but dont think you can

mattpilott avatar Aug 28 '24 13:08 mattpilott

@mattpilott thanks for the snippet, it works just fine for CSS files. However, a similar strategy doesn't work with styles declared inside the <style> block of Astro and Vue components. Is there a solution?

ShayanTheNerd avatar Sep 06 '24 09:09 ShayanTheNerd

Hey @ShayanTheNerd im not sure why it wouldnt work in those frameworks, i use svelte and its working in component

mattpilott avatar Sep 06 '24 09:09 mattpilott