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,
+ };
+ });
+ },
+ };
+}