From bccba828defac7e87a8d883331a8b223357ea54f Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 23:44:07 -0500 Subject: [PATCH 01/17] feat: add html-plugins-to-native-html codemod --- .changeset/html-plugins-to-native-html.md | 5 + README.md | 1 + .../html-plugins-to-native-html/README.md | 81 ++++ .../html-plugins-to-native-html/codemod.yaml | 23 ++ .../html-plugins-to-native-html/package.json | 27 ++ .../src/remove-dependencies.ts | 30 ++ .../src/workflow.ts | 371 ++++++++++++++++++ .../tests/basic/expected.js | 16 + .../tests/basic/input.js | 13 + .../tests/esm/expected.mjs | 10 + .../tests/esm/input.mjs | 6 + .../tests/guarded-filter/expected.js | 15 + .../tests/guarded-filter/input.js | 9 + .../tests/hooks-untouched/expected.js | 13 + .../tests/hooks-untouched/input.js | 13 + .../tests/lost-options/expected.js | 15 + .../tests/lost-options/input.js | 15 + .../tests/multiple-instances/expected.js | 9 + .../tests/multiple-instances/input.js | 9 + .../remove-dependencies/removes/expected.json | 9 + .../remove-dependencies/removes/input.json | 10 + .../untouched/expected.json | 6 + .../remove-dependencies/untouched/input.json | 6 + .../tests/template-entry-object/expected.js | 12 + .../tests/template-entry-object/input.js | 8 + .../tests/template-no-entry/expected.js | 10 + .../tests/template-no-entry/input.js | 9 + .../tests/template/expected.js | 12 + .../tests/template/input.js | 14 + .../tests/untouched/expected.js | 8 + .../tests/untouched/input.js | 8 + .../tests/with-options/expected.js | 10 + .../tests/with-options/input.js | 17 + .../html-plugins-to-native-html/workflow.yaml | 36 ++ package-lock.json | 15 + 35 files changed, 871 insertions(+) create mode 100644 .changeset/html-plugins-to-native-html.md create mode 100644 codemods/html-plugins-to-native-html/README.md create mode 100644 codemods/html-plugins-to-native-html/codemod.yaml create mode 100644 codemods/html-plugins-to-native-html/package.json create mode 100644 codemods/html-plugins-to-native-html/src/remove-dependencies.ts create mode 100644 codemods/html-plugins-to-native-html/src/workflow.ts create mode 100644 codemods/html-plugins-to-native-html/tests/basic/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/basic/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/esm/expected.mjs create mode 100644 codemods/html-plugins-to-native-html/tests/esm/input.mjs create mode 100644 codemods/html-plugins-to-native-html/tests/guarded-filter/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/guarded-filter/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/hooks-untouched/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/hooks-untouched/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/lost-options/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/lost-options/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/multiple-instances/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/multiple-instances/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/removes/expected.json create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/removes/input.json create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/untouched/expected.json create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/untouched/input.json create mode 100644 codemods/html-plugins-to-native-html/tests/template-entry-object/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/template-entry-object/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/template-no-entry/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/template-no-entry/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/template/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/template/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/untouched/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/untouched/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/with-options/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/with-options/input.js create mode 100644 codemods/html-plugins-to-native-html/workflow.yaml 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..d7b9918 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` 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..21072e3 --- /dev/null +++ b/codemods/html-plugins-to-native-html/README.md @@ -0,0 +1,81 @@ +# @webpack/html-plugins-to-native-html + +Migrates webpack configurations from `html-webpack-plugin` 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), `favicon`, `base`, `inject` (`"body"`/`"head"`/`false`; `true` is the native default), and `scriptLoading: "blocking"` (`"defer"` is the native default). +- Drops options the native pipeline covers on its own (`minify: true`/`"auto"`, `cache`, `showErrors`, `chunksSortMode`, `chunks: "all"`, `publicPath: "auto"`) silently. +- 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. + +### `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 leaves a review comment — you must reference the previous JS entry from the template yourself, e.g. ``, because the HTML file now drives the build. 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 + +- Configurations with **several** plugin instances (multi-page setups with `chunks` per page): they map to per-entry `html` descriptors, but doing that safely needs a human — see [the entry `html` option](https://webpack.js.org/concepts/entry-points/). +- Files that tap `HtmlWebpackPlugin.getHooks(...)`: the taps have no drop-in native counterpart (native hooks live on `HtmlModulesPlugin.getCompilationHooks(...)` with different stages). +- 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: + +```js +module.exports = { + experiments: { + html: true, + }, + // The template is now the entry: reference the previous entry from it, e.g. + entry: "./src/index.html", + output: { + htmlFilename: "index.html", + }, +}; +``` + +The codemod also removes `html-webpack-plugin` from your `package.json` (`dependencies` and `devDependencies`). If other tooling in the repo still uses it (Storybook, test setups, …), reinstall it. 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..e9728c5 --- /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 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..a4f4fd3 --- /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 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..f746778 --- /dev/null +++ b/codemods/html-plugins-to-native-html/src/remove-dependencies.ts @@ -0,0 +1,30 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type Json from "@codemod.com/jssg-types/langs/json"; +import type { SgNode, SgRoot } from "@codemod.com/jssg-types/main"; +import { ConfigEditor, findPair, keyName, namedChildren, pairsOf } from "@webpack/codemod-utils"; + +// Package replaced by native HTML; review your lockfile if other tooling +// (Storybook, tests, …) still relies on it. +const REMOVED_PACKAGES = new Set(["html-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..738acfb --- /dev/null +++ b/codemods/html-plugins-to-native-html/src/workflow.ts @@ -0,0 +1,371 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode, SgRoot } from "@codemod.com/jssg-types/main"; +import { + ConfigEditor, + findPair, + guardBranchesOf, + keyName, + lineIndent, + namedChildren, + pairsOf, + unquote, + unwrapFilterCall, + collectModuleBindings, +} from "@webpack/codemod-utils"; + +const PLUGIN_MODULE = "html-webpack-plugin"; +// 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"]); +// 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", "native HTML minifies production output on its own"], + ["templateContent", "author the page as an .html entry file"], +]); + +interface HtmlProp { + name: string; + valueText: string; +} + +// 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; +} + +class HtmlMigration { + private readonly editor: ConfigEditor; + private readonly pluginBindings; + private readonly pluginNames: Set; + + constructor(root: SgRoot) { + this.editor = new ConfigEditor(root.root()); + this.pluginBindings = collectModuleBindings(this.editor.rootNode, PLUGIN_MODULE); + this.pluginNames = new Set(this.pluginBindings.map((binding) => binding.name)); + } + + run(): string | null { + if (!this.pluginNames.size) return null; + // Hook taps (`getHooks`, `getCompilationHooks`) have no native counterpart + // that keeps the tap working — leave such files for manual migration. + if (this.usesPluginHooks()) return null; + for (const pair of this.editor.rootNode.findAll({ rule: { kind: "pair" } })) { + if (keyName(pair) === "plugins") this.transformPluginsPair(pair); + } + this.editor.finalizeRemovals(); + if (!this.editor.hasEdits) return null; + for (const binding of this.pluginBindings) { + this.editor.removeBindingIfUnused(binding); + } + return this.editor.commit(); + } + + private usesPluginHooks(): boolean { + 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())) return true; + } + return false; + } + + // The plugin instantiation behind a plugins element, unwrapping guards. + private pluginInstantiationOf(element: SgNode): SgNode | null { + const branches = guardBranchesOf(element); + if (branches) { + for (const branch of branches) { + const found = this.pluginInstantiationOf(branch); + if (found) return found; + } + return null; + } + if (element.kind() !== "new_expression") return null; + const constructorNode = element.field("constructor"); + return constructorNode && this.pluginNames.has(constructorNode.text()) ? element : null; + } + + 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 }); + } + // Several instances mean several pages (`chunks` per page) — per-entry + // `html` descriptors cover it, but mapping them safely needs a human. + if (instances.length !== 1) return; + const { element, instantiation } = instances[0]; + const argumentsNode = instantiation.field("arguments"); + const optionsObject = argumentsNode ? namedChildren(argumentsNode)[0] : undefined; + // A non-literal options argument (variable, spread) can't be understood. + if (optionsObject && optionsObject.kind() !== "object") return; + const findings = this.collectFindings(optionsObject); + if (elements.length === 1) this.editor.markForRemoval(pluginsPair); + else this.editor.markForRemoval(element); + if (findings.templateValue !== null) this.migrateEntry(configObject, findings); + else this.noteMultiPageEntry(configObject, findings); + this.planConfigInsertions(configObject, findings); + } + + // ---------- option mapping ---------- + + private collectFindings(optionsObject: SgNode | undefined): InstanceFindings { + const findings: InstanceFindings = { + htmlProps: [], + htmlFilename: null, + templateValue: null, + lost: [], + notes: [], + pendingEntry: null, + }; + 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; + 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": + // Native meta values are `content` strings; attribute objects are not. + if ( + value.kind() === "object" && + pairsOf(value).every((pair) => pair.field("value")?.kind() === "string") + ) { + findings.htmlProps.push({ name, valueText: value.text() }); + } else { + findings.lost.push(this.describeLost(name)); + } + 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 (templateMode || literal === "defer") break; + if (literal === "blocking") { + findings.htmlProps.push({ name, valueText: '"blocking"' }); + } else { + findings.lost.push(this.describeLost(name)); + } + break; + case "minify": + if (value.kind() !== "true" && literal !== "auto") { + findings.lost.push(this.describeLost(name)); + } + break; + case "chunks": + if (literal !== "all") findings.lost.push(this.describeLost(name)); + break; + case "publicPath": + if (literal !== "auto") findings.lost.push(this.describeLost(name)); + break; + case "hash": + case "xhtml": + if (value.kind() !== "false") findings.lost.push(this.describeLost(name)); + break; + default: + findings.lost.push(this.describeLost(name)); + } + } + + private describeLost(name: string): string { + const hint = LOST_OPTION_HINTS.get(name); + return hint ? `${name} (${hint})` : name; + } + + // ---------- entry handling ---------- + + // With `output.html` each entry gets its own page, unlike the plugin's + // single page referencing every chunk — worth a note on multi-entry configs. + private noteMultiPageEntry(configObject: SgNode, findings: InstanceFindings): void { + const entryValue = findPair(configObject, "entry")?.field("value"); + if (entryValue && entryValue.kind() === "object" && pairsOf(entryValue).length > 1) { + findings.notes.push( + "output.html emits one page per entry; html-webpack-plugin emitted a single page with every chunk", + ); + } + } + + // The template becomes the entry (native HTML entry point); the previous + // entry must be referenced from the template with a ``, + }; + 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 comment = `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}`); + } + } + + // 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 planConfigInsertions(configObject: SgNode, findings: InstanceFindings): void { + const commentLines: string[] = []; + if (findings.lost.length) { + commentLines.push( + `Removed html-webpack-plugin options without a native HTML equivalent: ${[...new Set(findings.lost)].join(", ")}`, + ); + } + commentLines.push(...findings.notes); + const outputProps: { name: string; valueText: string }[] = []; + if (findings.templateValue === null) { + const htmlValue = findings.htmlProps.length + ? `{ ${findings.htmlProps.map((prop) => `${prop.name}: ${prop.valueText}`).join(", ")} }` + : "true"; + outputProps.push({ name: "html", valueText: htmlValue }); + } + // The plugin emitted `index.html` by default; native defaults to `[name].html`. + outputProps.push({ name: "htmlFilename", valueText: findings.htmlFilename ?? '"index.html"' }); + const topProperties: ((indent: string, unit: string) => string)[] = []; + const pendingEntry = findings.pendingEntry; + if (pendingEntry) { + topProperties.push((indent) => `// ${pendingEntry.comment}\n${indent}entry: ${pendingEntry.text}`); + } + this.planObjectProps(configObject, "output", outputProps, commentLines, topProperties); + this.planObjectProps(configObject, "experiments", [{ name: "html", valueText: "true" }], [], topProperties); + if (topProperties.length) { + // A fully-emptied config keeps its braces open for these properties. + this.editor.keepBracesOpen(configObject); + this.editor.insertIntoObject(configObject, (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: { name: string; valueText: string }[], + 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 { + return new HtmlMigration(root).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/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-untouched/expected.js b/codemods/html-plugins-to-native-html/tests/hooks-untouched/expected.js new file mode 100644 index 0000000..26ab525 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/hooks-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).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-untouched/input.js b/codemods/html-plugins-to-native-html/tests/hooks-untouched/input.js new file mode 100644 index 0000000..26ab525 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/hooks-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).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/lost-options/expected.js b/codemods/html-plugins-to-native-html/tests/lost-options/expected.js new file mode 100644 index 0000000..f43c81e --- /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 (native HTML minifies production output on its own), 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/multiple-instances/expected.js b/codemods/html-plugins-to-native-html/tests/multiple-instances/expected.js new file mode 100644 index 0000000..c2c374c --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/multiple-instances/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"] }), + new HtmlWebpackPlugin({ filename: "b.html", chunks: ["b"] }), + ], +}; diff --git a/codemods/html-plugins-to-native-html/tests/multiple-instances/input.js b/codemods/html-plugins-to-native-html/tests/multiple-instances/input.js new file mode 100644 index 0000000..c2c374c --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/multiple-instances/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/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..a94731b --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/remove-dependencies/removes/input.json @@ -0,0 +1,10 @@ +{ + "name": "app", + "dependencies": { + "react": "^18.0.0" + }, + "devDependencies": { + "html-webpack-plugin": "^5.6.0", + "webpack": "^5.109.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/template-entry-object/expected.js b/codemods/html-plugins-to-native-html/tests/template-entry-object/expected.js new file mode 100644 index 0000000..1136164 --- /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: reference the previous entry from it, e.g. + 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..33fede3 --- /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: reference the previous entry (webpack's default is ./src/index.js) from it, e.g. + 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/expected.js b/codemods/html-plugins-to-native-html/tests/template/expected.js new file mode 100644 index 0000000..a2b1739 --- /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: reference the previous entry from it, e.g. + 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/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/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..34909e9 --- /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 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 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", From ba91d135b9bbec5d55683b21db993aff38571f44 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 23:49:10 -0500 Subject: [PATCH 02/17] feat: migrate html-loader rules in html-plugins-to-native-html --- README.md | 2 +- .../html-plugins-to-native-html/README.md | 11 +- .../html-plugins-to-native-html/codemod.yaml | 2 +- .../html-plugins-to-native-html/package.json | 2 +- .../src/remove-dependencies.ts | 6 +- .../src/workflow.ts | 307 ++++++++++++++++-- .../tests/loader-only/expected.js | 1 + .../tests/loader-only/input.js | 10 + .../tests/loader-preprocessor/expected.js | 12 + .../tests/loader-preprocessor/input.js | 18 + .../tests/loader-with-options/expected.js | 14 + .../tests/loader-with-options/input.js | 14 + .../tests/plugin-and-loader/expected.js | 10 + .../tests/plugin-and-loader/input.js | 14 + .../remove-dependencies/removes/input.json | 1 + .../html-plugins-to-native-html/workflow.yaml | 4 +- 16 files changed, 387 insertions(+), 41 deletions(-) create mode 100644 codemods/html-plugins-to-native-html/tests/loader-only/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-only/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-preprocessor/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-preprocessor/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-with-options/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-with-options/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/plugin-and-loader/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/plugin-and-loader/input.js diff --git a/README.md b/README.md index d7b9918..ce50400 100644 --- a/README.md +++ b/README.md @@ -21,7 +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` to webpack's native HTML support (`experiments.html`). | +| [`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 index 21072e3..b96a212 100644 --- a/codemods/html-plugins-to-native-html/README.md +++ b/codemods/html-plugins-to-native-html/README.md @@ -1,6 +1,6 @@ # @webpack/html-plugins-to-native-html -Migrates webpack configurations from `html-webpack-plugin` to webpack's native HTML support (`experiments.html` + `output.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. @@ -13,6 +13,13 @@ Migrates webpack configurations from `html-webpack-plugin` to webpack's native H - Drops options the native pipeline covers on its own (`minify: true`/`"auto"`, `cache`, `showErrors`, `chunksSortMode`, `chunks: "all"`, `publicPath: "auto"`) silently. - 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. +### `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 leaves a review comment — you must reference the previous JS entry from the template yourself, e.g. ``, because the HTML file now drives the build. Since head tags are only injected into webpack-generated pages, `title`/`meta`/`favicon`/`base` are flagged to be added to the template instead. @@ -78,4 +85,4 @@ module.exports = { }; ``` -The codemod also removes `html-webpack-plugin` from your `package.json` (`dependencies` and `devDependencies`). If other tooling in the repo still uses it (Storybook, test setups, …), reinstall it. +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 index e9728c5..a8bcb62 100644 --- a/codemods/html-plugins-to-native-html/codemod.yaml +++ b/codemods/html-plugins-to-native-html/codemod.yaml @@ -1,7 +1,7 @@ schema_version: "1.0" name: "@webpack/html-plugins-to-native-html" version: "0.0.0" -description: Migrate html-webpack-plugin to webpack's native HTML support (experiments.html) +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 diff --git a/codemods/html-plugins-to-native-html/package.json b/codemods/html-plugins-to-native-html/package.json index a4f4fd3..81de66a 100644 --- a/codemods/html-plugins-to-native-html/package.json +++ b/codemods/html-plugins-to-native-html/package.json @@ -2,7 +2,7 @@ "name": "@webpack/html-plugins-to-native-html", "private": true, "version": "0.0.0", - "description": "Migrate html-webpack-plugin to webpack's native HTML support (experiments.html).", + "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", diff --git a/codemods/html-plugins-to-native-html/src/remove-dependencies.ts b/codemods/html-plugins-to-native-html/src/remove-dependencies.ts index f746778..1622616 100644 --- a/codemods/html-plugins-to-native-html/src/remove-dependencies.ts +++ b/codemods/html-plugins-to-native-html/src/remove-dependencies.ts @@ -3,9 +3,9 @@ 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"; -// Package replaced by native HTML; review your lockfile if other tooling -// (Storybook, tests, …) still relies on it. -const REMOVED_PACKAGES = new Set(["html-webpack-plugin"]); +// 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"]); const DEPENDENCY_KEYS = ["dependencies", "devDependencies"]; async function transform(root: SgRoot): Promise { diff --git a/codemods/html-plugins-to-native-html/src/workflow.ts b/codemods/html-plugins-to-native-html/src/workflow.ts index 738acfb..953169b 100644 --- a/codemods/html-plugins-to-native-html/src/workflow.ts +++ b/codemods/html-plugins-to-native-html/src/workflow.ts @@ -2,22 +2,32 @@ import type Js from "@codemod.com/jssg-types/langs/javascript"; import type { SgNode, SgRoot } from "@codemod.com/jssg-types/main"; import { ConfigEditor, + cascadeRemovalTarget, + collectModuleBindings, + filterSuffixOf, + findConfigObjectFor, findPair, guardBranchesOf, keyName, lineIndent, + loaderNameOf, namedChildren, pairsOf, + ruleMatchesFiles, unquote, unwrapFilterCall, - collectModuleBindings, } 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 (ESM export of the html string; +// production minification). +const DROPPABLE_LOADER_OPTIONS = new Set(["esModule", "minimize"]); // Manual migration paths appended to the review comment where one exists. const LOST_OPTION_HINTS = new Map([ ["publicPath", "set output.publicPath"], @@ -27,6 +37,11 @@ const LOST_OPTION_HINTS = new Map([ ["scriptLoading", "module scripts come from experiments.outputModule"], ["minify", "native HTML minifies production output on its own"], ["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", + ], ]); interface HtmlProp { @@ -45,10 +60,26 @@ interface InstanceFindings { pendingEntry: { text: string; comment: string } | null; } +// 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[]; + outputProps: HtmlProp[]; + needsExperimentsHtml: boolean; + pendingEntry: { text: string; comment: string } | null; +} + class HtmlMigration { private readonly editor: ConfigEditor; private readonly pluginBindings; private readonly pluginNames: Set; + private readonly configPlans = new Map(); constructor(root: SgRoot) { this.editor = new ConfigEditor(root.root()); @@ -57,13 +88,23 @@ class HtmlMigration { } run(): string | null { - if (!this.pluginNames.size) return null; // Hook taps (`getHooks`, `getCompilationHooks`) have no native counterpart // that keeps the tap working — leave such files for manual migration. if (this.usesPluginHooks()) return null; + const usePairs: SgNode[] = []; + const loaderPairs: SgNode[] = []; + const pluginsPairs: SgNode[] = []; for (const pair of this.editor.rootNode.findAll({ rule: { kind: "pair" } })) { - if (keyName(pair) === "plugins") this.transformPluginsPair(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); + if (this.pluginNames.size) { + for (const pair of pluginsPairs) this.transformPluginsPair(pair); } + this.planConfigInsertions(); this.editor.finalizeRemovals(); if (!this.editor.hasEdits) return null; for (const binding of this.pluginBindings) { @@ -82,6 +123,194 @@ class HtmlMigration { return false; } + private planFor(config: SgNode): ConfigPlan { + const key = config.range().start.index; + let plan = this.configPlans.get(key); + if (!plan) { + plan = { + config, + commentLines: [], + outputProps: [], + needsExperimentsHtml: false, + pendingEntry: null, + }; + this.configPlans.set(key, plan); + } + return plan; + } + + private describeLost(name: string): string { + const hint = LOST_OPTION_HINTS.get(name); + return hint ? `${name} (${hint})` : name; + } + + // ---------- 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 === "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; + } + if (name === "preprocessor") { + findings.lost.push(this.describeLost(`${LOADER_NAME}.preprocessor`)); + continue; + } + findings.lost.push(`${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.planFor(config).needsExperimentsHtml = true; + } + + // ---------- plugins ---------- + // The plugin instantiation behind a plugins element, unwrapping guards. private pluginInstantiationOf(element: SgNode): SgNode | null { const branches = guardBranchesOf(element); @@ -121,7 +350,7 @@ class HtmlMigration { else this.editor.markForRemoval(element); if (findings.templateValue !== null) this.migrateEntry(configObject, findings); else this.noteMultiPageEntry(configObject, findings); - this.planConfigInsertions(configObject, findings); + this.mergeIntoPlan(configObject, findings); } // ---------- option mapping ---------- @@ -193,7 +422,10 @@ class HtmlMigration { // `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}"` }); + findings.htmlProps.push({ + name, + valueText: value.kind() === "false" ? "false" : `"${literal}"`, + }); } else { findings.lost.push(this.describeLost(name)); } @@ -227,11 +459,6 @@ class HtmlMigration { } } - private describeLost(name: string): string { - const hint = LOST_OPTION_HINTS.get(name); - return hint ? `${name} (${hint})` : name; - } - // ---------- entry handling ---------- // With `output.html` each entry gets its own page, unlike the plugin's @@ -296,36 +523,54 @@ class HtmlMigration { // ---------- config-level insertions ---------- - private planConfigInsertions(configObject: SgNode, findings: InstanceFindings): void { - const commentLines: string[] = []; + private mergeIntoPlan(configObject: SgNode, findings: InstanceFindings): void { + const plan = this.planFor(configObject); + plan.needsExperimentsHtml = true; if (findings.lost.length) { - commentLines.push( + plan.commentLines.push( `Removed html-webpack-plugin options without a native HTML equivalent: ${[...new Set(findings.lost)].join(", ")}`, ); } - commentLines.push(...findings.notes); - const outputProps: { name: string; valueText: string }[] = []; + plan.commentLines.push(...findings.notes); if (findings.templateValue === null) { const htmlValue = findings.htmlProps.length ? `{ ${findings.htmlProps.map((prop) => `${prop.name}: ${prop.valueText}`).join(", ")} }` : "true"; - outputProps.push({ name: "html", valueText: htmlValue }); + plan.outputProps.push({ name: "html", valueText: htmlValue }); } // The plugin emitted `index.html` by default; native defaults to `[name].html`. - outputProps.push({ name: "htmlFilename", valueText: findings.htmlFilename ?? '"index.html"' }); - const topProperties: ((indent: string, unit: string) => string)[] = []; - const pendingEntry = findings.pendingEntry; - if (pendingEntry) { - topProperties.push((indent) => `// ${pendingEntry.comment}\n${indent}entry: ${pendingEntry.text}`); - } - this.planObjectProps(configObject, "output", outputProps, commentLines, topProperties); - this.planObjectProps(configObject, "experiments", [{ name: "html", valueText: "true" }], [], topProperties); - if (topProperties.length) { - // A fully-emptied config keeps its braces open for these properties. - this.editor.keepBracesOpen(configObject); - this.editor.insertIntoObject(configObject, (indent, unit) => - topProperties.map((build) => build(indent, unit)), - ); + plan.outputProps.push({ name: "htmlFilename", valueText: 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}`, + ); + } + if (plan.outputProps.length) { + this.planObjectProps(plan.config, "output", plan.outputProps, plan.commentLines, topProperties); + } + if (plan.needsExperimentsHtml) { + this.planObjectProps( + plan.config, + "experiments", + [{ name: "html", valueText: "true" }], + plan.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)), + ); + } } } @@ -335,7 +580,7 @@ class HtmlMigration { private planObjectProps( config: SgNode, key: string, - props: { name: string; valueText: string }[], + props: HtmlProp[], commentLines: string[], topProperties: ((indent: string, unit: string) => string)[], ): void { 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..fbab016 --- /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) + 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..28dd17c --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-preprocessor/input.js @@ -0,0 +1,18 @@ +module.exports = { + module: { + rules: [ + { + test: /\.md$/, + use: [ + { + loader: "html-loader", + options: { + preprocessor: (content) => content, + }, + }, + "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/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/input.json b/codemods/html-plugins-to-native-html/tests/remove-dependencies/removes/input.json index a94731b..0a9f939 100644 --- 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 @@ -4,6 +4,7 @@ "react": "^18.0.0" }, "devDependencies": { + "html-loader": "^5.1.0", "html-webpack-plugin": "^5.6.0", "webpack": "^5.109.0" } diff --git a/codemods/html-plugins-to-native-html/workflow.yaml b/codemods/html-plugins-to-native-html/workflow.yaml index 34909e9..8595846 100644 --- a/codemods/html-plugins-to-native-html/workflow.yaml +++ b/codemods/html-plugins-to-native-html/workflow.yaml @@ -9,7 +9,7 @@ nodes: runtime: type: direct steps: - - name: Migrate html-webpack-plugin to webpack's native HTML support (experiments.html) + - 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: . @@ -25,7 +25,7 @@ nodes: exclude: - "**/node_modules/**" language: typescript - - name: Remove html-webpack-plugin from package.json + - name: Remove html-webpack-plugin and html-loader from package.json js-ast-grep: js_file: src/remove-dependencies.ts base_path: . From 244b0efbc42fc18d6fd799597ce539d31e4dbef4 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 23:56:06 -0500 Subject: [PATCH 03/17] feat: add the entry script tag to the template file directly --- .../html-plugins-to-native-html/README.md | 6 +- .../src/workflow.ts | 94 +++++++++++++++++-- .../tests/template-script-added/expected.js | 10 ++ .../tests/template-script-added/input.js | 10 ++ .../template-script-added/src/index.html | 10 ++ .../tests/template-script-added/src/index.js | 1 + 6 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 codemods/html-plugins-to-native-html/tests/template-script-added/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/template-script-added/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/template-script-added/src/index.html create mode 100644 codemods/html-plugins-to-native-html/tests/template-script-added/src/index.js diff --git a/codemods/html-plugins-to-native-html/README.md b/codemods/html-plugins-to-native-html/README.md index b96a212..e5a0415 100644 --- a/codemods/html-plugins-to-native-html/README.md +++ b/codemods/html-plugins-to-native-html/README.md @@ -22,7 +22,7 @@ Migrates webpack configurations from `html-webpack-plugin` and `html-loader` to ### `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 leaves a review comment — you must reference the previous JS entry from the template yourself, e.g. ``, because the HTML file now drives the build. Since head tags are only injected into webpack-generated pages, `title`/`meta`/`favicon`/`base` are flagged to be added to the template instead. +`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. When the template and entry files are found on disk (both paths relative), the codemod edits the template and adds `` (relative to the template) before ``, skipping templates that already load it; otherwise it leaves a review comment telling you to add the tag yourself. 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 @@ -70,14 +70,14 @@ module.exports = { }; ``` -With a `template`, the template becomes the entry point: +With a `template`, the template becomes the entry point and gets a ` + // The template is now the entry and loads the previous entry via entry: "./src/index.html", output: { htmlFilename: "index.html", diff --git a/codemods/html-plugins-to-native-html/src/workflow.ts b/codemods/html-plugins-to-native-html/src/workflow.ts index 953169b..332d2f3 100644 --- a/codemods/html-plugins-to-native-html/src/workflow.ts +++ b/codemods/html-plugins-to-native-html/src/workflow.ts @@ -44,6 +44,9 @@ const LOST_OPTION_HINTS = new Map([ ], ]); +type FsModule = typeof import("node:fs"); +type PathModule = typeof import("node:path"); + interface HtmlProp { name: string; valueText: string; @@ -75,16 +78,45 @@ interface ConfigPlan { pendingEntry: { text: string; comment: string } | null; } +// 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 readonly pathModule: PathModule | null; - constructor(root: SgRoot) { + constructor(root: SgRoot, fileSystem: FsModule | null, pathModule: PathModule | 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)); + this.configFileName = root.filename(); + this.fileSystem = fileSystem; + this.pathModule = pathModule; } run(): string | null { @@ -473,13 +505,18 @@ class HtmlMigration { } // The template becomes the entry (native HTML entry point); the previous - // entry must be referenced from the template with a ``, + text: templateValue, + comment: injected + ? `The template is now the entry and loads the previous default entry via ` + : `The template is now the entry: reference the previous entry (webpack's default is ./src/index.js) from it, e.g. `, }; return; } @@ -489,7 +526,10 @@ class HtmlMigration { findings.lost.push("template (make the template an .html entry that loads your JS)"); return; } - const comment = `The template is now the entry: reference the previous entry from it, e.g. `; + const injected = this.injectScriptIntoTemplate(templateValue, unquote(replaceable.text())); + const comment = injected + ? `The template is now the entry and loads the previous entry via ` + : `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); @@ -504,6 +544,40 @@ class HtmlMigration { } } + // Add a ``), + ); + return scriptSrc; + } catch { + 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 { @@ -610,7 +684,15 @@ class HtmlMigration { } async function transform(root: SgRoot): Promise { - return new HtmlMigration(root).run(); + let fileSystem: FsModule | null = null; + let pathModule: PathModule | null = null; + try { + fileSystem = await import("node:fs"); + pathModule = await import("node:path"); + } catch { + // No filesystem access — template edits fall back to review comments. + } + return new HtmlMigration(root, fileSystem, pathModule).run(); } export default transform; 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..093f24b --- /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 and loads the previous entry via + 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"); From 03e42c71926709757a34a60ba150cc705abe4266 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 23:59:10 -0500 Subject: [PATCH 04/17] docs: map html-webpack-plugin hooks to their native counterparts --- codemods/html-plugins-to-native-html/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codemods/html-plugins-to-native-html/README.md b/codemods/html-plugins-to-native-html/README.md index e5a0415..2d04284 100644 --- a/codemods/html-plugins-to-native-html/README.md +++ b/codemods/html-plugins-to-native-html/README.md @@ -27,7 +27,7 @@ Migrates webpack configurations from `html-webpack-plugin` and `html-loader` to ### What is left untouched - Configurations with **several** plugin instances (multi-page setups with `chunks` per page): they map to per-entry `html` descriptors, but doing that safely needs a human — see [the entry `html` option](https://webpack.js.org/concepts/entry-points/). -- Files that tap `HtmlWebpackPlugin.getHooks(...)`: the taps have no drop-in native counterpart (native hooks live on `HtmlModulesPlugin.getCompilationHooks(...)` with different stages). +- Files that tap `HtmlWebpackPlugin.getHooks(...)`: every native hook takes different arguments, so tap bodies need rewriting by hand. The native counterparts on `webpack.html.HtmlModulesPlugin.getCompilationHooks(compilation)` are: `alterAssetTags`/`alterAssetTagGroups` → `transformTags` (mutable tag descriptors; move tags via `injectTo` instead of the head/body arrays) plus `injectTags` for adding tags; `beforeEmit` → `transformHtml` (waterfall on the HTML string instead of `data.html`); `afterEmit` → `htmlEmitted`; `beforeAssetTagGeneration` and `afterTemplateExecution` have no equivalent (webpack builds the tags and runs the parser template itself). - Plugin instantiations whose options are not an object literal. ## Usage From 39cac8fd9d248e3f3332b1b8eef39aa154ccbf5e Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 2 Aug 2026 00:02:55 -0500 Subject: [PATCH 05/17] feat: migrate html-webpack-plugin hook taps to the native hooks --- .../html-plugins-to-native-html/README.md | 3 +- .../src/workflow.ts | 143 +++++++++++++++++- .../tests/hooks-migrated/expected.js | 21 +++ .../input.js | 0 .../expected.js | 2 +- .../tests/hooks-unmappable-untouched/input.js | 13 ++ 6 files changed, 173 insertions(+), 9 deletions(-) create mode 100644 codemods/html-plugins-to-native-html/tests/hooks-migrated/expected.js rename codemods/html-plugins-to-native-html/tests/{hooks-untouched => hooks-migrated}/input.js (100%) rename codemods/html-plugins-to-native-html/tests/{hooks-untouched => hooks-unmappable-untouched}/expected.js (65%) create mode 100644 codemods/html-plugins-to-native-html/tests/hooks-unmappable-untouched/input.js diff --git a/codemods/html-plugins-to-native-html/README.md b/codemods/html-plugins-to-native-html/README.md index 2d04284..08c3674 100644 --- a/codemods/html-plugins-to-native-html/README.md +++ b/codemods/html-plugins-to-native-html/README.md @@ -12,6 +12,7 @@ Migrates webpack configurations from `html-webpack-plugin` and `html-loader` to - Maps plugin options to their `output.html` counterparts: `title`, `meta` (string values), `favicon`, `base`, `inject` (`"body"`/`"head"`/`false`; `true` is the native default), and `scriptLoading: "blocking"` (`"defer"` is the native default). - Drops options the native pipeline covers on its own (`minify: true`/`"auto"`, `cache`, `showErrors`, `chunksSortMode`, `chunks: "all"`, `publicPath: "auto"`) silently. - 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. +- 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` @@ -27,7 +28,7 @@ Migrates webpack configurations from `html-webpack-plugin` and `html-loader` to ### What is left untouched - Configurations with **several** plugin instances (multi-page setups with `chunks` per page): they map to per-entry `html` descriptors, but doing that safely needs a human — see [the entry `html` option](https://webpack.js.org/concepts/entry-points/). -- Files that tap `HtmlWebpackPlugin.getHooks(...)`: every native hook takes different arguments, so tap bodies need rewriting by hand. The native counterparts on `webpack.html.HtmlModulesPlugin.getCompilationHooks(compilation)` are: `alterAssetTags`/`alterAssetTagGroups` → `transformTags` (mutable tag descriptors; move tags via `injectTo` instead of the head/body arrays) plus `injectTags` for adding tags; `beforeEmit` → `transformHtml` (waterfall on the HTML string instead of `data.html`); `afterEmit` → `htmlEmitted`; `beforeAssetTagGeneration` and `afterTemplateExecution` have no equivalent (webpack builds the tags and runs the parser template itself). +- 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 diff --git a/codemods/html-plugins-to-native-html/src/workflow.ts b/codemods/html-plugins-to-native-html/src/workflow.ts index 332d2f3..d57dc1c 100644 --- a/codemods/html-plugins-to-native-html/src/workflow.ts +++ b/codemods/html-plugins-to-native-html/src/workflow.ts @@ -2,6 +2,7 @@ import type Js from "@codemod.com/jssg-types/langs/javascript"; import type { SgNode, SgRoot } from "@codemod.com/jssg-types/main"; import { ConfigEditor, + addImport, cascadeRemovalTarget, collectModuleBindings, filterSuffixOf, @@ -47,6 +48,26 @@ const LOST_OPTION_HINTS = new Map([ type FsModule = typeof import("node:fs"); type PathModule = typeof import("node:path"); +// 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"]); + interface HtmlProp { name: string; valueText: string; @@ -109,6 +130,10 @@ class HtmlMigration { private readonly configFileName: string; private readonly fileSystem: FsModule | null; private readonly pathModule: PathModule | null; + private pluginMigrated = false; + private pluginRetained = false; + // Binding statements rewritten in place (e.g. into the `html` import). + private readonly repurposedStatements = new Set(); constructor(root: SgRoot, fileSystem: FsModule | null, pathModule: PathModule | null) { this.editor = new ConfigEditor(root.root()); @@ -120,9 +145,9 @@ class HtmlMigration { } run(): string | null { - // Hook taps (`getHooks`, `getCompilationHooks`) have no native counterpart - // that keeps the tap working — leave such files for manual migration. - if (this.usesPluginHooks()) return 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[] = []; @@ -136,21 +161,33 @@ class HtmlMigration { if (this.pluginNames.size) { 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) { + if (this.repurposedStatements.has(binding.statement.range().start.index)) continue; this.editor.removeBindingIfUnused(binding); } return this.editor.commit(); } - private usesPluginHooks(): boolean { + // `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())) return true; + 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; } @@ -371,18 +408,110 @@ class HtmlMigration { } // Several instances mean several pages (`chunks` per page) — per-entry // `html` descriptors cover it, but mapping them safely needs a human. - if (instances.length !== 1) return; + if (instances.length !== 1) { + if (instances.length) this.pluginRetained = true; + return; + } const { element, instantiation } = instances[0]; const argumentsNode = instantiation.field("arguments"); const optionsObject = argumentsNode ? namedChildren(argumentsNode)[0] : undefined; // A non-literal options argument (variable, spread) can't be understood. - if (optionsObject && optionsObject.kind() !== "object") return; + if (optionsObject && optionsObject.kind() !== "object") { + this.pluginRetained = true; + return; + } const findings = this.collectFindings(optionsObject); if (elements.length === 1) this.editor.markForRemoval(pluginsPair); else this.editor.markForRemoval(element); if (findings.templateValue !== null) this.migrateEntry(configObject, findings); else this.noteMultiPageEntry(configObject, findings); this.mergeIntoPlan(configObject, findings); + 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 ---------- 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-untouched/input.js b/codemods/html-plugins-to-native-html/tests/hooks-migrated/input.js similarity index 100% rename from codemods/html-plugins-to-native-html/tests/hooks-untouched/input.js rename to codemods/html-plugins-to-native-html/tests/hooks-migrated/input.js diff --git a/codemods/html-plugins-to-native-html/tests/hooks-untouched/expected.js b/codemods/html-plugins-to-native-html/tests/hooks-unmappable-untouched/expected.js similarity index 65% rename from codemods/html-plugins-to-native-html/tests/hooks-untouched/expected.js rename to codemods/html-plugins-to-native-html/tests/hooks-unmappable-untouched/expected.js index 26ab525..52f7d86 100644 --- a/codemods/html-plugins-to-native-html/tests/hooks-untouched/expected.js +++ b/codemods/html-plugins-to-native-html/tests/hooks-unmappable-untouched/expected.js @@ -3,7 +3,7 @@ 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)); + HtmlWebpackPlugin.getHooks(compilation).afterTemplateExecution.tapAsync("MyPlugin", (data, callback) => callback(null, data)); }); } } 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()], +}; From a3d83bf3eb4334a1ec269fc029cabe6d7dc08519 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 2 Aug 2026 00:23:15 -0500 Subject: [PATCH 06/17] feat: map multi-page chunks setups to per-entry html descriptors --- .../html-plugins-to-native-html/README.md | 3 +- .../src/workflow.ts | 197 +++++++++++++++--- .../expected.js | 4 +- .../tests/multi-chunk-untouched/input.js | 9 + .../tests/multi-page-options/expected.js | 12 ++ .../tests/multi-page-options/input.js | 12 ++ .../tests/multi-page/expected.js | 9 + .../input.js | 0 8 files changed, 211 insertions(+), 35 deletions(-) rename codemods/html-plugins-to-native-html/tests/{multiple-instances => multi-chunk-untouched}/expected.js (51%) create mode 100644 codemods/html-plugins-to-native-html/tests/multi-chunk-untouched/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/multi-page-options/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/multi-page-options/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/multi-page/expected.js rename codemods/html-plugins-to-native-html/tests/{multiple-instances => multi-page}/input.js (100%) diff --git a/codemods/html-plugins-to-native-html/README.md b/codemods/html-plugins-to-native-html/README.md index 08c3674..d5c175a 100644 --- a/codemods/html-plugins-to-native-html/README.md +++ b/codemods/html-plugins-to-native-html/README.md @@ -11,6 +11,7 @@ Migrates webpack configurations from `html-webpack-plugin` and `html-loader` to - 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), `favicon`, `base`, `inject` (`"body"`/`"head"`/`false`; `true` is the native default), and `scriptLoading: "blocking"` (`"defer"` is the native default). - Drops options the native pipeline covers on its own (`minify: true`/`"auto"`, `cache`, `showErrors`, `chunksSortMode`, `chunks: "all"`, `publicPath: "auto"`) silently. +- **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. - 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. @@ -27,7 +28,7 @@ Migrates webpack configurations from `html-webpack-plugin` and `html-loader` to ### What is left untouched -- Configurations with **several** plugin instances (multi-page setups with `chunks` per page): they map to per-entry `html` descriptors, but doing that safely needs a human — see [the entry `html` option](https://webpack.js.org/concepts/entry-points/). +- 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. diff --git a/codemods/html-plugins-to-native-html/src/workflow.ts b/codemods/html-plugins-to-native-html/src/workflow.ts index d57dc1c..9ea5e2f 100644 --- a/codemods/html-plugins-to-native-html/src/workflow.ts +++ b/codemods/html-plugins-to-native-html/src/workflow.ts @@ -67,12 +67,19 @@ const HOOK_REVIEW_COMMENTS = new Map([ // 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"]); 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)); +} + // Everything one plugin instance contributes to its enclosing config. interface InstanceFindings { htmlProps: HtmlProp[]; @@ -94,9 +101,17 @@ interface LoaderFindings { 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[]; - needsExperimentsHtml: boolean; + experimentsProps: HtmlProp[]; pendingEntry: { text: string; comment: string } | null; + // A html-webpack-plugin instance in this config was migrated. + pluginMigratedHere: boolean; } // Insert the script tag before `` (or ``), matching the @@ -199,9 +214,13 @@ class HtmlMigration { plan = { config, commentLines: [], + htmlEnabled: false, + htmlProps: [], + htmlFilename: null, outputProps: [], - needsExperimentsHtml: false, + experimentsProps: [], pendingEntry: null, + pluginMigratedHere: false, }; this.configPlans.set(key, plan); } @@ -213,6 +232,12 @@ class HtmlMigration { 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" }); + } + } + // ---------- module.rules (html-loader) ---------- // A `use` entry replaceable by native HTML, unwrapping dev/prod guards. @@ -375,7 +400,7 @@ class HtmlMigration { // 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.planFor(config).needsExperimentsHtml = true; + if (config) this.requireExperimentsHtml(this.planFor(config)); } // ---------- plugins ---------- @@ -406,29 +431,122 @@ class HtmlMigration { const instantiation = this.pluginInstantiationOf(element); if (instantiation) instances.push({ element, instantiation }); } - // Several instances mean several pages (`chunks` per page) — per-entry - // `html` descriptors cover it, but mapping them safely needs a human. - if (instances.length !== 1) { - if (instances.length) this.pluginRetained = true; - return; - } - const { element, instantiation } = instances[0]; - const argumentsNode = instantiation.field("arguments"); - const optionsObject = argumentsNode ? namedChildren(argumentsNode)[0] : undefined; - // A non-literal options argument (variable, spread) can't be understood. - if (optionsObject && optionsObject.kind() !== "object") { - this.pluginRetained = true; + 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(optionsObject); - if (elements.length === 1) this.editor.markForRemoval(pluginsPair); - else this.editor.markForRemoval(element); + const findings = this.collectFindings(firstOptions); + this.removeInstances(pluginsPair, elements, [instances[0].element]); if (findings.templateValue !== null) this.migrateEntry(configObject, findings); else this.noteMultiPageEntry(configObject, findings); this.mergeIntoPlan(configObject, findings); 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); + } + } + + // 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 pages: { entryDescriptor: SgNode; htmlValue: string }[] = []; + const lost: string[] = []; + 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); + const htmlValue = findings.htmlProps.length + ? `{ ${findings.htmlProps.map((prop) => `${prop.name}: ${prop.valueText}`).join(", ")} }` + : "true"; + pages.push({ entryDescriptor, htmlValue }); + } + this.removeInstances(pluginsPair, elements, instances.map((instance) => instance.element)); + 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} }`); + } + } + const plan = this.planFor(configObject); + plan.pluginMigratedHere = true; + this.requireExperimentsHtml(plan); + 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 @@ -516,7 +634,10 @@ class HtmlMigration { // ---------- option mapping ---------- - private collectFindings(optionsObject: SgNode | undefined): InstanceFindings { + private collectFindings( + optionsObject: SgNode | undefined, + skippedOptions?: Set, + ): InstanceFindings { const findings: InstanceFindings = { htmlProps: [], htmlFilename: null, @@ -540,6 +661,7 @@ class HtmlMigration { continue; } if (name === "template" || DROPPABLE_OPTIONS.has(name)) continue; + if (skippedOptions && skippedOptions.has(name)) continue; this.collectOption(name, optionValue, templateMode, findings); } return findings; @@ -728,21 +850,18 @@ class HtmlMigration { private mergeIntoPlan(configObject: SgNode, findings: InstanceFindings): void { const plan = this.planFor(configObject); - plan.needsExperimentsHtml = true; + 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) { - const htmlValue = findings.htmlProps.length - ? `{ ${findings.htmlProps.map((prop) => `${prop.name}: ${prop.valueText}`).join(", ")} }` - : "true"; - plan.outputProps.push({ name: "html", valueText: htmlValue }); - } + if (findings.templateValue === null) plan.htmlEnabled = true; + plan.htmlProps.push(...findings.htmlProps); // The plugin emitted `index.html` by default; native defaults to `[name].html`. - plan.outputProps.push({ name: "htmlFilename", valueText: findings.htmlFilename ?? '"index.html"' }); + plan.htmlFilename ??= findings.htmlFilename ?? '"index.html"'; plan.pendingEntry = findings.pendingEntry; } @@ -755,15 +874,29 @@ class HtmlMigration { (indent) => `// ${pendingEntry.comment}\n${indent}entry: ${pendingEntry.text}`, ); } - if (plan.outputProps.length) { - this.planObjectProps(plan.config, "output", plan.outputProps, plan.commentLines, topProperties); + 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.needsExperimentsHtml) { + if (plan.experimentsProps.length) { this.planObjectProps( plan.config, "experiments", - [{ name: "html", valueText: "true" }], - plan.outputProps.length ? [] : plan.commentLines, + plan.experimentsProps, + outputProps.length ? [] : plan.commentLines, topProperties, ); } diff --git a/codemods/html-plugins-to-native-html/tests/multiple-instances/expected.js b/codemods/html-plugins-to-native-html/tests/multi-chunk-untouched/expected.js similarity index 51% rename from codemods/html-plugins-to-native-html/tests/multiple-instances/expected.js rename to codemods/html-plugins-to-native-html/tests/multi-chunk-untouched/expected.js index c2c374c..92a9507 100644 --- a/codemods/html-plugins-to-native-html/tests/multiple-instances/expected.js +++ b/codemods/html-plugins-to-native-html/tests/multi-chunk-untouched/expected.js @@ -3,7 +3,7 @@ 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"] }), + 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/multiple-instances/input.js b/codemods/html-plugins-to-native-html/tests/multi-page/input.js similarity index 100% rename from codemods/html-plugins-to-native-html/tests/multiple-instances/input.js rename to codemods/html-plugins-to-native-html/tests/multi-page/input.js From f4c3dbf518e74a71041d03f5d2dade3f8b2afb08 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 2 Aug 2026 00:29:21 -0500 Subject: [PATCH 07/17] feat: migrate csp/sri/favicons companion plugins to output.html options --- .../html-plugins-to-native-html/README.md | 1 + .../src/remove-dependencies.ts | 8 +- .../src/workflow.ts | 206 +++++++++++++++++- .../remove-dependencies/removes/input.json | 5 +- .../tests/sibling-plugins/expected.js | 15 ++ .../tests/sibling-plugins/input.js | 16 ++ 6 files changed, 245 insertions(+), 6 deletions(-) create mode 100644 codemods/html-plugins-to-native-html/tests/sibling-plugins/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/sibling-plugins/input.js diff --git a/codemods/html-plugins-to-native-html/README.md b/codemods/html-plugins-to-native-html/README.md index d5c175a..7eb981e 100644 --- a/codemods/html-plugins-to-native-html/README.md +++ b/codemods/html-plugins-to-native-html/README.md @@ -13,6 +13,7 @@ Migrates webpack configurations from `html-webpack-plugin` and `html-loader` to - Drops options the native pipeline covers on its own (`minify: true`/`"auto"`, `cache`, `showErrors`, `chunksSortMode`, `chunks: "all"`, `publicPath: "auto"`) silently. - **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` diff --git a/codemods/html-plugins-to-native-html/src/remove-dependencies.ts b/codemods/html-plugins-to-native-html/src/remove-dependencies.ts index 1622616..794b48c 100644 --- a/codemods/html-plugins-to-native-html/src/remove-dependencies.ts +++ b/codemods/html-plugins-to-native-html/src/remove-dependencies.ts @@ -5,7 +5,13 @@ import { ConfigEditor, findPair, keyName, namedChildren, pairsOf } from "@webpac // 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"]); +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 { diff --git a/codemods/html-plugins-to-native-html/src/workflow.ts b/codemods/html-plugins-to-native-html/src/workflow.ts index 9ea5e2f..82bf605 100644 --- a/codemods/html-plugins-to-native-html/src/workflow.ts +++ b/codemods/html-plugins-to-native-html/src/workflow.ts @@ -2,6 +2,7 @@ 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, @@ -69,6 +70,14 @@ const HOOK_REVIEW_COMMENTS = new Map([ 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; @@ -80,6 +89,15 @@ function dedupeProps(props: HtmlProp[]): HtmlProp[] { 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[]; @@ -150,10 +168,21 @@ class HtmlMigration { // 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, pathModule: PathModule | 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; this.pathModule = pathModule; @@ -180,7 +209,7 @@ class HtmlMigration { this.planConfigInsertions(); this.editor.finalizeRemovals(); if (!this.editor.hasEdits) return null; - for (const binding of this.pluginBindings) { + for (const binding of [...this.pluginBindings, ...this.siblingBindings]) { if (this.repurposedStatements.has(binding.statement.range().start.index)) continue; this.editor.removeBindingIfUnused(binding); } @@ -455,10 +484,13 @@ class HtmlMigration { return; } const findings = this.collectFindings(firstOptions); - this.removeInstances(pluginsPair, elements, [instances[0].element]); + 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; } @@ -474,6 +506,163 @@ class HtmlMigration { } } + // ---------- 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. @@ -524,7 +713,17 @@ class HtmlMigration { : "true"; pages.push({ entryDescriptor, htmlValue }); } - this.removeInstances(pluginsPair, elements, instances.map((instance) => instance.element)); + const plan = this.planFor(configObject); + 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") { @@ -535,7 +734,6 @@ class HtmlMigration { this.editor.replace(node, `{ import: ${node.text()}, html: ${page.htmlValue} }`); } } - const plan = this.planFor(configObject); plan.pluginMigratedHere = true; this.requireExperimentsHtml(plan); plan.htmlFilename ??= '"[name].html"'; 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 index 0a9f939..387e01b 100644 --- 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 @@ -4,8 +4,11 @@ "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": "^5.109.0", + "webpack-subresource-integrity": "^5.2.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" }), + ], +}; From 27514e00c1bd1333ab8617e5c9359b9194bbc49e Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 2 Aug 2026 00:32:35 -0500 Subject: [PATCH 08/17] feat: map scriptLoading module and structured meta values --- .../html-plugins-to-native-html/README.md | 2 +- .../src/workflow.ts | 82 ++++++++++++++++--- .../tests/module-scripts/expected.js | 13 +++ .../tests/module-scripts/input.js | 15 ++++ 4 files changed, 99 insertions(+), 13 deletions(-) create mode 100644 codemods/html-plugins-to-native-html/tests/module-scripts/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/module-scripts/input.js diff --git a/codemods/html-plugins-to-native-html/README.md b/codemods/html-plugins-to-native-html/README.md index 7eb981e..2f32d47 100644 --- a/codemods/html-plugins-to-native-html/README.md +++ b/codemods/html-plugins-to-native-html/README.md @@ -9,7 +9,7 @@ Migrates webpack configurations from `html-webpack-plugin` and `html-loader` to - 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), `favicon`, `base`, `inject` (`"body"`/`"head"`/`false`; `true` is the native default), and `scriptLoading: "blocking"` (`"defer"` is the native default). +- 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. - **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. diff --git a/codemods/html-plugins-to-native-html/src/workflow.ts b/codemods/html-plugins-to-native-html/src/workflow.ts index 82bf605..84a613e 100644 --- a/codemods/html-plugins-to-native-html/src/workflow.ts +++ b/codemods/html-plugins-to-native-html/src/workflow.ts @@ -107,6 +107,8 @@ interface InstanceFindings { 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. @@ -267,6 +269,17 @@ class HtmlMigration { } } + // `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. @@ -678,8 +691,10 @@ class HtmlMigration { 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; @@ -708,12 +723,12 @@ class HtmlMigration { } } 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 plan = this.planFor(configObject); const siblingRemoved = this.processSiblings( elements, instances.map((instance) => instance.element), @@ -736,6 +751,7 @@ class HtmlMigration { } plan.pluginMigratedHere = true; this.requireExperimentsHtml(plan); + this.applyModuleScripts(plan, moduleScripts); plan.htmlFilename ??= '"[name].html"'; if (lost.length) { plan.commentLines.push( @@ -843,6 +859,7 @@ class HtmlMigration { lost: [], notes: [], pendingEntry: null, + scriptLoadingModule: false, }; if (!optionsObject) return findings; const templateValue = findPair(optionsObject, "template")?.field("value"); @@ -889,15 +906,7 @@ class HtmlMigration { findings.htmlProps.push({ name, valueText: value.text() }); break; case "meta": - // Native meta values are `content` strings; attribute objects are not. - if ( - value.kind() === "object" && - pairsOf(value).every((pair) => pair.field("value")?.kind() === "string") - ) { - findings.htmlProps.push({ name, valueText: value.text() }); - } else { - findings.lost.push(this.describeLost(name)); - } + this.collectMetaOption(value, findings); break; case "inject": // `true` is the native default placement; the template authors its own tags. @@ -913,9 +922,17 @@ class HtmlMigration { break; case "scriptLoading": // Native `"auto"` already defers classic scripts. - if (templateMode || literal === "defer") break; - if (literal === "blocking") { + if (literal === "defer") break; + if (templateMode) { + if (literal === "module") { + findings.lost.push( + 'scriptLoading (enable experiments.outputModule and use ` - : `The template is now the entry: reference the previous entry from it, e.g. `; + : `The template is now the entry: reference the previous entry from it, e.g. [${this.injectDebug}]`; const multiline = configObject.text().includes("\n"); if (multiline) { const indent = lineIndent(this.editor.source, entryPair.range().start.index); @@ -1071,14 +1072,20 @@ class HtmlMigration { private injectScriptIntoTemplate(templateQuoted: string, entryRel: string): string | null { const fs = this.fileSystem; const path = this.pathModule; - if (!fs || !path || !this.configFileName) return null; + if (!fs || !path || !this.configFileName) { + this.injectDebug = `no-modules fs=${Boolean(fs)} path=${Boolean(path)} file=${this.configFileName}`; + return null; + } const templateRel = unquote(templateQuoted); if (!templateRel.startsWith(".") || !entryRel.startsWith(".")) return null; try { const configDir = path.dirname(this.configFileName); const templateFile = path.resolve(configDir, templateRel); const entryFile = path.resolve(configDir, entryRel); - if (!fs.existsSync(templateFile) || !fs.existsSync(entryFile)) return null; + if (!fs.existsSync(templateFile) || !fs.existsSync(entryFile)) { + this.injectDebug = `missing file=${this.configFileName} dir=${configDir} tpl=${templateFile}(${fs.existsSync(templateFile)}) entry=${entryFile}(${fs.existsSync(entryFile)})`; + return null; + } const relative = path.relative(path.dirname(templateFile), entryFile).replace(/\\/g, "/"); const scriptSrc = relative.startsWith(".") ? relative : `./${relative}`; const html = fs.readFileSync(templateFile, "utf8"); @@ -1093,7 +1100,8 @@ class HtmlMigration { insertScriptTag(html, ``), ); return scriptSrc; - } catch { + } catch (error) { + this.injectDebug = `error ${(error as Error).message}`; return null; } } From eb0932d3f3dcc031f775df5ba810c078526ad95a Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 2 Aug 2026 01:06:58 -0500 Subject: [PATCH 13/17] fix: resolve template paths by segments, drop the runtime path module --- .../src/workflow.ts | 78 +++++++++++++------ 1 file changed, 53 insertions(+), 25 deletions(-) diff --git a/codemods/html-plugins-to-native-html/src/workflow.ts b/codemods/html-plugins-to-native-html/src/workflow.ts index 378b057..9eeec04 100644 --- a/codemods/html-plugins-to-native-html/src/workflow.ts +++ b/codemods/html-plugins-to-native-html/src/workflow.ts @@ -52,7 +52,6 @@ const LOST_OPTION_HINTS = new Map([ ]); type FsModule = typeof import("node:fs"); -type PathModule = typeof import("node:path"); // Plugin hooks renamed to the native `HtmlModulesPlugin.getCompilationHooks` // stage covering the same moment; arguments differ, hence the review comments. @@ -139,6 +138,42 @@ interface ConfigPlan { 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; +} + +// 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 { @@ -169,8 +204,6 @@ class HtmlMigration { private readonly configPlans = new Map(); private readonly configFileName: string; private readonly fileSystem: FsModule | null; - private readonly pathModule: PathModule | null; - private injectDebug = ""; private pluginMigrated = false; private pluginRetained = false; // Binding statements rewritten in place (e.g. into the `html` import). @@ -179,7 +212,7 @@ class HtmlMigration { private readonly siblingBindings: ModuleBinding[] = []; private readonly siblingNameToModule = new Map(); - constructor(root: SgRoot, fileSystem: FsModule | null, pathModule: PathModule | null) { + 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)); @@ -193,7 +226,6 @@ class HtmlMigration { } this.configFileName = root.filename(); this.fileSystem = fileSystem; - this.pathModule = pathModule; } run(): string | null { @@ -1050,7 +1082,7 @@ class HtmlMigration { const injected = this.injectScriptIntoTemplate(templateValue, unquote(replaceable.text())); const comment = injected ? `The template is now the entry and loads the previous entry via ` - : `The template is now the entry: reference the previous entry from it, e.g. [${this.injectDebug}]`; + : `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); @@ -1071,23 +1103,22 @@ class HtmlMigration { // (no filesystem access, non-relative paths, or files not found on disk). private injectScriptIntoTemplate(templateQuoted: string, entryRel: string): string | null { const fs = this.fileSystem; - const path = this.pathModule; - if (!fs || !path || !this.configFileName) { - this.injectDebug = `no-modules fs=${Boolean(fs)} path=${Boolean(path)} file=${this.configFileName}`; - return null; - } + if (!fs || !this.configFileName) return null; const templateRel = unquote(templateQuoted); if (!templateRel.startsWith(".") || !entryRel.startsWith(".")) return null; try { - const configDir = path.dirname(this.configFileName); - const templateFile = path.resolve(configDir, templateRel); - const entryFile = path.resolve(configDir, entryRel); - if (!fs.existsSync(templateFile) || !fs.existsSync(entryFile)) { - this.injectDebug = `missing file=${this.configFileName} dir=${configDir} tpl=${templateFile}(${fs.existsSync(templateFile)}) entry=${entryFile}(${fs.existsSync(entryFile)})`; - return null; - } - const relative = path.relative(path.dirname(templateFile), entryFile).replace(/\\/g, "/"); - const scriptSrc = relative.startsWith(".") ? relative : `./${relative}`; + // Paths are handled as segment arrays — the runtime's `path` module + // mangles Windows extended-length (`\\?\`) filenames. + const configSegments = pathSegments(this.configFileName); + const separator = this.configFileName.includes("\\") ? "\\" : "/"; + configSegments.pop(); + const templateSegments = applyRelativePath(configSegments, templateRel); + const entrySegments = applyRelativePath(configSegments, entryRel); + if (!templateSegments || !entrySegments) return null; + const templateFile = templateSegments.join(separator); + const entryFile = entrySegments.join(separator); + if (!fs.existsSync(templateFile) || !fs.existsSync(entryFile)) return null; + const scriptSrc = relativeUrl(templateSegments.slice(0, -1), entrySegments); const html = fs.readFileSync(templateFile, "utf8"); // Already loaded (e.g. a re-run) — nothing to write. const sourcePattern = /]*\bsrc\s*=\s*["']([^"']*)["']/gi; @@ -1100,8 +1131,7 @@ class HtmlMigration { insertScriptTag(html, ``), ); return scriptSrc; - } catch (error) { - this.injectDebug = `error ${(error as Error).message}`; + } catch { return null; } } @@ -1225,14 +1255,12 @@ class HtmlMigration { async function transform(root: SgRoot): Promise { let fileSystem: FsModule | null = null; - let pathModule: PathModule | null = null; try { fileSystem = await import("node:fs"); - pathModule = await import("node:path"); } catch { // No filesystem access — template edits fall back to review comments. } - return new HtmlMigration(root, fileSystem, pathModule).run(); + return new HtmlMigration(root, fileSystem).run(); } export default transform; From 9e9885db2a5ca355e61d2229ef3bb558effd7b16 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 2 Aug 2026 01:09:16 -0500 Subject: [PATCH 14/17] debug: probe existsSync variants on windows (temporary) --- codemods/html-plugins-to-native-html/src/workflow.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/codemods/html-plugins-to-native-html/src/workflow.ts b/codemods/html-plugins-to-native-html/src/workflow.ts index 9eeec04..0df5c6f 100644 --- a/codemods/html-plugins-to-native-html/src/workflow.ts +++ b/codemods/html-plugins-to-native-html/src/workflow.ts @@ -204,6 +204,7 @@ class HtmlMigration { private readonly configPlans = new Map(); private readonly configFileName: string; private readonly fileSystem: FsModule | null; + private injectDebug = ""; private pluginMigrated = false; private pluginRetained = false; // Binding statements rewritten in place (e.g. into the `html` import). @@ -1082,7 +1083,7 @@ class HtmlMigration { const injected = this.injectScriptIntoTemplate(templateValue, unquote(replaceable.text())); const comment = injected ? `The template is now the entry and loads the previous entry via ` - : `The template is now the entry: reference the previous entry from it, e.g. `; + : `The template is now the entry: reference the previous entry from it, e.g. [${this.injectDebug}]`; const multiline = configObject.text().includes("\n"); if (multiline) { const indent = lineIndent(this.editor.source, entryPair.range().start.index); @@ -1117,6 +1118,14 @@ class HtmlMigration { if (!templateSegments || !entrySegments) return null; const templateFile = templateSegments.join(separator); const entryFile = entrySegments.join(separator); + const probe = (candidate: string): string => { + try { + return String(fs.existsSync(candidate)); + } catch (error) { + return `ERR:${(error as Error).message}`; + } + }; + this.injectDebug = `raw=${this.configFileName} tpl=${templateFile} e1=${probe(templateFile)} e2=${probe(templateFile.replace(/\\/g, "/"))} e3=${probe(`\\\\?\\${templateFile}`)} cwd=${(globalThis as { process?: { cwd?: () => string } }).process?.cwd?.()}`; if (!fs.existsSync(templateFile) || !fs.existsSync(entryFile)) return null; const scriptSrc = relativeUrl(templateSegments.slice(0, -1), entrySegments); const html = fs.readFileSync(templateFile, "utf8"); From 30285c6b5f24a5177ca56f7c52b7a24ca7ca2a23 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 2 Aug 2026 01:11:24 -0500 Subject: [PATCH 15/17] debug: probe readFileSync on windows (temporary) --- codemods/html-plugins-to-native-html/src/workflow.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/codemods/html-plugins-to-native-html/src/workflow.ts b/codemods/html-plugins-to-native-html/src/workflow.ts index 0df5c6f..6b167b8 100644 --- a/codemods/html-plugins-to-native-html/src/workflow.ts +++ b/codemods/html-plugins-to-native-html/src/workflow.ts @@ -1120,12 +1120,12 @@ class HtmlMigration { const entryFile = entrySegments.join(separator); const probe = (candidate: string): string => { try { - return String(fs.existsSync(candidate)); + return `len${fs.readFileSync(candidate, "utf8").length}`; } catch (error) { return `ERR:${(error as Error).message}`; } }; - this.injectDebug = `raw=${this.configFileName} tpl=${templateFile} e1=${probe(templateFile)} e2=${probe(templateFile.replace(/\\/g, "/"))} e3=${probe(`\\\\?\\${templateFile}`)} cwd=${(globalThis as { process?: { cwd?: () => string } }).process?.cwd?.()}`; + this.injectDebug = `r1=${probe(templateFile)} r2=${probe(templateFile.replace(/\\/g, "/"))} r3=${probe(this.configFileName)} stat=${typeof fs.statSync} access=${typeof fs.accessSync}`; if (!fs.existsSync(templateFile) || !fs.existsSync(entryFile)) return null; const scriptSrc = relativeUrl(templateSegments.slice(0, -1), entrySegments); const html = fs.readFileSync(templateFile, "utf8"); From ea8b55632fadb82b143b46a05db77d29351e8029 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 2 Aug 2026 01:14:44 -0500 Subject: [PATCH 16/17] fix: probe sandbox-accepted path forms with readFileSync --- .../src/workflow.ts | 58 ++++++++++++++----- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/codemods/html-plugins-to-native-html/src/workflow.ts b/codemods/html-plugins-to-native-html/src/workflow.ts index 6b167b8..8de89c8 100644 --- a/codemods/html-plugins-to-native-html/src/workflow.ts +++ b/codemods/html-plugins-to-native-html/src/workflow.ts @@ -159,6 +159,28 @@ function applyRelativePath(baseSegments: string[], relative: string): string[] | 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; @@ -204,7 +226,6 @@ class HtmlMigration { private readonly configPlans = new Map(); private readonly configFileName: string; private readonly fileSystem: FsModule | null; - private injectDebug = ""; private pluginMigrated = false; private pluginRetained = false; // Binding statements rewritten in place (e.g. into the `html` import). @@ -1083,7 +1104,7 @@ class HtmlMigration { const injected = this.injectScriptIntoTemplate(templateValue, unquote(replaceable.text())); const comment = injected ? `The template is now the entry and loads the previous entry via ` - : `The template is now the entry: reference the previous entry from it, e.g. [${this.injectDebug}]`; + : `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); @@ -1116,19 +1137,11 @@ class HtmlMigration { const templateSegments = applyRelativePath(configSegments, templateRel); const entrySegments = applyRelativePath(configSegments, entryRel); if (!templateSegments || !entrySegments) return null; - const templateFile = templateSegments.join(separator); - const entryFile = entrySegments.join(separator); - const probe = (candidate: string): string => { - try { - return `len${fs.readFileSync(candidate, "utf8").length}`; - } catch (error) { - return `ERR:${(error as Error).message}`; - } - }; - this.injectDebug = `r1=${probe(templateFile)} r2=${probe(templateFile.replace(/\\/g, "/"))} r3=${probe(this.configFileName)} stat=${typeof fs.statSync} access=${typeof fs.accessSync}`; - if (!fs.existsSync(templateFile) || !fs.existsSync(entryFile)) return null; + const template = this.readFirst(fs, pathCandidates(templateSegments, separator)); + if (!template) return null; + if (!this.readFirst(fs, pathCandidates(entrySegments, separator))) return null; const scriptSrc = relativeUrl(templateSegments.slice(0, -1), entrySegments); - const html = fs.readFileSync(templateFile, "utf8"); + const html = template.content; // Already loaded (e.g. a re-run) — nothing to write. const sourcePattern = /]*\bsrc\s*=\s*["']([^"']*)["']/gi; for (let match = sourcePattern.exec(html); match; match = sourcePattern.exec(html)) { @@ -1136,7 +1149,7 @@ class HtmlMigration { if (existing === scriptSrc) return scriptSrc; } fs.writeFileSync( - templateFile, + template.path, insertScriptTag(html, ``), ); return scriptSrc; @@ -1145,6 +1158,21 @@ class HtmlMigration { } } + // 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 { From 245a8e2fc72ebea6521093d823eba5ebb9989f10 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 2 Aug 2026 01:18:42 -0500 Subject: [PATCH 17/17] fix: make the template edit best-effort with a platform-stable comment --- .../html-plugins-to-native-html/README.md | 2 +- .../src/workflow.ts | 47 ++++++++++++------- .../tests/template-entry-object/expected.js | 2 +- .../tests/template-no-entry/expected.js | 2 +- .../tests/template-script-added/expected.js | 2 +- .../tests/template/expected.js | 2 +- 6 files changed, 34 insertions(+), 23 deletions(-) diff --git a/codemods/html-plugins-to-native-html/README.md b/codemods/html-plugins-to-native-html/README.md index 01698ed..370f66b 100644 --- a/codemods/html-plugins-to-native-html/README.md +++ b/codemods/html-plugins-to-native-html/README.md @@ -25,7 +25,7 @@ Migrates webpack configurations from `html-webpack-plugin` and `html-loader` to ### `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. When the template and entry files are found on disk (both paths relative), the codemod edits the template and adds `` (relative to the template) before ``, skipping templates that already load it; otherwise it leaves a review comment telling you to add the tag yourself. Since head tags are only injected into webpack-generated pages, `title`/`meta`/`favicon`/`base` are flagged to be added to the template instead. +`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 diff --git a/codemods/html-plugins-to-native-html/src/workflow.ts b/codemods/html-plugins-to-native-html/src/workflow.ts index 8de89c8..2905ff6 100644 --- a/codemods/html-plugins-to-native-html/src/workflow.ts +++ b/codemods/html-plugins-to-native-html/src/workflow.ts @@ -1090,7 +1090,7 @@ class HtmlMigration { findings.pendingEntry = { text: templateValue, comment: injected - ? `The template is now the entry and loads the previous default entry via ` + ? `The template is now the entry: it must load the previous default entry via (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; @@ -1103,7 +1103,7 @@ class HtmlMigration { } const injected = this.injectScriptIntoTemplate(templateValue, unquote(replaceable.text())); const comment = injected - ? `The template is now the entry and loads the previous entry via ` + ? `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) { @@ -1124,37 +1124,48 @@ class HtmlMigration { // when the template already loads it; `null` falls back to a review comment // (no filesystem access, non-relative paths, or files not found on disk). private injectScriptIntoTemplate(templateQuoted: string, entryRel: string): string | null { - const fs = this.fileSystem; - if (!fs || !this.configFileName) return null; + if (!this.configFileName) return null; const templateRel = unquote(templateQuoted); if (!templateRel.startsWith(".") || !entryRel.startsWith(".")) return null; + // Paths are handled as segment arrays — the runtime's `path` module + // mangles Windows extended-length (`\\?\`) filenames. + const configSegments = pathSegments(this.configFileName); + const separator = this.configFileName.includes("\\") ? "\\" : "/"; + configSegments.pop(); + const templateSegments = applyRelativePath(configSegments, templateRel); + const entrySegments = applyRelativePath(configSegments, entryRel); + if (!templateSegments || !entrySegments) return null; + const scriptSrc = relativeUrl(templateSegments.slice(0, -1), entrySegments); + // Best effort: edit the template in place. The runtime's sandboxed fs + // denies transform reads on Windows, so failure just leaves the edit to + // the review comment (which carries the exact tag either way). + this.tryEditTemplate(templateSegments, separator, scriptSrc); + return scriptSrc; + } + + private tryEditTemplate( + templateSegments: string[], + separator: string, + scriptSrc: string, + ): void { + const fs = this.fileSystem; + if (!fs) return; try { - // Paths are handled as segment arrays — the runtime's `path` module - // mangles Windows extended-length (`\\?\`) filenames. - const configSegments = pathSegments(this.configFileName); - const separator = this.configFileName.includes("\\") ? "\\" : "/"; - configSegments.pop(); - const templateSegments = applyRelativePath(configSegments, templateRel); - const entrySegments = applyRelativePath(configSegments, entryRel); - if (!templateSegments || !entrySegments) return null; const template = this.readFirst(fs, pathCandidates(templateSegments, separator)); - if (!template) return null; - if (!this.readFirst(fs, pathCandidates(entrySegments, separator))) return null; - const scriptSrc = relativeUrl(templateSegments.slice(0, -1), entrySegments); + if (!template) return; const html = template.content; // Already loaded (e.g. a re-run) — nothing to write. const sourcePattern = /]*\bsrc\s*=\s*["']([^"']*)["']/gi; for (let match = sourcePattern.exec(html); match; match = sourcePattern.exec(html)) { const existing = match[1].startsWith(".") ? match[1] : `./${match[1]}`; - if (existing === scriptSrc) return scriptSrc; + if (existing === scriptSrc) return; } fs.writeFileSync( template.path, insertScriptTag(html, ``), ); - return scriptSrc; } catch { - return null; + // Unreachable template — the review comment covers the manual step. } } 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 index 1136164..2a9119e 100644 --- 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 @@ -5,7 +5,7 @@ module.exports = { experiments: { html: true, }, - // The template is now the entry: reference the previous entry from it, e.g. + // 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-no-entry/expected.js b/codemods/html-plugins-to-native-html/tests/template-no-entry/expected.js index 33fede3..4a3d47c 100644 --- 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 @@ -1,5 +1,5 @@ module.exports = { - // The template is now the entry: reference the previous entry (webpack's default is ./src/index.js) from it, e.g. + // 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", 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 index 093f24b..52918df 100644 --- 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 @@ -5,6 +5,6 @@ module.exports = { experiments: { html: true, }, - // The template is now the entry and loads the previous entry via + // 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/expected.js b/codemods/html-plugins-to-native-html/tests/template/expected.js index a2b1739..0d61dfa 100644 --- a/codemods/html-plugins-to-native-html/tests/template/expected.js +++ b/codemods/html-plugins-to-native-html/tests/template/expected.js @@ -2,7 +2,7 @@ module.exports = { experiments: { html: true, }, - // The template is now the entry: reference the previous entry from it, e.g. + // 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)