diff --git a/.changeset/large-colts-drop.md b/.changeset/large-colts-drop.md new file mode 100644 index 00000000000..87743524060 --- /dev/null +++ b/.changeset/large-colts-drop.md @@ -0,0 +1,8 @@ +--- +"remix": patch +"@remix-run/dev": patch +--- + +add CSS plugin to `esbuild` so that any assets in css files are also copied (and hashed) to the `assetsBuildDirectory` + +currently if you import a css file that has `background: url('./relative.png');` the `relative.png` file is not copied to the build directory, which is a problem when dealing with npm packages that have css files with font files in them like fontsource diff --git a/contributors.yml b/contributors.yml index 3da96a5e8c6..3c5f7015ee3 100644 --- a/contributors.yml +++ b/contributors.yml @@ -230,6 +230,7 @@ - kilian - kiliman - kimdontdoit +- KingSora - klauspaiva - knowler - konradkalemba diff --git a/integration/compiler-test.ts b/integration/compiler-test.ts index b33e5ddc6bd..4e4cdb5710a 100644 --- a/integration/compiler-test.ts +++ b/integration/compiler-test.ts @@ -1,5 +1,5 @@ import path from "path"; -import fs from "fs/promises"; +import fse from "fs-extra"; import { test, expect } from "@playwright/test"; import { PassThrough } from "stream"; @@ -9,6 +9,7 @@ import { js, json, createFixtureProject, + css, } from "./helpers/create-fixture"; import type { Fixture, AppFixture } from "./helpers/create-fixture"; import { PlaywrightFixture } from "./helpers/playwright-fixture"; @@ -89,6 +90,18 @@ test.describe("compiler", () => { return
{submodule()}
; } `, + "app/routes/css.jsx": js` + import stylesUrl from "@org/css/index.css"; + + export function links() { + return [{ rel: "stylesheet", href: stylesUrl }] + } + + export default function PackageWithSubModule() { + return
{submodule()}
; + } + `, + "remix.config.js": js` let { getDependenciesToBundle } = require("@remix-run/dev"); module.exports = { @@ -156,6 +169,22 @@ test.describe("compiler", () => { return "package-with-submodule"; } `, + "node_modules/@org/css/package.json": json({ + name: "@org/css", + version: "1.0.0", + main: "index.css", + }), + "node_modules/@org/css/font.woff2": "font", + "node_modules/@org/css/index.css": css` + body { + background: red; + } + + @font-face { + font-family: "MyFont"; + src: url("./font.woff2"); + } + `, }, }); @@ -266,6 +295,17 @@ test.describe("compiler", () => { ); }); + test("copies imports in css files to assetsBuildDirectory", async () => { + let buildDir = path.join(fixture.projectDir, "public", "build", "_assets"); + let files = await fse.readdir(buildDir); + expect(files).toHaveLength(2); + + let cssFile = files.find((file) => file.match(/index-[a-z0-9]{8}\.css/i)); + let fontFile = files.find((file) => file.match(/font-[a-z0-9]{8}\.woff2/i)); + expect(cssFile).toBeTruthy(); + expect(fontFile).toBeTruthy(); + }); + // TODO: remove this when we get rid of that feature. test("magic imports still works", async () => { let magicExportsForNode = [ @@ -314,7 +354,7 @@ test.describe("compiler", () => { "useSubmit", "useTransition", ]; - let magicRemix = await fs.readFile( + let magicRemix = await fse.readFile( path.resolve(fixture.projectDir, "node_modules/remix/dist/index.js"), "utf8" ); diff --git a/packages/remix-dev/compiler.ts b/packages/remix-dev/compiler.ts index 74d6c1c89e3..ce1a2ca14ea 100644 --- a/packages/remix-dev/compiler.ts +++ b/packages/remix-dev/compiler.ts @@ -23,10 +23,11 @@ import { serverAssetsManifestPlugin } from "./compiler/plugins/serverAssetsManif import { serverBareModulesPlugin } from "./compiler/plugins/serverBareModulesPlugin"; import { serverEntryModulePlugin } from "./compiler/plugins/serverEntryModulePlugin"; import { serverRouteModulesPlugin } from "./compiler/plugins/serverRouteModulesPlugin"; +import { cssFilePlugin } from "./compiler/plugins/cssFilePlugin"; import { writeFileSafe } from "./compiler/utils/fs"; import { urlImportsPlugin } from "./compiler/plugins/urlImportsPlugin"; -interface BuildConfig { +export interface BuildConfig { mode: BuildMode; target: BuildTarget; sourcemap: boolean; @@ -347,6 +348,7 @@ async function createBrowserBuild( } let plugins = [ + cssFilePlugin(options), urlImportsPlugin(), mdxPlugin(config), browserRouteModulesPlugin(config, /\?browser$/), @@ -415,6 +417,7 @@ function createServerBuild( let isDenoRuntime = config.serverBuildTarget === "deno"; let plugins: esbuild.Plugin[] = [ + cssFilePlugin(options), urlImportsPlugin(), mdxPlugin(config), emptyModulesPlugin(config, /\.client(\.[jt]sx?)?$/), diff --git a/packages/remix-dev/compiler/plugins/cssFilePlugin.ts b/packages/remix-dev/compiler/plugins/cssFilePlugin.ts new file mode 100644 index 00000000000..b2262c5e6fd --- /dev/null +++ b/packages/remix-dev/compiler/plugins/cssFilePlugin.ts @@ -0,0 +1,113 @@ +import * as path from "path"; +import * as fse from "fs-extra"; +import esbuild from "esbuild"; + +import { BuildMode } from "../../build"; +import type { BuildConfig } from "../../compiler"; +import invariant from "../../invariant"; + +const isExtendedLengthPath = /^\\\\\?\\/; + +function normalizePathSlashes(p: string) { + return isExtendedLengthPath.test(p) ? p : p.replace(/\\/g, "/"); +} + +/** + * This plugin loads css files with the "css" loader (bundles and moves assets to assets directory) + * and exports the url of the css file as its default export. + */ +export function cssFilePlugin( + buildConfig: Pick, "mode"> +): esbuild.Plugin { + return { + name: "css-file", + + async setup(build) { + let buildOps = build.initialOptions; + + build.onLoad({ filter: /\.css$/ }, async (args) => { + let { outfile, outdir, assetNames } = buildOps; + let { metafile, outputFiles, warnings, errors } = await esbuild.build({ + ...buildOps, + minify: buildConfig.mode === BuildMode.Production, + minifySyntax: true, + metafile: true, + write: false, + sourcemap: false, + incremental: false, + splitting: false, + stdin: undefined, + outfile: undefined, + outdir: outfile ? path.dirname(outfile) : outdir, + entryNames: assetNames, + entryPoints: [args.path], + loader: { + ...buildOps.loader, + ".css": "css", + }, + // this plugin treats absolute paths in 'url()' css rules as external to prevent breaking changes + plugins: [ + { + name: "resolve-absolute", + async setup(build) { + build.onResolve({ filter: /.*/ }, async (args) => { + let { kind, path: resolvePath } = args; + if (kind === "url-token" && path.isAbsolute(resolvePath)) { + return { + path: resolvePath, + external: true, + }; + } + }); + }, + }, + ], + }); + + if (errors && errors.length) { + return { errors }; + } + + invariant(metafile, "metafile is missing"); + let { outputs } = metafile; + let entry = Object.keys(outputs).find((out) => outputs[out].entryPoint); + invariant(entry, "entry point not found"); + + let normalizedEntry = normalizePathSlashes(entry); + let entryFile = outputFiles.find((file) => { + return normalizePathSlashes(file.path).endsWith(normalizedEntry); + }); + + invariant(entryFile, "entry file not found"); + + let outputFilesWithoutEntry = outputFiles.filter( + (file) => file !== entryFile + ); + + // write all assets + await Promise.all( + outputFilesWithoutEntry.map(({ path: filepath, contents }) => + fse.outputFile(filepath, contents) + ) + ); + + return { + contents: entryFile.contents, + loader: "file", + // add all css assets to watchFiles + watchFiles: Object.values(outputs).reduce( + (arr, { inputs }) => { + let resolvedInputs = Object.keys(inputs).map((input) => { + return path.resolve(input); + }); + arr.push(...resolvedInputs); + return arr; + }, + [] + ), + warnings, + }; + }); + }, + }; +}