diff --git a/.changeset/html-plugins-to-native-html.md b/.changeset/html-plugins-to-native-html.md new file mode 100644 index 0000000..c6601fe --- /dev/null +++ b/.changeset/html-plugins-to-native-html.md @@ -0,0 +1,5 @@ +--- +"@webpack/html-plugins-to-native-html": major +--- + +Add codemod migrating html-webpack-plugin setups to webpack's native HTML support. diff --git a/README.md b/README.md index 120eb19..ce50400 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ npx codemod run @webpack/ | 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`). | +| [`html-plugins-to-native-html`](codemods/html-plugins-to-native-html) | Migrate `html-webpack-plugin` and `html-loader` rules to webpack's native HTML support (`experiments.html`). | ## Contributing diff --git a/codemods/html-plugins-to-native-html/README.md b/codemods/html-plugins-to-native-html/README.md new file mode 100644 index 0000000..370f66b --- /dev/null +++ b/codemods/html-plugins-to-native-html/README.md @@ -0,0 +1,91 @@ +# @webpack/html-plugins-to-native-html + +Migrates webpack configurations from `html-webpack-plugin` and `html-loader` to webpack's native HTML support (`experiments.html` + `output.html`). + +> Requires **webpack >= 5.109.0**: the transform relies on the `output.html` options (`title`, `meta`, `favicon`, `base`, `inject`, …) introduced there. + +## What it does + +- Removes `new HtmlWebpackPlugin(...)` from `plugins` (and the whole `plugins` entry when it becomes empty), and the `html-webpack-plugin` `require`/`import` once it is unused. +- Enables the native pipeline with `experiments: { html: true }` and `output.html` on each migrated configuration. +- Sets `output.htmlFilename` to the plugin's `filename` — or to `"index.html"`, the plugin's default, since the native default is `[name].html`. +- Maps plugin options to their `output.html` counterparts: `title`, `meta` (string values, plus `{ name | property, content }` objects — `og:*` keys included), `favicon`, `base`, `inject` (`"body"`/`"head"`/`false`; `true` is the native default), and `scriptLoading` — `"blocking"` maps directly, `"defer"` is the native default, and `"module"` becomes `output.module: true` + `experiments.outputModule: true` (native module scripts). +- Drops options the native pipeline covers on its own (`minify: true`/`"auto"`, `cache`, `showErrors`, `chunksSortMode`, `chunks: "all"`, `publicPath: "auto"`) silently — webpack's default `optimization.minimizer` (`minimizer-webpack-plugin`) already minifies the emitted HTML/CSS in production; custom `minify` objects are flagged towards it. +- **Multi-page setups**: several instances (or a `chunks: ["name"]` list) map to per-entry `html` descriptors — each listed entry becomes `{ import: …, html: }`, unlisted entries get no page, and `output.htmlFilename: "[name].html"` covers the per-page filenames. Requires each instance to own exactly one entry via `chunks` and no `template`; instance filenames other than `.html`/`[name].html` are flagged. +- Options without a native equivalent (`hash`, a `minify` object, `chunks` arrays, `templateContent`, `templateParameters`, …) are dropped with a `// Removed html-webpack-plugin options without a native HTML equivalent: …` comment so you can review the behavior change; a manual migration path is appended where one exists. +- **Companion plugins** found next to a migrated `html-webpack-plugin` instance are migrated too: `csp-html-webpack-plugin` → `output.html.csp` (its policy argument becomes `csp.policy`), `webpack-subresource-integrity` → `output.html.integrity` (`hashFuncNames` becomes the algorithm list; `enabled: false` just removes it), and `favicons-webpack-plugin` → `output.html.favicon` (the logo path; the native option also emits the icon set). Options beyond that are dropped with a review comment; instances whose arguments can't be understood are left in place with a comment. In template/multi-page modes options that only apply to generated pages are flagged instead. +- Migrates `HtmlWebpackPlugin.getHooks(...)` taps to the native `webpack.html.HtmlModulesPlugin.getCompilationHooks(...)` stage covering the same moment: `alterAssetTags`/`alterAssetTagGroups` → `transformTags`, `beforeEmit` → `transformHtml`, `afterEmit` → `htmlEmitted`. The native stages take different arguments (`transformTags` hands you mutable tag descriptors instead of `data.assetTags`/head-body arrays; `transformHtml` is a waterfall on the HTML string instead of `data.html`), so each renamed tap gets a `// Review: …` comment describing the new signature — review the callback body. + +### `html-loader` + +- Removes rules that only wire up `html-loader` (cascading to empty `rules`/`module` entries): with no user rule matching `.html`, webpack's `experiments.html: "auto"` default enables native HTML by itself, and importing an `.html` file from JS natively yields the processed HTML string — the same shape `html-loader` exported. +- Rules with extra conditions or surviving options are kept with `type: "html"` instead — and since their presence disables the `"auto"` default, `experiments.html: true` is added to that configuration. +- Any other loader in the chain (template compilers, custom ones) keeps working in front of native HTML: it stays in `use` while `html-loader` is dropped. +- Loader options: boolean `sources` becomes the rule's `parser: { sources }`; `esModule` and `minimize` are dropped silently (native HTML covers them); a `sources` object or `preprocessor` function is flagged with a review comment (`preprocessor` maps manually to the rule's `parser.template`, which is synchronous and receives `(source, { module, resource })`). + +### `template` + +`output.html` generates each page from scratch, so an authored template maps to webpack's other native mode instead: the **HTML entry point**. The codemod turns the template into the entry, and — because the HTML file now drives the build — it must load the previous JS entry itself. The review comment always states the exact tag (``, relative to the template), and as a best effort the codemod also edits the template in place, inserting the tag before `` unless it is already there (the in-place edit is skipped where the runtime sandbox blocks file access, e.g. on Windows — the comment still tells you what to add). Since head tags are only injected into webpack-generated pages, `title`/`meta`/`favicon`/`base` are flagged to be added to the template instead. + +### What is left untouched + +- Multi-page configurations the per-entry shape can't express: an instance whose `chunks` lists several entries (one page aggregating several chunks), combines `chunks` with `template`, or names an entry the config's `entry` object doesn't declare. +- Files that tap `beforeAssetTagGeneration` or `afterTemplateExecution` via `HtmlWebpackPlugin.getHooks(...)`: those stages have no native equivalent (webpack builds the tags and runs the parser template itself). +- Plugin instantiations whose options are not an object literal. + +## Usage + +```sh +npx codemod run @webpack/html-plugins-to-native-html +``` + +## Example + +Before: + +```js +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +module.exports = { + entry: "./src/index.js", + plugins: [ + new HtmlWebpackPlugin({ + filename: "app.html", + title: "My App", + meta: { viewport: "width=device-width, initial-scale=1" }, + }), + ], +}; +``` + +After: + +```js +module.exports = { + output: { + html: { title: "My App", meta: { viewport: "width=device-width, initial-scale=1" } }, + htmlFilename: "app.html", + }, + experiments: { + html: true, + }, + entry: "./src/index.js", +}; +``` + +With a `template`, the template becomes the entry point and gets a ` + entry: "./src/index.html", + output: { + htmlFilename: "index.html", + }, +}; +``` + +The codemod also removes `html-webpack-plugin` and `html-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/html-plugins-to-native-html/codemod.yaml b/codemods/html-plugins-to-native-html/codemod.yaml new file mode 100644 index 0000000..a8bcb62 --- /dev/null +++ b/codemods/html-plugins-to-native-html/codemod.yaml @@ -0,0 +1,23 @@ +schema_version: "1.0" +name: "@webpack/html-plugins-to-native-html" +version: "0.0.0" +description: Migrate html-webpack-plugin and html-loader rules to webpack's native HTML support (experiments.html) +author: bjohansebas (Sebastian Beltran) +license: MIT +workflow: workflow.yaml +repository: "https://github.com/webpack/codemods/tree/HEAD/codemods/html-plugins-to-native-html" +category: migration + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + - webpack + +registry: + access: public + visibility: public diff --git a/codemods/html-plugins-to-native-html/package.json b/codemods/html-plugins-to-native-html/package.json new file mode 100644 index 0000000..81de66a --- /dev/null +++ b/codemods/html-plugins-to-native-html/package.json @@ -0,0 +1,27 @@ +{ + "name": "@webpack/html-plugins-to-native-html", + "private": true, + "version": "0.0.0", + "description": "Migrate html-webpack-plugin and html-loader rules to webpack's native HTML support (experiments.html).", + "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/html-plugins-to-native-html", + "bugs": "https://github.com/webpack/codemods/issues" + }, + "author": "Sebastian Beltran ", + "license": "MIT", + "homepage": "https://github.com/webpack/codemods/blob/main/codemods/html-plugins-to-native-html/README.md", + "dependencies": { + "@webpack/codemod-utils": "*" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.2" + } +} diff --git a/codemods/html-plugins-to-native-html/src/remove-dependencies.ts b/codemods/html-plugins-to-native-html/src/remove-dependencies.ts new file mode 100644 index 0000000..794b48c --- /dev/null +++ b/codemods/html-plugins-to-native-html/src/remove-dependencies.ts @@ -0,0 +1,36 @@ +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 HTML; review your lockfile if other tooling +// (Storybook, tests, …) still relies on them. +const REMOVED_PACKAGES = new Set([ + "html-webpack-plugin", + "html-loader", + "csp-html-webpack-plugin", + "webpack-subresource-integrity", + "favicons-webpack-plugin", +]); +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/html-plugins-to-native-html/src/workflow.ts b/codemods/html-plugins-to-native-html/src/workflow.ts new file mode 100644 index 0000000..2905ff6 --- /dev/null +++ b/codemods/html-plugins-to-native-html/src/workflow.ts @@ -0,0 +1,1314 @@ +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 = "html-webpack-plugin"; +const LOADER_NAME = "html-loader"; +const HTML_SAMPLE_FILES = ["/file.html"]; +// Native HTML defaults already covered by these plugin option values. +const HEAD_TAG_OPTIONS = new Set(["title", "meta", "favicon", "base"]); +// Build-ergonomics options with no effect on the emitted page. +const DROPPABLE_OPTIONS = new Set(["cache", "showErrors", "chunksSortMode"]); +// Loader options native HTML covers on its own: `esModule` (native exports are +// ESM) and boolean `minimize` (optimization.minimizer minifies in production). +const DROPPABLE_LOADER_OPTIONS = new Set(["esModule"]); +// Manual migration paths appended to the review comment where one exists. +const LOST_OPTION_HINTS = new Map([ + ["publicPath", "set output.publicPath"], + ["hash", "use [contenthash] in output.htmlFilename"], + ["chunks", "use per-entry `html` descriptors"], + ["excludeChunks", "use per-entry `html` descriptors"], + ["scriptLoading", "module scripts come from experiments.outputModule"], + ["minify", "production HTML is minified by default; customize via optimization.minimizer (minimizer-webpack-plugin)"], + ["templateContent", "author the page as an .html entry file"], + ["html-loader.sources", "customize the rule's parser.sources list"], + [ + "html-loader.preprocessor", + "move it to the rule's parser.template — synchronous (source, { module, resource }) => string", + ], + [ + "html-loader.postprocessor", + "tap HtmlModulesPlugin.getCompilationHooks(compilation).transformHtml for emitted pages", + ], + ["html-loader.minimize", "customize via optimization.minimizer (minimizer-webpack-plugin)"], +]); + +type FsModule = typeof import("node:fs"); + +// Plugin hooks renamed to the native `HtmlModulesPlugin.getCompilationHooks` +// stage covering the same moment; arguments differ, hence the review comments. +const HOOK_RENAMES = new Map([ + ["alterAssetTags", "transformTags"], + ["alterAssetTagGroups", "transformTags"], + ["beforeEmit", "transformHtml"], + ["afterEmit", "htmlEmitted"], +]); +const HOOK_REVIEW_COMMENTS = new Map([ + [ + "transformTags", + "transformTags receives mutable tag descriptors (tags, { outputName, html }); mutate attrs/injectTo/remove, add tags via the injectTags hook", + ], + ["transformHtml", "transformHtml receives (html, { outputName }) and must return the html string"], + ["htmlEmitted", "htmlEmitted receives ({ outputName }); nothing to return"], +]); +// Stages webpack handles itself — a tap on them cannot be carried over, so +// files using them are left untouched. +const UNMAPPABLE_HOOKS = new Set(["beforeAssetTagGeneration", "afterTemplateExecution"]); +// Handled by the per-entry migration itself rather than the option mapping. +const MULTI_PAGE_SKIPPED_OPTIONS = new Set(["chunks", "filename"]); +// Companion plugins that extended html-webpack-plugin; each maps to one +// `output.html` option. Only migrated alongside a migrated html-webpack-plugin. +const CSP_MODULE = "csp-html-webpack-plugin"; +const SRI_MODULE = "webpack-subresource-integrity"; +const FAVICONS_MODULE = "favicons-webpack-plugin"; +const SIBLING_PLUGIN_MODULES = [CSP_MODULE, SRI_MODULE, FAVICONS_MODULE]; + +type PageMode = "single" | "template" | "multi"; + +interface HtmlProp { + name: string; + valueText: string; +} + +function dedupeProps(props: HtmlProp[]): HtmlProp[] { + const seen = new Set(); + return props.filter((prop) => !seen.has(prop.name) && seen.add(prop.name)); +} + +function isRequireOf(node: SgNode, moduleName: string): 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()) === moduleName; +} + +// Everything one plugin instance contributes to its enclosing config. +interface InstanceFindings { + htmlProps: HtmlProp[]; + htmlFilename: string | null; + templateValue: string | null; + lost: string[]; + notes: string[]; + // `entry` property to create when the config had none (template mode). + pendingEntry: { text: string; comment: string } | null; + // `scriptLoading: "module"` — ESM output, set config-wide. + scriptLoadingModule: boolean; +} + +// Loader options translated into the surviving rule, plus the ones lost. +interface LoaderFindings { + lost: string[]; + parserProps: HtmlProp[]; +} + +// Properties to add to one webpack config object once all removals are known. +interface ConfigPlan { + config: SgNode; + commentLines: string[]; + // Emit a global `output.html` (single-page mode; per-entry pages skip it). + htmlEnabled: boolean; + // Props composing the `output.html` object (plugin options, sibling plugins). + htmlProps: HtmlProp[]; + htmlFilename: string | null; + // Other output-level props (e.g. `module` for ESM script loading). + outputProps: HtmlProp[]; + experimentsProps: HtmlProp[]; + pendingEntry: { text: string; comment: string } | null; + // A html-webpack-plugin instance in this config was migrated. + pluginMigratedHere: boolean; +} + +// Split a filename into segments, dropping Windows extended-length prefixes. +function pathSegments(fileName: string): string[] { + const plain = fileName.replace(/^\\\\\?\\(UNC\\)?/, ""); + return plain.split(/[\\/]/); +} + +// Resolve a `./`/`../` request against base directory segments. +function applyRelativePath(baseSegments: string[], relative: string): string[] | null { + const segments = [...baseSegments]; + for (const part of relative.split("/")) { + if (part === "" || part === ".") continue; + if (part === "..") { + if (segments.length <= 1) return null; + segments.pop(); + } else { + segments.push(part); + } + } + return segments; +} + +// Path forms to try against the runtime's sandboxed fs: native separators, +// forward slashes, and cwd-relative — on Windows the sandbox normalizes +// absolute drive paths into a form its allow-list check rejects, while a +// relative path resolves internally and passes. +function pathCandidates(segments: string[], separator: string): string[] { + const list = [segments.join(separator)]; + const forward = segments.join("/"); + if (!list.includes(forward)) list.push(forward); + const cwd = (globalThis as { process?: { cwd?: () => string } }).process?.cwd?.(); + if (cwd) { + const cwdSegments = pathSegments(cwd); + const isUnder = + cwdSegments.length > 0 && + cwdSegments.length < segments.length && + cwdSegments.every( + (segment, index) => segment.toLowerCase() === segments[index].toLowerCase(), + ); + if (isUnder) list.push(segments.slice(cwdSegments.length).join("/")); + } + return list; +} + +// Relative URL (forward slashes) from a directory to a file. +function relativeUrl(fromDirSegments: string[], toSegments: string[]): string { + let common = 0; + while ( + common < fromDirSegments.length && + common < toSegments.length - 1 && + fromDirSegments[common] === toSegments[common] + ) { + common += 1; + } + const ups = fromDirSegments.length - common; + const down = toSegments.slice(common).join("/"); + return ups ? `${"../".repeat(ups)}${down}` : `./${down}`; +} + +// Insert the script tag before `` (or ``), matching the +// closing tag's indentation; append when the template has neither. +function insertScriptTag(html: string, tag: string): string { + for (const marker of [/<\/head>/i, /<\/body>/i]) { + const match = marker.exec(html); + if (!match) continue; + const lineStart = html.lastIndexOf("\n", match.index) + 1; + const closingIndent = html.slice(lineStart, match.index); + if (!/^[ \t]*$/.test(closingIndent)) { + // Closing tag mid-line — insert inline right before it. + return `${html.slice(0, match.index)}${tag}${html.slice(match.index)}`; + } + // Indent like the previous sibling line when it sits deeper. + const before = html.slice(0, lineStart); + const prevLineStart = before.lastIndexOf("\n", before.length - 2) + 1; + const prevIndentMatch = /^[ \t]*/.exec(before.slice(prevLineStart)); + const prevIndent = prevIndentMatch ? prevIndentMatch[0] : ""; + const indent = prevIndent.length > closingIndent.length ? prevIndent : `${closingIndent} `; + return `${before}${indent}${tag}\n${html.slice(lineStart)}`; + } + return `${html.replace(/\s*$/, "")}\n${tag}\n`; +} + +class HtmlMigration { + private readonly editor: ConfigEditor; + private readonly pluginBindings; + private readonly pluginNames: Set; + private readonly configPlans = new Map(); + private readonly configFileName: string; + private readonly fileSystem: FsModule | null; + private pluginMigrated = false; + private pluginRetained = false; + // Binding statements rewritten in place (e.g. into the `html` import). + private readonly repurposedStatements = new Set(); + + private readonly siblingBindings: ModuleBinding[] = []; + private readonly siblingNameToModule = new Map(); + + constructor(root: SgRoot, fileSystem: FsModule | null) { + this.editor = new ConfigEditor(root.root()); + this.pluginBindings = collectModuleBindings(this.editor.rootNode, PLUGIN_MODULE); + this.pluginNames = new Set(this.pluginBindings.map((binding) => binding.name)); + for (const moduleName of SIBLING_PLUGIN_MODULES) { + const named = this.collectNamedBindings(moduleName); + for (const binding of [...collectModuleBindings(this.editor.rootNode, moduleName), ...named]) { + if (this.siblingNameToModule.has(binding.name)) continue; + this.siblingBindings.push(binding); + this.siblingNameToModule.set(binding.name, moduleName); + } + } + this.configFileName = root.filename(); + this.fileSystem = fileSystem; + } + + run(): string | null { + // Taps on stages the native pipeline doesn't expose can't be carried over + // — leave such files for manual migration. + if (this.usesUnmappableHooks()) return 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); + for (const pair of pluginsPairs) this.transformPluginsPair(pair); + this.migrateHooks(); + this.planConfigInsertions(); + this.editor.finalizeRemovals(); + if (!this.editor.hasEdits) return null; + for (const binding of [...this.pluginBindings, ...this.siblingBindings]) { + if (this.repurposedStatements.has(binding.statement.range().start.index)) continue; + this.editor.removeBindingIfUnused(binding); + } + return this.editor.commit(); + } + + // `X.getHooks(...)` / `X.getCompilationHooks(...)` receivers on the plugin. + private pluginHookReceivers(): SgNode[] { + const receivers: SgNode[] = []; + for (const node of this.editor.rootNode.findAll({ rule: { kind: "member_expression" } })) { + const property = node.field("property")?.text(); + if (property !== "getHooks" && property !== "getCompilationHooks") continue; + const objectPart = node.field("object"); + if (objectPart && this.pluginNames.has(objectPart.text())) receivers.push(node); + } + return receivers; + } + + private usesUnmappableHooks(): boolean { + if (!this.pluginNames.size || !this.pluginHookReceivers().length) return false; + for (const node of this.editor.rootNode.findAll({ rule: { kind: "property_identifier" } })) { + if (UNMAPPABLE_HOOKS.has(node.text())) return true; + } + return false; + } + + private planFor(config: SgNode): ConfigPlan { + const key = config.range().start.index; + let plan = this.configPlans.get(key); + if (!plan) { + plan = { + config, + commentLines: [], + htmlEnabled: false, + htmlProps: [], + htmlFilename: null, + outputProps: [], + experimentsProps: [], + pendingEntry: null, + pluginMigratedHere: false, + }; + this.configPlans.set(key, plan); + } + return plan; + } + + private describeLost(name: string): string { + const hint = LOST_OPTION_HINTS.get(name); + return hint ? `${name} (${hint})` : name; + } + + private requireExperimentsHtml(plan: ConfigPlan): void { + if (!plan.experimentsProps.some((prop) => prop.name === "html")) { + plan.experimentsProps.push({ name: "html", valueText: "true" }); + } + } + + // `scriptLoading: "module"` means ESM output — a config-wide switch. + private applyModuleScripts(plan: ConfigPlan, moduleScripts: boolean): void { + if (!moduleScripts) return; + if (!plan.outputProps.some((prop) => prop.name === "module")) { + plan.outputProps.push({ name: "module", valueText: "true" }); + } + if (!plan.experimentsProps.some((prop) => prop.name === "outputModule")) { + plan.experimentsProps.push({ name: "outputModule", valueText: "true" }); + } + } + + // ---------- module.rules (html-loader) ---------- + + // A `use` entry replaceable by native HTML, 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)); + } + return loaderNameOf(node) === LOADER_NAME; + } + + // Translate each html-loader option into the surviving rule's `parser` when + // native HTML has an equivalent; everything else lands in `lost`. + private collectLoaderFindings(node: SgNode, findings: LoaderFindings): void { + const branches = guardBranchesOf(node); + if (branches) { + for (const branch of branches) this.collectLoaderFindings(branch, findings); + return; + } + if (node.kind() !== "object" || loaderNameOf(node) !== LOADER_NAME) return; + const optionsValue = findPair(node, "options")?.field("value"); + if (!optionsValue) return; + if (optionsValue.kind() !== "object") { + findings.lost.push(`${LOADER_NAME}.options`); + return; + } + for (const optionPair of pairsOf(optionsValue)) { + const name = keyName(optionPair); + const value = optionPair.field("value"); + if (name !== null && DROPPABLE_LOADER_OPTIONS.has(name)) continue; + if (name === "minimize" && value) { + // Custom minifier settings don't carry over; booleans do (production + // minification via optimization.minimizer is the native behavior). + if (value.kind() !== "true" && value.kind() !== "false") { + findings.lost.push(this.describeLost(`${LOADER_NAME}.minimize`)); + } + continue; + } + if (name === "sources" && value) { + // Booleans map to the rule's parser; source lists need a human. + if (value.kind() === "true" || value.kind() === "false") { + findings.parserProps.push({ name: "sources", valueText: value.text() }); + } else { + findings.lost.push(this.describeLost(`${LOADER_NAME}.sources`)); + } + continue; + } + findings.lost.push(this.describeLost(`${LOADER_NAME}.${name ?? "options"}`)); + } + } + + // Webpack owns the rule when its array hangs on a `rules`/`oneOf` pair or + // the config has a `module` ancestor — never fragments pushed into another + // tool's config (Storybook, craco, …). + private isWebpackRuleContext(pair: SgNode, arrayNode: SgNode): boolean { + const owner = arrayNode.parent(); + if (owner && owner.kind() === "pair") { + const name = keyName(owner); + if (name === "rules" || name === "oneOf") return true; + } + return findConfigObjectFor(pair) !== null; + } + + // Trivial html-loader rules are dropped (the `experiments.html: "auto"` + // default takes over); surviving rules get `type: "html"` and turn the + // default off, so `experiments.html: true` is added to their config. + private transformRules(usePairs: SgNode[], loaderPairs: SgNode[]): void { + 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 (template compilers, custom ones) stays in front. + const kept = elements.filter((element) => !this.isRemovableUseElement(element)); + if (kept.length === elements.length) continue; + const ruleObject = pair.parent(); + if (!ruleObject || ruleObject.kind() !== "object") continue; + if (!this.isWebpackRuleContext(pair, ruleObject.parent() ?? ruleObject)) continue; + const findings: LoaderFindings = { lost: [], parserProps: [] }; + for (const element of elements) this.collectLoaderFindings(element, findings); + const trivial = pairsOf(ruleObject).every((rulePair) => { + const name = keyName(rulePair); + return name === "test" || name === "use"; + }); + this.applyRuleMigration({ + pair, + ruleObject, + kept, + filterSuffix: filterSuffixOf(originalValue, value), + findings, + trivial, + optionsPair: null, + }); + } + // Rule-level `loader:`/`options:` shorthand. + 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; + if (!this.isWebpackRuleContext(pair, ruleObject.parent() ?? ruleObject)) continue; + const findings: LoaderFindings = { lost: [], parserProps: [] }; + this.collectLoaderFindings(ruleObject, findings); + const trivial = pairsOf(ruleObject).every((rulePair) => { + const name = keyName(rulePair); + return name === "test" || name === "loader" || name === "options"; + }); + this.applyRuleMigration({ + pair, + ruleObject, + kept: [], + filterSuffix: "", + findings, + trivial, + optionsPair: findPair(ruleObject, "options") ?? null, + }); + } + } + + private applyRuleMigration(swap: { + pair: SgNode; + ruleObject: SgNode; + kept: SgNode[]; + filterSuffix: string; + findings: LoaderFindings; + trivial: boolean; + optionsPair: SgNode | null; + }): void { + const survives = + !swap.trivial || + swap.kept.length > 0 || + swap.findings.lost.length > 0 || + swap.findings.parserProps.length > 0; + if (!survives) { + this.editor.markForRemoval(cascadeRemovalTarget(swap.ruleObject)); + return; + } + 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.findings.lost.length) { + const message = `Removed loader options without a native HTML equivalent: ${[...new Set(swap.findings.lost)].join(", ")}`; + commentPrefix = multiline ? `// ${message}\n${indent}` : `/* ${message} */ `; + } + const separator = multiline ? `,\n${indent}` : ", "; + let replacement = `${commentPrefix}`; + if (swap.kept.length) { + // Guarded entries keep the original `.filter(...)` for their falsy branch. + const keptTexts = swap.kept.map((loader) => loader.text()); + const keepsGuard = swap.kept.some((loader) => guardBranchesOf(loader) !== null); + const filterSuffix = keepsGuard ? swap.filterSuffix || ".filter(Boolean)" : ""; + replacement += `use: [${keptTexts.join(", ")}]${filterSuffix}${separator}`; + } + replacement += `type: "html"`; + if (swap.findings.parserProps.length) { + const texts = swap.findings.parserProps.map((prop) => `${prop.name}: ${prop.valueText}`); + replacement += `${separator}parser: { ${texts.join(", ")} }`; + } + this.editor.replace(swap.pair, replacement); + if (swap.optionsPair) this.editor.markForRemoval(swap.optionsPair); + // A surviving rule that matches `.html` turns the "auto" default off. + if (!ruleMatchesFiles(swap.ruleObject, HTML_SAMPLE_FILES)) return; + const config = findConfigObjectFor(swap.pair); + if (config) this.requireExperimentsHtml(this.planFor(config)); + } + + // ---------- plugins ---------- + + // 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"); + if (!constructorNode) return null; + if (this.pluginNames.has(constructorNode.text())) return element; + // `new (require("html-webpack-plugin"))(...)` without a binding. + const inner = + constructorNode.kind() === "parenthesized_expression" + ? (namedChildren(constructorNode)[0] ?? constructorNode) + : constructorNode; + return isRequireOf(inner, PLUGIN_MODULE) ? element : null; + } + + private transformPluginsPair(pluginsPair: SgNode): void { + const configObject = pluginsPair.parent(); + if (!configObject || configObject.kind() !== "object") return; + const value = unwrapFilterCall(pluginsPair.field("value") ?? pluginsPair); + if (value.kind() !== "array") return; + const elements = namedChildren(value); + const instances: { element: SgNode; instantiation: SgNode }[] = []; + for (const element of elements) { + const instantiation = this.pluginInstantiationOf(element); + if (instantiation) instances.push({ element, instantiation }); + } + if (!instances.length) return; + // Every options argument must be an object literal to be understood. + const optionObjects: (SgNode | undefined)[] = []; + for (const { instantiation } of instances) { + const argumentsNode = instantiation.field("arguments"); + const optionsObject = argumentsNode ? namedChildren(argumentsNode)[0] : undefined; + if (optionsObject && optionsObject.kind() !== "object") { + this.pluginRetained = true; + return; + } + optionObjects.push(optionsObject); + } + // Several instances (or a `chunks` list) mean per-entry pages instead of + // the global `output.html`. + const firstOptions = optionObjects[0]; + const chunkRestricted = + instances.length > 1 || + (firstOptions !== undefined && + findPair(firstOptions, "chunks")?.field("value")?.kind() === "array"); + if (chunkRestricted) { + this.migrateMultiPage(configObject, pluginsPair, elements, instances, optionObjects); + return; + } + const findings = this.collectFindings(firstOptions); + const plan = this.planFor(configObject); + const mode: PageMode = findings.templateValue !== null ? "template" : "single"; + if (findings.templateValue !== null) this.migrateEntry(configObject, findings); + else this.noteMultiPageEntry(configObject, findings); + this.mergeIntoPlan(configObject, findings); + const siblingRemoved = this.processSiblings(elements, [instances[0].element], mode, plan); + this.removeInstances(pluginsPair, elements, [instances[0].element, ...siblingRemoved]); + this.pluginMigrated = true; + } + + private removeInstances( + pluginsPair: SgNode, + elements: SgNode[], + removedElements: SgNode[], + ): void { + if (removedElements.length === elements.length) { + this.editor.markForRemoval(pluginsPair); + } else { + for (const element of removedElements) this.editor.markForRemoval(element); + } + } + + // ---------- companion plugins ---------- + + // Named bindings (`import { X }` / `const { X } = require(...)`) — e.g. + // webpack-subresource-integrity's named export, which the default-import + // resolution doesn't cover. Only single-name patterns, so removing the + // statement can never drop another binding. + private collectNamedBindings(moduleName: string): ModuleBinding[] { + const bindings: ModuleBinding[] = []; + for (const statement of this.editor.rootNode.findAll({ rule: { kind: "import_statement" } })) { + const source = statement.field("source"); + if (!source || unquote(source.text()) !== moduleName) continue; + const specifiers = statement.findAll({ rule: { kind: "import_specifier" } }); + if (specifiers.length !== 1) continue; + const local = specifiers[0].field("alias") ?? specifiers[0].field("name"); + if (local) bindings.push({ name: local.text(), statement }); + } + for (const kind of ["lexical_declaration", "variable_declaration"] as const) { + for (const statement of this.editor.rootNode.findAll({ rule: { kind } })) { + const declarators = namedChildren(statement).filter( + (child) => child.kind() === "variable_declarator", + ); + if (declarators.length !== 1) continue; + const pattern = declarators[0].field("name"); + const valueNode = declarators[0].field("value"); + if (!pattern || pattern.kind() !== "object_pattern") continue; + if (!valueNode || !isRequireOf(valueNode, moduleName)) continue; + const properties = namedChildren(pattern); + if (properties.length !== 1) continue; + const property = properties[0]; + if (property.kind() === "shorthand_property_identifier_pattern") { + bindings.push({ name: property.text(), statement }); + } else if (property.kind() === "pair_pattern") { + const local = property.field("value"); + if (local) bindings.push({ name: local.text(), statement }); + } + } + } + return bindings; + } + + private siblingInstantiationOf( + element: SgNode, + ): { instantiation: SgNode; module: string } | null { + const branches = guardBranchesOf(element); + if (branches) { + for (const branch of branches) { + const found = this.siblingInstantiationOf(branch); + if (found) return found; + } + return null; + } + if (element.kind() !== "new_expression") return null; + const constructorNode = element.field("constructor"); + const module = constructorNode + ? this.siblingNameToModule.get(constructorNode.text()) + : undefined; + return constructorNode && module ? { instantiation: element, module } : null; + } + + // Companion plugins piggybacked on html-webpack-plugin, so they only migrate + // (or make sense at all) next to a migrated instance. Returns the elements + // to remove alongside it. + private processSiblings( + elements: SgNode[], + hwpElements: SgNode[], + mode: PageMode, + plan: ConfigPlan, + ): SgNode[] { + const hwpStarts = new Set(hwpElements.map((element) => element.range().start.index)); + const removed: SgNode[] = []; + for (const element of elements) { + if (hwpStarts.has(element.range().start.index)) continue; + const sibling = this.siblingInstantiationOf(element); + if (!sibling) continue; + if (this.migrateSibling(sibling.module, sibling.instantiation, mode, plan)) { + removed.push(element); + } else { + plan.commentLines.push( + `Left ${sibling.module} in place — review it, its options could not be mapped to output.html`, + ); + } + } + return removed; + } + + private migrateSibling( + module: string, + instantiation: SgNode, + mode: PageMode, + plan: ConfigPlan, + ): boolean { + const argumentsNode = instantiation.field("arguments"); + const args = argumentsNode ? namedChildren(argumentsNode) : []; + const lost: string[] = []; + const props: HtmlProp[] = []; + if (module === CSP_MODULE) { + const policy = args[0]; + if (!policy) props.push({ name: "csp", valueText: "true" }); + else if (policy.kind() === "object") { + props.push({ name: "csp", valueText: `{ policy: ${policy.text()} }` }); + } else return false; + const options = args[1]; + if (options && options.kind() === "object") { + for (const optionPair of pairsOf(options)) lost.push(keyName(optionPair) ?? "options"); + } else if (options) { + lost.push("options"); + } + } else if (module === SRI_MODULE) { + let integrity = "true"; + let enabled = true; + const options = args[0]; + if (options && options.kind() !== "object") return false; + if (options) { + for (const optionPair of pairsOf(options)) { + const name = keyName(optionPair); + const value = optionPair.field("value"); + if (name === "hashFuncNames" && value?.kind() === "array") integrity = value.text(); + else if (name === "enabled" && value?.kind() === "false") enabled = false; + else if (name === "enabled") continue; + else lost.push(name ?? "options"); + } + } + if (enabled) props.push({ name: "integrity", valueText: integrity }); + } else { + // favicons-webpack-plugin — only the logo path maps (native `favicon`). + const argument = args[0]; + if (argument && argument.kind() === "string") { + props.push({ name: "favicon", valueText: argument.text() }); + } else if (argument && argument.kind() === "object") { + const logo = findPair(argument, "logo")?.field("value"); + if (!logo || logo.kind() !== "string") return false; + props.push({ name: "favicon", valueText: logo.text() }); + for (const optionPair of pairsOf(argument)) { + const name = keyName(optionPair); + if (name !== "logo") lost.push(name ?? "options"); + } + } else return false; + } + for (const prop of props) { + if (mode === "multi") { + // A global `output.html` would generate a page for every entry. + lost.push(`${prop.name} (set output.html.${prop.name} by hand if every entry gets a page)`); + } else if (mode === "template" && prop.name === "favicon") { + // Favicons are only injected into webpack-generated pages. + lost.push("favicon (add it to the template)"); + } else { + plan.htmlProps.push(prop); + } + } + if (lost.length) { + plan.commentLines.push( + `Removed ${module} options without a native HTML equivalent: ${[...new Set(lost)].join(", ")}`, + ); + } + return true; + } + + // One instance per page (`chunks: ["name"]`) maps to the entry descriptor + // `html` option; entries no instance claims get no page, so the global + // `output.html` stays off. Anything the shape can't express bails out. + private migrateMultiPage( + configObject: SgNode, + pluginsPair: SgNode, + elements: SgNode[], + instances: { element: SgNode; instantiation: SgNode }[], + optionObjects: (SgNode | undefined)[], + ): void { + const entryValue = findPair(configObject, "entry")?.field("value"); + if (!entryValue || entryValue.kind() !== "object") { + this.pluginRetained = true; + return; + } + const plan = this.planFor(configObject); + const pages: { entryDescriptor: SgNode; htmlValue: string }[] = []; + const lost: string[] = []; + let moduleScripts = false; + for (const options of optionObjects) { + // Each page needs exactly one owning entry in `chunks`. + const chunksValue = options ? findPair(options, "chunks")?.field("value") : undefined; + const chunkNames = chunksValue?.kind() === "array" ? namedChildren(chunksValue) : []; + if (!options || chunkNames.length !== 1 || chunkNames[0].kind() !== "string") { + this.pluginRetained = true; + return; + } + const chunkName = unquote(chunkNames[0].text()); + const entryDescriptor = findPair(entryValue, chunkName)?.field("value"); + if (!entryDescriptor) { + this.pluginRetained = true; + return; + } + if (findPair(options, "template") || findPair(options, "templateContent")) { + this.pluginRetained = true; + return; + } + const findings = this.collectFindings(options, MULTI_PAGE_SKIPPED_OPTIONS); + // The page filename must fit the shared `htmlFilename: "[name].html"`. + const filenameValue = findPair(options, "filename")?.field("value"); + if (filenameValue) { + const filename = filenameValue.kind() === "string" ? unquote(filenameValue.text()) : null; + if (filename !== `${chunkName}.html` && filename !== "[name].html") { + lost.push(`filename "${filename ?? "?"}" (output.htmlFilename is "[name].html")`); + } + } + lost.push(...findings.lost); + moduleScripts ||= findings.scriptLoadingModule; + const htmlValue = findings.htmlProps.length + ? `{ ${findings.htmlProps.map((prop) => `${prop.name}: ${prop.valueText}`).join(", ")} }` + : "true"; + pages.push({ entryDescriptor, htmlValue }); + } + const siblingRemoved = this.processSiblings( + elements, + instances.map((instance) => instance.element), + "multi", + plan, + ); + this.removeInstances(pluginsPair, elements, [ + ...instances.map((instance) => instance.element), + ...siblingRemoved, + ]); + for (const page of pages) { + const node = page.entryDescriptor; + if (node.kind() === "object") { + if (!findPair(node, "html")) { + this.editor.insertIntoObject(node, () => [`html: ${page.htmlValue}`]); + } + } else { + this.editor.replace(node, `{ import: ${node.text()}, html: ${page.htmlValue} }`); + } + } + plan.pluginMigratedHere = true; + this.requireExperimentsHtml(plan); + this.applyModuleScripts(plan, moduleScripts); + plan.htmlFilename ??= '"[name].html"'; + if (lost.length) { + plan.commentLines.push( + `Removed html-webpack-plugin options without a native HTML equivalent: ${[...new Set(lost)].join(", ")}`, + ); + } + this.pluginMigrated = true; + } + + // ---------- compilation hooks ---------- + + // Retarget `HtmlWebpackPlugin.getHooks(...)` taps to the native + // `HtmlModulesPlugin.getCompilationHooks(...)` stages. Only done when this + // file's plugin setup was actually migrated and no instance survives. + private migrateHooks(): void { + if (!this.pluginMigrated || this.pluginRetained) return; + const receivers = this.pluginHookReceivers(); + if (!receivers.length) return; + const nativeReceiver = this.nativeHooksReceiver(); + for (const node of receivers) { + this.editor.replace(node, `${nativeReceiver}.getCompilationHooks`); + } + const rootNode = this.editor.rootNode; + // One review comment per statement holding a renamed tap. + const commentedStatements = new Set(); + for (const property of rootNode.findAll({ rule: { kind: "property_identifier" } })) { + const renamed = HOOK_RENAMES.get(property.text()); + if (!renamed) continue; + this.editor.replace(property, renamed); + const statement = this.statementOf(property); + const review = HOOK_REVIEW_COMMENTS.get(renamed); + if (!statement || !review) continue; + const key = `${statement.range().start.index}:${renamed}`; + if (commentedStatements.has(key)) continue; + commentedStatements.add(key); + const lineStart = + this.editor.source.lastIndexOf("\n", statement.range().start.index - 1) + 1; + const indent = lineIndent(this.editor.source, statement.range().start.index); + this.editor.addEdit({ + startPos: lineStart, + endPos: lineStart, + insertedText: `${indent}// Review: ${review}\n`, + }); + } + for (const shorthand of rootNode.findAll({ + rule: { kind: "shorthand_property_identifier_pattern" }, + })) { + const renamed = HOOK_RENAMES.get(shorthand.text()); + // Keep the local variable name; only the destructured key changes. + if (renamed) this.editor.replace(shorthand, `${renamed}: ${shorthand.text()}`); + } + } + + // The statement carrying a node, for placing a comment line above it. + private statementOf(node: SgNode): SgNode | null { + let current: SgNode | null = node; + while (current) { + const parent: SgNode | null = current.parent(); + if (!parent) return null; + const kind = parent.kind(); + if (kind === "statement_block" || kind === "program" || kind === "class_body") { + return current; + } + current = parent; + } + return null; + } + + // Existing webpack binding, or an `html` 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}.html.HtmlModulesPlugin`; + const binding = this.pluginBindings[0]; + if (binding) { + const isEsm = binding.statement.kind() === "import_statement"; + this.editor.replace( + binding.statement, + isEsm ? 'import { html } from "webpack";' : 'const { html } = require("webpack");', + ); + this.repurposedStatements.add(binding.statement.range().start.index); + } else { + const edit = addImport(this.editor.rootNode as SgNode, { + type: "named", + specifiers: [{ name: "html" }], + from: "webpack", + moduleType: "cjs", + }); + if (edit) this.editor.addEdit(edit); + } + return "html.HtmlModulesPlugin"; + } + + // ---------- option mapping ---------- + + private collectFindings( + optionsObject: SgNode | undefined, + skippedOptions?: Set, + ): InstanceFindings { + const findings: InstanceFindings = { + htmlProps: [], + htmlFilename: null, + templateValue: null, + lost: [], + notes: [], + pendingEntry: null, + scriptLoadingModule: false, + }; + if (!optionsObject) return findings; + const templateValue = findPair(optionsObject, "template")?.field("value"); + const templateMode = Boolean(templateValue); + if (templateValue) { + if (templateValue.kind() === "string") findings.templateValue = templateValue.text(); + else findings.lost.push("template"); + } + for (const optionPair of pairsOf(optionsObject)) { + const name = keyName(optionPair); + const optionValue = optionPair.field("value"); + if (!name || !optionValue) { + findings.lost.push("options"); + continue; + } + if (name === "template" || DROPPABLE_OPTIONS.has(name)) continue; + if (skippedOptions && skippedOptions.has(name)) continue; + this.collectOption(name, optionValue, templateMode, findings); + } + return findings; + } + + private collectOption( + name: string, + value: SgNode, + templateMode: boolean, + findings: InstanceFindings, + ): void { + const literal = value.kind() === "string" ? unquote(value.text()) : null; + // Head tags are only injected into webpack-generated pages, never into an + // authored template — there the tag belongs in the template itself. + if (HEAD_TAG_OPTIONS.has(name) && templateMode) { + findings.lost.push(`${name} (add it to the template)`); + return; + } + switch (name) { + case "filename": + if (value.kind() === "string") findings.htmlFilename = value.text(); + else findings.lost.push(this.describeLost(name)); + break; + case "title": + case "favicon": + case "base": + findings.htmlProps.push({ name, valueText: value.text() }); + break; + case "meta": + this.collectMetaOption(value, findings); + break; + case "inject": + // `true` is the native default placement; the template authors its own tags. + if (templateMode || value.kind() === "true") break; + if (value.kind() === "false" || literal === "body" || literal === "head") { + findings.htmlProps.push({ + name, + valueText: value.kind() === "false" ? "false" : `"${literal}"`, + }); + } else { + findings.lost.push(this.describeLost(name)); + } + break; + case "scriptLoading": + // Native `"auto"` already defers classic scripts. + if (literal === "defer") break; + if (templateMode) { + if (literal === "module") { + findings.lost.push( + 'scriptLoading (enable experiments.outputModule and use (added automatically when the template was found on disk)` + : `The template is now the entry: reference the previous entry (webpack's default is ./src/index.js) from it, e.g. `, + }; + return; + } + const entryValue = entryPair.field("value"); + const replaceable = entryValue ? this.replaceableEntryValue(entryValue) : null; + if (!replaceable) { + findings.lost.push("template (make the template an .html entry that loads your JS)"); + return; + } + const injected = this.injectScriptIntoTemplate(templateValue, unquote(replaceable.text())); + const comment = injected + ? `The template is now the entry: it must load the previous entry via (added automatically when the template was found on disk)` + : `The template is now the entry: reference the previous entry from it, e.g. `; + const multiline = configObject.text().includes("\n"); + if (multiline) { + const indent = lineIndent(this.editor.source, entryPair.range().start.index); + this.editor.addEdit({ + startPos: entryPair.range().start.index, + endPos: entryPair.range().start.index, + insertedText: `// ${comment}\n${indent}`, + }); + this.editor.replace(replaceable, findings.templateValue as string); + } else { + this.editor.replace(replaceable, `/* ${comment} */ ${findings.templateValue}`); + } + } + + // Add a ``), + ); + } catch { + // Unreachable template — the review comment covers the manual step. + } + } + + // First candidate form the sandboxed runtime lets us read. + private readFirst( + fs: FsModule, + candidates: string[], + ): { path: string; content: string } | null { + for (const candidate of candidates) { + try { + return { path: candidate, content: fs.readFileSync(candidate, "utf8") }; + } catch { + // Try the next path form. + } + } + return null; + } + + // A string entry value the template path can take over: the value itself, a + // single-element array, or a single-key object's string value. + private replaceableEntryValue(entryValue: SgNode): SgNode | null { + if (entryValue.kind() === "string") return entryValue; + if (entryValue.kind() === "array") { + const elements = namedChildren(entryValue); + return elements.length === 1 && elements[0].kind() === "string" ? elements[0] : null; + } + if (entryValue.kind() === "object") { + const entryPairs = pairsOf(entryValue); + if (entryPairs.length !== 1) return null; + const inner = entryPairs[0].field("value"); + return inner && inner.kind() === "string" ? inner : null; + } + return null; + } + + // ---------- config-level insertions ---------- + + private mergeIntoPlan(configObject: SgNode, findings: InstanceFindings): void { + const plan = this.planFor(configObject); + plan.pluginMigratedHere = true; + this.requireExperimentsHtml(plan); + if (findings.lost.length) { + plan.commentLines.push( + `Removed html-webpack-plugin options without a native HTML equivalent: ${[...new Set(findings.lost)].join(", ")}`, + ); + } + plan.commentLines.push(...findings.notes); + if (findings.templateValue === null) plan.htmlEnabled = true; + plan.htmlProps.push(...findings.htmlProps); + this.applyModuleScripts(plan, findings.scriptLoadingModule); + // The plugin emitted `index.html` by default; native defaults to `[name].html`. + plan.htmlFilename ??= findings.htmlFilename ?? '"index.html"'; + plan.pendingEntry = findings.pendingEntry; + } + + private planConfigInsertions(): void { + for (const plan of this.configPlans.values()) { + const topProperties: ((indent: string, unit: string) => string)[] = []; + const pendingEntry = plan.pendingEntry; + if (pendingEntry) { + topProperties.push( + (indent) => `// ${pendingEntry.comment}\n${indent}entry: ${pendingEntry.text}`, + ); + } + const outputProps: HtmlProp[] = []; + const htmlProps = dedupeProps(plan.htmlProps); + if (plan.htmlEnabled || htmlProps.length) { + outputProps.push({ + name: "html", + valueText: htmlProps.length + ? `{ ${htmlProps.map((prop) => `${prop.name}: ${prop.valueText}`).join(", ")} }` + : "true", + }); + } + if (plan.htmlFilename !== null) { + outputProps.push({ name: "htmlFilename", valueText: plan.htmlFilename }); + } + outputProps.push(...plan.outputProps); + if (outputProps.length) { + this.planObjectProps(plan.config, "output", outputProps, plan.commentLines, topProperties); + } + if (plan.experimentsProps.length) { + this.planObjectProps( + plan.config, + "experiments", + plan.experimentsProps, + outputProps.length ? [] : plan.commentLines, + topProperties, + ); + } + 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)), + ); + } + } + } + + // Insert props into the config's `key` object, creating it when absent; an + // existing non-object value (e.g. a variable) is left alone. Comment lines + // ride on the first inserted property. + private planObjectProps( + config: SgNode, + key: string, + props: HtmlProp[], + commentLines: string[], + topProperties: ((indent: string, unit: string) => string)[], + ): void { + const withComments = (propertyText: string, indent: string, first: boolean): string => { + if (!first || !commentLines.length) return propertyText; + return `${commentLines.map((line) => `// ${line}\n${indent}`).join("")}${propertyText}`; + }; + const value = findPair(config, key)?.field("value"); + if (value && value.kind() === "object") { + const missing = props.filter((prop) => !findPair(value, prop.name)); + if (!missing.length) return; + this.editor.insertIntoObject(value, (indent) => + missing.map((prop, index) => + withComments(`${prop.name}: ${prop.valueText}`, indent, index === 0), + ), + ); + } else if (!value) { + topProperties.push((indent, unit) => { + const inner = indent + unit; + const texts = props.map((prop, index) => + withComments(`${prop.name}: ${prop.valueText}`, inner, index === 0), + ); + return `${key}: {\n${texts.map((text) => `${inner}${text}`).join(",\n")},\n${indent}}`; + }); + } + } +} + +async function transform(root: SgRoot): Promise { + let fileSystem: FsModule | null = null; + try { + fileSystem = await import("node:fs"); + } catch { + // No filesystem access — template edits fall back to review comments. + } + return new HtmlMigration(root, fileSystem).run(); +} + +export default transform; diff --git a/codemods/html-plugins-to-native-html/tests/basic/expected.js b/codemods/html-plugins-to-native-html/tests/basic/expected.js new file mode 100644 index 0000000..aaab0a3 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/basic/expected.js @@ -0,0 +1,16 @@ +const { DefinePlugin } = require("webpack"); + +module.exports = { + experiments: { + html: true, + }, + entry: "./src/index.js", + output: { + html: true, + htmlFilename: "index.html", + filename: "[name].js", + }, + plugins: [ + new DefinePlugin({ DEBUG: "false" }), + ], +}; diff --git a/codemods/html-plugins-to-native-html/tests/basic/input.js b/codemods/html-plugins-to-native-html/tests/basic/input.js new file mode 100644 index 0000000..4dfe698 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/basic/input.js @@ -0,0 +1,13 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +const { DefinePlugin } = require("webpack"); + +module.exports = { + entry: "./src/index.js", + output: { + filename: "[name].js", + }, + plugins: [ + new HtmlWebpackPlugin(), + new DefinePlugin({ DEBUG: "false" }), + ], +}; diff --git a/codemods/html-plugins-to-native-html/tests/esm/expected.mjs b/codemods/html-plugins-to-native-html/tests/esm/expected.mjs new file mode 100644 index 0000000..4c6af74 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/esm/expected.mjs @@ -0,0 +1,10 @@ +export default { + output: { + html: { title: "App" }, + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, + entry: "./src/index.js", +}; diff --git a/codemods/html-plugins-to-native-html/tests/esm/input.mjs b/codemods/html-plugins-to-native-html/tests/esm/input.mjs new file mode 100644 index 0000000..c84787b --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/esm/input.mjs @@ -0,0 +1,6 @@ +import HtmlWebpackPlugin from "html-webpack-plugin"; + +export default { + entry: "./src/index.js", + plugins: [new HtmlWebpackPlugin({ title: "App" })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/function-config/expected.js b/codemods/html-plugins-to-native-html/tests/function-config/expected.js new file mode 100644 index 0000000..ecaa64f --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/function-config/expected.js @@ -0,0 +1,10 @@ +module.exports = (env) => ({ + output: { + html: true, + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, + entry: "./src/index.js", +}); diff --git a/codemods/html-plugins-to-native-html/tests/function-config/input.js b/codemods/html-plugins-to-native-html/tests/function-config/input.js new file mode 100644 index 0000000..7e18221 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/function-config/input.js @@ -0,0 +1,6 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +module.exports = (env) => ({ + entry: "./src/index.js", + plugins: [new HtmlWebpackPlugin()], +}); diff --git a/codemods/html-plugins-to-native-html/tests/guarded-filter/expected.js b/codemods/html-plugins-to-native-html/tests/guarded-filter/expected.js new file mode 100644 index 0000000..cde6509 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/guarded-filter/expected.js @@ -0,0 +1,15 @@ +const { DefinePlugin } = require("webpack"); + +const isProd = process.env.NODE_ENV === "production"; + +module.exports = { + output: { + html: true, + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, + entry: "./src/index.js", + plugins: [new DefinePlugin({ DEBUG: "false" })].filter(Boolean), +}; diff --git a/codemods/html-plugins-to-native-html/tests/guarded-filter/input.js b/codemods/html-plugins-to-native-html/tests/guarded-filter/input.js new file mode 100644 index 0000000..de3d6ed --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/guarded-filter/input.js @@ -0,0 +1,9 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +const { DefinePlugin } = require("webpack"); + +const isProd = process.env.NODE_ENV === "production"; + +module.exports = { + entry: "./src/index.js", + plugins: [isProd && new HtmlWebpackPlugin(), new DefinePlugin({ DEBUG: "false" })].filter(Boolean), +}; diff --git a/codemods/html-plugins-to-native-html/tests/hooks-migrated/expected.js b/codemods/html-plugins-to-native-html/tests/hooks-migrated/expected.js new file mode 100644 index 0000000..cfb76f3 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/hooks-migrated/expected.js @@ -0,0 +1,21 @@ +const { html } = require("webpack"); + +class MyPlugin { + apply(compiler) { + compiler.hooks.compilation.tap("MyPlugin", (compilation) => { + // Review: transformHtml receives (html, { outputName }) and must return the html string + html.HtmlModulesPlugin.getCompilationHooks(compilation).transformHtml.tapAsync("MyPlugin", (data, callback) => callback(null, data)); + }); + } +} + +module.exports = { + output: { + html: true, + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, + plugins: [new MyPlugin()], +}; diff --git a/codemods/html-plugins-to-native-html/tests/hooks-migrated/input.js b/codemods/html-plugins-to-native-html/tests/hooks-migrated/input.js new file mode 100644 index 0000000..26ab525 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/hooks-migrated/input.js @@ -0,0 +1,13 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +class MyPlugin { + apply(compiler) { + compiler.hooks.compilation.tap("MyPlugin", (compilation) => { + HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tapAsync("MyPlugin", (data, callback) => callback(null, data)); + }); + } +} + +module.exports = { + plugins: [new HtmlWebpackPlugin(), new MyPlugin()], +}; diff --git a/codemods/html-plugins-to-native-html/tests/hooks-unmappable-untouched/expected.js b/codemods/html-plugins-to-native-html/tests/hooks-unmappable-untouched/expected.js new file mode 100644 index 0000000..52f7d86 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/hooks-unmappable-untouched/expected.js @@ -0,0 +1,13 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +class MyPlugin { + apply(compiler) { + compiler.hooks.compilation.tap("MyPlugin", (compilation) => { + HtmlWebpackPlugin.getHooks(compilation).afterTemplateExecution.tapAsync("MyPlugin", (data, callback) => callback(null, data)); + }); + } +} + +module.exports = { + plugins: [new HtmlWebpackPlugin(), new MyPlugin()], +}; diff --git a/codemods/html-plugins-to-native-html/tests/hooks-unmappable-untouched/input.js b/codemods/html-plugins-to-native-html/tests/hooks-unmappable-untouched/input.js new file mode 100644 index 0000000..52f7d86 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/hooks-unmappable-untouched/input.js @@ -0,0 +1,13 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +class MyPlugin { + apply(compiler) { + compiler.hooks.compilation.tap("MyPlugin", (compilation) => { + HtmlWebpackPlugin.getHooks(compilation).afterTemplateExecution.tapAsync("MyPlugin", (data, callback) => callback(null, data)); + }); + } +} + +module.exports = { + plugins: [new HtmlWebpackPlugin(), new MyPlugin()], +}; diff --git a/codemods/html-plugins-to-native-html/tests/inline-require/expected.js b/codemods/html-plugins-to-native-html/tests/inline-require/expected.js new file mode 100644 index 0000000..63dcf80 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/inline-require/expected.js @@ -0,0 +1,10 @@ +module.exports = { + output: { + html: { title: "App" }, + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, + entry: "./src/index.js", +}; diff --git a/codemods/html-plugins-to-native-html/tests/inline-require/input.js b/codemods/html-plugins-to-native-html/tests/inline-require/input.js new file mode 100644 index 0000000..3f3270b --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/inline-require/input.js @@ -0,0 +1,4 @@ +module.exports = { + entry: "./src/index.js", + plugins: [new (require("html-webpack-plugin"))({ title: "App" })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-only/expected.js b/codemods/html-plugins-to-native-html/tests/loader-only/expected.js new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-only/expected.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-only/input.js b/codemods/html-plugins-to-native-html/tests/loader-only/input.js new file mode 100644 index 0000000..b3e1a7f --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-only/input.js @@ -0,0 +1,10 @@ +module.exports = { + module: { + rules: [ + { + test: /\.html$/i, + use: ["html-loader"], + }, + ], + }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-preprocessor/expected.js b/codemods/html-plugins-to-native-html/tests/loader-preprocessor/expected.js new file mode 100644 index 0000000..d8ea33c --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-preprocessor/expected.js @@ -0,0 +1,12 @@ +module.exports = { + module: { + rules: [ + { + test: /\.md$/, + // Removed loader options without a native HTML equivalent: html-loader.preprocessor (move it to the rule's parser.template — synchronous (source, { module, resource }) => string), html-loader.postprocessor (tap HtmlModulesPlugin.getCompilationHooks(compilation).transformHtml for emitted pages), html-loader.minimize (customize via optimization.minimizer (minimizer-webpack-plugin)) + use: ["markdown-loader"], + type: "html", + }, + ], + }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-preprocessor/input.js b/codemods/html-plugins-to-native-html/tests/loader-preprocessor/input.js new file mode 100644 index 0000000..872234f --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-preprocessor/input.js @@ -0,0 +1,20 @@ +module.exports = { + module: { + rules: [ + { + test: /\.md$/, + use: [ + { + loader: "html-loader", + options: { + preprocessor: (content) => content, + postprocessor: (content) => content, + minimize: { removeComments: true }, + }, + }, + "markdown-loader", + ], + }, + ], + }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-with-options/expected.js b/codemods/html-plugins-to-native-html/tests/loader-with-options/expected.js new file mode 100644 index 0000000..20d5956 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-with-options/expected.js @@ -0,0 +1,14 @@ +module.exports = { + experiments: { + html: true, + }, + module: { + rules: [ + { + test: /\.html$/i, + type: "html", + parser: { sources: false }, + }, + ], + }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-with-options/input.js b/codemods/html-plugins-to-native-html/tests/loader-with-options/input.js new file mode 100644 index 0000000..21ee454 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-with-options/input.js @@ -0,0 +1,14 @@ +module.exports = { + module: { + rules: [ + { + test: /\.html$/i, + loader: "html-loader", + options: { + sources: false, + esModule: true, + }, + }, + ], + }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/lost-options/expected.js b/codemods/html-plugins-to-native-html/tests/lost-options/expected.js new file mode 100644 index 0000000..323c8e0 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/lost-options/expected.js @@ -0,0 +1,15 @@ +module.exports = { + output: { + // Removed html-webpack-plugin options without a native HTML equivalent: hash (use [contenthash] in output.htmlFilename), minify (production HTML is minified by default; customize via optimization.minimizer (minimizer-webpack-plugin)), publicPath (set output.publicPath) + // output.html emits one page per entry; html-webpack-plugin emitted a single page with every chunk + html: true, + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, + entry: { + app: "./src/app.js", + admin: "./src/admin.js", + }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/lost-options/input.js b/codemods/html-plugins-to-native-html/tests/lost-options/input.js new file mode 100644 index 0000000..4a8ac23 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/lost-options/input.js @@ -0,0 +1,15 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +module.exports = { + entry: { + app: "./src/app.js", + admin: "./src/admin.js", + }, + plugins: [ + new HtmlWebpackPlugin({ + hash: true, + minify: { collapseWhitespace: true }, + publicPath: "/static/", + }), + ], +}; diff --git a/codemods/html-plugins-to-native-html/tests/module-scripts/expected.js b/codemods/html-plugins-to-native-html/tests/module-scripts/expected.js new file mode 100644 index 0000000..b16ec70 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/module-scripts/expected.js @@ -0,0 +1,13 @@ +module.exports = { + output: { + // Removed html-webpack-plugin options without a native HTML equivalent: meta.refresh + html: { meta: { viewport: "width=device-width, initial-scale=1", "og:title": "My App" } }, + htmlFilename: "index.html", + module: true, + }, + experiments: { + html: true, + outputModule: true, + }, + entry: "./src/index.js", +}; diff --git a/codemods/html-plugins-to-native-html/tests/module-scripts/input.js b/codemods/html-plugins-to-native-html/tests/module-scripts/input.js new file mode 100644 index 0000000..53afccb --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/module-scripts/input.js @@ -0,0 +1,15 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +module.exports = { + entry: "./src/index.js", + plugins: [ + new HtmlWebpackPlugin({ + scriptLoading: "module", + meta: { + viewport: "width=device-width, initial-scale=1", + "og:title": { property: "og:title", content: "My App" }, + refresh: { "http-equiv": "refresh", content: "30" }, + }, + }), + ], +}; diff --git a/codemods/html-plugins-to-native-html/tests/multi-chunk-untouched/expected.js b/codemods/html-plugins-to-native-html/tests/multi-chunk-untouched/expected.js new file mode 100644 index 0000000..92a9507 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/multi-chunk-untouched/expected.js @@ -0,0 +1,9 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +module.exports = { + entry: { a: "./src/a.js", b: "./src/b.js" }, + plugins: [ + new HtmlWebpackPlugin({ filename: "a.html", chunks: ["a", "b"] }), + new HtmlWebpackPlugin({ template: "./b.html", chunks: ["b"] }), + ], +}; diff --git a/codemods/html-plugins-to-native-html/tests/multi-chunk-untouched/input.js b/codemods/html-plugins-to-native-html/tests/multi-chunk-untouched/input.js new file mode 100644 index 0000000..92a9507 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/multi-chunk-untouched/input.js @@ -0,0 +1,9 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +module.exports = { + entry: { a: "./src/a.js", b: "./src/b.js" }, + plugins: [ + new HtmlWebpackPlugin({ filename: "a.html", chunks: ["a", "b"] }), + new HtmlWebpackPlugin({ template: "./b.html", chunks: ["b"] }), + ], +}; diff --git a/codemods/html-plugins-to-native-html/tests/multi-page-options/expected.js b/codemods/html-plugins-to-native-html/tests/multi-page-options/expected.js new file mode 100644 index 0000000..fa55f6b --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/multi-page-options/expected.js @@ -0,0 +1,12 @@ +module.exports = { + output: { + htmlFilename: "[name].html", + }, + experiments: { + html: true, + }, + entry: { + app: { import: "./src/app.js", html: { title: "App" } }, + admin: { import: "./src/admin.js", html: { title: "Admin", inject: "head" } }, + }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/multi-page-options/input.js b/codemods/html-plugins-to-native-html/tests/multi-page-options/input.js new file mode 100644 index 0000000..43888f0 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/multi-page-options/input.js @@ -0,0 +1,12 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +module.exports = { + entry: { + app: "./src/app.js", + admin: "./src/admin.js", + }, + plugins: [ + new HtmlWebpackPlugin({ chunks: ["app"], filename: "app.html", title: "App" }), + new HtmlWebpackPlugin({ chunks: ["admin"], filename: "admin.html", title: "Admin", inject: "head" }), + ], +}; diff --git a/codemods/html-plugins-to-native-html/tests/multi-page/expected.js b/codemods/html-plugins-to-native-html/tests/multi-page/expected.js new file mode 100644 index 0000000..8b4a381 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/multi-page/expected.js @@ -0,0 +1,9 @@ +module.exports = { + output: { + htmlFilename: "[name].html", + }, + experiments: { + html: true, + }, + entry: { a: { import: "./src/a.js", html: true }, b: { import: "./src/b.js", html: true } }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/multi-page/input.js b/codemods/html-plugins-to-native-html/tests/multi-page/input.js new file mode 100644 index 0000000..c2c374c --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/multi-page/input.js @@ -0,0 +1,9 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +module.exports = { + entry: { a: "./src/a.js", b: "./src/b.js" }, + plugins: [ + new HtmlWebpackPlugin({ filename: "a.html", chunks: ["a"] }), + new HtmlWebpackPlugin({ filename: "b.html", chunks: ["b"] }), + ], +}; diff --git a/codemods/html-plugins-to-native-html/tests/plugin-and-loader/expected.js b/codemods/html-plugins-to-native-html/tests/plugin-and-loader/expected.js new file mode 100644 index 0000000..0ee646a --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/plugin-and-loader/expected.js @@ -0,0 +1,10 @@ +module.exports = { + output: { + html: true, + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, + entry: "./src/index.js", +}; diff --git a/codemods/html-plugins-to-native-html/tests/plugin-and-loader/input.js b/codemods/html-plugins-to-native-html/tests/plugin-and-loader/input.js new file mode 100644 index 0000000..56f4ddf --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/plugin-and-loader/input.js @@ -0,0 +1,14 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +module.exports = { + entry: "./src/index.js", + module: { + rules: [ + { + test: /\.html$/i, + use: ["html-loader"], + }, + ], + }, + plugins: [new HtmlWebpackPlugin()], +}; diff --git a/codemods/html-plugins-to-native-html/tests/remove-dependencies/removes/expected.json b/codemods/html-plugins-to-native-html/tests/remove-dependencies/removes/expected.json new file mode 100644 index 0000000..829eb92 --- /dev/null +++ b/codemods/html-plugins-to-native-html/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/html-plugins-to-native-html/tests/remove-dependencies/removes/input.json b/codemods/html-plugins-to-native-html/tests/remove-dependencies/removes/input.json new file mode 100644 index 0000000..387e01b --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/remove-dependencies/removes/input.json @@ -0,0 +1,14 @@ +{ + "name": "app", + "dependencies": { + "react": "^18.0.0" + }, + "devDependencies": { + "csp-html-webpack-plugin": "^5.1.0", + "favicons-webpack-plugin": "^6.0.0", + "html-loader": "^5.1.0", + "html-webpack-plugin": "^5.6.0", + "webpack": "^5.109.0", + "webpack-subresource-integrity": "^5.2.0" + } +} diff --git a/codemods/html-plugins-to-native-html/tests/remove-dependencies/untouched/expected.json b/codemods/html-plugins-to-native-html/tests/remove-dependencies/untouched/expected.json new file mode 100644 index 0000000..111e5ea --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/remove-dependencies/untouched/expected.json @@ -0,0 +1,6 @@ +{ + "name": "app", + "dependencies": { + "react": "^18.0.0" + } +} diff --git a/codemods/html-plugins-to-native-html/tests/remove-dependencies/untouched/input.json b/codemods/html-plugins-to-native-html/tests/remove-dependencies/untouched/input.json new file mode 100644 index 0000000..111e5ea --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/remove-dependencies/untouched/input.json @@ -0,0 +1,6 @@ +{ + "name": "app", + "dependencies": { + "react": "^18.0.0" + } +} diff --git a/codemods/html-plugins-to-native-html/tests/sibling-plugins/expected.js b/codemods/html-plugins-to-native-html/tests/sibling-plugins/expected.js new file mode 100644 index 0000000..b891deb --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/sibling-plugins/expected.js @@ -0,0 +1,15 @@ +const { DefinePlugin } = require("webpack"); + +module.exports = { + output: { + html: { title: "App", csp: { policy: { "script-src": ["'self'"] } }, integrity: ["sha384"], favicon: "./src/logo.png" }, + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, + entry: "./src/index.js", + plugins: [ + new DefinePlugin({ DEBUG: "false" }), + ], +}; diff --git a/codemods/html-plugins-to-native-html/tests/sibling-plugins/input.js b/codemods/html-plugins-to-native-html/tests/sibling-plugins/input.js new file mode 100644 index 0000000..aa33ac1 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/sibling-plugins/input.js @@ -0,0 +1,16 @@ +const { DefinePlugin } = require("webpack"); +const HtmlWebpackPlugin = require("html-webpack-plugin"); +const CspHtmlWebpackPlugin = require("csp-html-webpack-plugin"); +const FaviconsWebpackPlugin = require("favicons-webpack-plugin"); +const { SubresourceIntegrityPlugin } = require("webpack-subresource-integrity"); + +module.exports = { + entry: "./src/index.js", + plugins: [ + new HtmlWebpackPlugin({ title: "App" }), + new CspHtmlWebpackPlugin({ "script-src": ["'self'"] }), + new SubresourceIntegrityPlugin({ hashFuncNames: ["sha384"] }), + new FaviconsWebpackPlugin("./src/logo.png"), + new DefinePlugin({ DEBUG: "false" }), + ], +}; diff --git a/codemods/html-plugins-to-native-html/tests/template-entry-object/expected.js b/codemods/html-plugins-to-native-html/tests/template-entry-object/expected.js new file mode 100644 index 0000000..2a9119e --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-entry-object/expected.js @@ -0,0 +1,12 @@ +module.exports = { + output: { + htmlFilename: "main.html", + }, + experiments: { + html: true, + }, + // The template is now the entry: it must load the previous entry via (added automatically when the template was found on disk) + entry: { + main: "./src/index.html", + }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/template-entry-object/input.js b/codemods/html-plugins-to-native-html/tests/template-entry-object/input.js new file mode 100644 index 0000000..2423b0f --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-entry-object/input.js @@ -0,0 +1,8 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +module.exports = { + entry: { + main: "./src/main.js", + }, + plugins: [new HtmlWebpackPlugin({ template: "./src/index.html", filename: "main.html" })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/template-no-entry/expected.js b/codemods/html-plugins-to-native-html/tests/template-no-entry/expected.js new file mode 100644 index 0000000..4a3d47c --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-no-entry/expected.js @@ -0,0 +1,10 @@ +module.exports = { + // The template is now the entry: it must load the previous default entry via (added automatically when the template was found on disk) + entry: "./public/index.html", + output: { + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/template-no-entry/input.js b/codemods/html-plugins-to-native-html/tests/template-no-entry/input.js new file mode 100644 index 0000000..037228d --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-no-entry/input.js @@ -0,0 +1,9 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +module.exports = { + plugins: [ + new HtmlWebpackPlugin({ + template: "./public/index.html", + }), + ], +}; diff --git a/codemods/html-plugins-to-native-html/tests/template-script-added/expected.js b/codemods/html-plugins-to-native-html/tests/template-script-added/expected.js new file mode 100644 index 0000000..52918df --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-script-added/expected.js @@ -0,0 +1,10 @@ +module.exports = { + output: { + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, + // The template is now the entry: it must load the previous entry via (added automatically when the template was found on disk) + entry: "./src/index.html", +}; diff --git a/codemods/html-plugins-to-native-html/tests/template-script-added/input.js b/codemods/html-plugins-to-native-html/tests/template-script-added/input.js new file mode 100644 index 0000000..36dc904 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-script-added/input.js @@ -0,0 +1,10 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +module.exports = { + entry: "./src/index.js", + plugins: [ + new HtmlWebpackPlugin({ + template: "./src/index.html", + }), + ], +}; diff --git a/codemods/html-plugins-to-native-html/tests/template-script-added/src/index.html b/codemods/html-plugins-to-native-html/tests/template-script-added/src/index.html new file mode 100644 index 0000000..dce0ce0 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-script-added/src/index.html @@ -0,0 +1,10 @@ + + + + App + + + +
+ + diff --git a/codemods/html-plugins-to-native-html/tests/template-script-added/src/index.js b/codemods/html-plugins-to-native-html/tests/template-script-added/src/index.js new file mode 100644 index 0000000..702645f --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-script-added/src/index.js @@ -0,0 +1 @@ +console.log("app"); diff --git a/codemods/html-plugins-to-native-html/tests/template/expected.js b/codemods/html-plugins-to-native-html/tests/template/expected.js new file mode 100644 index 0000000..0d61dfa --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template/expected.js @@ -0,0 +1,12 @@ +module.exports = { + experiments: { + html: true, + }, + // The template is now the entry: it must load the previous entry via (added automatically when the template was found on disk) + entry: "./src/index.html", + output: { + // Removed html-webpack-plugin options without a native HTML equivalent: title (add it to the template) + htmlFilename: "index.html", + filename: "[name].js", + }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/template/input.js b/codemods/html-plugins-to-native-html/tests/template/input.js new file mode 100644 index 0000000..0454032 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template/input.js @@ -0,0 +1,14 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +module.exports = { + entry: "./src/index.js", + output: { + filename: "[name].js", + }, + plugins: [ + new HtmlWebpackPlugin({ + template: "./src/index.html", + title: "My App", + }), + ], +}; diff --git a/codemods/html-plugins-to-native-html/tests/typescript-config/expected.ts b/codemods/html-plugins-to-native-html/tests/typescript-config/expected.ts new file mode 100644 index 0000000..bad4528 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/typescript-config/expected.ts @@ -0,0 +1,14 @@ +import type { Configuration } from "webpack"; + +const config: Configuration = { + output: { + html: { title: "App" }, + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, + entry: "./src/index.js", +}; + +export default config; diff --git a/codemods/html-plugins-to-native-html/tests/typescript-config/input.ts b/codemods/html-plugins-to-native-html/tests/typescript-config/input.ts new file mode 100644 index 0000000..994c0c4 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/typescript-config/input.ts @@ -0,0 +1,9 @@ +import HtmlWebpackPlugin from "html-webpack-plugin"; +import type { Configuration } from "webpack"; + +const config: Configuration = { + entry: "./src/index.js", + plugins: [new HtmlWebpackPlugin({ title: "App" })], +}; + +export default config; diff --git a/codemods/html-plugins-to-native-html/tests/untouched/expected.js b/codemods/html-plugins-to-native-html/tests/untouched/expected.js new file mode 100644 index 0000000..57d731c --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/untouched/expected.js @@ -0,0 +1,8 @@ +const { DefinePlugin } = require("webpack"); + +module.exports = { + module: { + rules: [{ test: /\.js$/, use: ["babel-loader"] }], + }, + plugins: [new DefinePlugin({ DEBUG: "false" })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/untouched/input.js b/codemods/html-plugins-to-native-html/tests/untouched/input.js new file mode 100644 index 0000000..57d731c --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/untouched/input.js @@ -0,0 +1,8 @@ +const { DefinePlugin } = require("webpack"); + +module.exports = { + module: { + rules: [{ test: /\.js$/, use: ["babel-loader"] }], + }, + plugins: [new DefinePlugin({ DEBUG: "false" })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/webpack-merge/expected.js b/codemods/html-plugins-to-native-html/tests/webpack-merge/expected.js new file mode 100644 index 0000000..310a0b9 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/webpack-merge/expected.js @@ -0,0 +1,13 @@ +const { merge } = require("webpack-merge"); +const base = require("./webpack.base"); + +module.exports = merge(base, { + output: { + html: { title: "App" }, + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, + entry: "./src/index.js", +}); diff --git a/codemods/html-plugins-to-native-html/tests/webpack-merge/input.js b/codemods/html-plugins-to-native-html/tests/webpack-merge/input.js new file mode 100644 index 0000000..2c2d581 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/webpack-merge/input.js @@ -0,0 +1,8 @@ +const { merge } = require("webpack-merge"); +const HtmlWebpackPlugin = require("html-webpack-plugin"); +const base = require("./webpack.base"); + +module.exports = merge(base, { + entry: "./src/index.js", + plugins: [new HtmlWebpackPlugin({ title: "App" })], +}); diff --git a/codemods/html-plugins-to-native-html/tests/with-options/expected.js b/codemods/html-plugins-to-native-html/tests/with-options/expected.js new file mode 100644 index 0000000..e91e6ff --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/with-options/expected.js @@ -0,0 +1,10 @@ +module.exports = { + output: { + html: { title: "My App", meta: { viewport: "width=device-width, initial-scale=1" }, inject: "head", scriptLoading: "blocking", favicon: "./src/favicon.ico" }, + htmlFilename: "app.html", + }, + experiments: { + html: true, + }, + entry: "./src/index.js", +}; diff --git a/codemods/html-plugins-to-native-html/tests/with-options/input.js b/codemods/html-plugins-to-native-html/tests/with-options/input.js new file mode 100644 index 0000000..a725b2e --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/with-options/input.js @@ -0,0 +1,17 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +module.exports = { + entry: "./src/index.js", + plugins: [ + new HtmlWebpackPlugin({ + filename: "app.html", + title: "My App", + meta: { viewport: "width=device-width, initial-scale=1" }, + inject: "head", + scriptLoading: "blocking", + favicon: "./src/favicon.ico", + minify: true, + cache: false, + }), + ], +}; diff --git a/codemods/html-plugins-to-native-html/workflow.yaml b/codemods/html-plugins-to-native-html/workflow.yaml new file mode 100644 index 0000000..8595846 --- /dev/null +++ b/codemods/html-plugins-to-native-html/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 html-webpack-plugin and html-loader rules to webpack's native HTML support (experiments.html) + 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 html-webpack-plugin and html-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/package-lock.json b/package-lock.json index 3f0ce0f..392d22e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,17 @@ "@codemod.com/jssg-types": "^1.6.2" } }, + "codemods/html-plugins-to-native-html": { + "name": "@webpack/html-plugins-to-native-html", + "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", @@ -1029,6 +1040,10 @@ "resolved": "codemods/css-plugins-to-native-css", "link": true }, + "node_modules/@webpack/html-plugins-to-native-html": { + "resolved": "codemods/html-plugins-to-native-html", + "link": true + }, "node_modules/acorn": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",