diff --git a/.changeset/config.json b/.changeset/config.json index 5c58ec9..0fd74b7 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,5 +7,9 @@ "access": "restricted", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [] + "ignore": [], + "privatePackages": { + "version": true, + "tag": true + } } diff --git a/.changeset/css-plugins-to-native-css.md b/.changeset/css-plugins-to-native-css.md new file mode 100644 index 0000000..184c376 --- /dev/null +++ b/.changeset/css-plugins-to-native-css.md @@ -0,0 +1,5 @@ +--- +"@webpack/css-plugins-to-native-css": major +--- + +Add codemod migrating mini-css-extract-plugin and style-loader/css-loader setups to webpack's native CSS support. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f022253 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Checkout with LF everywhere so generated fixture output matches on Windows. +* text=auto eol=lf + +# CRLF fixtures exercise the editor's EOL detection — keep their bytes as-is. +codemods/*/tests/crlf/* -text diff --git a/.github/scripts/check-utils-changesets.mjs b/.github/scripts/check-utils-changesets.mjs new file mode 100644 index 0000000..3abca6a --- /dev/null +++ b/.github/scripts/check-utils-changesets.mjs @@ -0,0 +1,52 @@ +// Internal packages under packages/ are never published: when their source +// changes, the codemods that bundle them must be re-released. Fail the PR +// unless at least one dependent codemod is covered by a pending changeset. +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +function readPackageName(dir, entry) { + const packagePath = join(dir, entry, "package.json"); + if (!existsSync(packagePath)) return null; + return JSON.parse(readFileSync(packagePath, "utf8")); +} + +const base = process.argv[2] ?? "origin/main"; +const changed = execFileSync("git", ["diff", "--name-only", base, "HEAD"], { encoding: "utf8" }) + .split("\n") + .filter(Boolean); + +const touchedInternal = []; +for (const entry of readdirSync("packages", { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const pkg = readPackageName("packages", entry.name); + if (!pkg) continue; + if (changed.some((file) => file.startsWith(`packages/${entry.name}/src/`))) { + touchedInternal.push(pkg.name); + } +} +if (!touchedInternal.length) process.exit(0); + +const dependents = []; +for (const entry of readdirSync("codemods", { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const pkg = readPackageName("codemods", entry.name); + if (!pkg) continue; + if (touchedInternal.some((name) => pkg.dependencies?.[name])) dependents.push(pkg.name); +} +if (!dependents.length) process.exit(0); + +const covered = readdirSync(".changeset") + .filter((file) => file.endsWith(".md") && file !== "README.md") + .some((file) => { + const content = readFileSync(join(".changeset", file), "utf8"); + return dependents.some((name) => content.includes(`"${name}"`)); + }); +if (covered) process.exit(0); + +console.error( + `${touchedInternal.join(", ")} changed, but no changeset bumps a dependent codemod.\n` + + "Internal packages are bundled into the published codemods, so add a changeset for the affected ones:\n" + + dependents.map((name) => ` - ${name}`).join("\n"), +); +process.exit(1); diff --git a/.github/scripts/sync-codemod-versions.mjs b/.github/scripts/sync-codemod-versions.mjs new file mode 100644 index 0000000..be82ffd --- /dev/null +++ b/.github/scripts/sync-codemod-versions.mjs @@ -0,0 +1,22 @@ +// Keeps each codemod.yaml `version` in sync with the package.json version +// bumped by `changeset version`. Run as part of the root `version` script. +import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const codemodsDir = "codemods"; +for (const entry of readdirSync(codemodsDir, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const packagePath = join(codemodsDir, entry.name, "package.json"); + const manifestPath = join(codemodsDir, entry.name, "codemod.yaml"); + if (!existsSync(packagePath) || !existsSync(manifestPath)) continue; + const { version } = JSON.parse(readFileSync(packagePath, "utf8")); + const manifest = readFileSync(manifestPath, "utf8"); + const updated = manifest.replace( + /^version:\s*(['"]?)([^'"\n]+)\1\s*$/m, + `version: "${version}"`, + ); + if (updated !== manifest) { + writeFileSync(manifestPath, updated); + console.log(`${manifestPath} -> ${version}`); + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b9d3241..8f414ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,14 @@ jobs: - name: Run type check run: npm run type-check + - name: Check utils changes have codemod changesets + if: github.event_name == 'pull_request' + env: + BASE_REF: ${{ github.base_ref }} + run: | + git fetch --no-tags --depth=1 origin "$BASE_REF" + node .github/scripts/check-utils-changesets.mjs "origin/$BASE_REF" + test: strategy: fail-fast: false diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d5e705e..3bbf134 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -41,6 +41,7 @@ jobs: id: changesets uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 with: + version: npm run version publish: npx changeset tag env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -92,9 +93,17 @@ jobs: }); ') echo "path=$DIR" >> "$GITHUB_OUTPUT" - echo "Publishing $PKG_NAME from $DIR" + # Internal packages (no codemod.yaml) are versioned but not published. + if [ -f "$DIR/codemod.yaml" ]; then + echo "has_manifest=true" >> "$GITHUB_OUTPUT" + echo "Publishing $PKG_NAME from $DIR" + else + echo "has_manifest=false" >> "$GITHUB_OUTPUT" + echo "Skipping $PKG_NAME ($DIR has no codemod.yaml)" + fi - name: Publish to Codemod registry + if: steps.dir.outputs.has_manifest == 'true' uses: codemod/publish-action@dd6c8dbc5ceb1a6146feba41481d88b43da50024 # v1 with: path: ${{ steps.dir.outputs.path }} diff --git a/AGENTS.md b/AGENTS.md index 2e545a5..adbb33f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # webpack codemods — agent guide -This repository hosts codemods that upgrade webpack configurations and APIs, published to the [Codemod Registry](https://app.codemod.com/registry) and run with `npx codemod@latest run @webpack/`. +This repository hosts codemods that upgrade webpack configurations and APIs, published to the [Codemod Registry](https://app.codemod.com/registry) and run with `npx codemod run @webpack/`. ## Commands @@ -12,6 +12,12 @@ This repository hosts codemods that upgrade webpack configurations and APIs, pub | `npm run lint` / `npm run lint:fix` | ESLint check / autofix | | `npm run type-check` | `tsc --noEmit` over `src/` files | +## Shared utilities + +Logic reused across codemods lives in the `packages/codemod-utils/` workspace (`@webpack/codemod-utils`), split into `ast.ts` (generic ast-grep helpers: `findPair`, `namedChildren`, `unwrapFilterCall`, …), `imports.ts` (`collectModuleBindings`, a thin wrapper over the official [`@jssg/utils`](https://github.com/codemod/codemod/tree/main/packages/jssg-utils) import resolution), and `index.ts` (webpack-config helpers — `loaderNameOf`, `findConfigObjectFor`, `ruleMatchesFiles` — plus the `ConfigEditor` class: grouped removals, brace-aware insertion, EOL-aware output, unused-import cleanup). Prefer `@jssg/utils` primitives over hand-rolled AST matching when they cover the case. Import it from a codemod by adding `"@webpack/codemod-utils": "*"` to its `dependencies`; the jssg runner bundles it. Prefer extending it over copying helpers between codemods. + +The utils package is internal: it is never published and stays at version `0.0.0`. When a change to it affects released codemods, add a changeset for **each affected codemod** (they bundle the utils, so they are what needs re-publishing) — CI fails the PR if utils sources change without one. + ## Creating a codemod Every codemod is a self-contained npm workspace under `codemods//`. Names are kebab-case and describe the migration (e.g. `hashed-module-ids-to-deterministic`); the published package is scoped as `@webpack/`. @@ -27,8 +33,7 @@ codemods// ├── src/ │ └── workflow.ts # The transform, written with jssg (ast-grep) └── tests/ - ├── input/ # Fixtures before the transform - └── expected/ # The same filenames after the transform + └── / # One directory per case: input.js + expected.js ``` ### File templates @@ -105,7 +110,7 @@ Add `semantic_analysis: file` under `js-ast-grep` when the transform needs scope "description": ".", "type": "module", "scripts": { - "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./" + "test": "npx codemod jssg test -l typescript ./src/workflow.ts" }, "repository": { "type": "git", @@ -113,7 +118,7 @@ Add `semantic_analysis: file` under `js-ast-grep` when the transform needs scope "directory": "codemods/", "bugs": "https://github.com/webpack/codemods/issues" }, - "author": " ()", + "author": " <>", "license": "MIT", "homepage": "https://github.com/webpack/codemods/blob/main/codemods//README.md", "devDependencies": { @@ -160,7 +165,7 @@ References: [jssg docs](https://docs.codemod.com/jssg) and [ast-grep rule refere ### Tests -Every file in `tests/input/` must have a file with the same name in `tests/expected/` containing the post-transform output. Cover at least: a file that is transformed, a file that must remain untouched, and both CJS/ESM variants when relevant. +Each case is a directory under `tests/` holding an `input.` file and an `expected.` file with the post-transform output (`.js`, `.mjs`, … — the extension decides how the input parses). Cover at least: a file that is transformed, a file that must remain untouched, and both CJS/ESM variants when relevant. ### Checklist before opening a PR diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec62bf2..59ed77a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ npm install ## Repository structure -Each codemod lives in its own directory under [`codemods/`](codemods/) and is an npm workspace: +Shared helpers used by several codemods live in [`packages/codemod-utils/`](packages/codemod-utils/) (`@webpack/codemod-utils`). Each codemod lives in its own directory under [`codemods/`](codemods/) and is an npm workspace: ```text codemods// @@ -23,8 +23,7 @@ codemods// ├── src/ │ └── workflow.ts # The transform, written with jssg (ast-grep) └── tests/ - ├── input/ # Fixture files before the transform - └── expected/ # The same files after the transform + └── / # One directory per case: input.js + expected.js ``` ## Adding a new codemod @@ -32,7 +31,7 @@ codemods// 1. Create a new directory under `codemods/` with the structure above. Use a short, kebab-case name that describes the migration (see [AGENTS.md](AGENTS.md) for file templates). 2. Update `codemod.yaml`, `workflow.yaml`, `package.json`, and `README.md` with the new name and description. The package name must be scoped as `@webpack/`. 3. Write the transform in `src/workflow.ts`. See the [jssg documentation](https://docs.codemod.com/jssg) and the [ast-grep rule reference](https://ast-grep.github.io/reference/rule.html). -4. Add fixtures: every file in `tests/input/` must have a matching file in `tests/expected/`. +4. Add fixtures: one directory per case under `tests/`, each with an `input.` and an `expected.` file. 5. Add the codemod to the table in the root [README.md](README.md). ## Testing diff --git a/README.md b/README.md index b8b29ea..120eb19 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,15 @@ A collection of codemods to automatically upgrade webpack configurations and API Run a codemod on your project with the [Codemod CLI](https://docs.codemod.com/cli): ```sh -npx codemod@latest run @webpack/ +npx codemod run @webpack/ ``` +## Codemods + +| Codemod | Description | +| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | +| [`css-plugins-to-native-css`](codemods/css-plugins-to-native-css) | Migrate `mini-css-extract-plugin` and `style-loader`/`css-loader` rules to webpack's native CSS support (`experiments.css`). | + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for how to add a new codemod or improve an existing one. diff --git a/codemods/css-plugins-to-native-css/README.md b/codemods/css-plugins-to-native-css/README.md new file mode 100644 index 0000000..55cc643 --- /dev/null +++ b/codemods/css-plugins-to-native-css/README.md @@ -0,0 +1,91 @@ +# @webpack/css-plugins-to-native-css + +Migrates webpack configurations from `mini-css-extract-plugin` and `style-loader`/`css-loader` rules to webpack's [native CSS support](https://webpack.js.org/configuration/experiments/#experimentscss) (`experiments.css`). + +> Requires **webpack >= 5.109.0**: the transform relies on the `experiments.css: "auto"` default introduced there, which enables native CSS whenever no user rule matches `.css` files. + +## What it does + +- Removes rules that only wire up `style-loader`, `css-loader`, and/or `MiniCssExtractPlugin.loader` (cascading to empty `rules`/`module` entries): with no user rule matching `.css`, webpack's `experiments.css: "auto"` default enables native CSS by itself, so no explicit option is needed. +- Rules with extra conditions (e.g. `include`) are kept with `type: "css/auto"` instead — and since their presence disables the `"auto"` default, `experiments.css: true` is added to that configuration. +- Any other loader in the chain (`sass-loader`, `less-loader`, `postcss-loader`, custom ones, …) keeps working in front of native CSS: it stays in `use` while the injection/extraction loaders are dropped, and the rule gets `type: "css/auto"`. +- Removes `new MiniCssExtractPlugin(...)` from `plugins` (and the whole `plugins` entry when it becomes empty), migrating its options to their native counterparts: `filename` → `output.cssFilename`, `chunkFilename` → `output.cssChunkFilename`. +- Removes the `mini-css-extract-plugin` `require`/`import` once it is unused. +- Migrates `MiniCssExtractPlugin.getCompilationHooks(...)` taps to the native runtime's `webpack.web.CssLoadingRuntimeModule.getCompilationHooks(...)`: `linkPreload`/`linkPrefetch` keep their name and signature, and `beforeTagInsert` maps to `linkInsert` — note its callback now receives `(source, chunk)` instead of `(source, varNames)`, so review taps that used the second argument. +- Understands conditional patterns (including ejected Create React App configs): the `isDev ? "style-loader" : MiniCssExtractPlugin.loader` ternary, `isEnvDevelopment && "style-loader"` guards, `require.resolve("css-loader")`, and `[...].filter(Boolean)` around `use` and `plugins`. Function-form configs (`module.exports = (env) => ({...})`) and `webpack-merge` fragments work too. +- Loader options are translated while migrating. `importLoaders`, `esModule`, and `sourceMap: true` are dropped silently (native CSS covers them). css-loader's boolean `url`/`import` become the rule's `parser: { url, import }`, `sourceMap: false` turns any non-array `devtool` into its per-asset-type form (`[{ type: "javascript", use: … }, { type: "css", use: false }]`), and on `.module.css`-scoped rules the `modules` sub-options map too: `localIdentName`/`exportOnlyLocals`/`exportLocalsConvention` → `generator`, `namedExport` → `parser.namedExports`. More translations: `exportType: "css-style-sheet"`/`"string"` → `parser.exportType`, the extract loader's `emit: false` (SSR) → `generator: { exportsOnly: true }`, `modules.mode` → `type: "css/global"` for `"global"` / `parser: { pure: true }` for `"pure"`, style-loader's `attributes.crossorigin` → `output.crossOriginLoading`, and the extract loader's `layer` → the rule-level `layer` (enabling `experiments.layers`). A flagged `attributes.nonce` migrates manually: set the `__webpack_nonce__` runtime global, or `output.html.csp.nonce` when webpack emits your HTML. Options native CSS cannot replicate — `modules.getLocalIdent`, style-loader's `insert`/`attributes`, non-literal values, or `modules` on a rule that also matches plain `.css` files — are dropped with a `// Removed loader options without a native CSS equivalent: …` comment so you can review the behavior change. + +The remaining `MiniCssExtractPlugin` options have **no native equivalent** and are dropped: `ignoreOrder`, `insert`, `attributes`, `linkType`, `runtime`, and `experimentalUseImportModule`. Review your build if you relied on them. + +About the loader's `publicPath`: relative values (`"../../"`) existed to fix `url()` resolution inside extracted CSS — native CSS handles that by itself, so they are dropped silently, as are values that just repeat `output.publicPath`. A genuinely different base URL is translated into an issuer-scoped asset rule (`{ issuer: , generator: { publicPath: … } }`) so assets referenced from those styles keep their custom URL; only non-literal values fall back to a review comment. + +Only rules the file demonstrably owns as webpack config are transformed (a `rules`/`oneOf` array, a `module` block, or an import of `mini-css-extract-plugin`). Rule fragments pushed into another tool's webpack config — Storybook's `webpackFinal`, craco, and similar — are never modified: there the config is a mutated parameter with no literal object to receive `experiments.css: true`, and the tool's own base config registers CSS rules that keep the `"auto"` default off, so a partial migration would break the build. + +## Usage + +```sh +npx codemod run @webpack/css-plugins-to-native-css +``` + +## Example + +Before: + +```js +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + module: { + rules: [ + { + test: /\.css$/i, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin()], +}; +``` + +After: + +```js +module.exports = {}; +``` + +Native CSS handles `.css` (and `.module.css`) files out of the box. Preprocessor rules keep their loader in front of it: + +```js +module.exports = { + module: { + rules: [ + { + test: /\.scss$/, + use: ["sass-loader"], + type: "css/auto", + }, + ], + }, +}; +``` + +When a rule matching `.css` carries extra conditions it is preserved too — and since its presence turns off the `experiments.css: "auto"` default, the option is set explicitly: + +```js +module.exports = { + experiments: { + css: true, + }, + module: { + rules: [ + { + test: /\.css$/i, + include: path.resolve(__dirname, "src"), + type: "css/auto", + }, + ], + }, +}; +``` + +The codemod also removes `mini-css-extract-plugin`, `style-loader`, and `css-loader` from your `package.json` (`dependencies` and `devDependencies`). If other tooling in the repo still uses them (Storybook, test setups, …), reinstall the ones you need. diff --git a/codemods/css-plugins-to-native-css/codemod.yaml b/codemods/css-plugins-to-native-css/codemod.yaml new file mode 100644 index 0000000..ee4df34 --- /dev/null +++ b/codemods/css-plugins-to-native-css/codemod.yaml @@ -0,0 +1,23 @@ +schema_version: "1.0" +name: "@webpack/css-plugins-to-native-css" +version: "0.0.0" +description: Migrate mini-css-extract-plugin and style-loader/css-loader rules to webpack's native CSS support (experiments.css) +author: bjohansebas (Sebastian Beltran) +license: MIT +workflow: workflow.yaml +repository: "https://github.com/webpack/codemods/tree/HEAD/codemods/css-plugins-to-native-css" +category: migration + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + - webpack + +registry: + access: public + visibility: public diff --git a/codemods/css-plugins-to-native-css/package.json b/codemods/css-plugins-to-native-css/package.json new file mode 100644 index 0000000..dd18732 --- /dev/null +++ b/codemods/css-plugins-to-native-css/package.json @@ -0,0 +1,27 @@ +{ + "name": "@webpack/css-plugins-to-native-css", + "private": true, + "version": "0.0.0", + "description": "Migrate mini-css-extract-plugin and style-loader/css-loader rules to webpack's native CSS support (experiments.css).", + "type": "module", + "scripts": { + "test": "npm run test:workflow && npm run test:dependencies", + "test:workflow": "npx codemod jssg test -l typescript ./src/workflow.ts", + "test:dependencies": "npx codemod jssg test -l json ./src/remove-dependencies.ts ./tests/remove-dependencies" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/webpack/codemods.git", + "directory": "codemods/css-plugins-to-native-css", + "bugs": "https://github.com/webpack/codemods/issues" + }, + "author": "Sebastian Beltran ", + "license": "MIT", + "homepage": "https://github.com/webpack/codemods/blob/main/codemods/css-plugins-to-native-css/README.md", + "dependencies": { + "@webpack/codemod-utils": "*" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.2" + } +} diff --git a/codemods/css-plugins-to-native-css/src/remove-dependencies.ts b/codemods/css-plugins-to-native-css/src/remove-dependencies.ts new file mode 100644 index 0000000..095c98b --- /dev/null +++ b/codemods/css-plugins-to-native-css/src/remove-dependencies.ts @@ -0,0 +1,30 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type Json from "@codemod.com/jssg-types/langs/json"; +import type { SgNode, SgRoot } from "@codemod.com/jssg-types/main"; +import { ConfigEditor, findPair, keyName, namedChildren, pairsOf } from "@webpack/codemod-utils"; + +// Packages replaced by native CSS; review your lockfile if other tooling +// (Storybook, tests, …) still relies on them. +const REMOVED_PACKAGES = new Set(["mini-css-extract-plugin", "style-loader", "css-loader"]); +const DEPENDENCY_KEYS = ["dependencies", "devDependencies"]; + +async function transform(root: SgRoot): Promise { + // JSON shares the object/pair/string node kinds the editor operates on. + const rootNode = root.root() as unknown as SgNode; + const editor = new ConfigEditor(rootNode); + const manifest = namedChildren(rootNode)[0]; + if (!manifest || manifest.kind() !== "object") return null; + for (const key of DEPENDENCY_KEYS) { + const value = findPair(manifest, key)?.field("value"); + if (!value || value.kind() !== "object") continue; + for (const pair of pairsOf(value)) { + const name = keyName(pair); + if (name && REMOVED_PACKAGES.has(name)) editor.markForRemoval(pair); + } + } + editor.finalizeRemovals(); + if (!editor.hasEdits) return null; + return editor.commit(); +} + +export default transform; diff --git a/codemods/css-plugins-to-native-css/src/workflow.ts b/codemods/css-plugins-to-native-css/src/workflow.ts new file mode 100644 index 0000000..50942bd --- /dev/null +++ b/codemods/css-plugins-to-native-css/src/workflow.ts @@ -0,0 +1,922 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode, SgRoot } from "@codemod.com/jssg-types/main"; +import { + ConfigEditor, + type ModuleBinding, + addImport, + cascadeRemovalTarget, + collectModuleBindings, + filterSuffixOf, + findConfigObjectFor, + findPair, + guardBranchesOf, + keyName, + lineIndent, + loaderNameOf, + namedChildren, + pairsOf, + ruleMatchesFiles, + unquote, + unwrapFilterCall, +} from "@webpack/codemod-utils"; + +const PLUGIN_MODULE = "mini-css-extract-plugin"; +const REMOVABLE_LOADERS = new Set(["style-loader", "css-loader"]); +const EXTRACT_LOADER_NAME = "MiniCssExtractPlugin.loader"; +// Plugin options with a native counterpart; the rest have none and are dropped. +const PLUGIN_OPTION_TO_OUTPUT = new Map([ + ["filename", "cssFilename"], + ["chunkFilename", "cssChunkFilename"], +]); +const CSS_SAMPLE_FILES = ["/file.css", "/file.module.css"]; +const PLAIN_CSS_SAMPLE = ["/file.css"]; +// css-loader `exportLocalsConvention` literals → native `exportsConvention`. +const EXPORTS_CONVENTION_MAP = new Map([ + ["asIs", "as-is"], + ["camelCase", "camel-case"], + ["camelCaseOnly", "camel-case-only"], + ["dashes", "dashes"], + ["dashesOnly", "dashes-only"], +]); +// Manual migration paths appended to the review comment where one exists. +const LOST_OPTION_HINTS = new Map([ + ["style-loader.insert", "tap webpack.web.CssLoadingRuntimeModule.getCompilationHooks().linkInsert"], + [ + "style-loader.styleTagTransform", + "tap webpack.web.CssLoadingRuntimeModule.getCompilationHooks().createStylesheet", + ], + ["style-loader.attributes.nonce", "set __webpack_nonce__ or output.html.csp.nonce"], + ["css-loader.sourceMap", "scope devtool entries per asset type"], + ["MiniCssExtractPlugin.loader.publicPath", "set generator.publicPath on the matching asset rules"], +]); +// Options native CSS covers on its own; any other option is flagged when dropped. +const DROPPABLE_CSS_LOADER_OPTIONS = new Set(["importLoaders", "esModule"]); +const DROPPABLE_INJECTION_LOADER_OPTIONS = new Set(["esModule"]); + +// Properties to add to one webpack config object once all removals are known. +interface ConfigPlan { + config: SgNode; + needsExperimentsCss: boolean; + needsLayers: boolean; + cssLoaderRules: number; + sourceMapOffRules: number; + outputProps: { name: string; valueText: string }[]; +} + +interface RuleProp { + name: string; + valueText: string; +} + +// Loader options translated into the surviving rule, plus the ones lost. +interface OptionFindings { + lost: string[]; + generatorProps: RuleProp[]; + parserProps: RuleProp[]; + cssSourceMapOff: boolean; + cssPublicPath: string | null; + // Module type overriding the default `css/auto` (e.g. `css/global`). + cssType: string | null; + // Props for the enclosing config's `output` (e.g. crossOriginLoading). + outputProps: RuleProp[]; + // Rule-level `layer` carried over from the extract loader's option. + layerValue: string | null; +} + +interface UseSwap { + pair: SgNode; + ruleObject: SgNode; + keptLoaders: SgNode[]; + filterSuffix: string; + lostOptions: string[]; + generatorProps: RuleProp[]; + parserProps: RuleProp[]; + cssPublicPath: string | null; + cssType: string | null; + layerValue: string | null; + trivial: boolean; + sourceMapOff: boolean; + plan: ConfigPlan | null; + // Rule-level `options:` sibling of a `loader:` shorthand, removed on swap. + optionsPair: SgNode | null; +} + +interface RulesArrayWork { + arrayNode: SgNode; + entries: UseSwap[]; +} + +interface PluginRemoval { + element: SgNode; + instantiation: SgNode; +} + +function dedupeProps(props: RuleProp[]): RuleProp[] { + const seen = new Set(); + return props.filter((prop) => !seen.has(prop.name) && seen.add(prop.name)); +} + +class CssMigration { + private readonly editor: ConfigEditor; + private readonly pluginBindings: ModuleBinding[]; + private readonly pluginNames: Set; + + private readonly configPlans = new Map(); + // Binding statements rewritten in place (e.g. into the `web` import). + private readonly repurposedStatements = new Set(); + + constructor(root: SgRoot) { + this.editor = new ConfigEditor(root.root()); + this.pluginBindings = collectModuleBindings(this.editor.rootNode, PLUGIN_MODULE); + this.pluginNames = new Set(this.pluginBindings.map((binding) => binding.name)); + } + + run(): string | null { + const usePairs: SgNode[] = []; + const loaderPairs: SgNode[] = []; + const pluginsPairs: SgNode[] = []; + for (const pair of this.editor.rootNode.findAll({ rule: { kind: "pair" } })) { + const name = keyName(pair); + if (name === "use") usePairs.push(pair); + else if (name === "loader") loaderPairs.push(pair); + else if (name === "plugins") pluginsPairs.push(pair); + } + this.transformRules(usePairs, loaderPairs); + this.transformPlugins(pluginsPairs); + this.migrateCompilationHooks(); + this.planConfigInsertions(); + this.editor.finalizeRemovals(); + if (!this.editor.hasEdits) return null; + for (const binding of this.pluginBindings) { + if (this.repurposedStatements.has(binding.statement.range().start.index)) continue; + this.editor.removeBindingIfUnused(binding); + } + return this.editor.commit(); + } + + // ---------- plugin recognition ---------- + + // The plugin's `.loader` in any access form: `MiniCssExtractPlugin.loader`, + // `MiniCssExtractPlugin["loader"]`, or `require("mini-css-extract-plugin").loader`. + private isPluginLoaderExpression(node: SgNode): boolean { + let objectPart: SgNode | null = null; + if (node.kind() === "member_expression") { + if (node.field("property")?.text() !== "loader") return false; + objectPart = node.field("object"); + } else if (node.kind() === "subscript_expression") { + const indexPart = node.field("index"); + if (!indexPart || indexPart.kind() !== "string" || unquote(indexPart.text()) !== "loader") { + return false; + } + objectPart = node.field("object"); + } + if (!objectPart) return false; + if (objectPart.kind() === "identifier") return this.pluginNames.has(objectPart.text()); + return this.isInlinePluginRequire(objectPart); + } + + // An inline `require("mini-css-extract-plugin")` call expression. + private isInlinePluginRequire(node: SgNode): boolean { + if (node.kind() !== "call_expression") return false; + const callee = node.field("function"); + if (!callee || callee.kind() !== "identifier" || callee.text() !== "require") return false; + const argumentsNode = node.field("arguments"); + const args = argumentsNode ? namedChildren(argumentsNode) : []; + return ( + args.length === 1 && args[0].kind() === "string" && unquote(args[0].text()) === PLUGIN_MODULE + ); + } + + // A `use` entry replaceable by native CSS, unwrapping dev/prod guards. + private isRemovableUseElement(node: SgNode): boolean { + const branches = guardBranchesOf(node); + if (branches) { + return branches.length > 0 && branches.every((branch) => this.isRemovableUseElement(branch)); + } + if (this.isPluginLoaderExpression(node)) return true; + if (node.kind() === "object") { + const loaderValue = findPair(node, "loader")?.field("value"); + if (!loaderValue) return false; + if (this.isPluginLoaderExpression(loaderValue)) return true; + const name = loaderNameOf(loaderValue); + return name !== null && REMOVABLE_LOADERS.has(name); + } + const name = loaderNameOf(node); + return name !== null && REMOVABLE_LOADERS.has(name); + } + + // Translate each loader option into the surviving rule's `generator`/`parser` + // when native CSS has an equivalent; everything else lands in `lost`. + private collectLoaderOptionFindings( + node: SgNode, + ruleObject: SgNode, + findings: OptionFindings, + ): void { + const branches = guardBranchesOf(node); + if (branches) { + for (const branch of branches) this.collectLoaderOptionFindings(branch, ruleObject, findings); + return; + } + if (node.kind() !== "object") return; + const loaderValue = findPair(node, "loader")?.field("value"); + const isExtractLoader = Boolean(loaderValue && this.isPluginLoaderExpression(loaderValue)); + const loaderName = isExtractLoader ? EXTRACT_LOADER_NAME : loaderNameOf(node); + if (!loaderName) return; + if (!isExtractLoader && !REMOVABLE_LOADERS.has(loaderName)) return; + const droppable = + loaderName === "css-loader" + ? DROPPABLE_CSS_LOADER_OPTIONS + : DROPPABLE_INJECTION_LOADER_OPTIONS; + const optionsPair = findPair(node, "options"); + if (!optionsPair) return; + const optionsValue = optionsPair.field("value"); + if (!optionsValue || optionsValue.kind() !== "object") { + findings.lost.push(`${loaderName}.options`); + return; + } + for (const optionPair of pairsOf(optionsValue)) { + const name = keyName(optionPair); + const value = optionPair.field("value"); + if (name !== null && droppable.has(name)) continue; + if (loaderName === "style-loader" && name === "attributes" && value) { + this.collectStyleAttributes(value, findings); + continue; + } + if (loaderName === EXTRACT_LOADER_NAME && name === "layer" && value) { + // Same semantics as the native rule-level `layer` (experiments.layers). + if (findPair(ruleObject, "layer")) findings.lost.push(`${EXTRACT_LOADER_NAME}.layer`); + else findings.layerValue ??= value.text(); + continue; + } + if (loaderName === EXTRACT_LOADER_NAME && name === "emit" && value) { + // `emit: false` (SSR) maps to the native exports-only generator. + if (value.kind() === "false") { + findings.generatorProps.push({ name: "exportsOnly", valueText: "true" }); + } else if (value.kind() !== "true") { + findings.lost.push(`${EXTRACT_LOADER_NAME}.emit`); + } + continue; + } + if (loaderName === EXTRACT_LOADER_NAME && name === "publicPath" && value) { + // A genuinely different base URL becomes an issuer-scoped asset rule; + // non-literal values can't be carried over safely. + if (this.isRedundantExtractPublicPath(value, ruleObject)) { + // Extraction-relative workaround — native CSS resolves this itself. + } else if (value.kind() === "string") { + findings.cssPublicPath ??= value.text(); + } else { + findings.lost.push(`${EXTRACT_LOADER_NAME}.publicPath`); + } + continue; + } + if (loaderName !== "css-loader" || name === null || !value) { + findings.lost.push(`${loaderName}.${name ?? "options"}`); + continue; + } + if (name === "sourceMap") { + // `true` means "follow devtool", which is native behavior; `false` + // becomes a per-type `devtool` entry on the enclosing config. + if (value.kind() === "false") findings.cssSourceMapOff = true; + else if (value.kind() !== "true") findings.lost.push(`${loaderName}.sourceMap`); + } else if (name === "exportType") { + // "array" only described the loader-chain format that is now gone. + const exportType = value.kind() === "string" ? unquote(value.text()) : null; + if (exportType === "css-style-sheet" || exportType === "string") { + const mapped = exportType === "string" ? "text" : exportType; + findings.parserProps.push({ name: "exportType", valueText: `"${mapped}"` }); + } else if (exportType !== "array") { + findings.lost.push(`${loaderName}.exportType`); + } + } else if (name === "url" || name === "import") { + // Booleans map to the rule's parser; filter functions have no equivalent. + if (value.kind() === "true" || value.kind() === "false") { + findings.parserProps.push({ name, valueText: value.text() }); + } else { + findings.lost.push(`${loaderName}.${name}`); + } + } else if (name === "modules") { + this.collectCssModulesFindings(value, ruleObject, findings); + } else { + findings.lost.push(`${loaderName}.${name}`); + } + } + } + + // css-loader `modules` applies to every matched file, while `css/auto` only + // treats `*.module.*` names as CSS modules — on a rule that also matches + // plain `.css` the whole option is lost. Otherwise its sub-options map to + // the native generator/parser where an equivalent exists. + private collectCssModulesFindings( + value: SgNode, + ruleObject: SgNode, + findings: OptionFindings, + ): void { + if (ruleMatchesFiles(ruleObject, PLAIN_CSS_SAMPLE)) { + findings.lost.push("css-loader.modules"); + return; + } + if (value.kind() === "true") return; + if (value.kind() !== "object") { + findings.lost.push("css-loader.modules"); + return; + } + for (const subPair of pairsOf(value)) { + const subName = keyName(subPair); + const subValue = subPair.field("value"); + if (!subName || !subValue) { + findings.lost.push("css-loader.modules"); + continue; + } + switch (subName) { + case "auto": + // Only `auto: true` matches the native `*.module.*` convention. + if (subValue.kind() !== "true") findings.lost.push("css-loader.modules.auto"); + break; + case "mode": { + // "local" is what `css/auto` already does for these rules; "global" + // and "pure" have dedicated native forms; functions do not. + const mode = subValue.kind() === "string" ? unquote(subValue.text()) : null; + if (mode === "global") findings.cssType = "css/global"; + else if (mode === "pure") findings.parserProps.push({ name: "pure", valueText: "true" }); + else if (mode !== "local") findings.lost.push("css-loader.modules.mode"); + break; + } + case "localIdentName": + case "localIdentHashSalt": + case "localIdentHashFunction": + case "localIdentHashDigest": + case "localIdentHashDigestLength": + findings.generatorProps.push({ name: subName, valueText: subValue.text() }); + break; + case "exportOnlyLocals": + findings.generatorProps.push({ name: "exportsOnly", valueText: subValue.text() }); + break; + case "namedExport": + findings.parserProps.push({ name: "namedExports", valueText: subValue.text() }); + break; + case "exportLocalsConvention": { + const mapped = + subValue.kind() === "string" + ? EXPORTS_CONVENTION_MAP.get(unquote(subValue.text())) + : undefined; + if (mapped) { + findings.generatorProps.push({ name: "exportsConvention", valueText: `"${mapped}"` }); + } else { + findings.lost.push("css-loader.modules.exportLocalsConvention"); + } + break; + } + default: + findings.lost.push(`css-loader.modules.${subName}`); + } + } + } + + // Native css injection reads `output.crossOriginLoading`; `nonce` is served + // by the `__webpack_nonce__` runtime global, which config cannot set. + private collectStyleAttributes(value: SgNode, findings: OptionFindings): void { + if (value.kind() !== "object") { + findings.lost.push("style-loader.attributes"); + return; + } + for (const attribute of pairsOf(value)) { + const name = keyName(attribute); + const attrValue = attribute.field("value"); + const literal = attrValue && attrValue.kind() === "string" ? unquote(attrValue.text()) : null; + if (name === "crossorigin" && (literal === "anonymous" || literal === "use-credentials")) { + findings.outputProps.push({ name: "crossOriginLoading", valueText: `"${literal}"` }); + } else { + findings.lost.push(`style-loader.attributes.${name ?? "attributes"}`); + } + } + } + + private mergeOutputProps(plan: ConfigPlan | null, findings: OptionFindings): void { + for (const prop of findings.outputProps) { + if (!plan) { + findings.lost.push("style-loader.attributes.crossorigin"); + } else if (!plan.outputProps.some((existing) => existing.name === prop.name)) { + plan.outputProps.push(prop); + } + } + if (findings.layerValue !== null) { + // Rule-level `layer` needs experiments.layers on the enclosing config. + if (plan) plan.needsLayers = true; + else { + findings.layerValue = null; + findings.lost.push(`${EXTRACT_LOADER_NAME}.layer`); + } + } + } + + private hasIssuerRule(arrayNode: SgNode | null, issuerText: string): boolean { + if (!arrayNode || arrayNode.kind() !== "array") return false; + return namedChildren(arrayNode).some((element) => { + if (element.kind() !== "object") return false; + return findPair(element, "issuer")?.field("value")?.text() === issuerText; + }); + } + + // The loader `publicPath` is redundant when it is the extraction-relative + // workaround ("./", "../") native CSS does not need, or when it just repeats + // the config's own `output.publicPath`. + private isRedundantExtractPublicPath(value: SgNode, ruleObject: SgNode): boolean { + if (value.kind() !== "string") return false; + const text = unquote(value.text()); + if (text.startsWith(".")) return true; + const config = findConfigObjectFor(ruleObject); + const outputValue = config ? findPair(config, "output")?.field("value") : undefined; + const publicPath = + outputValue && outputValue.kind() === "object" + ? findPair(outputValue, "publicPath")?.field("value") + : undefined; + return Boolean( + publicPath && publicPath.kind() === "string" && unquote(publicPath.text()) === text, + ); + } + + // The plugin instantiation behind a plugins element, unwrapping guards. + private pluginInstantiationOf(element: SgNode): SgNode | null { + const branches = guardBranchesOf(element); + if (branches) { + for (const branch of branches) { + const found = this.pluginInstantiationOf(branch); + if (found) return found; + } + return null; + } + if (element.kind() !== "new_expression") return null; + const constructorNode = element.field("constructor"); + return constructorNode && this.pluginNames.has(constructorNode.text()) ? element : null; + } + + // ---------- module.rules ---------- + + // Trivial rules are dropped (the `experiments.css: "auto"` default takes + // over); surviving rules get `type: "css/auto"`. Survival is decided after + // every rule was collected, so config-wide facts (mixed sourceMap settings) + // can pull a rule back in to carry its comment. + private transformRules(usePairs: SgNode[], loaderPairs: SgNode[]): void { + const rulesWork = this.collectRulesWork(usePairs, loaderPairs); + for (const work of rulesWork.values()) { + const removed: SgNode[] = []; + const swaps: UseSwap[] = []; + for (const entry of work.entries) { + // Mixed per-rule sourceMap settings can't scope `devtool` — flag them. + if (entry.sourceMapOff && entry.plan && this.hasMixedSourceMaps(entry.plan)) { + entry.lostOptions = [...new Set([...entry.lostOptions, "css-loader.sourceMap"])]; + } + const survives = + !entry.trivial || + entry.keptLoaders.length > 0 || + entry.lostOptions.length > 0 || + entry.generatorProps.length > 0 || + entry.parserProps.length > 0 || + entry.cssPublicPath !== null || + entry.cssType !== null || + entry.layerValue !== null; + if (survives) swaps.push(entry); + else removed.push(entry.ruleObject); + } + const allElements = namedChildren(work.arrayNode); + if (removed.length === allElements.length && !swaps.length) { + const target = cascadeRemovalTarget(work.arrayNode); + // A standalone array (assignment/declarator value) empties in place — + // grouped removal would strip the `= [...]` and leave a bare reference. + if (target === work.arrayNode) { + this.editor.replace(work.arrayNode, "[]"); + } else { + this.editor.markForRemoval(target); + } + continue; + } + for (const element of removed) { + this.editor.markForRemoval(element); + } + for (const swap of swaps) { + this.replaceUsePair(swap); + } + } + } + + private hasMixedSourceMaps(plan: ConfigPlan): boolean { + return plan.sourceMapOffRules > 0 && plan.sourceMapOffRules < plan.cssLoaderRules; + } + + private collectRulesWork( + usePairs: SgNode[], + loaderPairs: SgNode[], + ): Map { + const rulesWork = new Map(); + for (const pair of usePairs) { + const originalValue = pair.field("value"); + if (!originalValue) continue; + const value = unwrapFilterCall(originalValue); + const elements = value.kind() === "array" ? namedChildren(value) : [value]; + if (!elements.length) continue; + // Any other loader (preprocessors, custom ones) stays in front of native CSS. + const kept = elements.filter((element) => !this.isRemovableUseElement(element)); + if (kept.length === elements.length) continue; + const ruleObject = pair.parent(); + if (!ruleObject || ruleObject.kind() !== "object") continue; + const arrayNode = ruleObject.parent(); + // Only touch rules the file demonstrably owns as webpack config — never + // fragments pushed into another tool's config (Storybook, craco, …). + if (!arrayNode || arrayNode.kind() !== "array") continue; + if (!this.isWebpackRuleContext(pair, arrayNode)) continue; + const findings: OptionFindings = { + lost: [], + generatorProps: [], + parserProps: [], + cssSourceMapOff: false, + cssPublicPath: null, + cssType: null, + outputProps: [], + layerValue: null, + }; + for (const element of elements) { + this.collectLoaderOptionFindings(element, ruleObject, findings); + } + const config = findConfigObjectFor(pair); + const plan = config ? this.planFor(config) : null; + if (plan && this.ruleUsesCssLoader(elements)) { + plan.cssLoaderRules += 1; + if (findings.cssSourceMapOff) plan.sourceMapOffRules += 1; + } else if (findings.cssSourceMapOff) { + findings.lost.push("css-loader.sourceMap"); + } + this.mergeOutputProps(plan, findings); + const key = arrayNode.range().start.index; + let work = rulesWork.get(key); + if (!work) { + work = { arrayNode, entries: [] }; + rulesWork.set(key, work); + } + const trivial = pairsOf(ruleObject).every((rulePair) => { + const name = keyName(rulePair); + return name === "test" || name === "use"; + }); + work.entries.push({ + pair, + ruleObject, + keptLoaders: kept, + filterSuffix: filterSuffixOf(originalValue, value), + lostOptions: [...new Set(findings.lost)], + generatorProps: dedupeProps(findings.generatorProps), + parserProps: dedupeProps(findings.parserProps), + cssPublicPath: findings.cssPublicPath, + cssType: findings.cssType, + layerValue: findings.layerValue, + trivial, + sourceMapOff: findings.cssSourceMapOff, + plan, + optionsPair: null, + }); + } + this.collectLoaderShorthandWork(loaderPairs, rulesWork); + return rulesWork; + } + + // Rule-level `loader:`/`options:` shorthand — the rule object itself has the + // `{ loader, options }` shape the option findings already understand. + private collectLoaderShorthandWork( + loaderPairs: SgNode[], + rulesWork: Map, + ): void { + for (const pair of loaderPairs) { + const ruleObject = pair.parent(); + if (!ruleObject || ruleObject.kind() !== "object") continue; + if (!findPair(ruleObject, "test") || findPair(ruleObject, "use")) continue; + const value = pair.field("value"); + if (!value || !this.isRemovableUseElement(value)) continue; + const arrayNode = ruleObject.parent(); + if (!arrayNode || arrayNode.kind() !== "array") continue; + if (!this.isWebpackRuleContext(pair, arrayNode)) continue; + const findings: OptionFindings = { + lost: [], + generatorProps: [], + parserProps: [], + cssSourceMapOff: false, + cssPublicPath: null, + cssType: null, + outputProps: [], + layerValue: null, + }; + this.collectLoaderOptionFindings(ruleObject, ruleObject, findings); + const config = findConfigObjectFor(pair); + const plan = config ? this.planFor(config) : null; + if (plan && this.ruleUsesCssLoader([ruleObject])) { + plan.cssLoaderRules += 1; + if (findings.cssSourceMapOff) plan.sourceMapOffRules += 1; + } else if (findings.cssSourceMapOff) { + findings.lost.push("css-loader.sourceMap"); + } + this.mergeOutputProps(plan, findings); + const key = arrayNode.range().start.index; + let work = rulesWork.get(key); + if (!work) { + work = { arrayNode, entries: [] }; + rulesWork.set(key, work); + } + const trivial = pairsOf(ruleObject).every((rulePair) => { + const name = keyName(rulePair); + return name === "test" || name === "loader" || name === "options"; + }); + work.entries.push({ + pair, + ruleObject, + keptLoaders: [], + filterSuffix: "", + lostOptions: [...new Set(findings.lost)], + generatorProps: dedupeProps(findings.generatorProps), + parserProps: dedupeProps(findings.parserProps), + cssPublicPath: findings.cssPublicPath, + cssType: findings.cssType, + layerValue: findings.layerValue, + trivial, + sourceMapOff: findings.cssSourceMapOff, + plan, + optionsPair: findPair(ruleObject, "options") ?? null, + }); + } + } + + private ruleUsesCssLoader(elements: SgNode[]): boolean { + const usesIt = (node: SgNode): boolean => { + const branches = guardBranchesOf(node); + if (branches) return branches.some(usesIt); + return loaderNameOf(node) === "css-loader"; + }; + return elements.some(usesIt); + } + + // Webpack owns the rule when its array hangs on a `rules`/`oneOf` pair, the + // config has a `module` ancestor, or the file imports the extract plugin. + private isWebpackRuleContext(usePair: SgNode, arrayNode: SgNode): boolean { + const owner = arrayNode.parent(); + if (owner && owner.kind() === "pair") { + const name = keyName(owner); + if (name === "rules" || name === "oneOf") return true; + } + // Assignments into another tool's mutable config parameter + // (`config.module.rules = [...]` in next.config, Storybook, …) are not + // ours; assignments to the file's own exports are. + if (owner && owner.kind() === "assignment_expression") { + const left = owner.field("left")?.text() ?? ""; + const isOwnExport = + left === "module.exports" || + left.startsWith("module.exports.") || + left.startsWith("exports."); + if (!isOwnExport) return false; + } + if (this.pluginNames.size > 0) return true; + return findConfigObjectFor(usePair) !== null; + } + + private replaceUsePair(swap: UseSwap): void { + const indent = lineIndent(this.editor.source, swap.pair.range().start.index); + const multiline = swap.ruleObject.text().includes("\n"); + // Flag dropped loader options right where they lived. + let commentPrefix = ""; + if (swap.lostOptions.length) { + const described = swap.lostOptions.map((name) => { + const hint = LOST_OPTION_HINTS.get(name); + return hint ? `${name} (${hint})` : name; + }); + const message = `Removed loader options without a native CSS equivalent: ${described.join(", ")}`; + commentPrefix = multiline ? `// ${message}\n${indent}` : `/* ${message} */ `; + } + const separator = multiline ? `,\n${indent}` : ", "; + let replacement = `${commentPrefix}`; + if (swap.keptLoaders.length) { + // Guarded entries keep the original `.filter(...)` for their falsy branch. + const keptTexts = swap.keptLoaders.map((loader) => loader.text()); + const keepsGuard = swap.keptLoaders.some((loader) => guardBranchesOf(loader) !== null); + const filterSuffix = keepsGuard ? swap.filterSuffix || ".filter(Boolean)" : ""; + replacement += `use: [${keptTexts.join(", ")}]${filterSuffix}${separator}`; + } + replacement += `type: "${swap.cssType ?? "css/auto"}"`; + if (swap.layerValue !== null) replacement += `${separator}layer: ${swap.layerValue}`; + for (const [key, props] of [ + ["generator", swap.generatorProps], + ["parser", swap.parserProps], + ] as const) { + if (!props.length) continue; + const texts = props.map((prop) => `${prop.name}: ${prop.valueText}`); + replacement += `${separator}${key}: { ${texts.join(", ")} }`; + } + this.editor.replace(swap.pair, replacement); + if (swap.optionsPair) this.editor.markForRemoval(swap.optionsPair); + if (swap.cssPublicPath !== null) { + // Assets referenced from this rule's files keep their custom base URL — + // unless a rule for that issuer is already declared. + const issuer = findPair(swap.ruleObject, "test")?.field("value")?.text() ?? "/\\.css$/"; + if (!this.hasIssuerRule(swap.ruleObject.parent(), issuer)) { + const ruleIndent = lineIndent(this.editor.source, swap.ruleObject.range().start.index); + const sibling = `{ issuer: ${issuer}, generator: { publicPath: ${swap.cssPublicPath} } }`; + this.editor.insertAfter( + swap.ruleObject, + multiline ? `,\n${ruleIndent}${sibling}` : `, ${sibling}`, + ); + } + } + // A surviving rule that matches `.css` turns the "auto" default off. + if (!ruleMatchesFiles(swap.ruleObject, CSS_SAMPLE_FILES)) return; + const config = findConfigObjectFor(swap.pair); + if (config) this.planFor(config).needsExperimentsCss = true; + } + + // ---------- plugins ---------- + + private transformPlugins(pluginsPairs: SgNode[]): void { + for (const pair of pluginsPairs) { + const originalValue = pair.field("value"); + if (!originalValue) continue; + const value = unwrapFilterCall(originalValue); + if (value.kind() !== "array") continue; + const elements = namedChildren(value); + const removed: PluginRemoval[] = []; + for (const element of elements) { + const instantiation = this.pluginInstantiationOf(element); + if (instantiation) removed.push({ element, instantiation }); + } + if (!removed.length) continue; + this.collectPluginOptions(pair, removed); + if (removed.length === elements.length) { + this.editor.markForRemoval(pair); + } else { + for (const removal of removed) this.editor.markForRemoval(removal.element); + } + } + } + + private collectPluginOptions(pluginsPair: SgNode, removed: PluginRemoval[]): void { + const configObject = pluginsPair.parent(); + if (!configObject || configObject.kind() !== "object") return; + for (const removal of removed) { + const argumentsNode = removal.instantiation.field("arguments"); + const optionsObject = argumentsNode ? namedChildren(argumentsNode)[0] : undefined; + if (!optionsObject || optionsObject.kind() !== "object") continue; + const plan = this.planFor(configObject); + for (const optionPair of pairsOf(optionsObject)) { + const mapped = PLUGIN_OPTION_TO_OUTPUT.get(keyName(optionPair) ?? ""); + const optionValue = optionPair.field("value"); + if (!mapped || !optionValue) continue; + if (!plan.outputProps.some((prop) => prop.name === mapped)) { + plan.outputProps.push({ name: mapped, valueText: optionValue.text() }); + } + } + } + } + + // ---------- compilation hooks ---------- + + // Retarget `MiniCssExtractPlugin.getCompilationHooks(...)` to the native + // `CssLoadingRuntimeModule` — `linkPreload`/`linkPrefetch` keep their name + // and signature; `beforeTagInsert` maps to `linkInsert`. Only done when this + // file's CSS setup was actually migrated. + private migrateCompilationHooks(): void { + if (!this.editor.hasWork) return; + const rootNode = this.editor.rootNode; + const receivers: SgNode[] = []; + for (const node of rootNode.findAll({ rule: { kind: "member_expression" } })) { + if (node.field("property")?.text() !== "getCompilationHooks") continue; + const objectPart = node.field("object"); + if (!objectPart) continue; + if ( + (objectPart.kind() === "identifier" && this.pluginNames.has(objectPart.text())) || + this.isInlinePluginRequire(objectPart) + ) { + receivers.push(objectPart); + } + } + if (!receivers.length) return; + const nativeReceiver = this.nativeHooksReceiver(); + for (const objectPart of receivers) this.editor.replace(objectPart, nativeReceiver); + // `beforeTagInsert` has no same-name native hook; `linkInsert` replaces it. + for (const property of rootNode.findAll({ rule: { kind: "property_identifier" } })) { + if (property.text() === "beforeTagInsert") this.editor.replace(property, "linkInsert"); + } + for (const shorthand of rootNode.findAll({ + rule: { kind: "shorthand_property_identifier_pattern" }, + })) { + if (shorthand.text() === "beforeTagInsert") { + // Keep the local variable name; only the destructured key changes. + this.editor.replace(shorthand, "linkInsert: beforeTagInsert"); + } + } + } + + // Existing webpack binding, or a `web` import — rewriting the plugin's own + // import statement in place when possible (a separate added import would + // land inside the removed statement's range and be dropped). + private nativeHooksReceiver(): string { + const webpackBindings = collectModuleBindings(this.editor.rootNode, "webpack"); + if (webpackBindings.length) return `${webpackBindings[0].name}.web.CssLoadingRuntimeModule`; + const binding = this.pluginBindings[0]; + if (binding) { + const isEsm = binding.statement.kind() === "import_statement"; + this.editor.replace( + binding.statement, + isEsm ? 'import { web } from "webpack";' : 'const { web } = require("webpack");', + ); + this.repurposedStatements.add(binding.statement.range().start.index); + } else { + const edit = addImport(this.editor.rootNode as SgNode, { + type: "named", + specifiers: [{ name: "web" }], + from: "webpack", + moduleType: "cjs", + }); + if (edit) this.editor.addEdit(edit); + } + return "web.CssLoadingRuntimeModule"; + } + + // ---------- config-level insertions ---------- + + private planFor(config: SgNode): ConfigPlan { + const key = config.range().start.index; + let plan = this.configPlans.get(key); + if (!plan) { + plan = { + config, + needsExperimentsCss: false, + needsLayers: false, + cssLoaderRules: 0, + sourceMapOffRules: 0, + outputProps: [], + }; + this.configPlans.set(key, plan); + } + return plan; + } + + private planConfigInsertions(): void { + for (const plan of this.configPlans.values()) { + const topProperties: ((indent: string, unit: string) => string)[] = []; + const experimentProps: RuleProp[] = []; + if (plan.needsExperimentsCss) experimentProps.push({ name: "css", valueText: "true" }); + if (plan.needsLayers) experimentProps.push({ name: "layers", valueText: "true" }); + if (experimentProps.length) { + this.planObjectProps(plan.config, "experiments", experimentProps, topProperties); + } + if (plan.outputProps.length) { + this.planObjectProps(plan.config, "output", plan.outputProps, topProperties); + } + this.planCssDevtool(plan); + if (topProperties.length) { + // A fully-emptied config keeps its braces open for these properties. + this.editor.keepBracesOpen(plan.config); + this.editor.insertIntoObject(plan.config, (indent, unit) => + topProperties.map((build) => build(indent, unit)), + ); + } + } + } + + // css-loader's `sourceMap: false` scopes `devtool` per asset type. The css + // entry is a runtime no-op (array entries are additive) but documents the + // intent. Any non-array devtool expression wraps as the `use` value; + // `false` means no maps at all and an existing array is already explicit. + private planCssDevtool(plan: ConfigPlan): void { + // Only when every css-loader rule in this config disabled its maps. + if (!plan.sourceMapOffRules || this.hasMixedSourceMaps(plan)) return; + const value = findPair(plan.config, "devtool")?.field("value"); + if (!value || value.kind() === "false" || value.kind() === "array") return; + this.editor.replace( + value, + `[{ type: "javascript", use: ${value.text()} }, { type: "css", use: false }]`, + ); + } + + // Insert props into the config's `key` object, creating it when absent; an + // existing non-object value (e.g. a variable) is left alone. + private planObjectProps( + config: SgNode, + key: string, + props: { name: string; valueText: string }[], + topProperties: ((indent: string, unit: string) => string)[], + ): void { + const value = findPair(config, key)?.field("value"); + if (value && value.kind() === "object") { + const missing = props + .filter((prop) => !findPair(value, prop.name)) + .map((prop) => `${prop.name}: ${prop.valueText}`); + if (missing.length) this.editor.insertIntoObject(value, () => missing); + } else if (!value) { + topProperties.push((indent, unit) => { + const texts = props.map((prop) => `${prop.name}: ${prop.valueText}`); + return indent || unit + ? `${key}: {\n${texts.map((text) => `${indent}${unit}${text}`).join(",\n")},\n${indent}}` + : `${key}: { ${texts.join(", ")} }`; + }); + } + } +} + +async function transform(root: SgRoot): Promise { + return new CssMigration(root).run(); +} + +export default transform; diff --git a/codemods/css-plugins-to-native-css/tests/advanced-options/expected.js b/codemods/css-plugins-to-native-css/tests/advanced-options/expected.js new file mode 100644 index 0000000..13f2a8b --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/advanced-options/expected.js @@ -0,0 +1,20 @@ +module.exports = { + experiments: { + css: true, + }, + module: { + rules: [ + { + test: /\.css$/, + include: "src", + type: "css/auto", + generator: { exportsOnly: true }, + parser: { exportType: "css-style-sheet" }, + }, + { + test: /\.module\.css$/, + type: "css/global", + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/advanced-options/input.js b/codemods/css-plugins-to-native-css/tests/advanced-options/input.js new file mode 100644 index 0000000..d5d677b --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/advanced-options/input.js @@ -0,0 +1,21 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + include: "src", + use: [ + { loader: MiniCssExtractPlugin.loader, options: { emit: false } }, + { loader: "css-loader", options: { exportType: "css-style-sheet" } }, + ], + }, + { + test: /\.module\.css$/, + use: ["style-loader", { loader: "css-loader", options: { modules: { mode: "global" } } }], + }, + ], + }, + plugins: [new MiniCssExtractPlugin()], +}; diff --git a/codemods/css-plugins-to-native-css/tests/aliased-import/expected.js b/codemods/css-plugins-to-native-css/tests/aliased-import/expected.js new file mode 100644 index 0000000..4f4a82f --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/aliased-import/expected.js @@ -0,0 +1,5 @@ +module.exports = { + output: { + cssFilename: "[name].css", + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/aliased-import/input.js b/codemods/css-plugins-to-native-css/tests/aliased-import/input.js new file mode 100644 index 0000000..2ea5d6b --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/aliased-import/input.js @@ -0,0 +1,13 @@ +const CssExtract = require("mini-css-extract-plugin"); + +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: [CssExtract.loader, "css-loader"], + }, + ], + }, + plugins: [new CssExtract({ filename: "[name].css" })], +}; diff --git a/codemods/css-plugins-to-native-css/tests/basic/expected.js b/codemods/css-plugins-to-native-css/tests/basic/expected.js new file mode 100644 index 0000000..8ba01eb --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/basic/expected.js @@ -0,0 +1,22 @@ +const path = require("path"); +const { DefinePlugin } = require("webpack"); + +module.exports = { + entry: "./src/index.js", + output: { + cssFilename: "static/[name].css", + cssChunkFilename: "static/[id].css", + path: path.resolve(__dirname, "dist"), + }, + module: { + rules: [ + { + test: /\.js$/, + use: ["babel-loader"], + }, + ], + }, + plugins: [ + new DefinePlugin({ DEBUG: "false" }), + ], +}; diff --git a/codemods/css-plugins-to-native-css/tests/basic/input.js b/codemods/css-plugins-to-native-css/tests/basic/input.js new file mode 100644 index 0000000..5ce6f3b --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/basic/input.js @@ -0,0 +1,26 @@ +const path = require("path"); +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); +const { DefinePlugin } = require("webpack"); + +module.exports = { + entry: "./src/index.js", + output: { + path: path.resolve(__dirname, "dist"), + }, + module: { + rules: [ + { + test: /\.css$/i, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + { + test: /\.js$/, + use: ["babel-loader"], + }, + ], + }, + plugins: [ + new MiniCssExtractPlugin({ filename: "static/[name].css", chunkFilename: "static/[id].css" }), + new DefinePlugin({ DEBUG: "false" }), + ], +}; diff --git a/codemods/css-plugins-to-native-css/tests/computed-loader/expected.js b/codemods/css-plugins-to-native-css/tests/computed-loader/expected.js new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/computed-loader/expected.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/codemods/css-plugins-to-native-css/tests/computed-loader/input.js b/codemods/css-plugins-to-native-css/tests/computed-loader/input.js new file mode 100644 index 0000000..d5b9728 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/computed-loader/input.js @@ -0,0 +1,13 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin["loader"], "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin()], +}; diff --git a/codemods/css-plugins-to-native-css/tests/cra-ejected/expected.js b/codemods/css-plugins-to-native-css/tests/cra-ejected/expected.js new file mode 100644 index 0000000..6c19cac --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/cra-ejected/expected.js @@ -0,0 +1,9 @@ +const isEnvDevelopment = process.env.NODE_ENV === "development"; +const isEnvProduction = process.env.NODE_ENV === "production"; + +module.exports = { + output: { + cssFilename: "static/css/[name].[contenthash:8].css", + cssChunkFilename: "static/css/[name].[contenthash:8].chunk.css", + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/cra-ejected/input.js b/codemods/css-plugins-to-native-css/tests/cra-ejected/input.js new file mode 100644 index 0000000..ab9b4dd --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/cra-ejected/input.js @@ -0,0 +1,32 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +const isEnvDevelopment = process.env.NODE_ENV === "development"; +const isEnvProduction = process.env.NODE_ENV === "production"; + +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: [ + isEnvDevelopment && require.resolve("style-loader"), + isEnvProduction && { + loader: MiniCssExtractPlugin.loader, + options: { publicPath: "../../" }, + }, + { + loader: require.resolve("css-loader"), + options: { importLoaders: 1 }, + }, + ].filter(Boolean), + }, + ], + }, + plugins: [ + isEnvProduction && + new MiniCssExtractPlugin({ + filename: "static/css/[name].[contenthash:8].css", + chunkFilename: "static/css/[name].[contenthash:8].chunk.css", + }), + ].filter(Boolean), +}; diff --git a/codemods/css-plugins-to-native-css/tests/crlf/expected.js b/codemods/css-plugins-to-native-css/tests/crlf/expected.js new file mode 100644 index 0000000..76e02a2 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/crlf/expected.js @@ -0,0 +1,18 @@ +module.exports = { + experiments: { + css: true, + }, + module: { + rules: [ + { + test: /\.css$/, + include: "src", + use: [{ + loader: "postcss-loader", + options: { postcssOptions: {} }, + }], + type: "css/auto", + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/crlf/input.js b/codemods/css-plugins-to-native-css/tests/crlf/input.js new file mode 100644 index 0000000..830871e --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/crlf/input.js @@ -0,0 +1,18 @@ +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + include: "src", + use: [ + "style-loader", + "css-loader", + { + loader: "postcss-loader", + options: { postcssOptions: {} }, + }, + ], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/css-modules-only/expected.js b/codemods/css-plugins-to-native-css/tests/css-modules-only/expected.js new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/css-modules-only/expected.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/codemods/css-plugins-to-native-css/tests/css-modules-only/input.js b/codemods/css-plugins-to-native-css/tests/css-modules-only/input.js new file mode 100644 index 0000000..974b84e --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/css-modules-only/input.js @@ -0,0 +1,10 @@ +module.exports = { + module: { + rules: [ + { + test: /\.module\.css$/, + use: ["style-loader", { loader: "css-loader", options: { modules: true } }], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/css-modules-options/expected.js b/codemods/css-plugins-to-native-css/tests/css-modules-options/expected.js new file mode 100644 index 0000000..3b229f5 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/css-modules-options/expected.js @@ -0,0 +1,15 @@ +module.exports = { + experiments: { + css: true, + }, + module: { + rules: [ + { + test: /\.module\.css$/, + type: "css/auto", + generator: { localIdentName: "[name]__[local]___[hash:base64:5]", localIdentHashSalt: "app-styles", exportsOnly: false, exportsConvention: "camel-case" }, + parser: { namedExports: true }, + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/css-modules-options/input.js b/codemods/css-plugins-to-native-css/tests/css-modules-options/input.js new file mode 100644 index 0000000..909795e --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/css-modules-options/input.js @@ -0,0 +1,27 @@ +module.exports = { + module: { + rules: [ + { + test: /\.module\.css$/, + use: [ + "style-loader", + { + loader: "css-loader", + options: { + sourceMap: true, + modules: { + auto: true, + localIdentName: "[name]__[local]___[hash:base64:5]", + localIdentHashSalt: "app-styles", + exportOnlyLocals: false, + namedExport: true, + exportLocalsConvention: "camelCase", + mode: "local", + }, + }, + }, + ], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/css-modules/expected.js b/codemods/css-plugins-to-native-css/tests/css-modules/expected.js new file mode 100644 index 0000000..7f071e8 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/css-modules/expected.js @@ -0,0 +1,14 @@ +module.exports = { + experiments: { + css: true, + }, + module: { + rules: [ + { + test: /\.css$/, + // Removed loader options without a native CSS equivalent: css-loader.modules + type: "css/auto", + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/css-modules/input.js b/codemods/css-plugins-to-native-css/tests/css-modules/input.js new file mode 100644 index 0000000..970bd63 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/css-modules/input.js @@ -0,0 +1,10 @@ +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: ["style-loader", { loader: "css-loader", options: { modules: true } }], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/css-only/expected.js b/codemods/css-plugins-to-native-css/tests/css-only/expected.js new file mode 100644 index 0000000..4f4a82f --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/css-only/expected.js @@ -0,0 +1,5 @@ +module.exports = { + output: { + cssFilename: "[name].css", + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/css-only/input.js b/codemods/css-plugins-to-native-css/tests/css-only/input.js new file mode 100644 index 0000000..c500be9 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/css-only/input.js @@ -0,0 +1,13 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + module: { + rules: [ + { + test: /\.css$/i, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin({ filename: "[name].css" })], +}; diff --git a/codemods/css-plugins-to-native-css/tests/css-url-option/expected.js b/codemods/css-plugins-to-native-css/tests/css-url-option/expected.js new file mode 100644 index 0000000..8cd5b9f --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/css-url-option/expected.js @@ -0,0 +1,15 @@ +module.exports = { + experiments: { + css: true, + }, + devtool: [{ type: "javascript", use: "source-map" }, { type: "css", use: false }], + module: { + rules: [ + { + test: /\.css$/, + type: "css/auto", + parser: { url: false, import: false }, + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/css-url-option/input.js b/codemods/css-plugins-to-native-css/tests/css-url-option/input.js new file mode 100644 index 0000000..30d779e --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/css-url-option/input.js @@ -0,0 +1,11 @@ +module.exports = { + devtool: "source-map", + module: { + rules: [ + { + test: /\.css$/, + use: ["style-loader", { loader: "css-loader", options: { url: false, import: false, sourceMap: false } }], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/custom-filter/expected.js b/codemods/css-plugins-to-native-css/tests/custom-filter/expected.js new file mode 100644 index 0000000..3c0a07c --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/custom-filter/expected.js @@ -0,0 +1,16 @@ +const isDev = process.env.NODE_ENV !== "production"; + +module.exports = { + experiments: { + css: true, + }, + module: { + rules: [ + { + test: /\.css$/, + use: [isDev && "postcss-loader"].filter((loader) => !!loader), + type: "css/auto", + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/custom-filter/input.js b/codemods/css-plugins-to-native-css/tests/custom-filter/input.js new file mode 100644 index 0000000..fb728f3 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/custom-filter/input.js @@ -0,0 +1,16 @@ +const isDev = process.env.NODE_ENV !== "production"; + +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: [ + isDev && "style-loader", + "css-loader", + isDev && "postcss-loader", + ].filter((loader) => !!loader), + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/define-config/expected.js b/codemods/css-plugins-to-native-css/tests/define-config/expected.js new file mode 100644 index 0000000..05eedfe --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/define-config/expected.js @@ -0,0 +1,19 @@ +const { defineConfig } = require("webpack"); + +module.exports = defineConfig({ + experiments: { + css: true, + }, + output: { + cssFilename: "[name].css", + }, + module: { + rules: [ + { + test: /\.css$/, + include: "src", + type: "css/auto", + }, + ], + }, +}); diff --git a/codemods/css-plugins-to-native-css/tests/define-config/input.js b/codemods/css-plugins-to-native-css/tests/define-config/input.js new file mode 100644 index 0000000..3ccde59 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/define-config/input.js @@ -0,0 +1,15 @@ +const { defineConfig } = require("webpack"); +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = defineConfig({ + module: { + rules: [ + { + test: /\.css$/, + include: "src", + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin({ filename: "[name].css" })], +}); diff --git a/codemods/css-plugins-to-native-css/tests/devtool-variable/expected.js b/codemods/css-plugins-to-native-css/tests/devtool-variable/expected.js new file mode 100644 index 0000000..4fd327c --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/devtool-variable/expected.js @@ -0,0 +1,5 @@ +const mapsSetting = process.env.CI ? "source-map" : "eval"; + +module.exports = { + devtool: [{ type: "javascript", use: mapsSetting }, { type: "css", use: false }], +}; diff --git a/codemods/css-plugins-to-native-css/tests/devtool-variable/input.js b/codemods/css-plugins-to-native-css/tests/devtool-variable/input.js new file mode 100644 index 0000000..a74c55b --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/devtool-variable/input.js @@ -0,0 +1,13 @@ +const mapsSetting = process.env.CI ? "source-map" : "eval"; + +module.exports = { + devtool: mapsSetting, + module: { + rules: [ + { + test: /\.css$/, + use: ["style-loader", { loader: "css-loader", options: { sourceMap: false } }], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/esm/expected.mjs b/codemods/css-plugins-to-native-css/tests/esm/expected.mjs new file mode 100644 index 0000000..df49cf8 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/esm/expected.mjs @@ -0,0 +1,6 @@ +export default { + entry: "./src/index.js", + experiments: { + outputModule: true, + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/esm/input.mjs b/codemods/css-plugins-to-native-css/tests/esm/input.mjs new file mode 100644 index 0000000..4ddcc8e --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/esm/input.mjs @@ -0,0 +1,14 @@ +export default { + entry: "./src/index.js", + module: { + rules: [ + { + test: /\.css$/, + use: ["style-loader", { loader: "css-loader", options: { importLoaders: 1 } }], + }, + ], + }, + experiments: { + outputModule: true, + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/exports-rules/expected.js b/codemods/css-plugins-to-native-css/tests/exports-rules/expected.js new file mode 100644 index 0000000..a5840d0 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/exports-rules/expected.js @@ -0,0 +1 @@ +exports.cssRules = []; diff --git a/codemods/css-plugins-to-native-css/tests/exports-rules/input.js b/codemods/css-plugins-to-native-css/tests/exports-rules/input.js new file mode 100644 index 0000000..5e648e0 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/exports-rules/input.js @@ -0,0 +1,8 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +exports.cssRules = [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, +]; diff --git a/codemods/css-plugins-to-native-css/tests/extract-public-path-existing/expected.js b/codemods/css-plugins-to-native-css/tests/extract-public-path-existing/expected.js new file mode 100644 index 0000000..d1c881e --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/extract-public-path-existing/expected.js @@ -0,0 +1,14 @@ +module.exports = { + experiments: { + css: true, + }, + module: { + rules: [ + { + test: /\.css$/, + type: "css/auto", + }, + { issuer: /\.css$/, generator: { publicPath: "https://cdn.example.com/" } }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/extract-public-path-existing/input.js b/codemods/css-plugins-to-native-css/tests/extract-public-path-existing/input.js new file mode 100644 index 0000000..76239a3 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/extract-public-path-existing/input.js @@ -0,0 +1,17 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: [ + { loader: MiniCssExtractPlugin.loader, options: { publicPath: "https://cdn.example.com/" } }, + "css-loader", + ], + }, + { issuer: /\.css$/, generator: { publicPath: "https://cdn.example.com/" } }, + ], + }, + plugins: [new MiniCssExtractPlugin()], +}; diff --git a/codemods/css-plugins-to-native-css/tests/extract-public-path/expected.js b/codemods/css-plugins-to-native-css/tests/extract-public-path/expected.js new file mode 100644 index 0000000..c3c2579 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/extract-public-path/expected.js @@ -0,0 +1,17 @@ +module.exports = { + experiments: { + css: true, + }, + output: { + publicPath: "/app/", + }, + module: { + rules: [ + { + test: /\.css$/, + type: "css/auto", + }, + { issuer: /\.css$/, generator: { publicPath: "https://cdn.example.com/" } }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/extract-public-path/input.js b/codemods/css-plugins-to-native-css/tests/extract-public-path/input.js new file mode 100644 index 0000000..836e54b --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/extract-public-path/input.js @@ -0,0 +1,26 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + output: { + publicPath: "/app/", + }, + module: { + rules: [ + { + test: /\.css$/, + use: [ + { loader: MiniCssExtractPlugin.loader, options: { publicPath: "https://cdn.example.com/" } }, + "css-loader", + ], + }, + { + test: /\.module\.css$/, + use: [ + { loader: MiniCssExtractPlugin.loader, options: { publicPath: "/app/" } }, + "css-loader", + ], + }, + ], + }, + plugins: [new MiniCssExtractPlugin()], +}; diff --git a/codemods/css-plugins-to-native-css/tests/function-config/expected.js b/codemods/css-plugins-to-native-css/tests/function-config/expected.js new file mode 100644 index 0000000..226cf2b --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/function-config/expected.js @@ -0,0 +1,6 @@ +module.exports = (env, argv) => ({ + output: { + cssFilename: "app.css", + }, + entry: "./src/index.js", +}); diff --git a/codemods/css-plugins-to-native-css/tests/function-config/input.js b/codemods/css-plugins-to-native-css/tests/function-config/input.js new file mode 100644 index 0000000..e38edb0 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/function-config/input.js @@ -0,0 +1,17 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = (env, argv) => ({ + entry: "./src/index.js", + module: { + rules: [ + { + test: /\.css$/i, + use: [ + argv.mode === "development" ? "style-loader" : MiniCssExtractPlugin.loader, + "css-loader", + ], + }, + ], + }, + plugins: [new MiniCssExtractPlugin({ filename: "app.css" })], +}); diff --git a/codemods/css-plugins-to-native-css/tests/import-meta-resolve/expected.mjs b/codemods/css-plugins-to-native-css/tests/import-meta-resolve/expected.mjs new file mode 100644 index 0000000..ff8b4c5 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/import-meta-resolve/expected.mjs @@ -0,0 +1 @@ +export default {}; diff --git a/codemods/css-plugins-to-native-css/tests/import-meta-resolve/input.mjs b/codemods/css-plugins-to-native-css/tests/import-meta-resolve/input.mjs new file mode 100644 index 0000000..8b511df --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/import-meta-resolve/input.mjs @@ -0,0 +1,10 @@ +export default { + module: { + rules: [ + { + test: /\.css$/, + use: [import.meta.resolve("style-loader"), import.meta.resolve("css-loader")], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/inline-require/expected.js b/codemods/css-plugins-to-native-css/tests/inline-require/expected.js new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/inline-require/expected.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/codemods/css-plugins-to-native-css/tests/inline-require/input.js b/codemods/css-plugins-to-native-css/tests/inline-require/input.js new file mode 100644 index 0000000..d0b5d86 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/inline-require/input.js @@ -0,0 +1,10 @@ +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: [require("mini-css-extract-plugin").loader, "css-loader"], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/keep-rule/expected.js b/codemods/css-plugins-to-native-css/tests/keep-rule/expected.js new file mode 100644 index 0000000..b457631 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/keep-rule/expected.js @@ -0,0 +1,16 @@ +const path = require("path"); + +module.exports = { + experiments: { + css: true, + }, + module: { + rules: [ + { + test: /\.css$/, + include: path.resolve(__dirname, "src"), + type: "css/auto", + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/keep-rule/input.js b/codemods/css-plugins-to-native-css/tests/keep-rule/input.js new file mode 100644 index 0000000..e742231 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/keep-rule/input.js @@ -0,0 +1,13 @@ +const path = require("path"); + +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + include: path.resolve(__dirname, "src"), + use: ["style-loader", "css-loader"], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/layer-option/expected.js b/codemods/css-plugins-to-native-css/tests/layer-option/expected.js new file mode 100644 index 0000000..00f5f15 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/layer-option/expected.js @@ -0,0 +1,16 @@ +module.exports = { + experiments: { + css: true, + layers: true, + }, + module: { + rules: [ + { + test: /\.css$/, + include: "src", + type: "css/auto", + layer: "styles", + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/layer-option/input.js b/codemods/css-plugins-to-native-css/tests/layer-option/input.js new file mode 100644 index 0000000..e4e3904 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/layer-option/input.js @@ -0,0 +1,17 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + experiments: { + layers: true, + }, + module: { + rules: [ + { + test: /\.css$/, + include: "src", + use: [{ loader: MiniCssExtractPlugin.loader, options: { layer: "styles" } }, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin()], +}; diff --git a/codemods/css-plugins-to-native-css/tests/legacy/expected.js b/codemods/css-plugins-to-native-css/tests/legacy/expected.js new file mode 100644 index 0000000..afd19cf --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/legacy/expected.js @@ -0,0 +1,7 @@ +const devMode = process.env.NODE_ENV !== "production"; + +module.exports = { + output: { + cssFilename: "[name].[contenthash].css", + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/legacy/input.js b/codemods/css-plugins-to-native-css/tests/legacy/input.js new file mode 100644 index 0000000..a7df0d9 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/legacy/input.js @@ -0,0 +1,23 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +const devMode = process.env.NODE_ENV !== "production"; + +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: [ + devMode ? "style-loader" : MiniCssExtractPlugin.loader, + { + loader: require.resolve("css-loader"), + options: { importLoaders: 1 }, + }, + ], + }, + ], + }, + plugins: [ + !devMode && new MiniCssExtractPlugin({ filename: "[name].[contenthash].css" }), + ].filter(Boolean), +}; diff --git a/codemods/css-plugins-to-native-css/tests/loader-shorthand/expected.js b/codemods/css-plugins-to-native-css/tests/loader-shorthand/expected.js new file mode 100644 index 0000000..ae50820 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/loader-shorthand/expected.js @@ -0,0 +1,15 @@ +module.exports = { + experiments: { + css: true, + }, + target: "node", + module: { + rules: [ + { + test: /\.module\.css$/, + type: "css/auto", + generator: { exportsOnly: true }, + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/loader-shorthand/input.js b/codemods/css-plugins-to-native-css/tests/loader-shorthand/input.js new file mode 100644 index 0000000..0e95f44 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/loader-shorthand/input.js @@ -0,0 +1,12 @@ +module.exports = { + target: "node", + module: { + rules: [ + { + test: /\.module\.css$/, + loader: "css-loader", + options: { modules: { exportOnlyLocals: true } }, + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/mixed-sourcemaps/expected.js b/codemods/css-plugins-to-native-css/tests/mixed-sourcemaps/expected.js new file mode 100644 index 0000000..1ab8e2e --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/mixed-sourcemaps/expected.js @@ -0,0 +1,16 @@ +module.exports = { + experiments: { + css: true, + }, + devtool: "source-map", + module: { + rules: [ + { + test: /\.css$/, + include: "src", + // Removed loader options without a native CSS equivalent: css-loader.sourceMap (scope devtool entries per asset type) + type: "css/auto", + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/mixed-sourcemaps/input.js b/codemods/css-plugins-to-native-css/tests/mixed-sourcemaps/input.js new file mode 100644 index 0000000..890545e --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/mixed-sourcemaps/input.js @@ -0,0 +1,16 @@ +module.exports = { + devtool: "source-map", + module: { + rules: [ + { + test: /\.css$/, + include: "src", + use: ["style-loader", { loader: "css-loader", options: { sourceMap: false } }], + }, + { + test: /\.module\.css$/, + use: ["style-loader", "css-loader"], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/multi-compiler/expected.js b/codemods/css-plugins-to-native-css/tests/multi-compiler/expected.js new file mode 100644 index 0000000..c51875d --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/multi-compiler/expected.js @@ -0,0 +1,26 @@ +module.exports = [ + { + output: { + cssFilename: "web/[name].css", + }, + name: "web", + entry: "./src/index.js", + }, + { + experiments: { + css: true, + }, + name: "ssr", + target: "node", + entry: "./src/server.js", + module: { + rules: [ + { + test: /\.css$/, + include: "src", + type: "css/auto", + }, + ], + }, + }, +]; diff --git a/codemods/css-plugins-to-native-css/tests/multi-compiler/input.js b/codemods/css-plugins-to-native-css/tests/multi-compiler/input.js new file mode 100644 index 0000000..dcdbee0 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/multi-compiler/input.js @@ -0,0 +1,31 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = [ + { + name: "web", + entry: "./src/index.js", + module: { + rules: [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin({ filename: "web/[name].css" })], + }, + { + name: "ssr", + target: "node", + entry: "./src/server.js", + module: { + rules: [ + { + test: /\.css$/, + include: "src", + use: ["style-loader", "css-loader"], + }, + ], + }, + }, +]; diff --git a/codemods/css-plugins-to-native-css/tests/next-config/expected.js b/codemods/css-plugins-to-native-css/tests/next-config/expected.js new file mode 100644 index 0000000..1ccfc67 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/next-config/expected.js @@ -0,0 +1,22 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + reactStrictMode: true, + webpack(config, { isServer }) { + if (!isServer) { + config.plugins.push(new MiniCssExtractPlugin({ filename: "static/css/[name].css" })); + config.module.rules.push({ + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }); + config.module.rules = [ + ...config.module.rules, + { + test: /\.scss$/, + use: ["style-loader", "css-loader", "sass-loader"], + }, + ]; + } + return config; + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/next-config/input.js b/codemods/css-plugins-to-native-css/tests/next-config/input.js new file mode 100644 index 0000000..1ccfc67 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/next-config/input.js @@ -0,0 +1,22 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + reactStrictMode: true, + webpack(config, { isServer }) { + if (!isServer) { + config.plugins.push(new MiniCssExtractPlugin({ filename: "static/css/[name].css" })); + config.module.rules.push({ + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }); + config.module.rules = [ + ...config.module.rules, + { + test: /\.scss$/, + use: ["style-loader", "css-loader", "sass-loader"], + }, + ]; + } + return config; + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/no-css/expected.js b/codemods/css-plugins-to-native-css/tests/no-css/expected.js new file mode 100644 index 0000000..2b5ae71 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/no-css/expected.js @@ -0,0 +1,5 @@ +module.exports = { + module: { + rules: [{ test: /\.js$/, use: ["babel-loader"] }], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/no-css/input.js b/codemods/css-plugins-to-native-css/tests/no-css/input.js new file mode 100644 index 0000000..2b5ae71 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/no-css/input.js @@ -0,0 +1,5 @@ +module.exports = { + module: { + rules: [{ test: /\.js$/, use: ["babel-loader"] }], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/one-of-only/expected.js b/codemods/css-plugins-to-native-css/tests/one-of-only/expected.js new file mode 100644 index 0000000..e9bba3d --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/one-of-only/expected.js @@ -0,0 +1,3 @@ +module.exports = { + mode: "production", +}; diff --git a/codemods/css-plugins-to-native-css/tests/one-of-only/input.js b/codemods/css-plugins-to-native-css/tests/one-of-only/input.js new file mode 100644 index 0000000..d016844 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/one-of-only/input.js @@ -0,0 +1,18 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + mode: "production", + module: { + rules: [ + { + oneOf: [ + { + test: /\.css$/i, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + ], + }, + plugins: [new MiniCssExtractPlugin()], +}; diff --git a/codemods/css-plugins-to-native-css/tests/one-of/expected.js b/codemods/css-plugins-to-native-css/tests/one-of/expected.js new file mode 100644 index 0000000..f346e18 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/one-of/expected.js @@ -0,0 +1,14 @@ +module.exports = { + module: { + rules: [ + { + oneOf: [ + { + test: /\.js$/, + use: ["babel-loader"], + }, + ], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/one-of/input.js b/codemods/css-plugins-to-native-css/tests/one-of/input.js new file mode 100644 index 0000000..08ec09b --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/one-of/input.js @@ -0,0 +1,21 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + module: { + rules: [ + { + oneOf: [ + { + test: /\.css$/i, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + { + test: /\.js$/, + use: ["babel-loader"], + }, + ], + }, + ], + }, + plugins: [new MiniCssExtractPlugin()], +}; diff --git a/codemods/css-plugins-to-native-css/tests/plugin-hooks-esm/expected.mjs b/codemods/css-plugins-to-native-css/tests/plugin-hooks-esm/expected.mjs new file mode 100644 index 0000000..05c8523 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/plugin-hooks-esm/expected.mjs @@ -0,0 +1,14 @@ +import { web } from "webpack"; + +class IntegrityPlugin { + apply(compiler) { + compiler.hooks.thisCompilation.tap("IntegrityPlugin", (compilation) => { + const { linkInsert: beforeTagInsert } = web.CssLoadingRuntimeModule.getCompilationHooks(compilation); + beforeTagInsert.tap("IntegrityPlugin", (source) => source); + }); + } +} + +export default { + plugins: [new IntegrityPlugin()], +}; diff --git a/codemods/css-plugins-to-native-css/tests/plugin-hooks-esm/input.mjs b/codemods/css-plugins-to-native-css/tests/plugin-hooks-esm/input.mjs new file mode 100644 index 0000000..be548f3 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/plugin-hooks-esm/input.mjs @@ -0,0 +1,22 @@ +import MiniCssExtractPlugin from "mini-css-extract-plugin"; + +class IntegrityPlugin { + apply(compiler) { + compiler.hooks.thisCompilation.tap("IntegrityPlugin", (compilation) => { + const { beforeTagInsert } = MiniCssExtractPlugin.getCompilationHooks(compilation); + beforeTagInsert.tap("IntegrityPlugin", (source) => source); + }); + } +} + +export default { + module: { + rules: [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin(), new IntegrityPlugin()], +}; diff --git a/codemods/css-plugins-to-native-css/tests/plugin-hooks/expected.js b/codemods/css-plugins-to-native-css/tests/plugin-hooks/expected.js new file mode 100644 index 0000000..269abe9 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/plugin-hooks/expected.js @@ -0,0 +1,15 @@ +const { web } = require("webpack"); + +class IntegrityPlugin { + apply(compiler) { + compiler.hooks.thisCompilation.tap("IntegrityPlugin", (compilation) => { + const hooks = web.CssLoadingRuntimeModule.getCompilationHooks(compilation); + hooks.linkInsert.tap("IntegrityPlugin", (source) => source); + hooks.linkPreload.tap("IntegrityPlugin", (source) => source); + }); + } +} + +module.exports = { + plugins: [new IntegrityPlugin()], +}; diff --git a/codemods/css-plugins-to-native-css/tests/plugin-hooks/input.js b/codemods/css-plugins-to-native-css/tests/plugin-hooks/input.js new file mode 100644 index 0000000..211f516 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/plugin-hooks/input.js @@ -0,0 +1,23 @@ +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +class IntegrityPlugin { + apply(compiler) { + compiler.hooks.thisCompilation.tap("IntegrityPlugin", (compilation) => { + const hooks = MiniCssExtractPlugin.getCompilationHooks(compilation); + hooks.beforeTagInsert.tap("IntegrityPlugin", (source) => source); + hooks.linkPreload.tap("IntegrityPlugin", (source) => source); + }); + } +} + +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin(), new IntegrityPlugin()], +}; diff --git a/codemods/css-plugins-to-native-css/tests/remove-dependencies/removes/expected.json b/codemods/css-plugins-to-native-css/tests/remove-dependencies/removes/expected.json new file mode 100644 index 0000000..829eb92 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/remove-dependencies/removes/expected.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "dependencies": { + "react": "^18.0.0" + }, + "devDependencies": { + "webpack": "^5.109.0" + } +} diff --git a/codemods/css-plugins-to-native-css/tests/remove-dependencies/removes/input.json b/codemods/css-plugins-to-native-css/tests/remove-dependencies/removes/input.json new file mode 100644 index 0000000..422c1ca --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/remove-dependencies/removes/input.json @@ -0,0 +1,12 @@ +{ + "name": "app", + "dependencies": { + "react": "^18.0.0", + "mini-css-extract-plugin": "^2.9.0" + }, + "devDependencies": { + "css-loader": "^7.0.0", + "style-loader": "^4.0.0", + "webpack": "^5.109.0" + } +} diff --git a/codemods/css-plugins-to-native-css/tests/remove-dependencies/untouched/expected.json b/codemods/css-plugins-to-native-css/tests/remove-dependencies/untouched/expected.json new file mode 100644 index 0000000..111e5ea --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/remove-dependencies/untouched/expected.json @@ -0,0 +1,6 @@ +{ + "name": "app", + "dependencies": { + "react": "^18.0.0" + } +} diff --git a/codemods/css-plugins-to-native-css/tests/remove-dependencies/untouched/input.json b/codemods/css-plugins-to-native-css/tests/remove-dependencies/untouched/input.json new file mode 100644 index 0000000..111e5ea --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/remove-dependencies/untouched/input.json @@ -0,0 +1,6 @@ +{ + "name": "app", + "dependencies": { + "react": "^18.0.0" + } +} diff --git a/codemods/css-plugins-to-native-css/tests/sass/expected.js b/codemods/css-plugins-to-native-css/tests/sass/expected.js new file mode 100644 index 0000000..dfe0c00 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/sass/expected.js @@ -0,0 +1,11 @@ +module.exports = { + module: { + rules: [ + { + test: /\.scss$/, + use: ["sass-loader"], + type: "css/auto", + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/sass/input.js b/codemods/css-plugins-to-native-css/tests/sass/input.js new file mode 100644 index 0000000..3133523 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/sass/input.js @@ -0,0 +1,10 @@ +module.exports = { + module: { + rules: [ + { + test: /\.scss$/, + use: ["style-loader", "css-loader", "sass-loader"], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/spread-config/expected.js b/codemods/css-plugins-to-native-css/tests/spread-config/expected.js new file mode 100644 index 0000000..13d91a0 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/spread-config/expected.js @@ -0,0 +1,20 @@ +const base = require("./webpack.base.js"); + +module.exports = { + ...base, + module: { + rules: [ + { + test: /\.css$/, + include: "src", + type: "css/auto", + }, + ], + }, + experiments: { + css: true, + }, + output: { + cssFilename: "[name].css", + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/spread-config/input.js b/codemods/css-plugins-to-native-css/tests/spread-config/input.js new file mode 100644 index 0000000..1937451 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/spread-config/input.js @@ -0,0 +1,16 @@ +const base = require("./webpack.base.js"); +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); + +module.exports = { + ...base, + module: { + rules: [ + { + test: /\.css$/, + include: "src", + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin({ filename: "[name].css" })], +}; diff --git a/codemods/css-plugins-to-native-css/tests/storybook-main/expected.js b/codemods/css-plugins-to-native-css/tests/storybook-main/expected.js new file mode 100644 index 0000000..cd16598 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/storybook-main/expected.js @@ -0,0 +1,10 @@ +module.exports = { + stories: ["../src/**/*.stories.js"], + webpackFinal: async (config) => { + config.module.rules.push({ + test: /\.css$/, + use: ["style-loader", "css-loader"], + }); + return config; + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/storybook-main/input.js b/codemods/css-plugins-to-native-css/tests/storybook-main/input.js new file mode 100644 index 0000000..cd16598 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/storybook-main/input.js @@ -0,0 +1,10 @@ +module.exports = { + stories: ["../src/**/*.stories.js"], + webpackFinal: async (config) => { + config.module.rules.push({ + test: /\.css$/, + use: ["style-loader", "css-loader"], + }); + return config; + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/style-attributes/expected.js b/codemods/css-plugins-to-native-css/tests/style-attributes/expected.js new file mode 100644 index 0000000..3e04ddd --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/style-attributes/expected.js @@ -0,0 +1,18 @@ +module.exports = { + experiments: { + css: true, + }, + output: { + crossOriginLoading: "anonymous", + }, + module: { + rules: [ + { + test: /\.css$/, + include: "src", + // Removed loader options without a native CSS equivalent: style-loader.attributes.nonce (set __webpack_nonce__ or output.html.csp.nonce) + type: "css/auto", + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/style-attributes/input.js b/codemods/css-plugins-to-native-css/tests/style-attributes/input.js new file mode 100644 index 0000000..0322abd --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/style-attributes/input.js @@ -0,0 +1,14 @@ +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + include: "src", + use: [ + { loader: "style-loader", options: { attributes: { crossorigin: "anonymous", nonce: "abc123" } } }, + "css-loader", + ], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/typescript-config/expected.ts b/codemods/css-plugins-to-native-css/tests/typescript-config/expected.ts new file mode 100644 index 0000000..805ca08 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/typescript-config/expected.ts @@ -0,0 +1,5 @@ +import type { Configuration } from "webpack"; + +const config: Configuration = {}; + +export default config; diff --git a/codemods/css-plugins-to-native-css/tests/typescript-config/input.ts b/codemods/css-plugins-to-native-css/tests/typescript-config/input.ts new file mode 100644 index 0000000..0e8a529 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/typescript-config/input.ts @@ -0,0 +1,16 @@ +import MiniCssExtractPlugin from "mini-css-extract-plugin"; +import type { Configuration } from "webpack"; + +const config: Configuration = { + module: { + rules: [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin()], +}; + +export default config; diff --git a/codemods/css-plugins-to-native-css/tests/unknown-loader/expected.js b/codemods/css-plugins-to-native-css/tests/unknown-loader/expected.js new file mode 100644 index 0000000..2cc8a3c --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/unknown-loader/expected.js @@ -0,0 +1,14 @@ +module.exports = { + experiments: { + css: true, + }, + module: { + rules: [ + { + test: /\.css$/, + use: ["my-custom-loader"], + type: "css/auto", + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/unknown-loader/input.js b/codemods/css-plugins-to-native-css/tests/unknown-loader/input.js new file mode 100644 index 0000000..cd30e38 --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/unknown-loader/input.js @@ -0,0 +1,10 @@ +module.exports = { + module: { + rules: [ + { + test: /\.css$/, + use: ["style-loader", "css-loader", "my-custom-loader"], + }, + ], + }, +}; diff --git a/codemods/css-plugins-to-native-css/tests/webpack-merge/expected.js b/codemods/css-plugins-to-native-css/tests/webpack-merge/expected.js new file mode 100644 index 0000000..80ceacd --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/webpack-merge/expected.js @@ -0,0 +1,6 @@ +const { merge } = require("webpack-merge"); +const common = require("./webpack.common.js"); + +module.exports = merge(common, { + mode: "production", +}); diff --git a/codemods/css-plugins-to-native-css/tests/webpack-merge/input.js b/codemods/css-plugins-to-native-css/tests/webpack-merge/input.js new file mode 100644 index 0000000..0f89daf --- /dev/null +++ b/codemods/css-plugins-to-native-css/tests/webpack-merge/input.js @@ -0,0 +1,16 @@ +const { merge } = require("webpack-merge"); +const MiniCssExtractPlugin = require("mini-css-extract-plugin"); +const common = require("./webpack.common.js"); + +module.exports = merge(common, { + mode: "production", + module: { + rules: [ + { + test: /\.css$/, + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + plugins: [new MiniCssExtractPlugin()], +}); diff --git a/codemods/css-plugins-to-native-css/workflow.yaml b/codemods/css-plugins-to-native-css/workflow.yaml new file mode 100644 index 0000000..c43846e --- /dev/null +++ b/codemods/css-plugins-to-native-css/workflow.yaml @@ -0,0 +1,36 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod-com/codemod/refs/heads/main/schemas/workflow.json + +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + runtime: + type: direct + steps: + - name: Migrate mini-css-extract-plugin and style-loader/css-loader rules to webpack's native CSS support (experiments.css) + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.cjs" + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript + - name: Remove mini-css-extract-plugin, style-loader, and css-loader from package.json + js-ast-grep: + js_file: src/remove-dependencies.ts + base_path: . + include: + - "**/package.json" + exclude: + - "**/node_modules/**" + language: json diff --git a/eslint.config.mjs b/eslint.config.mjs index 567ace9..a8826d4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,4 +12,13 @@ export default tseslint.config( reportUnusedDisableDirectives: "error", }, }, + { + files: [".github/scripts/**/*.mjs"], + languageOptions: { + globals: { + console: "readonly", + process: "readonly", + }, + }, + }, ); diff --git a/package-lock.json b/package-lock.json index ccdfb57..3f0ce0f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.0", "license": "MIT", "workspaces": [ - "./codemods/*" + "./codemods/*", + "./packages/*" ], "devDependencies": { "@changesets/cli": "^2.31.1", @@ -20,6 +21,17 @@ "typescript-eslint": "^8.39.0" } }, + "codemods/css-plugins-to-native-css": { + "name": "@webpack/css-plugins-to-native-css", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@webpack/codemod-utils": "*" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.2" + } + }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", @@ -292,6 +304,13 @@ "prettier": "^2.7.1" } }, + "node_modules/@codemod.com/jssg-types": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@codemod.com/jssg-types/-/jssg-types-1.6.3.tgz", + "integrity": "sha512-b5829ixO5TdGL5xg5YcL9YfiLC3WwFZU+8Zx78NpsHlJ1iySbqfA0g0psqNXv8ss/r7hdpy2KiBXm3GE6bN5bQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", @@ -524,6 +543,12 @@ } } }, + "node_modules/@jssg/utils": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/@jssg/utils/-/utils-0.0.9.tgz", + "integrity": "sha512-dQDR5At7VEXfEuiwl7pIehm7MyIGJpDn4C0I015xMDHWeceJuWMMjmBcrDVfi75CLgfB90CSLi1tP1HErduznQ==", + "license": "Apache-2.0" + }, "node_modules/@manypkg/find-root": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@manypkg/find-root/-/find-root-1.1.0.tgz", @@ -996,6 +1021,14 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@webpack/codemod-utils": { + "resolved": "packages/codemod-utils", + "link": true + }, + "node_modules/@webpack/css-plugins-to-native-css": { + "resolved": "codemods/css-plugins-to-native-css", + "link": true + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -2627,6 +2660,17 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "packages/codemod-utils": { + "name": "@webpack/codemod-utils", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@jssg/utils": "0.0.9" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.2" + } } } } diff --git a/package.json b/package.json index a7cccd4..ae55058 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,9 @@ "scripts": { "lint": "eslint .", "lint:fix": "eslint . --fix", - "test": "npm run test --workspaces", - "type-check": "tsc --noEmit" + "test": "npm run test --workspaces --if-present", + "type-check": "tsc --noEmit", + "version": "changeset version && node .github/scripts/sync-codemod-versions.mjs" }, "repository": { "type": "git", @@ -34,6 +35,7 @@ "typescript-eslint": "^8.39.0" }, "workspaces": [ - "./codemods/*" + "./codemods/*", + "./packages/*" ] } diff --git a/packages/codemod-utils/package.json b/packages/codemod-utils/package.json new file mode 100644 index 0000000..23c77b7 --- /dev/null +++ b/packages/codemod-utils/package.json @@ -0,0 +1,27 @@ +{ + "name": "@webpack/codemod-utils", + "private": true, + "version": "0.0.0", + "description": "Shared ast-grep helpers for webpack codemods.", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "repository": { + "type": "git", + "url": "git+https://github.com/webpack/codemods.git", + "directory": "packages/codemod-utils", + "bugs": "https://github.com/webpack/codemods/issues" + }, + "author": "Sebastian Beltran ", + "license": "MIT", + "dependencies": { + "@jssg/utils": "0.0.9" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.2" + } +} diff --git a/packages/codemod-utils/src/ast.ts b/packages/codemod-utils/src/ast.ts new file mode 100644 index 0000000..f081386 --- /dev/null +++ b/packages/codemod-utils/src/ast.ts @@ -0,0 +1,99 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode } from "@codemod.com/jssg-types/main"; + +export interface Range { + start: number; + end: number; +} + +export function rangeOf(node: SgNode): Range { + const range = node.range(); + return { start: range.start.index, end: range.end.index }; +} + +export function unquote(text: string): string { + return text.replace(/^["'`]/, "").replace(/["'`]$/, ""); +} + +export function namedChildren(node: SgNode): SgNode[] { + return node.children().filter((child) => child.isNamed()); +} + +export function keyName(pair: SgNode): string | null { + const key = pair.field("key"); + return key ? unquote(key.text()) : null; +} + +export function pairsOf(objectNode: SgNode): SgNode[] { + return namedChildren(objectNode).filter((child) => child.kind() === "pair"); +} + +export function findPair(objectNode: SgNode, name: string): SgNode | undefined { + return pairsOf(objectNode).find((pair) => keyName(pair) === name); +} + +// Whitespace at the start of the line containing `index`. +export function lineIndent(source: string, index: number): string { + const lineStart = source.lastIndexOf("\n", index - 1) + 1; + const match = /^[ \t]*/.exec(source.slice(lineStart, index)); + return match ? match[0] : ""; +} + +export function isInsideAny(range: Range, ranges: Range[]): boolean { + return ranges.some((outer) => range.start >= outer.start && range.end <= outer.end); +} + +// Effective branches behind a dev/prod guard (`cond && x`, `cond ? a : b`), +// or null when the node is not a guard. +export function guardBranchesOf(node: SgNode): SgNode[] | null { + if (node.kind() === "binary_expression" && node.field("operator")?.text() === "&&") { + const right = node.field("right"); + return right ? [right] : []; + } + if (node.kind() === "ternary_expression") { + const branches = [node.field("consequence"), node.field("alternative")]; + return branches.filter((branch): branch is SgNode => branch !== null); + } + return null; +} + +// Climb while the removal would leave an empty container behind, so chains +// like a css-only `oneOf` → rule → `rules` → `module` collapse as one removal. +// Stops where a container keeps other members or is not a property/element. +export function cascadeRemovalTarget(node: SgNode): SgNode { + let target = node; + for (;;) { + const parent = target.parent(); + if (!parent) return target; + if (parent.kind() === "pair") { + target = parent; + continue; + } + if (parent.kind() !== "object" && parent.kind() !== "array") return target; + const members = parent.kind() === "object" ? pairsOf(parent) : namedChildren(parent); + if (members.length !== 1) return target; + const grandparent = parent.parent(); + if (!grandparent || (grandparent.kind() !== "pair" && grandparent.kind() !== "array")) { + return target; + } + target = parent; + } +} + +// `[ ... ].filter()` — return the inner array literal. +export function unwrapFilterCall(node: SgNode): SgNode { + if (node.kind() !== "call_expression") return node; + const callee = node.field("function"); + if (!callee || callee.kind() !== "member_expression") return node; + if (callee.field("property")?.text() !== "filter") return node; + const receiver = callee.field("object"); + return receiver && receiver.kind() === "array" ? receiver : node; +} + +// The `.filter(...)` text that followed the array, e.g. `.filter((x) => !!x)`. +export function filterSuffixOf(originalValue: SgNode, arrayNode: SgNode): string { + if (originalValue.range().start.index === arrayNode.range().start.index) { + return originalValue.text().slice(arrayNode.text().length); + } + return ""; +} diff --git a/packages/codemod-utils/src/imports.ts b/packages/codemod-utils/src/imports.ts new file mode 100644 index 0000000..a602a64 --- /dev/null +++ b/packages/codemod-utils/src/imports.ts @@ -0,0 +1,46 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode } from "@codemod.com/jssg-types/main"; +import { getAllImports } from "@jssg/utils/javascript/imports"; + +import { namedChildren } from "./ast"; + +export { addImport } from "@jssg/utils/javascript/imports"; + +// A top-level `require`/`import` binding of a given module. +export interface ModuleBinding { + name: string; + statement: SgNode; +} + +// The whole removable statement behind a binding identifier: its import +// statement, or its variable statement when it declares nothing else. +function bindingStatementOf(identifier: SgNode): SgNode | null { + let current = identifier.parent(); + while (current) { + const kind = current.kind(); + if (kind === "import_statement") return current; + if (kind === "lexical_declaration" || kind === "variable_declaration") { + const declarators = namedChildren(current).filter( + (child) => child.kind() === "variable_declarator", + ); + return declarators.length === 1 ? current : null; + } + current = current.parent(); + } + return null; +} + +// Top-level `require`/`import` bindings of the given module, resolved by +// @jssg/utils (ESM, CJS, aliases, namespace, dynamic import). +export function collectModuleBindings(rootNode: SgNode, moduleName: string): ModuleBinding[] { + const bindings: ModuleBinding[] = []; + const seen = new Set(); + const program = rootNode as SgNode; + for (const resolved of getAllImports(program, { type: "default", from: moduleName })) { + const statement = bindingStatementOf(resolved.node); + if (!statement || seen.has(resolved.alias)) continue; + seen.add(resolved.alias); + bindings.push({ name: resolved.alias, statement }); + } + return bindings; +} diff --git a/packages/codemod-utils/src/index.ts b/packages/codemod-utils/src/index.ts new file mode 100644 index 0000000..70beff8 --- /dev/null +++ b/packages/codemod-utils/src/index.ts @@ -0,0 +1,280 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { Edit, SgNode } from "@codemod.com/jssg-types/main"; + +import { + type Range, + findPair, + isInsideAny, + keyName, + lineIndent, + namedChildren, + rangeOf, + unquote, +} from "./ast"; +import type { ModuleBinding } from "./imports"; + +export * from "./ast"; +export * from "./imports"; + +// ---------- webpack config helpers ---------- + +// Loader name behind a `use` entry: a plain string, `require.resolve("...")`, +// `import.meta.resolve("...")`, or `{ loader: }`. +export function loaderNameOf(node: SgNode): string | null { + if (node.kind() === "string") return unquote(node.text()); + if (node.kind() === "call_expression") { + const callee = node.field("function"); + const receiver = callee?.kind() === "member_expression" ? callee.field("object")?.text() : null; + if ( + !callee || + (receiver !== "require" && receiver !== "import.meta") || + callee.field("property")?.text() !== "resolve" + ) { + return null; + } + const argumentsNode = node.field("arguments"); + const args = argumentsNode ? namedChildren(argumentsNode) : []; + return args.length === 1 && args[0].kind() === "string" ? unquote(args[0].text()) : null; + } + if (node.kind() !== "object") return null; + const loaderValue = findPair(node, "loader")?.field("value"); + return loaderValue ? loaderNameOf(loaderValue) : null; +} + +// Whether the rule claims one of the sample resource paths — used to know if it +// disables an `experiments.*: "auto"` default. Unreadable conditions count as +// matching, to be safe. +export function ruleMatchesFiles(ruleObject: SgNode, sampleFiles: string[]): boolean { + const testValue = findPair(ruleObject, "test")?.field("value"); + if (!testValue) return true; + if (testValue.kind() !== "regex") return true; + const pattern = testValue.field("pattern"); + if (!pattern) return true; + try { + const regex = new RegExp(pattern.text(), testValue.field("flags")?.text() ?? ""); + return sampleFiles.some((file) => regex.test(file)); + } catch { + return true; + } +} + +// The enclosing webpack config object: nearest ancestor holding a `module` pair. +export function findConfigObjectFor(node: SgNode): SgNode | null { + let current = node.parent(); + while (current) { + if (current.kind() === "pair" && keyName(current) === "module") { + const parent = current.parent(); + if (parent && parent.kind() === "object") return parent; + } + current = current.parent(); + } + return null; +} + +// ---------- edit collection ---------- + +// Accumulates edits over one config file: text removals grouped per parent +// container (so sibling removals never overlap), brace-aware property +// insertion, and unused-import cleanup. +export class ConfigEditor { + readonly rootNode: SgNode; + readonly source: string; + + private readonly edits: Edit[] = []; + private readonly editedRanges: Range[] = []; + private readonly pendingRemovals = new Map< + number, + { parent: SgNode; removed: Set } + >(); + // Fully-emptied objects that must keep their braces open for new properties. + private readonly keepOpenTargets = new Set(); + private identifierNodes: SgNode[] | null = null; + + // Line ending of the source file; generated text must use it too. + readonly eol: string; + + constructor(rootNode: SgNode) { + this.rootNode = rootNode; + this.source = rootNode.text(); + this.eol = this.source.includes("\r\n") ? "\r\n" : "\n"; + } + + get hasEdits(): boolean { + return this.edits.length > 0; + } + + // Whether any edit or pending removal has been registered so far. + get hasWork(): boolean { + return this.edits.length > 0 || this.pendingRemovals.size > 0; + } + + addEdit(edit: Edit): void { + this.edits.push(edit); + } + + insertAfter(node: SgNode, text: string): void { + const position = node.range().end.index; + this.edits.push({ startPos: position, endPos: position, insertedText: this.toSourceEol(text) }); + } + + // Generated text may mix "\n" with source fragments that already carry the + // file's EOL — normalize both so the conversion never doubles a "\r". + private toSourceEol(text: string): string { + return text.replace(/\r?\n/g, this.eol); + } + + replace(node: SgNode, text: string): void { + this.edits.push(node.replace(this.toSourceEol(text))); + this.editedRanges.push(rangeOf(node)); + } + + removeText(range: Range): void { + this.edits.push({ startPos: range.start, endPos: range.end, insertedText: "" }); + this.editedRanges.push(range); + } + + markForRemoval(node: SgNode): void { + const parent = node.parent(); + if (!parent) return; + const key = parent.range().start.index; + let group = this.pendingRemovals.get(key); + if (!group) { + group = { parent, removed: new Set() }; + this.pendingRemovals.set(key, group); + } + group.removed.add(node.range().start.index); + } + + keepBracesOpen(objectNode: SgNode): void { + this.keepOpenTargets.add(objectNode.range().start.index); + } + + finalizeRemovals(): void { + for (const { parent, removed } of this.pendingRemovals.values()) { + const children = namedChildren(parent); + if (children.every((child) => removed.has(child.range().start.index))) { + this.clearContainer(parent, children); + continue; + } + this.removeChildRuns(children, removed); + } + } + + // Insert properties into an object, matching its layout. They go right after + // the opening brace — except when the object contains spreads, where a later + // spread could override them: then they go after the last property instead. + insertIntoObject( + objectNode: SgNode, + buildProperties: (indent: string, indentUnit: string) => string[], + ): void { + const properties = namedChildren(objectNode); + const multiline = objectNode.text().includes("\n") && properties.length > 0; + const indent = multiline ? lineIndent(this.source, properties[0].range().start.index) : ""; + // One indentation step: the first property's indent minus the object's own. + const objectIndent = lineIndent(this.source, objectNode.range().start.index); + const indentUnit = indent.slice(objectIndent.length) || (indent.includes("\t") ? "\t" : " "); + const built = buildProperties(indent, indentUnit); + const hasSpread = properties.some((property) => property.kind() === "spread_element"); + let insertAt: number; + let insertedText: string; + if (hasSpread) { + insertAt = properties[properties.length - 1].range().end.index; + const hasComma = this.source[insertAt] === ","; + if (hasComma) insertAt += 1; + insertedText = built + .map((property) => (multiline ? `\n${indent}${property},` : ` ${property},`)) + .join(""); + if (!hasComma) insertedText = `,${insertedText}`; + } else { + insertAt = objectNode.range().start.index + 1; + if (multiline) { + insertedText = built.map((property) => `\n${indent}${property},`).join(""); + } else if (properties.length) { + insertedText = ` ${built.join(", ")},`; + } else { + insertedText = ` ${built.join(", ")} `; + } + } + this.edits.push({ + startPos: insertAt, + endPos: insertAt, + insertedText: this.toSourceEol(insertedText), + }); + } + + // Drop the binding's statement once no reference survives outside the edited + // ranges. Call after finalizeRemovals so those ranges are complete. + removeBindingIfUnused(binding: ModuleBinding): void { + const statementRange = rangeOf(binding.statement); + this.identifierNodes ??= this.rootNode.findAll({ rule: { kind: "identifier" } }); + const survivingReference = this.identifierNodes.some((identifier) => { + if (identifier.text() !== binding.name) return false; + const range = rangeOf(identifier); + if (range.start >= statementRange.start && range.end <= statementRange.end) return false; + return !isInsideAny(range, this.editedRanges); + }); + if (survivingReference) return; + let end = statementRange.end; + if (this.source[end] === "\r") end += 1; + if (this.source[end] === "\n") end += 1; + // At the top of the file also swallow the blank line that separated it. + while ( + statementRange.start === 0 && + (this.source[end] === "\n" || this.source[end] === "\r") + ) { + end += 1; + } + this.edits.push({ startPos: statementRange.start, endPos: end, insertedText: "" }); + } + + commit(): string { + return this.rootNode.commitEdits(this.edits); + } + + private clearContainer(parent: SgNode, children: SgNode[]): void { + if (this.keepOpenTargets.has(parent.range().start.index) && children.length) { + // New properties will be inserted after "{" — clear the content only. + const first = children[0].range().start.index; + const lineStart = this.source.lastIndexOf("\n", first - 1) + 1; + this.removeText({ + start: lineStart > parent.range().start.index ? lineStart : first, + end: parent.range().end.index - 1, + }); + } else { + this.edits.push(parent.replace(parent.kind() === "array" ? "[]" : "{}")); + this.editedRanges.push(rangeOf(parent)); + } + } + + // Delete each contiguous run of removed children up to the next kept + // sibling, or back to the previous kept one for a trailing run. + private removeChildRuns(children: SgNode[], removed: Set): void { + let index = 0; + while (index < children.length) { + if (!removed.has(children[index].range().start.index)) { + index += 1; + continue; + } + let runEnd = index; + while ( + runEnd + 1 < children.length && + removed.has(children[runEnd + 1].range().start.index) + ) { + runEnd += 1; + } + const next = children[runEnd + 1]; + if (next) { + this.removeText({ + start: children[index].range().start.index, + end: next.range().start.index, + }); + } else { + this.removeText({ + start: children[index - 1].range().end.index, + end: children[runEnd].range().end.index, + }); + } + index = runEnd + 1; + } + } +} diff --git a/tsconfig.json b/tsconfig.json index 3dba9b4..428000f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,15 +1,15 @@ { "compilerOptions": { - "target": "ES2022", + "target": "ES2023", "module": "ESNext", "moduleResolution": "bundler", - "lib": ["ES2022"], + "lib": ["ES2023"], "strict": true, "noEmit": true, "skipLibCheck": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true }, - "include": ["codemods/*/src/**/*.ts"], + "include": ["codemods/*/src/**/*.ts", "packages/*/src/**/*.ts"], "exclude": ["node_modules", "**/tests/**"] }