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 `