evanw / esbuild

An extremely fast bundler for the web
https://esbuild.github.io/
MIT License
38.14k stars 1.15k forks source link

How to use esbuild with css import assertions/attributes #3384

Open jogibear9988 opened 1 year ago

jogibear9988 commented 1 year ago

I've code where some librarys import css without a assertion or a with attribute:

   import aaSheet from "aa.css";

this works, but if I have this code:

  import bbSheet from "bb.css" assert {type: 'css'};

or this:

 import bbSheet from "bb.css" with {type: 'css'};

then the type of bbSheet hast to be a CSSStyleSheet, but esbuild does not create one. And I don't know in the onResolveCallback if the "assert" or "with" attribute is present (the assert keyword was changed to with, but it should be nearly the same), so I don't know how I could create a plugin

evanw commented 1 year ago

The assert keyword doesn't affect how the module is loaded (deliberately, by design). So you would just write a normal plugin that works on .css files and that constructs a CSSStyleSheet for them. You will have to find some other way to distinguish between the two cases (perhaps by file path). I agree the design of the assert keyword sucks, but that's how it was designed. Luckily the designers realized their mistake and the assert keyword is being deprecated and removed from the specification.

The with keyword is too new and hasn't yet been added to any JavaScript specification or implementation, so esbuild also hasn't added support for it yet (it's not clear how it's supposed to work without a real implementation to compare against). Support for it can be added to esbuild once it becomes a real thing.

jogibear9988 commented 1 year ago

In the browser (chrome) it had an effect. A import with assert type css, always returned a css stylesheet object. And if you served a wrong mime type to this the import failed. And so it will be the same with the "with" keyword. But as I said, wouldn't it be possible to add the additional import attributes to the resolve callback, so my plugin can decide?

And can I return a script in the onLoad callback wich creates a CSS Stylesheet Object like I tried here: https://github.com/evanw/esbuild/issues/1871#issuecomment-1717977783 Or does onLoad only allow to transform the loaded text, so this will not be possible at all?

jogibear9988 commented 1 year ago

I also tried to ignore all css imports (external: ['*.css']), but this will not work, now all my relative import paths are wrong (where I use the asserts), and monaco editor, wich uses import statements without assert, has also wrong paht, but will also not work in browser if not transpiled.

So atm. I've no Idee if or how I could use esbuild too bundle my app.

jogibear9988 commented 1 year ago

There is already special code for "assert" with type "json" : https://github.com/evanw/esbuild/blob/a111cc48edaebe55419396ffaedcd0fd819ccae9/internal/js_parser/js_parser.go#L6336

evanw commented 1 year ago

Yes, there is. But the only thing that an import assertion does is cause a build error if the assertion fails. For example, you will get a build error if you use assert { type: "json" } and the imported file is not a JSON file:

✘ [ERROR] The file "file.js" was loaded with the "js" loader

    entry.js:1:14:
      1 │ import x from './file.js' assert { type: 'json' }
        ╵               ~~~~~~~~~~~

  This import assertion requires the loader to be "json" instead:

    entry.js:1:35:
      1 │ import x from './file.js' assert { type: 'json' }
        ╵                                    ~~~~~~~~~~~~

  You need to either reconfigure esbuild to ensure that the loader for this file is "json" or you need to remove this import assertion.

The type: json assert doesn't tell esbuild to load the file as JSON. Instead, it tells esbuild to emit a build error if the file was loaded as some type other than JSON. According to the import assertion specification, an import assertion cannot affect how the module is loaded:

Implementations are not permitted to interpret a module differently at multiple import sites if the only difference between the sites is the set of import assertions.

I realize that you wish import assertions worked that way, but they don't. Doing that would be going against the specification. Import attributes do work that way but they are a separate feature (and one that hasn't been released yet).

jogibear9988 commented 1 year ago

I do understand, but a browser could load 'css' with assert 'css' only if it was a css file, and then it would be a CSSStyleSheet object. If the file was no CSS, the browser would also fail. So I don't get why this could not be done by the bundler. If we have a file with".css" extension and with 'css' assertion, the bundler could treat it as CSSStyleSheet? What should break if this would be done? But if this is not done, there are many libs wich could not be bundeled by esbuild.

Also imports without assertions or attributes are no feature of the ecosystem, but are realized by esbuild.

I'm trying at the moment for myself to update esbuild so the import assertions or attributes are hand over to the onResolve call, so I can implement the correct handling myself.

Is ther ean easy way how I can debug esbuild when run from inside javascript?

justinfagnani commented 1 year ago

Why do you need to see if the type assertion is present? Can you just assume any import of a .css file produces a module that default exports a CSSStyleSheet? The trick to be spec compliant is to throw if the type assertion (or attribute) isn't present.

jogibear9988 commented 1 year ago

Because for example I also use monaco editor package, wich uses import of css files without assertions, and these need to return the text.

jogibear9988 commented 1 year ago

Many librarys wich are only usable via bundlers import css (or other types), but without any assertion. So to distinguish between them and a correct import I need to know

evanw commented 11 months ago

The latest release of esbuild now supports bundling with import assertions: https://github.com/evanw/esbuild/releases/tag/v0.19.7. This should let you write an esbuild plugin that implements with { type: 'css' }.

jogibear9988 commented 11 months ago

i'll try

justinfagnani commented 11 months ago

@evanw that's awesome news!

I'm not familiar with esbuild plugin authoring, but one question I have from the cheese example:

const cheesePlugin = {
  name: 'cheese',
  setup(build) {
    build.onLoad({ filter: /.*/ }, args => {
      if (args.with.type === 'cheese') return {
        contents: `export default "🧀"`,
      }
    })
  }
}

is whether the plugin can filter on import attributes?

I figure, like the cheese example, a lot of plugins might only care about attributes and so have a filter of /.*/. Would it be beneficial to also filter on attribute, maybe like build.onLoad({ filterAttribute: /^css$/ }?

jogibear9988 commented 11 months ago

Do I only get the "with" attribute or also the old "assert" ?

evanw commented 11 months ago

is whether the plugin can filter on import attributes?

Yes, I think this would be a good idea. I also need to expose this to on-resolve plugins, and maybe make some other changes as well. Part of the problem is that I don't use these features myself so I'm not familiar with the use cases. I wanted to get something basic out there quickly so people could experiment with it and then have that inform the design. For example, I don't think { filterAttribute: /^css$/ } is necessarily the right design because the type property name isn't present. You could imagine something like { with: { type: /^css$/ } }. But that doesn't let you match on the property name themselves. Is it important to be able to match on /^type-.*$/? I have no idea. Anyway, I'm planning to improve this over time but not necessarily right away. It would be helpful to see someone try to write some plugins with this feature first.

I'm also still learning about these features, which is why I haven't added support for them to esbuild yet. I've read that CSS module scripts forbid using @import in the imported CSS. So I guess esbuild would respect that too. But presumably url() still works? Is it expected that a bundler would be able to inline those? If so, that would be the first instance of esbuild doing a recursive bundling operation. I also recently realized that these modules are keyed off of the (referrer, specifier, attributes) tuple as a cache key. I think this might mean that each import of type: 'css' is supposed to be an independent copy of the imported module that returns a new CSSStyleSheet object (but I haven't had the time to check for myself yet), which is somewhat unusual for a bundler as bundlers usually prioritize efficiency. I haven't yet done a survey of how browsers and other tools handle these things. I don't need answers to all of this right now; I'm just mentioning some things that I'll need to do before esbuild supports type: 'css' natively (which I'm sure it will at some point). A link to a real code base that uses type: 'css' would be very helpful though as all I have to go off of at the moment is some tutorial blog posts. I currently have never seen a real app that uses this stuff.

Do I only get the "with" attribute or also the old "assert" ?

Yes, this only works with import attributes (the with keyword), not with import assertions (the assert keyword). They are two separate features with different specifications (the new one having overwritten the old one) and they behave differently. Here's what I said about this in the release notes:

You can already use esbuild to bundle code that uses import assertions (the first iteration). However, this feature is mostly useless for bundlers because import assertions are not allowed to affect module resolution. It's basically only useful as an annotation on external imports, which esbuild will then preserve in the output for use in a browser (which would otherwise refuse to load certain imports).

With this release, esbuild now supports bundling code that uses import attributes (the second iteration). This is much more useful for bundlers because they are allowed to affect module resolution, which means the key-value pairs can be provided to plugins.

jogibear9988 commented 10 months ago

I now created a small sample repo wich includes css with import attributes into build: https://github.com/jogibear9988/esbuild-test

jogibear9988 commented 10 months ago

Is there a way to run the esbuild css minifcation in my plugin on the css code?

jogibear9988 commented 10 months ago

Is there a way to run the esbuild css minifcation in my plugin on the css code?

Found it...

 fixedCss = (await esbuild.transform(fixedCss, {
                loader: 'css',
                minify: build.initialOptions.minify,
            })).code;
evanw commented 10 months ago

You can use esbuild to minify CSS using esbuild the API: https://esbuild.github.io/api/#minify. It’s safe to call esbuild’s API from within a plugin. You can also access the esbuild API directly from within a plugin using the esbuild property: https://github.com/evanw/esbuild/releases/tag/v0.14.3.

justinfagnani commented 8 months ago

@evanw

I've read that CSS module scripts forbid using @import in the imported CSS. So I guess esbuild would respect that too.

Yes, for now, until the semantics are agreed upon. (I prefer that @import work like JS imports and use the cached module rather than return a new stylesheet every time)

But presumably url() still works?

Yes, but I don't know that this needs to be bundled. I suppose if the URL is relative, either the user will be responsible for ensuring that it's valid in the build output, or esbuild could transform the URL similar to how some bundlers handle new URL('./foo.txt', import.meta.url).

I also recently realized that these modules are keyed off of the (referrer, specifier, attributes) tuple as a cache key. I think this might mean that each import of type: 'css' is supposed to be an independent copy of the imported module that returns a new CSSStyleSheet object

I don't think this is true, because the pre-attribute cache key was already (referrer, specifier). I don't think the additional presence of attributes implies that every import gets a fresh module.

justinfagnani commented 8 months ago

@evanw

But presumably url() still works? Is it expected that a bundler would be able to inline those? If so, that would be the first instance of esbuild doing a recursive bundling operation.

I seem to be hitting this issue with my plugin. The CSS I'm loading has a relative URL to a font in a url(...) call. That URL isn't being bundled or transformed, so the font isn't loading.

Is there a way I can call esbuild.build() from within my plugin to get it to use either dataurl or file loader for the .tff URLs?

jogibear9988 commented 8 months ago

@justinfagnani

do you mean smth like this?

    import * as esbuild from 'esbuild'
    import { readFile } from 'fs/promises';
    import * as path from 'path';

    const cssResolvePlugin = {
        name: 'cssresolve',
        setup(build) {
            build.onResolve({ filter: /.*/ }, args => {
                if (args.kind == 'url-token')
                    return { path: path.join('my-url-prefix', args.path), external: true }
                return null;
            })
        }
    }

    const cssConstructStylesheetPlugin = {
        name: 'css imports',
        setup(build) {
            build.onLoad({ filter: /\.css$/ }, async (args) => {
                if (args.with.type === 'css') {
                    const result = await esbuild.build({
                        bundle: true,
                        entryPoints: [args.path],
                        minify: build.initialOptions.minify,
                        plugins: [cssResolvePlugin],
                        write: false
                    });
                    const contents = `
            const styles = new CSSStyleSheet();
            styles.replaceSync(\`${result.outputFiles[0].text}\`);
            export default styles;`;
                    return { contents, loader: 'js' };
                }
            });
        }
    }

    await esbuild.build({
        entryPoints: ['./dist/index.js'],
        bundle: true,
        minify: true,
        format: 'esm',
        outfile: './dist/index-bundle.js',
        plugins: [cssConstructStylesheetPlugin],
    });
jogibear9988 commented 8 months ago

also added this as a sample here: https://github.com/jogibear9988/esbuild-test

magoniac commented 6 months ago

Might be a dumb question, but is it feasible to get "args.with" property in onResolve() callback? This might[?] be handy when resolving imported ".css" to externals and just keeping import statement to be interpreted by the browser. E.g. smth. like this: const importCssPlugin = { name: 'css-import-attrs', setup(build) { build.onResolve({ filter: /\.css\?importAttrs$/ }, async (args) => { //if (args.with.type === 'css') - not possible so far if (args.path.includes("?importAttrs")) { const pathParts = args.path.split('/'); const cssFile = pathParts[pathParts.length - 1].split('?')[0]; return { path: "./styles/" + cssFile, external: true } } }

However, with current implementation it is not possible to check for .with property, thus this cumbersome workaround is used which entails importing "css" files with some "resource query" identifier (stolen from angular/webpack) in order to discern those from other imports.

Regards

magoniac commented 6 months ago

Also I've encountered an issue when "with" property is not set for dynamic import globs with import attributes, e.g. styleSheetPromise = import(./../styles/${styleSheetId}.css, { with: { type: 'css' } });. After heavy and fun debugging I discovered that in bundler's parseFile() function logger.Path's ImportAttributes property, which is further used for setting "with" in OnLoadArgs, is not set for resolved glob results. Is it by design or an issue that might be possibly fixed in the future?

daniel-nagy commented 6 months ago

@magoniac I don't think the glob has anything to do with it. I have a dynamic import that uses a static string and the import assertion is removed by esbuild 🙁

mangelozzi commented 6 months ago

The latest release of esbuild now supports bundling with import assertions: https://github.com/evanw/esbuild/releases/tag/v0.19.7. This should let you write an esbuild plugin that implements with { type: 'css' }.

Thanks that is great. I used to use --supported:import-assertions=true, but since assert has been renamed to with, that flag no longer works. I can't find what is the appropiate flag to use now that it is supported, I checked https://esbuild.github.io/api/#supported, the most promising flag seemed import-meta but it did not retain the with: { type: 'css' }. Side note: maybe a description for each flag would help people understand them.

gulshan commented 2 months ago

Is it possible for esbuild to determine the loader from import attributes? Like-

import image from "./image.png" with { type: "dataurl" }
// or
import image from "./image.png" with { loader: "dataurl" }

It will make it clear how an import is actually working. I can just look at the import and I would know it is imported as a dataurl or a base64 string. Also, it will make the import more flexible as I can specify importing the same type of files or the file differently using different import attributes. No need to specify a loader for all files of a type during the build.

jogibear9988 commented 2 months ago

you can acces sthe with.type like I did: (I think) https://github.com/jogibear9988/esbuild-test/blob/main/esbuild.js#L25