From 8b9d1b80100443af9ea8b9f97c8e3fef17a23120 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Thu, 24 Nov 2022 11:25:32 +1100 Subject: [PATCH] add initial CSS Modules integration --- package.json | 1 + packages/remix-css-bundle/README.md | 13 + packages/remix-css-bundle/browser.ts | 3 + packages/remix-css-bundle/package.json | 23 + packages/remix-css-bundle/rollup.config.js | 69 +++ packages/remix-css-bundle/server.ts | 2 + packages/remix-css-bundle/tsconfig.json | 18 + packages/remix-dev/assets-manifest.d.ts | 4 + packages/remix-dev/compiler/assets.ts | 29 +- packages/remix-dev/compiler/compileBrowser.ts | 83 ++- packages/remix-dev/compiler/compilerServer.ts | 2 + .../compiler/plugins/cssEntryModulePlugin.ts | 43 ++ .../compiler/plugins/cssModulesPlugin.ts | 13 + .../esbuild-plugin-css-modules/LICENSE | 21 + .../esbuild-plugin-css-modules/README.md | 3 + .../esbuild-plugin-css-modules/index.d.ts | 112 ++++ .../esbuild-plugin-css-modules/index.js | 29 + .../esbuild-plugin-css-modules/lib/cache.js | 78 +++ .../esbuild-plugin-css-modules/lib/plugin.js | 545 ++++++++++++++++++ .../esbuild-plugin-css-modules/lib/utils.js | 269 +++++++++ .../plugins/serverBareModulesPlugin.ts | 5 + packages/remix-dev/compiler/virtualModules.ts | 5 + packages/remix-dev/index.ts | 1 + packages/remix-dev/modules.ts | 4 + packages/remix-dev/package.json | 1 + scripts/publish.js | 1 + scripts/utils.js | 2 +- tsconfig.json | 1 + yarn.lock | 61 ++ 29 files changed, 1411 insertions(+), 30 deletions(-) create mode 100644 packages/remix-css-bundle/README.md create mode 100644 packages/remix-css-bundle/browser.ts create mode 100644 packages/remix-css-bundle/package.json create mode 100644 packages/remix-css-bundle/rollup.config.js create mode 100644 packages/remix-css-bundle/server.ts create mode 100644 packages/remix-css-bundle/tsconfig.json create mode 100644 packages/remix-dev/assets-manifest.d.ts create mode 100644 packages/remix-dev/compiler/plugins/cssEntryModulePlugin.ts create mode 100644 packages/remix-dev/compiler/plugins/cssModulesPlugin.ts create mode 100644 packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/LICENSE create mode 100644 packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/README.md create mode 100644 packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/index.d.ts create mode 100644 packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/index.js create mode 100644 packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/lib/cache.js create mode 100644 packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/lib/plugin.js create mode 100644 packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/lib/utils.js diff --git a/package.json b/package.json index f7dc676e39b..33b1c119cb2 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "packages/remix-cloudflare", "packages/remix-cloudflare-pages", "packages/remix-cloudflare-workers", + "packages/remix-css-bundle", "packages/remix-deno", "packages/remix-dev", "packages/remix-eslint-config", diff --git a/packages/remix-css-bundle/README.md b/packages/remix-css-bundle/README.md new file mode 100644 index 00000000000..40685a7476f --- /dev/null +++ b/packages/remix-css-bundle/README.md @@ -0,0 +1,13 @@ +# Welcome to Remix! + +[Remix](https://remix.run) is a web framework that helps you build better websites with React. + +To get started, open a new shell and run: + +```sh +npx create-remix@latest +``` + +Then follow the prompts you see in your terminal. + +For more information about Remix, [visit remix.run](https://remix.run)! diff --git a/packages/remix-css-bundle/browser.ts b/packages/remix-css-bundle/browser.ts new file mode 100644 index 00000000000..24d68c7f48c --- /dev/null +++ b/packages/remix-css-bundle/browser.ts @@ -0,0 +1,3 @@ +import type { AssetsManifest } from "@remix-run/dev/assets-manifest"; +let assetsManifest: AssetsManifest = (window as any).__remixManifest; +export default assetsManifest.cssBundleHref; diff --git a/packages/remix-css-bundle/package.json b/packages/remix-css-bundle/package.json new file mode 100644 index 00000000000..92b4f3a9f8f --- /dev/null +++ b/packages/remix-css-bundle/package.json @@ -0,0 +1,23 @@ +{ + "name": "@remix-run/css-bundle", + "description": "Entrypoint for the CSS bundle created by Remix", + "version": "1.7.5", + "license": "MIT", + "main": "./server.js", + "module": "./esm/server.js", + "browser": { + "./server.js": "./browser.js", + "./esm/server.js": "./esm/browser.js" + }, + "repository": { + "type": "git", + "url": "https://github.com/remix-run/remix", + "directory": "packages/remix-css-bundle" + }, + "bugs": { + "url": "https://github.com/remix-run/remix/issues" + }, + "dependencies": { + "@remix-run/dev": "1.7.6" + } +} diff --git a/packages/remix-css-bundle/rollup.config.js b/packages/remix-css-bundle/rollup.config.js new file mode 100644 index 00000000000..7bbdaf6e610 --- /dev/null +++ b/packages/remix-css-bundle/rollup.config.js @@ -0,0 +1,69 @@ +const babel = require("@rollup/plugin-babel").default; +const nodeResolve = require("@rollup/plugin-node-resolve").default; +const copy = require("rollup-plugin-copy"); + +const { + copyToPlaygrounds, + createBanner, + getOutputDir, + isBareModuleId, +} = require("../../rollup.utils"); +const { name: packageName, version } = require("./package.json"); + +/** @returns {import("rollup").RollupOptions[]} */ +module.exports = function rollup() { + let sourceDir = "packages/remix-css-bundle"; + let outputDir = getOutputDir(packageName); + + return [ + { + external(id) { + return isBareModuleId(id); + }, + input: [`${sourceDir}/browser.ts`, `${sourceDir}/server.ts`], + output: { + banner: createBanner(packageName, version), + dir: outputDir, + format: "cjs", + preserveModules: true, + exports: "named", + }, + plugins: [ + babel({ + babelHelpers: "bundled", + exclude: /node_modules/, + extensions: [".ts"], + }), + nodeResolve({ extensions: [".ts"] }), + copy({ + targets: [ + { src: `LICENSE.md`, dest: outputDir }, + { src: `${sourceDir}/package.json`, dest: outputDir }, + { src: `${sourceDir}/README.md`, dest: outputDir }, + ], + }), + ], + }, + { + external(id) { + return isBareModuleId(id); + }, + input: [`${sourceDir}/browser.ts`, `${sourceDir}/server.ts`], + output: { + banner: createBanner(packageName, version), + dir: `${outputDir}/esm`, + format: "esm", + preserveModules: true, + }, + plugins: [ + babel({ + babelHelpers: "bundled", + exclude: /node_modules/, + extensions: [".ts"], + }), + nodeResolve({ extensions: [".ts"] }), + copyToPlaygrounds(), + ], + }, + ]; +}; diff --git a/packages/remix-css-bundle/server.ts b/packages/remix-css-bundle/server.ts new file mode 100644 index 00000000000..1d0b0373a15 --- /dev/null +++ b/packages/remix-css-bundle/server.ts @@ -0,0 +1,2 @@ +import assetsManifest from "@remix-run/dev/assets-manifest"; +export default assetsManifest.cssBundleHref; diff --git a/packages/remix-css-bundle/tsconfig.json b/packages/remix-css-bundle/tsconfig.json new file mode 100644 index 00000000000..f85d66deb0e --- /dev/null +++ b/packages/remix-css-bundle/tsconfig.json @@ -0,0 +1,18 @@ +{ + "exclude": ["__tests__"], + "compilerOptions": { + "lib": ["ES2019", "DOM.Iterable"], + "target": "ES2019", + + "module": "CommonJS", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "strict": true, + "declaration": true, + "emitDeclarationOnly": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "outDir": "../../build/node_modules/@remix-run/css-bundle", + "rootDir": "." + } + } \ No newline at end of file diff --git a/packages/remix-dev/assets-manifest.d.ts b/packages/remix-dev/assets-manifest.d.ts new file mode 100644 index 00000000000..7526558f4bc --- /dev/null +++ b/packages/remix-dev/assets-manifest.d.ts @@ -0,0 +1,4 @@ +import type { AssetsManifest } from "@remix-run/dev"; +declare const manifest: AssetsManifest; +export type { AssetsManifest }; +export default manifest; diff --git a/packages/remix-dev/compiler/assets.ts b/packages/remix-dev/compiler/assets.ts index 5bd5b0a9fa7..6706f31a1f3 100644 --- a/packages/remix-dev/compiler/assets.ts +++ b/packages/remix-dev/compiler/assets.ts @@ -31,12 +31,31 @@ export interface AssetsManifest { hasErrorBoundary: boolean; }; }; + cssBundleHref?: string; } -export async function createAssetsManifest( - config: RemixConfig, - metafile: esbuild.Metafile -): Promise { +export async function createAssetsManifest({ + config, + metafile, + cssMetafile, +}: { + config: RemixConfig; + metafile: esbuild.Metafile; + cssMetafile: esbuild.Metafile; +}): Promise { + let cssBundlePathPrefix = path.join( + config.relativeAssetsBuildDirectory, + "css-bundle" + ); + + let cssBundleHref = Object.keys(cssMetafile.outputs).find( + (output) => + output.startsWith(cssBundlePathPrefix) && output.endsWith(".css") + ); + if (cssBundleHref) { + cssBundleHref = resolveUrl(cssBundleHref); + } + function resolveUrl(outputPath: string): string { return createUrl( config.publicPath, @@ -109,7 +128,7 @@ export async function createAssetsManifest( optimizeRoutes(routes, entry.imports); let version = getHash(JSON.stringify({ entry, routes })).slice(0, 8); - return { version, entry, routes }; + return { version, entry, routes, cssBundleHref }; } type ImportsCache = { [routeId: string]: string[] }; diff --git a/packages/remix-dev/compiler/compileBrowser.ts b/packages/remix-dev/compiler/compileBrowser.ts index 338dd8a7dd8..49240330814 100644 --- a/packages/remix-dev/compiler/compileBrowser.ts +++ b/packages/remix-dev/compiler/compileBrowser.ts @@ -10,12 +10,15 @@ import { getAppDependencies } from "./dependencies"; import { loaders } from "./loaders"; import { type CompileOptions } from "./options"; import { browserRouteModulesPlugin } from "./plugins/browserRouteModulesPlugin"; +import { cssModulesPlugin } from "./plugins/cssModulesPlugin"; import { cssFilePlugin } from "./plugins/cssFilePlugin"; import { emptyModulesPlugin } from "./plugins/emptyModulesPlugin"; import { mdxPlugin } from "./plugins/mdx"; import { urlImportsPlugin } from "./plugins/urlImportsPlugin"; import { type WriteChannel } from "./utils/channel"; import { writeFileSafe } from "./utils/fs"; +import { cssBuildVirtualModule } from "./virtualModules"; +import { cssEntryModulePlugin } from "./plugins/cssEntryModulePlugin"; export type BrowserCompiler = { // produce ./public/build/ @@ -57,20 +60,31 @@ const writeAssetsManifest = async ( }; const createEsbuildConfig = ( + build: "app" | "css", config: RemixConfig, options: CompileOptions ): esbuild.BuildOptions | esbuild.BuildIncremental => { - let entryPoints: esbuild.BuildOptions["entryPoints"] = { - "entry.client": path.resolve(config.appDirectory, config.entryClientFile), - }; - for (let id of Object.keys(config.routes)) { - // All route entry points are virtual modules that will be loaded by the - // browserEntryPointsPlugin. This allows us to tree-shake server-only code - // that we don't want to run in the browser (i.e. action & loader). - entryPoints[id] = config.routes[id].file + "?browser"; + let entryPoints: esbuild.BuildOptions["entryPoints"] = {}; + if (build === "css") { + entryPoints = { + "css-bundle": cssBuildVirtualModule.id, + }; + } else { + entryPoints = { + "entry.client": path.resolve(config.appDirectory, config.entryClientFile), + }; + + for (let id of Object.keys(config.routes)) { + // All route entry points are virtual modules that will be loaded by the + // browserEntryPointsPlugin. This allows us to tree-shake server-only code + // that we don't want to run in the browser (i.e. action & loader). + entryPoints[id] = config.routes[id].file + "?browser"; + } } let plugins = [ + cssModulesPlugin(options), + cssEntryModulePlugin(config), cssFilePlugin(options), urlImportsPlugin(), mdxPlugin(config), @@ -89,7 +103,7 @@ const createEsbuildConfig = ( loader: loaders, bundle: true, logLevel: "silent", - splitting: true, + splitting: build !== "css", sourcemap: options.sourcemap, // As pointed out by https://github.com/evanw/esbuild/issues/2440, when tsconfig is set to // `undefined`, esbuild will keep looking for a tsconfig.json recursively up. This unwanted @@ -118,26 +132,47 @@ export const createBrowserCompiler = ( remixConfig: RemixConfig, options: CompileOptions ): BrowserCompiler => { - let compiler: esbuild.BuildIncremental; - let esbuildConfig = createEsbuildConfig(remixConfig, options); + let appCompiler: esbuild.BuildIncremental; + let cssCompiler: esbuild.BuildIncremental; + + let appEsbuildConfig = createEsbuildConfig("app", remixConfig, options); + let cssEsbuildConfig = createEsbuildConfig("css", remixConfig, options); + let compile = async (manifestChannel: WriteChannel) => { - let metafile: esbuild.Metafile; - if (compiler === undefined) { - compiler = await esbuild.build({ - ...esbuildConfig, - metafile: true, - incremental: true, - }); - metafile = compiler.metafile!; - } else { - metafile = (await compiler.rebuild()).metafile!; - } - let manifest = await createAssetsManifest(remixConfig, metafile); + let appBuildResult = !appCompiler + ? esbuild.build({ + ...appEsbuildConfig, + metafile: true, + incremental: true, + }) + : appCompiler.rebuild(); + + let cssBuildResult = !cssCompiler + ? esbuild.build({ + ...cssEsbuildConfig, + metafile: true, + incremental: true, + }) + : cssCompiler.rebuild(); + + [appCompiler, cssCompiler] = await Promise.all([ + appBuildResult, + cssBuildResult, + ]); + + let manifest = await createAssetsManifest({ + config: remixConfig, + metafile: appCompiler.metafile!, + cssMetafile: cssCompiler.metafile!, + }); manifestChannel.write(manifest); await writeAssetsManifest(remixConfig, manifest); }; return { compile, - dispose: () => compiler?.rebuild.dispose(), + dispose: () => { + appCompiler?.rebuild.dispose(); + cssCompiler?.rebuild.dispose(); + }, }; }; diff --git a/packages/remix-dev/compiler/compilerServer.ts b/packages/remix-dev/compiler/compilerServer.ts index fa5289b61e9..460364a7161 100644 --- a/packages/remix-dev/compiler/compilerServer.ts +++ b/packages/remix-dev/compiler/compilerServer.ts @@ -8,6 +8,7 @@ import { type RemixConfig } from "../config"; import { type AssetsManifest } from "./assets"; import { loaders } from "./loaders"; import { type CompileOptions } from "./options"; +import { cssModulesPlugin } from "./plugins/cssModulesPlugin"; import { cssFilePlugin } from "./plugins/cssFilePlugin"; import { emptyModulesPlugin } from "./plugins/emptyModulesPlugin"; import { mdxPlugin } from "./plugins/mdx"; @@ -48,6 +49,7 @@ const createEsbuildConfig = ( let isDenoRuntime = config.serverBuildTarget === "deno"; let plugins: esbuild.Plugin[] = [ + cssModulesPlugin(options), cssFilePlugin(options), urlImportsPlugin(), mdxPlugin(config), diff --git a/packages/remix-dev/compiler/plugins/cssEntryModulePlugin.ts b/packages/remix-dev/compiler/plugins/cssEntryModulePlugin.ts new file mode 100644 index 00000000000..73a77380751 --- /dev/null +++ b/packages/remix-dev/compiler/plugins/cssEntryModulePlugin.ts @@ -0,0 +1,43 @@ +import type { Plugin } from "esbuild"; + +import type { RemixConfig } from "../../config"; +import { cssBuildVirtualModule } from "../virtualModules"; + +/** + * Creates a virtual module called `@remix-run/dev/css-build` that imports all + * browser build entry points so that any reachable CSS can be included in a + * single file at the end of the build. + */ +export function cssEntryModulePlugin(config: RemixConfig): Plugin { + let filter = cssBuildVirtualModule.filter; + + return { + name: "css-entry-module", + setup(build) { + build.onResolve({ filter }, ({ path }) => { + return { + path, + namespace: "css-entry-module", + }; + }); + + build.onLoad({ filter }, async () => { + return { + resolveDir: config.appDirectory, + loader: "js", + contents: [ + `export * as entryClient from ${JSON.stringify( + `./${config.entryClientFile}` + )};`, + ...Object.keys(config.routes).map((key, index) => { + let route = config.routes[key]; + return `export * as route${index} from ${JSON.stringify( + `./${route.file}` + )};`; + }), + ].join("\n"), + }; + }); + }, + }; +} diff --git a/packages/remix-dev/compiler/plugins/cssModulesPlugin.ts b/packages/remix-dev/compiler/plugins/cssModulesPlugin.ts new file mode 100644 index 00000000000..225c7fc3132 --- /dev/null +++ b/packages/remix-dev/compiler/plugins/cssModulesPlugin.ts @@ -0,0 +1,13 @@ +import { type CompileOptions } from "../options"; +import esbuildCssModulesPlugin from "./esbuild-plugin-css-modules/index.js"; + +export function cssModulesPlugin({ mode }: { mode: CompileOptions["mode"] }) { + return esbuildCssModulesPlugin({ + inject: false, + filter: /\.module\.css$/i, // The default includes support for "*.modules.css", so we're limiting the scope here + v2: true, + v2CssModulesOption: { + pattern: mode === "production" ? "[hash]" : "[name]_[local]_[hash]", + }, + }); +} diff --git a/packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/LICENSE b/packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/LICENSE new file mode 100644 index 00000000000..1fdc00142b1 --- /dev/null +++ b/packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021-present indooorsman, https://me.csser.top + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/README.md b/packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/README.md new file mode 100644 index 00000000000..21ea488b1ae --- /dev/null +++ b/packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/README.md @@ -0,0 +1,3 @@ +This is a local copy of https://github.com/indooorsman/esbuild-css-modules-plugin (commit hash f64cdbad1da2bed61336adfbf20511da4b4e811f) with modifications to get it working with our setup. + +The goal is to submit any required changes upstream, all of which have been marked with comments that look like this: `// CHANGE: ...`. diff --git a/packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/index.d.ts b/packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/index.d.ts new file mode 100644 index 00000000000..d9f7b510274 --- /dev/null +++ b/packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/index.d.ts @@ -0,0 +1,112 @@ +// Local patched copy of https://github.com/indooorsman/esbuild-css-modules-plugin +// More details in readme and license included in plugin's root directory + +/* eslint-disable */ +import type { OnLoadResult, Plugin, PluginBuild } from "esbuild"; +import BuildCache from "./lib/cache"; + +declare type GenerateScopedNameFunction = ( + name: string, + filename: string, + css: string +) => string; + +declare type LocalsConventionFunction = ( + originalClassName: string, + generatedClassName: string, + inputFile: string +) => string; + +declare class Loader { + constructor(root: string, plugins: Plugin[]); + + fetch( + file: string, + relativeTo: string, + depTrace: string + ): Promise<{ [key: string]: string }>; + + finalSource?: string | undefined; +} + +declare interface CssModulesOptions { + getJSON?( + cssFilename: string, + json: { [name: string]: string }, + outputFilename?: string + ): void; + + localsConvention?: + | "camelCase" + | "camelCaseOnly" + | "dashes" + | "dashesOnly" + | LocalsConventionFunction; + + scopeBehaviour?: "global" | "local"; + globalModulePaths?: RegExp[]; + + generateScopedName?: string | GenerateScopedNameFunction; + + hashPrefix?: string; + exportGlobals?: boolean; + root?: string; + + Loader?: typeof Loader; + + resolve?: (file: string) => string | Promise; +} + +declare interface PluginOptions { + inject?: boolean | string | ((css: string, digest: string) => string); + localsConvention?: CssModulesOptions["localsConvention"]; + generateScopedName?: CssModulesOptions["generateScopedName"]; + cssModulesOption?: CssModulesOptions; + filter?: RegExp; + v2?: boolean; + generateTsFile?: boolean; + v2CssModulesOption?: { + /** + * refer to: https://github.com/parcel-bundler/parcel-css/releases/tag/v1.9.0 + */ + dashedIndents?: boolean; + /** + * The currently supported segments are: + * [name] - the base name of the CSS file, without the extension + * [hash] - a hash of the full file path + * [local] - the original class name + */ + pattern?: string; + }; + root?: string; + package?: { + name: string; + main?: string; + module?: string; + version?: string; + }; + usePascalCase?: boolean; +} + +declare interface BuildContext { + // CHANGE: The buildId property is now optional because we no longer + // generate it when options.inject is false + buildId?: string; + buildRoot: string; + packageRoot?: string; + packageVersion: string; + log: (...args: any[]) => void; + relative: (to: string) => `.${string}`; + cache: BuildCache; +} + +declare function CssModulesPlugin(options?: PluginOptions): Plugin; + +declare namespace CssModulesPlugin { + export type Options = PluginOptions; + export interface Build extends PluginBuild { + context: BuildContext; + } +} + +export = CssModulesPlugin; diff --git a/packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/index.js b/packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/index.js new file mode 100644 index 00000000000..3089de68056 --- /dev/null +++ b/packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/index.js @@ -0,0 +1,29 @@ +// Local patched copy of https://github.com/indooorsman/esbuild-css-modules-plugin +// More details in readme and license included in plugin's root directory + +/* eslint-disable */ +// import pluginV1 from "./lib/v1"; +import plugin from "./lib/plugin"; +import { pluginName } from "./lib/utils"; + +/** + * @type {(options: import('.').Options) => import('esbuild').Plugin} + */ +const CssModulesPlugin = (options = {}) => { + return { + name: pluginName, + setup: async (build) => { + const { bundle } = build.initialOptions; + const { v2 } = options; + const useV2 = v2 && bundle; + + if (useV2) { + await plugin.setup(build, options); + } else { + // await pluginV1.setup(build, options); + } + }, + }; +}; + +export default CssModulesPlugin; diff --git a/packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/lib/cache.js b/packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/lib/cache.js new file mode 100644 index 00000000000..17670a5e478 --- /dev/null +++ b/packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/lib/cache.js @@ -0,0 +1,78 @@ +// Local patched copy of https://github.com/indooorsman/esbuild-css-modules-plugin +// More details in readme and license included in plugin's root directory + +/* eslint-disable */ +import { readFile } from "fs/promises"; +class BuildCache { + /** + * @param {import('..').Build} build + */ + constructor(build) { + this.build = build; + /** + * @type {import('..').Build['context']['log']} + */ + this.log = build.context.log; + /** + * @type {Map} + */ + async get(absPath) { + const cachedData = this.cache.get(absPath); + if (cachedData) { + this.log( + `find cache data, check if content changed(${this.build.context.relative( + absPath + )})...` + ); + const input = await readFile(absPath, { encoding: "utf8" }); + if (input === cachedData.input) { + this.log( + `content not changed, return cache(${this.build.context.relative( + absPath + )})` + ); + return cachedData.result; + } + this.log( + `content changed(${this.build.context.relative( + absPath + )}), rebuilding...` + ); + return void 0; + } + this.log( + `cache data not found(${this.build.context.relative( + absPath + )}), building...` + ); + return void 0; + } + /** + * @param {string} absPath + * @param {import('esbuild').OnLoadResult} result + * @param {string} originContent + * @returns {Promise} + */ + async set(absPath, result, originContent) { + const m = process.memoryUsage().rss; + if (m / 1024 / 1024 > 250) { + this.log("memory usage > 250M"); + this.clear(); + } + const input = + originContent || (await readFile(absPath, { encoding: "utf8" })); + this.cache.set(absPath, { input, result }); + } + clear() { + this.log("clear cache"); + this.cache.clear(); + } +} + +export default BuildCache; diff --git a/packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/lib/plugin.js b/packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/lib/plugin.js new file mode 100644 index 00000000000..83f937b5d93 --- /dev/null +++ b/packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/lib/plugin.js @@ -0,0 +1,545 @@ +// Local patched copy of https://github.com/indooorsman/esbuild-css-modules-plugin +// More details in readme and license included in plugin's root directory + +/* eslint-disable */ +import path from "path"; +import { createHash } from "crypto"; +import { readFile, writeFile, unlink, appendFile } from "fs/promises"; +import { + getLogger, + buildInjectCode, + pluginName, + getRootDir, + pluginNamespace, + buildingCssSuffix, + builtCssSuffix, + getModulesCssRegExp, + getBuiltModulesCssRegExp, + getRelativePath, + getBuildId, + validateNamedExport, + getPackageVersion, +} from "./utils.js"; +import cssHandler from "lightningcss"; +import camelCase from "lodash/camelCase"; +import upperFirst from "lodash/upperFirst"; +import BuildCache from "./cache.js"; + +/** + * buildCssModulesJs + * @param {{fullPath: string; options: import('..').Options; digest: string; build: import('..').Build}} params + * @returns {Promise<{resolveDir: string; js: string; css: string; originCss: string; exports: Record}>} + */ +const buildCssModulesJs = async ({ fullPath, options, build }) => { + const cssFileName = path.basename(fullPath); // e.g. xxx.module.css?esbuild-css-modules-plugin-building + const { buildId, relative, packageVersion, log } = build.context; + const resolveDir = path.dirname(fullPath); + const classPrefix = + path + .basename(fullPath, path.extname(fullPath)) + .replace(/[^a-zA-Z0-9]/g, "-") + "__"; + const versionString = packageVersion?.replace(/[^a-zA-Z0-9]/g, "") ?? ""; + const originCss = await readFile(fullPath); + const cssModulesOption = options.v2CssModulesOption || {}; + const genTs = !!options.generateTsFile; + + /** + * @type {import('lightningcss').BundleOptions} + */ + const bundleConfig = { + filename: relative(fullPath), // use relative path to keep hash stable in different machines + code: originCss, + minify: false, + sourceMap: true, + cssModules: { + pattern: `${classPrefix}[local]_[hash]${versionString}`, + ...cssModulesOption, + }, + analyzeDependencies: false, + }; + const { code, exports = {}, map } = cssHandler.transform(bundleConfig); + let cssModulesContent = code.toString("utf-8"); + + const cssModulesJSON = {}; + + Object.keys(exports) + .sort() // to keep order consistent in different builds + .forEach((originClass) => { + const patchedClass = exports[originClass].name; + let name = camelCase(originClass); + + if (options.usePascalCase) { + name = upperFirst(name); + } + + cssModulesJSON[name] = patchedClass; + }); + const classNamesMapString = JSON.stringify(cssModulesJSON); + + let cssWithSourceMap = cssModulesContent; + if (map) { + cssWithSourceMap += `\n/*# sourceMappingURL=data:application/json;base64,${map.toString( + "base64" + )} */`; + } + + // fix path issue on Windows: https://github.com/indooorsman/esbuild-css-modules-plugin/issues/12 + const cssImportPath = + "./" + + cssFileName + .split(path.sep) + .join(path.posix.sep) + .trim() + .replace(buildingCssSuffix, "") + + builtCssSuffix; + // => ./xxx.module.css?esbuild-css-modules-plugin-built + const importStatement = `import "${cssImportPath}";`; + + const exportStatement = options.inject + ? ` +export default new Proxy(${classNamesMapString}, { + get: function(source, key) { + setTimeout(() => { + window.__inject_${buildId}__ && window.__inject_${buildId}__(); + }, 0); + return source[key]; + } +}); + ` + : `export default ${classNamesMapString};`; + + const namedExportsTs = []; + const namedExportStatements = Object.entries(cssModulesJSON) + .map(([camelCaseClassName, className]) => { + if (!validateNamedExport(camelCaseClassName)) { + throw new Error( + `the class name "${camelCaseClassName}" in file ${fullPath} is a reserved keyword in javascript, please change it to someother word to avoid potential errors` + ); + } + const line = `export const ${camelCaseClassName} = "${className}"`; + genTs && namedExportsTs.push(`${line} as const;`); + return `${line};`; + }) + .join("\n"); + + const js = `${importStatement}\n${exportStatement};\n${namedExportStatements}`; + + if (genTs) { + const ts = `export default ${classNamesMapString} as const;\n${namedExportsTs.join( + "\n" + )}\n`; + const tsPath = `${fullPath.replace(/\?.+$/, "")}.ts`; + log(tsPath, ts); + await writeFile(tsPath, ts, { encoding: "utf-8" }); + } + + return { + js, + css: cssWithSourceMap, + originCss: originCss.toString("utf8"), + exports, + resolveDir, + }; +}; + +/** + * prepareBuild + * @param {import('..').Build} build + * @param {import('..').Options} options + * @return {Promise} + */ +const prepareBuild = async (build, options) => { + // CHANGE: buildId is only needed when injected styles, so we bail out + // This is mainly to patch over issues with getBuildId, but a nice + // side effect of this is that we avoid a bunch of work + const buildId = options.inject ? await getBuildId(build) : null; + const packageVersion = getPackageVersion(build); + build.initialOptions.metafile = true; + const packageRoot = options.root; + const buildRoot = getRootDir(build); + const log = getLogger(build); + const relative = (to) => getRelativePath(build, to); + + build.context = { + buildId, + buildRoot, + packageRoot, + packageVersion, + log, + relative, + }; + build.context.cache = new BuildCache(build); + + log(`root of this build${buildId ? `(#${buildId})` : ""}:`, buildRoot); +}; + +/** + * onResolveModulesCss + * @description mark module(s).css as sideEffects and add namespace + * @param {import('esbuild').OnResolveArgs} args + * @param {import('..').Build} build + * @returns {Promise} + */ +const onResolveModulesCss = async (args, build) => { + const { resolve, initialOptions, context } = build; + const { resolveDir, path: p, pluginData = {} } = args; + const { log, relative } = context; + const { path: absPath } = await resolve(p, { resolveDir }); + const rpath = relative(absPath); + log("resolve", p, "to", rpath, "from build root"); + + /** + * @type {import('esbuild').OnResolveResult} + */ + const result = { + namespace: pluginNamespace, + suffix: buildingCssSuffix, + path: rpath, + external: false, + pluginData: { + ...pluginData, + relativePathToBuildRoot: rpath, + }, + sideEffects: true, + pluginName, + }; + + if (initialOptions.watch) { + log("watching", rpath); + result.watchFiles = [absPath]; + } + + return result; +}; + +/** + * onLoadModulesCss + * @param {import('..').Build} build + * @param {import('..').Options} options + * @param {import('esbuild').OnLoadArgs} args + * @return {(import('esbuild').OnLoadResult | null | undefined | Promise)} + */ +const onLoadModulesCss = async (build, options, args) => { + const { path: maybeFullPath, pluginData = {} } = args; + const { buildRoot, log, cache } = build.context; + const absPath = path.isAbsolute(maybeFullPath) + ? maybeFullPath + : path.resolve(buildRoot, maybeFullPath); + const rpath = pluginData.relativePathToBuildRoot; + + log(`loading ${rpath}${args.suffix}`); + + const useCache = build.initialOptions.watch; + + useCache && log(`checking cache for`, rpath); + const cached = useCache && (await cache.get(absPath)); + if (cached) { + log("return build cache for", rpath); + return cached; + } + + const hex = createHash("sha256").update(rpath).digest("hex"); + const digest = hex.slice(hex.length - 255, hex.length); + + const { js, ts, resolveDir, css, exports, originCss } = + await buildCssModulesJs({ + fullPath: absPath, + options, + digest, + build, + }); + + const result = { + pluginName, + resolveDir, + pluginData: { + ...pluginData, + css, + exports, + digest, + }, + contents: js, + loader: "js", + }; + + if (useCache) { + await cache.set(absPath, result, originCss); + log(`add build result to cache for ${rpath}`); + } + + return result; +}; + +/** + * onResolveBuiltModulesCss + * @param {import('esbuild').OnResolveArgs} args + * @param {import('..').Build} build + * @returns {Promise} + */ +const onResolveBuiltModulesCss = async (args, build) => { + const { path: p, pluginData = {} } = args; + const { relativePathToBuildRoot } = pluginData; + + build.context?.log( + `resolve virtual path ${p} to ${relativePathToBuildRoot}${builtCssSuffix}` + ); + + /** + * @type {import('esbuild').OnResolveResult} + */ + const result = { + namespace: pluginNamespace, + path: relativePathToBuildRoot + builtCssSuffix, + external: false, + pluginData, + sideEffects: true, + pluginName, + }; + + return result; +}; + +/** + * onLoadBuiltModulesCss + * @param {import('esbuild').OnLoadArgs} args + * @param {import('..').Build} build + * @returns {Promise} + */ +const onLoadBuiltModulesCss = async ({ pluginData }, build) => { + const { log, buildRoot } = build.context; + const { css, relativePathToBuildRoot } = pluginData; + const absPath = path.resolve(buildRoot, relativePathToBuildRoot); + const resolveDir = path.dirname(absPath); + log("loading built css for", relativePathToBuildRoot); + + /** + * @type {import('esbuild').OnLoadResult} + */ + const result = { + contents: css, + loader: "css", + pluginName, + resolveDir, + pluginData, + }; + + return result; +}; + +/** + * onEnd + * @param {import('..').Build} build + * @param {import('..').Options} options + * @param {import('esbuild').BuildResult} result + */ +const onEnd = async (build, options, result) => { + const { initialOptions, context, esbuild } = build; + const { buildId, buildRoot } = context; + const log = getLogger(build); + + if (options.inject) { + const { + charset = "utf8", + outdir, + sourceRoot, + sourcemap, + sourcesContent, + entryPoints, + minify, + logLevel, + format, + target, + external, + publicPath, + } = initialOptions; + const absOutdir = path.isAbsolute(outdir) + ? outdir + : path.resolve(buildRoot, outdir); + + const transformCss = async (css) => { + const r = await esbuild.transform(css, { + charset, + loader: "css", + sourcemap: false, + minify: true, + logLevel, + format, + target, + }); + return r.code; + }; + + const buildJs = async (entryName, entryPath, jsCode) => { + const r = (p) => + path.relative(absOutdir, p).split(path.sep).join(path.posix.sep); + const imports = `import "./${r(entryPath)}";\nexport * from "./${r( + entryPath + )}";`; + if (sourcemap === "external") { + await appendFile( + entryPath, + `\n//# sourceMappingURL=${r(entryPath)}.map`, + { + encoding: "utf8", + } + ); + } else if (publicPath && sourcemap) { + const fixedPublicPath = publicPath.endsWith("/") + ? publicPath + : publicPath + "/"; + const entryContent = await readFile(entryPath, { encoding: "utf8" }); + await writeFile( + entryPath, + entryContent.replace( + `sourceMappingURL=${fixedPublicPath}`, + "sourceMappingURL=" + ), + { encoding: "utf8" } + ); + } + const tmpJsCode = `${imports}\n${jsCode}`; + const tmpJsPath = path.resolve(absOutdir, ".build.inject.js"); + await writeFile(tmpJsPath, tmpJsCode, { encoding: "utf8" }); + await esbuild.build({ + charset, + absWorkingDir: absOutdir, + write: true, + allowOverwrite: true, + treeShaking: false, + logLevel, + format, + target, + minify, + sourceRoot, + publicPath, + sourcemap, + sourcesContent, + entryPoints: { + [entryName]: tmpJsPath, + }, + outdir: absOutdir, + bundle: true, + external, + }); + await unlink(tmpJsPath); + }; + + const cssContents = []; + + let entriesArray = []; + if (Array.isArray(entryPoints)) { + entriesArray = [...entryPoints]; + } else { + Object.keys(entryPoints) + .sort() + .forEach((k) => { + entriesArray.push(entryPoints[k]); + }); + } + const entries = entriesArray.map((p) => + path.isAbsolute(p) ? p : path.resolve(buildRoot, p) + ); + + log("entries:", entries); + + let entryToInject = null; + const outputs = Object.keys(result.metafile?.outputs ?? []); + + await Promise.all( + outputs.map(async (f) => { + if ( + !entryToInject && + result.metafile.outputs[f].entryPoint && + entries.includes( + path.resolve(buildRoot, result.metafile.outputs[f].entryPoint) + ) && + [".js", ".mjs", ".cjs"].includes(path.extname(f)) + ) { + entryToInject = path.resolve(buildRoot, f); + } + if (path.extname(f) === ".css") { + const fullpath = path.resolve(buildRoot, f); + const css = await readFile(fullpath, { encoding: "utf8" }); + const transformed = await transformCss(css); + cssContents.push(`${transformed}`); + } + }) + ); + + if (entryToInject && cssContents.length) { + log("inject css to", path.relative(buildRoot, entryToInject)); + const entryName = path.basename( + entryToInject, + path.extname(entryToInject) + ); + const allCss = cssContents.join("\n"); + const container = + typeof options.inject === "string" ? options.inject : "head"; + + if (!buildId) { + throw new Error("Build ID must be present in order to inject CSS"); + } + + const injectedCode = buildInjectCode(container, allCss, buildId, options); + await buildJs(entryName, entryToInject, injectedCode); + } + } + + log("finished"); +}; + +/** + * setup + * @param {import('..').Build} build + * @param {import('..').Options} options + * @returns {Promise} + */ +const setup = async (build, options) => { + await prepareBuild(build, options); + const modulesCssRegExp = getModulesCssRegExp(options); + const builtModulesCssRegExp = getBuiltModulesCssRegExp(options); + + // resolve xxx.module.css to xxx.module.css?esbuild-css-modules-plugin-building + build.onResolve( + { filter: modulesCssRegExp, namespace: "file" }, + async (args) => { + return await onResolveModulesCss(args, build); + } + ); + + // load xxx.module.css?esbuild-css-modules-plugin-building + build.onLoad( + { filter: modulesCssRegExp, namespace: pluginNamespace }, + async (args) => { + return await onLoadModulesCss(build, options, args); + } + ); + + // resolve virtual path xxx.module.css?esbuild-css-modules-plugin-built + build.onResolve( + { + filter: builtModulesCssRegExp, + namespace: pluginNamespace, + }, + async (args) => { + return await onResolveBuiltModulesCss(args, build); + } + ); + + // load virtual path xxx.module.css?esbuild-css-modules-plugin-built + build.onLoad( + { + filter: builtModulesCssRegExp, + namespace: pluginNamespace, + }, + async (args) => { + return await onLoadBuiltModulesCss(args, build); + } + ); + + build.onEnd(async (result) => { + await onEnd(build, options, result); + }); +}; + +export default { setup }; diff --git a/packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/lib/utils.js b/packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/lib/utils.js new file mode 100644 index 00000000000..ef695b214af --- /dev/null +++ b/packages/remix-dev/compiler/plugins/esbuild-plugin-css-modules/lib/utils.js @@ -0,0 +1,269 @@ +// Local patched copy of https://github.com/indooorsman/esbuild-css-modules-plugin +// More details in readme and license included in plugin's root directory + +/* eslint-disable */ +import path from "path"; +import { createHash } from "crypto"; +import { readFile } from "fs/promises"; +import fs from "fs"; +const pluginName = "esbuild-plugin-css-modules"; +const pluginNamespace = `${pluginName}-namespace`; +const buildingCssSuffix = `?${pluginName}-building`; +const builtCssSuffix = `?${pluginName}-built`; +const builtCssSuffixRegExp = builtCssSuffix + .replace("?", "\\?") + .replace(/\-/g, "\\-"); + +/** + * getModulesCssRegExp + * @param {import('..').Options} options + * @returns {RegExp} + */ +const getModulesCssRegExp = (options) => { + return options.filter ?? /\.modules?\.css$/i; +}; + +/** + * getBuiltModulesCssRegExp + * @param {import('..').Options} options + * @returns {RegExp} + */ +const getBuiltModulesCssRegExp = (options) => { + const baseRegExp = getModulesCssRegExp(options); + const baseRegExpSource = baseRegExp.source.endsWith("$") + ? baseRegExp.source.slice(0, -1) + : baseRegExp.source; + return new RegExp(`${baseRegExpSource}${builtCssSuffixRegExp}$`, "i"); +}; + +/** + * getLogger + * @param {import('..').Build} build + * @returns {(...args: any[]) => void} + */ +const getLogger = (build) => { + const { logLevel } = build.initialOptions; + if (logLevel === "debug" || logLevel === "verbose") { + return (...args) => { + console.log(`[${pluginName}]`, ...args); + }; + } + return () => void 0; +}; + +/** + * buidInjectCode + * @param {string} injectToSelector + * @param {string} css + * @param {string} digest + * @param {import('..').Options} options + * @returns {string} + */ +const buildInjectCode = (injectToSelector = "head", css, digest, options) => { + if (typeof options.inject === "function") { + return ` +(function(){ + const doInject = () => { + ${options.inject(css, digest)} + delete window.__inject_${digest}__; + }; + window.__inject_${digest}__ = doInject; +})(); + `; + } + return ` +(function(){ + const css = \`${css}\`; + const doInject = () => { + if (typeof document === 'undefined') { + return; + } + let root = document.querySelector('${injectToSelector}'); + if (root && root.shadowRoot) { + root = root.shadowRoot; + } + if (!root) { + root = document.head; + } + let container = root.querySelector('#_${digest}'); + if (!container) { + container = document.createElement('style'); + container.id = '_${digest}'; + root.appendChild(container); + } + const text = document.createTextNode(css); + container.appendChild(text); + delete window.__inject_${digest}__; + } + window.__inject_${digest}__ = doInject; +})(); + `; +}; + +/** + * getRootDir + * @param {import('..').Build} build + * @returns {string} + */ +const getRootDir = (build) => { + const { absWorkingDir } = build.initialOptions; + const abs = absWorkingDir ? absWorkingDir : process.cwd(); + const rootDir = path.isAbsolute(abs) ? abs : path.resolve(process.cwd(), abs); + return rootDir; +}; + +/** + * getPackageInfo + * @param {import('..').Build} build + * @returns {{name: string; version: string;}} + */ +const getPackageInfo = (build) => { + const rootDir = getRootDir(build); + const packageJsonFile = path.resolve(rootDir, "./package.json"); + try { + fs.accessSync(packageJsonFile, fs.constants.R_OK); + return require(packageJsonFile); + } catch (error) { + return { name: "", version: "" }; + } +}; + +/** + * getPackageVersion + * @param {import('..').Build} build + * @returns {string} + */ +const getPackageVersion = (build) => { + return getPackageInfo(build).version; +}; + +/** + * getRelativePath + * @description get relative path from build root + * @param {import('..').Build} build + * @param {string} to + * @returns {string} + */ +const getRelativePath = (build, to) => { + if (!path.isAbsolute(to)) { + return to.startsWith(".") ? to : `.${path.sep}${to}`; + } + const root = build.context?.buildRoot ?? getRootDir(build); + return `.${path.sep}${path.relative(root, to)}`; +}; + +/** + * getBuildId + * @description buildId should be stable so that the hash of output files are stable + * @param {import('..').Build} build + * @returns {Promise} + */ +const getBuildId = async (build) => { + // CHANGE: We need to default entryPoints to an empty object because it's possible + // for it to be undefined when using stdin, like with our server build + const { entryPoints = {} } = build.initialOptions; + const buildRoot = getRootDir(build); + const { version: packageVersion, name: packageName } = getPackageInfo(build); + let entries = []; + if (Array.isArray(entryPoints)) { + entries = [...entryPoints]; + } else { + Object.keys(entryPoints) + .sort() + .forEach((k) => { + entries.push(entryPoints[k]); + }); + } + const entryContents = + `// ${packageName}@${packageVersion}\n` + + ( + await Promise.all( + entries.map(async (p) => { + const absPath = path.isAbsolute(p) ? p : path.resolve(buildRoot, p); + + // CHANGE: This code path doesn't work for virtual modules or paths + // with query strings. As a hack we're just returning the path here. + // I'm not sure what a proper solution for this would look like since + // we don't have access to the source of virtual modules, and I'm not + // sure what the significance of this build ID even is. + return absPath; + // return (await readFile(absPath, { encoding: "utf8" })).trim(); + }) + ) + ).join("\n"); + return createHash("sha256").update(entryContents).digest("hex"); +}; + +const jsKeywords = [ + "await", + "break", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "else", + "enum", + "export", + "extends", + "false", + "finally", + "for", + "function", + "if", + "implements", + "import", + "in", + "instanceof", + "interface", + "let", + "new", + "null", + "package", + "private", + "protected", + "public", + "return", + "super", + "switch", + "static", + "this", + "throw", + "try", + "true", + "typeof", + "var", + "void", + "while", + "with", + "yield", +]; + +/** + * @param {string} name + * @returns {boolean} + */ +const validateNamedExport = (name) => { + return !jsKeywords.includes(name); +}; + +export { + pluginName, + pluginNamespace, + getLogger, + getRootDir, + buildInjectCode, + builtCssSuffix, + getModulesCssRegExp, + getBuiltModulesCssRegExp, + buildingCssSuffix, + getRelativePath, + getBuildId, + validateNamedExport, + getPackageInfo, + getPackageVersion, +}; diff --git a/packages/remix-dev/compiler/plugins/serverBareModulesPlugin.ts b/packages/remix-dev/compiler/plugins/serverBareModulesPlugin.ts index 8fb9e719eb7..7129bea759a 100644 --- a/packages/remix-dev/compiler/plugins/serverBareModulesPlugin.ts +++ b/packages/remix-dev/compiler/plugins/serverBareModulesPlugin.ts @@ -45,6 +45,11 @@ export function serverBareModulesPlugin( return undefined; } + // Always bundle @remix-run/css-bundle + if (path === "@remix-run/css-bundle") { + return undefined; + } + // To prevent `import xxx from "remix"` from ending up in the bundle // we "bundle" remix but the other modules where the code lives. if (path === "remix") { diff --git a/packages/remix-dev/compiler/virtualModules.ts b/packages/remix-dev/compiler/virtualModules.ts index e7df45fbd92..ed9ab4595f4 100644 --- a/packages/remix-dev/compiler/virtualModules.ts +++ b/packages/remix-dev/compiler/virtualModules.ts @@ -8,6 +8,11 @@ export const serverBuildVirtualModule: VirtualModule = { filter: /^@remix-run\/dev\/server-build$/, }; +export const cssBuildVirtualModule: VirtualModule = { + id: "@remix-run/dev/css-build", + filter: /^@remix-run\/dev\/css-build$/, +}; + export const assetsManifestVirtualModule: VirtualModule = { id: "@remix-run/dev/assets-manifest", filter: /^@remix-run\/dev\/assets-manifest$/, diff --git a/packages/remix-dev/index.ts b/packages/remix-dev/index.ts index ba236fd8bc6..206f30f194b 100644 --- a/packages/remix-dev/index.ts +++ b/packages/remix-dev/index.ts @@ -6,4 +6,5 @@ export * as cli from "./cli/index"; export { createApp } from "./cli/create"; export { CliError } from "./cli/error"; +export type { AssetsManifest } from "./compiler/assets"; export { getDependenciesToBundle } from "./compiler/dependencies"; diff --git a/packages/remix-dev/modules.ts b/packages/remix-dev/modules.ts index db6c7eba031..db92495ca7b 100644 --- a/packages/remix-dev/modules.ts +++ b/packages/remix-dev/modules.ts @@ -6,6 +6,10 @@ declare module "*.avif" { let asset: string; export default asset; } +declare module "*.module.css" { + let styles: { [key: string]: string }; + export default styles; +} declare module "*.css" { let asset: string; export default asset; diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index 6e0caa2ef7e..17bd46aaa6c 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -45,6 +45,7 @@ "jscodeshift": "^0.13.1", "jsesc": "3.0.2", "json5": "^2.2.1", + "lightningcss": "^1.16.0", "lodash": "^4.17.21", "lodash.debounce": "^4.0.8", "minimatch": "^3.0.4", diff --git a/scripts/publish.js b/scripts/publish.js index 12accadb20f..2d9e4ed2a43 100644 --- a/scripts/publish.js +++ b/scripts/publish.js @@ -56,6 +56,7 @@ async function run() { "netlify", "react", "serve", + "css-bundle", ]) { publish(path.join(buildDir, "@remix-run", name), tag); } diff --git a/scripts/utils.js b/scripts/utils.js index 1f710af293d..1ebdcfb2faf 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -17,7 +17,7 @@ let remixPackages = { "vercel", ], runtimes: ["cloudflare", "deno", "node"], - core: ["dev", "server-runtime", "react", "eslint-config"], + core: ["dev", "server-runtime", "react", "css-bundle", "eslint-config"], get all() { return [...this.adapters, ...this.runtimes, ...this.core, "serve"]; }, diff --git a/tsconfig.json b/tsconfig.json index fee9d10bfcf..6bed9e64518 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ { "path": "packages/remix-cloudflare" }, { "path": "packages/remix-cloudflare-pages" }, { "path": "packages/remix-cloudflare-workers" }, + { "path": "packages/remix-css-bundle" }, { "path": "packages/remix-dev" }, { "path": "packages/remix-express" }, { "path": "packages/remix-netlify" }, diff --git a/yarn.lock b/yarn.lock index f177824e62b..05045861759 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5172,6 +5172,11 @@ detect-indent@^6.0.0: resolved "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz" integrity sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA== +detect-libc@^1.0.3: + version "1.0.3" + resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" + integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg== + detect-newline@3.1.0, detect-newline@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz" @@ -8571,6 +8576,62 @@ levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +lightningcss-darwin-arm64@1.16.1: + version "1.16.1" + resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.16.1.tgz#f67287c500b96bc5d21e6d04de0e2607ff2784ff" + integrity sha512-/J898YSAiGVqdybHdIF3Ao0Hbh2vyVVj5YNm3NznVzTSvkOi3qQCAtO97sfmNz+bSRHXga7ZPLm+89PpOM5gAg== + +lightningcss-darwin-x64@1.16.1: + version "1.16.1" + resolved "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.16.1.tgz#2dc89dd4e1eb3c39ca4abbeca769a276c206b038" + integrity sha512-vyKCNPRNRqke+5i078V+N0GLfMVLEaNcqIcv28hA/vUNRGk/90EDkDB9EndGay0MoPIrC2y0qE3Y74b/OyedqQ== + +lightningcss-linux-arm-gnueabihf@1.16.1: + version "1.16.1" + resolved "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.16.1.tgz#9edacb0d9bd18fa1830a9d9f9ba00bdc9f8dabc9" + integrity sha512-0AJC52l40VbrzkMJz6qRvlqVVGykkR2MgRS4bLjVC2ab0H0I/n4p6uPZXGvNIt5gw1PedeND/hq+BghNdgfuPQ== + +lightningcss-linux-arm64-gnu@1.16.1: + version "1.16.1" + resolved "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.16.1.tgz#b6986324d21de3813b84432b51c3e7b019dd2224" + integrity sha512-NqxYXsRvI3/Fb9AQLXKrYsU0Q61LqKz5It+Es9gidsfcw1lamny4lmlUgO3quisivkaLCxEkogaizcU6QeZeWQ== + +lightningcss-linux-arm64-musl@1.16.1: + version "1.16.1" + resolved "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.16.1.tgz#d13a01ed19a72c99b4ef9c5b9d8ee0dcdc4bf3ed" + integrity sha512-VUPQ4dmB9yDQxpJF8/imtwNcbIPzlL6ArLHSUInOGxipDk1lOAklhUjbKUvlL3HVlDwD3WHCxggAY01WpFcjiA== + +lightningcss-linux-x64-gnu@1.16.1: + version "1.16.1" + resolved "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.16.1.tgz#6c888ef4faac53333d6d2241da463c405b19ec79" + integrity sha512-A40Jjnbellnvh4YF+kt047GLnUU59iLN2LFRCyWQG+QqQZeXOCzXfTQ6EJB4yvHB1mQvWOVdAzVrtEmRw3Vh8g== + +lightningcss-linux-x64-musl@1.16.1: + version "1.16.1" + resolved "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.16.1.tgz#20b51081679dd6b7271ce11df825dc536a0c617c" + integrity sha512-VZf76GxW+8mk238tpw0u9R66gBi/m0YB0TvD54oeGiOqvTZ/mabkBkbsuXTSWcKYj8DSrLW+A42qu+6PLRsIgA== + +lightningcss-win32-x64-msvc@1.16.1: + version "1.16.1" + resolved "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.16.1.tgz#7546b4dca78314b1d2701ed220cb6e50b8c6b5ca" + integrity sha512-Djy+UzlTtJMayVJU3eFuUW5Gdo+zVTNPJhlYw25tNC9HAoMCkIdSDDrGsWEdEyibEV7xwB8ySTmLuxilfhBtgg== + +lightningcss@^1.16.0: + version "1.16.1" + resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.16.1.tgz#b5a16632b6824d023af2fb7d35b1c6fc42608bc6" + integrity sha512-zU8OTaps3VAodmI2MopfqqOQQ4A9L/2Eo7xoTH/4fNkecy6ftfiGwbbRMTQqtIqJjRg3f927e+lnyBBPhucY1Q== + dependencies: + detect-libc "^1.0.3" + optionalDependencies: + lightningcss-darwin-arm64 "1.16.1" + lightningcss-darwin-x64 "1.16.1" + lightningcss-linux-arm-gnueabihf "1.16.1" + lightningcss-linux-arm64-gnu "1.16.1" + lightningcss-linux-arm64-musl "1.16.1" + lightningcss-linux-x64-gnu "1.16.1" + lightningcss-linux-x64-musl "1.16.1" + lightningcss-win32-x64-msvc "1.16.1" + lines-and-columns@^1.1.6: version "1.1.6" resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz"