From dcfd19cd62ad2f149bc8c82532f6800b11765816 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 23:44:07 -0500 Subject: [PATCH 01/42] 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 316d457b440463a6e54605c4e0b80be0438a8ec4 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 23:49:10 -0500 Subject: [PATCH 02/42] 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 bacb4a28dd53f4249a19f23c639b2f232c1afaba Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 23:56:06 -0500 Subject: [PATCH 03/42] 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 cc265e5f49a740a145326379b284cd6ddc168833 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 1 Aug 2026 23:59:10 -0500 Subject: [PATCH 04/42] 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 ab2a803c959aab3c9a0b7a0b3fd6fdfc814d846b Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 2 Aug 2026 00:02:55 -0500 Subject: [PATCH 05/42] 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 3dfe1b3aa76c3e6da97376f794a2364aa4121342 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 2 Aug 2026 00:23:15 -0500 Subject: [PATCH 06/42] 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 1134c408eab17d7721a40d2b7361f357544cf881 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 2 Aug 2026 00:29:21 -0500 Subject: [PATCH 07/42] 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 ac1dda3793d8a128d2468358ce3e612dc582f58a Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 2 Aug 2026 00:32:35 -0500 Subject: [PATCH 08/42] 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 0a5af623afb69484810e25bd3305882753d3bf3b Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 2 Aug 2026 01:06:58 -0500 Subject: [PATCH 13/42] 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 215a3e189d8638ba8b6ef4ba4ccfdef35b2e9777 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 2 Aug 2026 01:09:16 -0500 Subject: [PATCH 14/42] 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 aad5b56010eed2727ac2ce532cc838c2e8ea1f3f Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 2 Aug 2026 01:11:24 -0500 Subject: [PATCH 15/42] 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 0cddfb864c9ed06d42b796a8cb4608b6fff2fa0d Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 2 Aug 2026 01:14:44 -0500 Subject: [PATCH 16/42] 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 fec910f98205b3e91bd6d22ff0ae9e1efc961ddd Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sun, 2 Aug 2026 01:18:42 -0500 Subject: [PATCH 17/42] 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) From 7d86405f4d81f8170d23983067c435e8fdd6b588 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 7 Nov 2026 11:31:38 -0500 Subject: [PATCH 18/42] feat: improve native HTML migration and minification Translate supported HTML minification settings, preserve existing configuration and used dependencies, and apply template changes through AST workflow steps without filesystem APIs. Add regression and workflow tests covering dry runs and idempotence. --- .changeset/html-plugins-to-native-html.md | 4 +- README.md | 2 +- .../html-plugins-to-native-html/README.md | 52 ++- .../html-plugins-to-native-html/codemod.yaml | 2 +- .../html-plugins-to-native-html/package.json | 8 +- .../src/migration-state.ts | 81 ++++ .../src/minification.ts | 120 ++++++ .../src/remove-dependencies.ts | 28 +- .../src/templates.ts | 49 +++ .../src/workflow.ts | 365 ++++++++---------- .../dynamic-output-untouched/expected.js | 2 + .../tests/dynamic-output-untouched/input.js | 2 + .../dynamic-template-untouched/expected.js | 2 + .../tests/dynamic-template-untouched/input.js | 2 + .../tests/existing-native-html/expected.js | 4 + .../tests/existing-native-html/input.js | 6 + .../tests/hooks-destructured/expected.js | 10 + .../tests/hooks-destructured/input.js | 3 + .../tests/hooks-migrated/expected.js | 3 +- .../hooks-unrelated-properties/expected.js | 10 + .../tests/hooks-unrelated-properties/input.js | 5 + .../tests/inline-hooks/expected.js | 8 + .../tests/inline-hooks/input.js | 3 + .../tests/inline-lost-options/expected.js | 3 + .../tests/inline-lost-options/input.js | 2 + .../tests/integration/project/package.json | 7 + .../project/packages/migrated/package.json | 6 + .../packages/migrated/webpack.config.cjs | 3 + .../packages/skipped/webpack.config.cjs | 3 + .../tests/integration/project/src/index.html | 7 + .../tests/integration/project/src/index.js | 1 + .../tests/integration/project/unrelated.html | 1 + .../integration/project/webpack.config.cjs | 6 + .../expected.js | 3 + .../loader-dynamic-parser-untouched/input.js | 3 + .../loader-existing-type-parser/expected.js | 11 + .../loader-existing-type-parser/input.js | 11 + .../tests/loader-minify-conflict/expected.js | 3 + .../tests/loader-minify-conflict/input.js | 6 + .../tests/loader-minimize-false/expected.js | 3 + .../tests/loader-minimize-false/input.js | 3 + .../tests/loader-minimize-options/expected.js | 3 + .../tests/loader-minimize-options/input.js | 4 + .../tests/loader-other-extensions/expected.js | 6 + .../tests/loader-other-extensions/input.js | 3 + .../tests/loader-preprocessor/expected.js | 3 +- .../tests/lost-options/expected.js | 3 +- .../tests/minify-crlf/expected.js | 10 + .../tests/minify-crlf/input.js | 4 + .../tests/minify-custom-minimizer/expected.js | 10 + .../tests/minify-custom-minimizer/input.js | 5 + .../minify-dynamic-minimizer/expected.js | 12 + .../tests/minify-dynamic-minimizer/input.js | 5 + .../minify-dynamic-optimization/expected.js | 11 + .../minify-dynamic-optimization/input.js | 5 + .../tests/minify-existing-native/expected.js | 14 + .../tests/minify-existing-native/input.js | 8 + .../minify-existing-optimization/expected.js | 14 + .../minify-existing-optimization/input.js | 9 + .../minify-false-custom-minimizer/expected.js | 10 + .../minify-false-custom-minimizer/input.js | 5 + .../tests/minify-false/expected.js | 11 + .../tests/minify-false/input.js | 5 + .../tests/minify-idempotent/expected.js | 5 + .../tests/minify-idempotent/input.js | 5 + .../tests/minify-options/expected.mjs | 12 + .../tests/minify-options/input.mjs | 17 + .../tests/minify-shorthand/expected.js | 10 + .../tests/minify-shorthand/input.js | 5 + .../tests/minify-template/expected.js | 11 + .../tests/minify-template/input.js | 5 + .../tests/minify-unknown-options/expected.js | 10 + .../tests/minify-unknown-options/input.js | 4 + .../expected.js | 5 + .../multi-entry-template-untouched/input.js | 5 + .../expected.js | 8 + .../input.js | 8 + .../tests/multi-page-minify/expected.js | 10 + .../tests/multi-page-minify/input.js | 8 + .../tests/native-html-disabled/expected.js | 4 + .../tests/native-html-disabled/input.js | 6 + .../tests/remove-dependencies.ts | 9 + .../manifest-reference/expected.json | 4 + .../manifest-reference/input.json | 4 + .../spread-options-untouched/expected.js | 2 + .../tests/spread-options-untouched/input.js | 2 + .../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 +- .../tests/templates.ts | 7 + .../tests/templates/body/expected.html | 1 + .../tests/templates/body/input.html | 1 + .../comment-is-not-script/expected.html | 1 + .../comment-is-not-script/input.html | 1 + .../tests/templates/crlf/expected.html | 3 + .../tests/templates/crlf/input.html | 2 + .../templates/existing-unquoted/expected.html | 1 + .../templates/existing-unquoted/input.html | 1 + .../tests/templates/existing/expected.html | 1 + .../tests/templates/existing/input.html | 1 + .../tests/templates/fragment/expected.html | 2 + .../tests/templates/fragment/input.html | 1 + .../tests/templates/head/expected.html | 8 + .../tests/templates/head/input.html | 7 + .../tests/templates/inline/expected.html | 1 + .../tests/templates/inline/input.html | 1 + .../tests/workflow.test.mjs | 60 +++ .../html-plugins-to-native-html/workflow.yaml | 13 +- 109 files changed, 1060 insertions(+), 233 deletions(-) create mode 100644 codemods/html-plugins-to-native-html/src/migration-state.ts create mode 100644 codemods/html-plugins-to-native-html/src/minification.ts create mode 100644 codemods/html-plugins-to-native-html/src/templates.ts create mode 100644 codemods/html-plugins-to-native-html/tests/dynamic-output-untouched/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/dynamic-output-untouched/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/dynamic-template-untouched/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/dynamic-template-untouched/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/existing-native-html/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/existing-native-html/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/hooks-destructured/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/hooks-destructured/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/hooks-unrelated-properties/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/hooks-unrelated-properties/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/inline-hooks/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/inline-hooks/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/inline-lost-options/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/inline-lost-options/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/integration/project/package.json create mode 100644 codemods/html-plugins-to-native-html/tests/integration/project/packages/migrated/package.json create mode 100644 codemods/html-plugins-to-native-html/tests/integration/project/packages/migrated/webpack.config.cjs create mode 100644 codemods/html-plugins-to-native-html/tests/integration/project/packages/skipped/webpack.config.cjs create mode 100644 codemods/html-plugins-to-native-html/tests/integration/project/src/index.html create mode 100644 codemods/html-plugins-to-native-html/tests/integration/project/src/index.js create mode 100644 codemods/html-plugins-to-native-html/tests/integration/project/unrelated.html create mode 100644 codemods/html-plugins-to-native-html/tests/integration/project/webpack.config.cjs create mode 100644 codemods/html-plugins-to-native-html/tests/loader-dynamic-parser-untouched/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-dynamic-parser-untouched/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-existing-type-parser/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-existing-type-parser/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-minify-conflict/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-minify-conflict/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-minimize-false/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-minimize-false/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-minimize-options/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-minimize-options/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-other-extensions/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-other-extensions/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-crlf/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-crlf/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-custom-minimizer/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-custom-minimizer/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-dynamic-minimizer/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-dynamic-minimizer/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-dynamic-optimization/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-dynamic-optimization/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-existing-native/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-existing-native/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-existing-optimization/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-existing-optimization/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-false-custom-minimizer/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-false-custom-minimizer/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-false/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-false/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-idempotent/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-idempotent/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-options/expected.mjs create mode 100644 codemods/html-plugins-to-native-html/tests/minify-options/input.mjs create mode 100644 codemods/html-plugins-to-native-html/tests/minify-shorthand/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-shorthand/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-template/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-template/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-unknown-options/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/minify-unknown-options/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/multi-entry-template-untouched/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/multi-entry-template-untouched/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/multi-page-minify-conflict-untouched/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/multi-page-minify-conflict-untouched/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/multi-page-minify/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/multi-page-minify/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/native-html-disabled/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/native-html-disabled/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies.ts create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/manifest-reference/expected.json create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/manifest-reference/input.json create mode 100644 codemods/html-plugins-to-native-html/tests/spread-options-untouched/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/spread-options-untouched/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/templates.ts create mode 100644 codemods/html-plugins-to-native-html/tests/templates/body/expected.html create mode 100644 codemods/html-plugins-to-native-html/tests/templates/body/input.html create mode 100644 codemods/html-plugins-to-native-html/tests/templates/comment-is-not-script/expected.html create mode 100644 codemods/html-plugins-to-native-html/tests/templates/comment-is-not-script/input.html create mode 100644 codemods/html-plugins-to-native-html/tests/templates/crlf/expected.html create mode 100644 codemods/html-plugins-to-native-html/tests/templates/crlf/input.html create mode 100644 codemods/html-plugins-to-native-html/tests/templates/existing-unquoted/expected.html create mode 100644 codemods/html-plugins-to-native-html/tests/templates/existing-unquoted/input.html create mode 100644 codemods/html-plugins-to-native-html/tests/templates/existing/expected.html create mode 100644 codemods/html-plugins-to-native-html/tests/templates/existing/input.html create mode 100644 codemods/html-plugins-to-native-html/tests/templates/fragment/expected.html create mode 100644 codemods/html-plugins-to-native-html/tests/templates/fragment/input.html create mode 100644 codemods/html-plugins-to-native-html/tests/templates/head/expected.html create mode 100644 codemods/html-plugins-to-native-html/tests/templates/head/input.html create mode 100644 codemods/html-plugins-to-native-html/tests/templates/inline/expected.html create mode 100644 codemods/html-plugins-to-native-html/tests/templates/inline/input.html create mode 100644 codemods/html-plugins-to-native-html/tests/workflow.test.mjs diff --git a/.changeset/html-plugins-to-native-html.md b/.changeset/html-plugins-to-native-html.md index c6601fe..4201a63 100644 --- a/.changeset/html-plugins-to-native-html.md +++ b/.changeset/html-plugins-to-native-html.md @@ -2,4 +2,6 @@ "@webpack/html-plugins-to-native-html": major --- -Add codemod migrating html-webpack-plugin setups to webpack's native HTML support. +Add a codemod migrating html-webpack-plugin and html-loader to webpack 5.110.0+ native HTML support and minification. Translate supported minification options, preserve existing configuration and still-used dependencies, and leave unsupported template/multi-page setups for manual migration. + +Apply template and dependency changes through AST workflow steps coordinated by shared state, without filesystem APIs or permissions; preserve dry-run behavior and idempotence. diff --git a/README.md b/README.md index ce50400..c16dc66 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` and `html-loader` rules 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` to native HTML support and minification. | ## Contributing diff --git a/codemods/html-plugins-to-native-html/README.md b/codemods/html-plugins-to-native-html/README.md index 370f66b..acf03a3 100644 --- a/codemods/html-plugins-to-native-html/README.md +++ b/codemods/html-plugins-to-native-html/README.md @@ -2,7 +2,7 @@ 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. +> Requires **webpack >= 5.110.0**: the transform uses native HTML generation and the built-in HTML minifier, configured through `optimization.minimizeOptions.html`. ## What it does @@ -10,9 +10,9 @@ Migrates webpack configurations from `html-webpack-plugin` and `html-loader` to - Enables the native pipeline with `experiments: { html: true }` and `output.html` on each migrated configuration. - Sets `output.htmlFilename` to the plugin's `filename` — or to `"index.html"`, the plugin's default, since the native default is `[name].html`. - Maps plugin options to their `output.html` counterparts: `title`, `meta` (string values, plus `{ name | property, content }` objects — `og:*` keys included), `favicon`, `base`, `inject` (`"body"`/`"head"`/`false`; `true` is the native default), and `scriptLoading` — `"blocking"` maps directly, `"defer"` is the native default, and `"module"` becomes `output.module: true` + `experiments.outputModule: true` (native module scripts). -- Drops options the native pipeline covers on its own (`minify: true`/`"auto"`, `cache`, `showErrors`, `chunksSortMode`, `chunks: "all"`, `publicPath: "auto"`) silently — webpack's default `optimization.minimizer` (`minimizer-webpack-plugin`) already minifies the emitted HTML/CSS in production; custom `minify` objects are flagged towards it. +- Uses webpack's built-in HTML minifier and translates supported `minify` options to `optimization.minimizeOptions.html`; see [Minification](#minification). Build-only options (`cache`, `showErrors`, `chunksSortMode`, `chunks: "all"`, `publicPath: "auto"`) are dropped. - **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. +- Options without a native equivalent (`hash`, unsupported `minify` settings, `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. @@ -21,17 +21,51 @@ Migrates webpack configurations from `html-webpack-plugin` and `html-loader` to - 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 })`). +- Loader options: boolean `sources` becomes the rule's `parser: { sources }`; `esModule` is dropped (native exports are ESM), and `minimize` uses the same native minifier option mapping as the plugin; a `sources` object or `preprocessor` function is flagged with a review comment (`preprocessor` maps manually to the rule's `parser.template`, which is synchronous and receives `(source, { module, resource })`). ### `template` -`output.html` generates each page from scratch, so an authored template maps to webpack's other native mode instead: the **HTML entry point**. The codemod turns the template into the entry, and — because the HTML file now drives the build — it must load the previous JS entry itself. The review comment always states the exact tag (``, relative to the template), and as a best effort the codemod also edits the template in place, inserting the tag before `` unless it is already there (the in-place edit is skipped where the runtime sandbox blocks file access, e.g. on Windows — the comment still tells you what to add). Since head tags are only injected into webpack-generated pages, `title`/`meta`/`favicon`/`base` are flagged to be added to the template instead. +`output.html` generates each page from scratch, so an authored template maps to webpack's other native mode instead: the **HTML entry point**. The configuration transform makes the template the entry and records the previous JS entry in workflow state. A separate HTML AST transform adds the corresponding `` before `` (or ``), preserving line endings and avoiding duplicate script references. Comments containing script markup do not count as existing scripts. + +All changes are returned to the codemod runner; the transforms do not use filesystem APIs. This keeps template changes inside the normal workflow and makes `--dry-run` safe. Only `.html` templates included in the workflow are edited. The configuration keeps a review comment with the script reference for missing/excluded templates and when running the configuration transform alone. Since head tags are only injected into webpack-generated pages, `title`/`meta`/`favicon`/`base` are flagged to be added to the template instead. ### What is left untouched - Multi-page configurations the per-entry shape can't express: an instance whose `chunks` lists several entries (one page aggregating several chunks), combines `chunks` with `template`, or names an entry the config's `entry` object doesn't declare. - Files that tap `beforeAssetTagGeneration` or `afterTemplateExecution` via `HtmlWebpackPlugin.getHooks(...)`: those stages have no native equivalent (webpack builds the tags and runs the parser template itself). -- Plugin instantiations whose options are not an object literal. +- Plugin instantiations whose options are not an object literal, or contain spreads/shorthand properties. +- Configurations with computed `output` or `experiments`, dynamic template paths, or templates whose JS entry cannot be represented by a single script. +- Multi-page configurations with different minification settings per page: native minification applies to the whole compilation. +- Rules whose existing dynamic `parser` or `experiments` cannot safely receive the migrated settings. + +### Minification + +The [built-in HTML minifier](https://webpack.js.org/configuration/optimization/#optimizationminimizeoptions) needs no additional package. Absent options, `minify: true`, and `minify: "auto"` use webpack's defaults. The codemod preserves `mode` and `optimization.minimize`: minification runs in production by default. If the old plugin explicitly minified in development, set `optimization.minimize: true` after migration; this also enables the other configured minimizers. + +Both plugin `minify` and loader `minimize` accept these translations: + +| Original setting | Native setting under `optimization.minimizeOptions` | +| --- | --- | +| `false` | `html: false` (JS/CSS keep their settings) | +| `collapseWhitespace`, `collapseBooleanAttributes`, `minifyConditionalComments`, `removeEmptyAttributes`, `removeEmptyElements`, `removeRedundantAttributes`, `sortAttributes` | Same boolean property in `html` | +| `removeComments` | Inverted `html.comments` | +| `removeAttributeQuotes` | `html.normalizeAttributeQuotes` | +| `removeOptionalTags` | Both `html.removeOptionalTags` and `html.removeImpliedTags` | +| `conservativeCollapse: true` | Covered by native whitespace handling | + +For example, `new HtmlWebpackPlugin({ minify: { removeComments: false, collapseWhitespace: false } })` adds: + +```js +optimization: { + minimizeOptions: { + html: { comments: true, collapseWhitespace: false }, + }, +}, +``` + +Existing native HTML minification options take precedence and receive a review comment. Other asset types' settings are preserved, including the `optimization.minimize: { javascript: … }` shorthand. A custom literal `optimization.minimizer` list gets `"..."` appended once, after its existing plugins, to restore webpack's default minimizers; this also restores default JS/CSS processing for assets the custom plugins leave unminimized. A migration that disables HTML minification does not add that default entry. Dynamic optimization/minimizer values receive instructions for manual integration. + +The native minifier has different whitespace and attribute normalization rules from `html-minifier-terser`; this is not a byte-for-byte output conversion. Unknown or dynamic options, including embedded `minifyJS`/`minifyCSS` options, get review comments instead of being passed to an incompatible API. Loader settings become compilation-wide settings. Conflicting loader/page settings are flagged for manual review. ## Usage @@ -39,6 +73,8 @@ Migrates webpack configurations from `html-webpack-plugin` and `html-loader` to npx codemod run @webpack/html-plugins-to-native-html ``` +The workflow runs the configuration, HTML-template, and dependency transforms in sequence. It does not require filesystem permissions. + ## Example Before: @@ -80,7 +116,7 @@ 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 by the HTML workflow step when the template is included) entry: "./src/index.html", output: { htmlFilename: "index.html", @@ -88,4 +124,4 @@ module.exports = { }; ``` -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. +After migration, the codemod removes unused `html-webpack-plugin`, `html-loader`, and supported companion plugins from `dependencies` and `devDependencies`. The configuration step records remaining package references in workflow state, scoped by source filename. The JSON transform combines those references with other manifest fields, preserving packages still referenced beneath that manifest by skipped configs or other tooling. Running the JSON transform alone keeps dependencies because no source-reference scan is available. Run your package manager afterward to update the lockfile. diff --git a/codemods/html-plugins-to-native-html/codemod.yaml b/codemods/html-plugins-to-native-html/codemod.yaml index a8bcb62..bf39e0c 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 and html-loader rules to webpack's native HTML support (experiments.html) +description: Migrate html-webpack-plugin and html-loader to webpack's native HTML support and minification 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 81de66a..cd317f2 100644 --- a/codemods/html-plugins-to-native-html/package.json +++ b/codemods/html-plugins-to-native-html/package.json @@ -2,12 +2,14 @@ "name": "@webpack/html-plugins-to-native-html", "private": true, "version": "0.0.0", - "description": "Migrate html-webpack-plugin and html-loader rules to webpack's native HTML support (experiments.html).", + "description": "Migrate html-webpack-plugin and html-loader to webpack's native HTML support and minification.", "type": "module", "scripts": { - "test": "npm run test:workflow && npm run test:dependencies", + "test": "npm run test:workflow && npm run test:templates && npm run test:dependencies && npm run test:integration", "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" + "test:dependencies": "npx codemod jssg test -l json ./tests/remove-dependencies.ts ./tests/remove-dependencies", + "test:templates": "npx codemod jssg test -l html ./tests/templates.ts ./tests/templates", + "test:integration": "node --test ./tests/workflow.test.mjs" }, "repository": { "type": "git", diff --git a/codemods/html-plugins-to-native-html/src/migration-state.ts b/codemods/html-plugins-to-native-html/src/migration-state.ts new file mode 100644 index 0000000..c676151 --- /dev/null +++ b/codemods/html-plugins-to-native-html/src/migration-state.ts @@ -0,0 +1,81 @@ +import type {} from "@codemod.com/jssg-types/workflow"; +import { acquireLock, getState, setState } from "codemod:workflow"; + +const SOURCES_KEY = "webpack-native-html:sources"; +const TEMPLATES_KEY = "webpack-native-html:templates"; + +export const HTML_PACKAGES = [ + "html-webpack-plugin", + "html-loader", + "csp-html-webpack-plugin", + "webpack-subresource-integrity", + "favicons-webpack-plugin", +]; + +// Use one spelling for filenames from the JS, HTML and JSON parsers, including +// Windows filenames. Resolving these paths never opens or writes a file. +export function normalizeFilename(filename: string): string { + const segments: string[] = []; + for (const part of filename.replace(/^\\\\\?\\/, "").replace(/\\/g, "/").split("/")) { + if (part === ".") continue; + if (part === ".." && segments.length && segments.at(-1) !== "..") segments.pop(); + else segments.push(part); + } + return segments.join("/"); +} + +export function directoryOf(filename: string): string { + const normalized = normalizeFilename(filename); + const slash = normalized.lastIndexOf("/"); + return slash < 0 ? "." : normalized.slice(0, slash); +} + +export function recordSource(filename: string, source: string): void { + const release = acquireLock(SOURCES_KEY); + try { + const sources = getState>(SOURCES_KEY) ?? {}; + sources[normalizeFilename(filename)] = HTML_PACKAGES.filter((name) => source.includes(name)); + setState(SOURCES_KEY, sources); + } finally { + release(); + } +} + +export function remainingPackages(manifestFilename: string): Set | null { + const sources = getState>(SOURCES_KEY); + // Running the JSON transform alone provides no evidence for safe removal. + if (!sources) return null; + const directory = directoryOf(manifestFilename); + const remaining = new Set(); + for (const [filename, packages] of Object.entries(sources)) { + if (directory !== "." && !filename.startsWith(`${directory}/`)) continue; + for (const name of packages) remaining.add(name); + } + return remaining; +} + +export function queueTemplate(configFilename: string, template: string, entry: string): string | null { + if (!template.startsWith(".") || !entry.startsWith(".")) return null; + const directory = directoryOf(configFilename); + const templateFilename = normalizeFilename(`${directory}/${template}`); + const entryFilename = normalizeFilename(`${directory}/${entry}`); + const from = directoryOf(templateFilename).split("/"); + const to = entryFilename.split("/"); + let common = 0; + while (common < from.length && common < to.length - 1 && from[common] === to[common]) common += 1; + const up = from.length - common; + const script = `${up ? "../".repeat(up) : "./"}${to.slice(common).join("/")}`; + const release = acquireLock(TEMPLATES_KEY); + try { + const templates = getState>(TEMPLATES_KEY) ?? {}; + templates[templateFilename] = [...new Set([...(templates[templateFilename] ?? []), script])].sort(); + setState(TEMPLATES_KEY, templates); + } finally { + release(); + } + return script; +} + +export function scriptsForTemplate(filename: string): string[] { + return getState>(TEMPLATES_KEY)?.[normalizeFilename(filename)] ?? []; +} diff --git a/codemods/html-plugins-to-native-html/src/minification.ts b/codemods/html-plugins-to-native-html/src/minification.ts new file mode 100644 index 0000000..db9ab5c --- /dev/null +++ b/codemods/html-plugins-to-native-html/src/minification.ts @@ -0,0 +1,120 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode } from "@codemod.com/jssg-types/main"; +import { ConfigEditor, findPair, keyName, namedChildren, unquote } from "@webpack/codemod-utils"; + +export interface Minification { + // null uses webpack's production default; false disables only HTML. + html: string | null; + lost: string[]; +} + +const BOOLEAN_OPTIONS = new Set([ + "collapseBooleanAttributes", + "collapseWhitespace", + "minifyConditionalComments", + "removeEmptyAttributes", + "removeEmptyElements", + "removeRedundantAttributes", + "sortAttributes", +]); + +// html-minifier-terser and webpack do not accept the same option object. +// Only translate known literal values; retain a review note for everything else. +export function collectMinification(value: SgNode | null | undefined, name: string): Minification { + const result: Minification = { html: null, lost: [] }; + if (!value || value.kind() === "true" || (value.kind() === "string" && unquote(value.text()) === "auto")) { + return result; + } + if (value.kind() === "false") return { html: "false", lost: [] }; + if (value.kind() !== "object") { + result.lost.push(`${name} (configure optimization.minimizeOptions.html)`); + return result; + } + const props = new Map(); + for (const child of namedChildren(value)) { + if (child.kind() === "comment") continue; + const key = keyName(child); + const option = child.field("value"); + if (!key || !option || (option.kind() !== "true" && option.kind() !== "false")) { + result.lost.push(`${name}.${key ?? "options"} (configure optimization.minimizeOptions.html)`); + continue; + } + const enabled = option.kind() === "true"; + if (BOOLEAN_OPTIONS.has(key)) { + props.set(key, option.text()); + } else if (key === "removeComments") { + props.set("comments", enabled ? "false" : "true"); + } else if (key === "removeAttributeQuotes") { + props.set("normalizeAttributeQuotes", option.text()); + } else if (key === "removeOptionalTags") { + props.set("removeOptionalTags", option.text()); + props.set("removeImpliedTags", option.text()); + } else if (key === "conservativeCollapse") { + // webpack's `true` already uses conservative whitespace collapsing. + if (!enabled) result.lost.push(`${name}.${key} (review collapseWhitespace: "all")`); + } else { + result.lost.push(`${name}.${key} (configure optimization.minimizeOptions.html)`); + } + } + if (props.size) { + result.html = `{ ${[...props].map(([key, text]) => `${key}: ${text}`).join(", ")} }`; + } + return result; +} + +// Keep the build's mode and minimize switch: using `minimize: { html: ... }` +// would also turn JavaScript/CSS minimization on in development. +export function planMinification( + editor: ConfigEditor, + config: SgNode, + requests: Minification[], + notes: string[], +): string | null { + if (!requests.length) return null; + const values = new Set(requests.map((request) => request.html ?? "{}")); + let html = requests.find((request) => request.html !== null)?.html ?? null; + if (values.size > 1) { + notes.push("Review: HTML minification differs between pages or loaders; configure optimization.minimizeOptions.html for the whole compilation"); + html = null; + } + const optimization = findPair(config, "optimization")?.field("value"); + if (!optimization) { + return html === null ? null : `optimization: { minimizeOptions: { html: ${html} } }`; + } + if (optimization.kind() !== "object") { + notes.push("Review: configure the existing optimization value to use webpack's native HTML minimizer (minimizeOptions.html and minimizer: [\"...\"])"); + return null; + } + const minimizer = findPair(optimization, "minimizer")?.field("value"); + if (minimizer?.kind() === "array" && html !== "false") { + const elements = namedChildren(minimizer).filter((element) => element.kind() !== "comment"); + if (!elements.some((element) => element.kind() === "string" && unquote(element.text()) === "...")) { + // Append after custom minimizers, so assets they marked minimized stay so. + const end = elements.at(-1)?.range().end.index ?? minimizer.range().start.index + 1; + editor.addEdit({ + startPos: end, + endPos: end, + insertedText: `${elements.length ? "," : ""} "..."`, + }); + } + } else if (minimizer && minimizer.kind() !== "array") { + notes.push('Review: keep "..." in optimization.minimizer to enable native HTML minification'); + } + if (html === null) return null; + const minimize = findPair(optimization, "minimize")?.field("value"); + const separateOptions = findPair(optimization, "minimizeOptions")?.field("value"); + // The shorthand takes precedence during webpack normalization. + const options = minimize?.kind() === "object" ? minimize : separateOptions; + if (options?.kind() === "object") { + if (!findPair(options, "html")) { + editor.insertIntoObject(options, () => [`html: ${html}`]); + } else { + notes.push("Review: existing native HTML minification options take precedence over migrated minify/minimize options"); + } + } else if (options) { + notes.push("Review: merge the HTML minification options into the existing optimization.minimizeOptions value"); + } else { + editor.insertIntoObject(optimization, () => [`minimizeOptions: { html: ${html} }`]); + } + return null; +} 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 794b48c..4dedd4f 100644 --- a/codemods/html-plugins-to-native-html/src/remove-dependencies.ts +++ b/codemods/html-plugins-to-native-html/src/remove-dependencies.ts @@ -3,29 +3,31 @@ import type Json from "@codemod.com/jssg-types/langs/json"; import type { SgNode, SgRoot } from "@codemod.com/jssg-types/main"; import { ConfigEditor, findPair, keyName, namedChildren, pairsOf } from "@webpack/codemod-utils"; -// Packages replaced by native HTML; review your lockfile if other tooling -// (Storybook, tests, …) still relies on them. -const REMOVED_PACKAGES = new Set([ - "html-webpack-plugin", - "html-loader", - "csp-html-webpack-plugin", - "webpack-subresource-integrity", - "favicons-webpack-plugin", -]); +import { HTML_PACKAGES, remainingPackages } from "./migration-state"; + +const REMOVED_PACKAGES = new Set(HTML_PACKAGES); const DEPENDENCY_KEYS = ["dependencies", "devDependencies"]; -async function transform(root: SgRoot): Promise { +export function removeUnusedDependencies(root: SgRoot, remaining: Set | null): string | null { // 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; + if (!remaining) return null; + // Scripts and other manifest fields can invoke these packages as well. + for (const pair of pairsOf(manifest)) { + if (DEPENDENCY_KEYS.includes(keyName(pair) ?? "")) continue; + for (const name of REMOVED_PACKAGES) { + if (pair.text().includes(name)) remaining.add(name); + } + } 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); + if (name && REMOVED_PACKAGES.has(name) && !remaining.has(name)) editor.markForRemoval(pair); } } editor.finalizeRemovals(); @@ -33,4 +35,6 @@ async function transform(root: SgRoot): Promise { return editor.commit(); } -export default transform; +export default async function transform(root: SgRoot): Promise { + return removeUnusedDependencies(root, remainingPackages(root.filename())); +} diff --git a/codemods/html-plugins-to-native-html/src/templates.ts b/codemods/html-plugins-to-native-html/src/templates.ts new file mode 100644 index 0000000..30e499f --- /dev/null +++ b/codemods/html-plugins-to-native-html/src/templates.ts @@ -0,0 +1,49 @@ +import type Html from "@codemod.com/jssg-types/langs/html"; +import type { SgRoot } from "@codemod.com/jssg-types/main"; +import { normalizeFilename, scriptsForTemplate } from "./migration-state"; + +function attributeValue(text: string): string { + return text.replace(/&/g, "&").replace(/"/g, """).replace(/, scripts: string[]): string | null { + if (!scripts.length) return null; + const node = root.root(); + const source = node.text(); + const eol = source.includes("\r\n") ? "\r\n" : "\n"; + const existing = new Set(); + for (const script of node.findAll({ rule: { kind: "script_element" } })) { + const start = script.children().find((child) => child.kind() === "start_tag"); + for (const attribute of start?.children() ?? []) { + if (attribute.kind() !== "attribute") continue; + const name = attribute.children().find((child) => child.kind() === "attribute_name"); + if (name?.text().toLowerCase() !== "src") continue; + const value = attribute.children().find((child) => child.kind() === "quoted_attribute_value" || child.kind() === "attribute_value"); + if (value) existing.add(normalizeFilename(value.text().replace(/^["']|["']$/g, ""))); + } + } + const missing = [...new Set(scripts)].filter((script) => !existing.has(normalizeFilename(attributeValue(script)))); + if (!missing.length) return null; + const tags = missing.map((script) => ``); + const endings = node.findAll({ rule: { kind: "end_tag" } }); + const closing = endings.find((tag) => /^<\/head\s*>$/i.test(tag.text())) + ?? endings.find((tag) => /^<\/body\s*>$/i.test(tag.text())); + let position = source.length; + let text = `${source.endsWith("\n") || !source ? "" : eol}${tags.join(eol)}${eol}`; + if (closing) { + position = closing.range().start.index; + const lineStart = source.lastIndexOf("\n", position - 1) + 1; + const indent = source.slice(lineStart, position); + if (/^[ \t]*$/.test(indent)) { + position = lineStart; + text = tags.map((tag) => `${indent} ${tag}${eol}`).join(""); + } else { + text = tags.join(""); + } + } + return node.commitEdits([{ startPos: position, endPos: position, insertedText: text }]); +} + +export default async function transform(root: SgRoot): Promise { + return addTemplateScripts(root, scriptsForTemplate(root.filename())); +} diff --git a/codemods/html-plugins-to-native-html/src/workflow.ts b/codemods/html-plugins-to-native-html/src/workflow.ts index 2905ff6..818aab5 100644 --- a/codemods/html-plugins-to-native-html/src/workflow.ts +++ b/codemods/html-plugins-to-native-html/src/workflow.ts @@ -19,16 +19,18 @@ import { unquote, unwrapFilterCall, } from "@webpack/codemod-utils"; +import { collectMinification, planMinification, type Minification } from "./minification"; +import { queueTemplate, recordSource } from "./migration-state"; const PLUGIN_MODULE = "html-webpack-plugin"; const LOADER_NAME = "html-loader"; const HTML_SAMPLE_FILES = ["/file.html"]; +const DEFAULT_HTML_TESTS = new Set(["/\\.html$/", "/\\.html$/i"]); // Native HTML defaults already covered by these plugin option values. const HEAD_TAG_OPTIONS = new Set(["title", "meta", "favicon", "base"]); // Build-ergonomics options with no effect on the emitted page. const DROPPABLE_OPTIONS = new Set(["cache", "showErrors", "chunksSortMode"]); -// Loader options native HTML covers on its own: `esModule` (native exports are -// ESM) and boolean `minimize` (optimization.minimizer minifies in production). +// Native HTML exports are ESM. const DROPPABLE_LOADER_OPTIONS = new Set(["esModule"]); // Manual migration paths appended to the review comment where one exists. const LOST_OPTION_HINTS = new Map([ @@ -37,7 +39,6 @@ const LOST_OPTION_HINTS = new Map([ ["chunks", "use per-entry `html` descriptors"], ["excludeChunks", "use per-entry `html` descriptors"], ["scriptLoading", "module scripts come from experiments.outputModule"], - ["minify", "production HTML is minified by default; customize via optimization.minimizer (minimizer-webpack-plugin)"], ["templateContent", "author the page as an .html entry file"], ["html-loader.sources", "customize the rule's parser.sources list"], [ @@ -48,11 +49,8 @@ const LOST_OPTION_HINTS = new Map([ "html-loader.postprocessor", "tap HtmlModulesPlugin.getCompilationHooks(compilation).transformHtml for emitted pages", ], - ["html-loader.minimize", "customize via optimization.minimizer (minimizer-webpack-plugin)"], ]); -type FsModule = typeof import("node:fs"); - // Plugin hooks renamed to the native `HtmlModulesPlugin.getCompilationHooks` // stage covering the same moment; arguments differ, hence the review comments. const HOOK_RENAMES = new Map([ @@ -104,6 +102,7 @@ function isRequireOf(node: SgNode, moduleName: string): boolean { // Everything one plugin instance contributes to its enclosing config. interface InstanceFindings { + minification: Minification; htmlProps: HtmlProp[]; htmlFilename: string | null; templateValue: string | null; @@ -119,11 +118,13 @@ interface InstanceFindings { interface LoaderFindings { lost: string[]; parserProps: HtmlProp[]; + minification: Minification[]; } // Properties to add to one webpack config object once all removals are known. interface ConfigPlan { config: SgNode; + minification: Minification[]; commentLines: string[]; // Emit a global `output.html` (single-page mode; per-entry pages skip it). htmlEnabled: boolean; @@ -138,94 +139,12 @@ 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; -} - -// Path forms to try against the runtime's sandboxed fs: native separators, -// forward slashes, and cwd-relative — on Windows the sandbox normalizes -// absolute drive paths into a form its allow-list check rejects, while a -// relative path resolves internally and passes. -function pathCandidates(segments: string[], separator: string): string[] { - const list = [segments.join(separator)]; - const forward = segments.join("/"); - if (!list.includes(forward)) list.push(forward); - const cwd = (globalThis as { process?: { cwd?: () => string } }).process?.cwd?.(); - if (cwd) { - const cwdSegments = pathSegments(cwd); - const isUnder = - cwdSegments.length > 0 && - cwdSegments.length < segments.length && - cwdSegments.every( - (segment, index) => segment.toLowerCase() === segments[index].toLowerCase(), - ); - if (isUnder) list.push(segments.slice(cwdSegments.length).join("/")); - } - return list; -} - -// Relative URL (forward slashes) from a directory to a file. -function relativeUrl(fromDirSegments: string[], toSegments: string[]): string { - let common = 0; - while ( - common < fromDirSegments.length && - common < toSegments.length - 1 && - fromDirSegments[common] === toSegments[common] - ) { - common += 1; - } - const ups = fromDirSegments.length - common; - const down = toSegments.slice(common).join("/"); - return ups ? `${"../".repeat(ups)}${down}` : `./${down}`; -} - -// Insert the script tag before `` (or ``), matching the -// closing tag's indentation; append when the template has neither. -function insertScriptTag(html: string, tag: string): string { - for (const marker of [/<\/head>/i, /<\/body>/i]) { - const match = marker.exec(html); - if (!match) continue; - const lineStart = html.lastIndexOf("\n", match.index) + 1; - const closingIndent = html.slice(lineStart, match.index); - if (!/^[ \t]*$/.test(closingIndent)) { - // Closing tag mid-line — insert inline right before it. - return `${html.slice(0, match.index)}${tag}${html.slice(match.index)}`; - } - // Indent like the previous sibling line when it sits deeper. - const before = html.slice(0, lineStart); - const prevLineStart = before.lastIndexOf("\n", before.length - 2) + 1; - const prevIndentMatch = /^[ \t]*/.exec(before.slice(prevLineStart)); - const prevIndent = prevIndentMatch ? prevIndentMatch[0] : ""; - const indent = prevIndent.length > closingIndent.length ? prevIndent : `${closingIndent} `; - return `${before}${indent}${tag}\n${html.slice(lineStart)}`; - } - return `${html.replace(/\s*$/, "")}\n${tag}\n`; -} - class HtmlMigration { private readonly editor: ConfigEditor; private readonly pluginBindings; private readonly pluginNames: Set; private readonly configPlans = new Map(); private readonly configFileName: string; - private readonly fileSystem: FsModule | null; private pluginMigrated = false; private pluginRetained = false; // Binding statements rewritten in place (e.g. into the `html` import). @@ -234,7 +153,7 @@ class HtmlMigration { private readonly siblingBindings: ModuleBinding[] = []; private readonly siblingNameToModule = new Map(); - constructor(root: SgRoot, fileSystem: FsModule | null) { + 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)); @@ -247,7 +166,6 @@ class HtmlMigration { } } this.configFileName = root.filename(); - this.fileSystem = fileSystem; } run(): string | null { @@ -290,18 +208,48 @@ class HtmlMigration { private usesUnmappableHooks(): boolean { if (!this.pluginNames.size || !this.pluginHookReceivers().length) return false; - for (const node of this.editor.rootNode.findAll({ rule: { kind: "property_identifier" } })) { + for (const node of this.pluginHookProperties()) { if (UNMAPPABLE_HOOKS.has(node.text())) return true; } return false; } + // Only keys read from this plugin's hook object belong to its API. Other + // plugins and ordinary application properties may use exactly the same names. + private pluginHookProperties(): SgNode[] { + const properties: SgNode[] = []; + for (const receiver of this.pluginHookReceivers()) { + const call = receiver.parent(); + const access = call?.parent(); + if (access?.kind() === "member_expression") { + const property = access.field("property"); + if (property) properties.push(property); + } else if (access?.kind() === "variable_declarator") { + const name = access.field("name"); + if (name?.kind() === "object_pattern") { + for (const member of namedChildren(name)) { + const key = member.kind() === "pair_pattern" ? member.field("key") : member; + if (key) properties.push(key); + } + } else if (name?.kind() === "identifier") { + for (const member of this.editor.rootNode.findAll({ rule: { kind: "member_expression" } })) { + if (member.field("object")?.text() !== name.text()) continue; + const property = member.field("property"); + if (property) properties.push(property); + } + } + } + } + return properties; + } + private planFor(config: SgNode): ConfigPlan { const key = config.range().start.index; let plan = this.configPlans.get(key); if (!plan) { plan = { config, + minification: [], commentLines: [], htmlEnabled: false, htmlProps: [], @@ -359,6 +307,12 @@ class HtmlMigration { } if (node.kind() !== "object" || loaderNameOf(node) !== LOADER_NAME) return; const optionsValue = findPair(node, "options")?.field("value"); + const minification = collectMinification( + optionsValue?.kind() === "object" ? findPair(optionsValue, "minimize")?.field("value") : undefined, + `${LOADER_NAME}.minimize`, + ); + findings.minification.push(minification); + findings.lost.push(...minification.lost); if (!optionsValue) return; if (optionsValue.kind() !== "object") { findings.lost.push(`${LOADER_NAME}.options`); @@ -368,14 +322,7 @@ class HtmlMigration { const name = keyName(optionPair); const value = optionPair.field("value"); if (name !== null && DROPPABLE_LOADER_OPTIONS.has(name)) continue; - if (name === "minimize" && value) { - // Custom minifier settings don't carry over; booleans do (production - // minification via optimization.minimizer is the native behavior). - if (value.kind() !== "true" && value.kind() !== "false") { - findings.lost.push(this.describeLost(`${LOADER_NAME}.minimize`)); - } - continue; - } + if (name === "minimize") continue; if (name === "sources" && value) { // Booleans map to the rule's parser; source lists need a human. if (value.kind() === "true" || value.kind() === "false") { @@ -417,7 +364,7 @@ class HtmlMigration { const ruleObject = pair.parent(); if (!ruleObject || ruleObject.kind() !== "object") continue; if (!this.isWebpackRuleContext(pair, ruleObject.parent() ?? ruleObject)) continue; - const findings: LoaderFindings = { lost: [], parserProps: [] }; + const findings: LoaderFindings = { lost: [], parserProps: [], minification: [] }; for (const element of elements) this.collectLoaderFindings(element, findings); const trivial = pairsOf(ruleObject).every((rulePair) => { const name = keyName(rulePair); @@ -441,7 +388,7 @@ class HtmlMigration { const value = pair.field("value"); if (!value || !this.isRemovableUseElement(value)) continue; if (!this.isWebpackRuleContext(pair, ruleObject.parent() ?? ruleObject)) continue; - const findings: LoaderFindings = { lost: [], parserProps: [] }; + const findings: LoaderFindings = { lost: [], parserProps: [], minification: [] }; this.collectLoaderFindings(ruleObject, findings); const trivial = pairsOf(ruleObject).every((rulePair) => { const name = keyName(rulePair); @@ -468,12 +415,29 @@ class HtmlMigration { trivial: boolean; optionsPair: SgNode | null; }): void { + const config = findConfigObjectFor(swap.pair); + const experiments = config && findPair(config, "experiments")?.field("value"); + const parser = findPair(swap.ruleObject, "parser")?.field("value"); + if ( + (experiments && experiments.kind() !== "object") || + (swap.findings.parserProps.length && parser && parser.kind() !== "object") + ) return; + if (config) { + this.planFor(config).minification.push( + ...(swap.findings.minification.length ? swap.findings.minification : [{ html: null, lost: [] }]), + ); + } const survives = !swap.trivial || + !DEFAULT_HTML_TESTS.has(findPair(swap.ruleObject, "test")?.field("value")?.text() ?? "") || swap.kept.length > 0 || swap.findings.lost.length > 0 || swap.findings.parserProps.length > 0; if (!survives) { + // Explicitly disabled HTML must be enabled even when its loader rule goes. + if (config && experiments && findPair(experiments, "html")) { + this.requireExperimentsHtml(this.planFor(config)); + } this.editor.markForRemoval(cascadeRemovalTarget(swap.ruleObject)); return; } @@ -494,16 +458,24 @@ class HtmlMigration { const filterSuffix = keepsGuard ? swap.filterSuffix || ".filter(Boolean)" : ""; replacement += `use: [${keptTexts.join(", ")}]${filterSuffix}${separator}`; } + const typePair = findPair(swap.ruleObject, "type"); replacement += `type: "html"`; + if (typePair) this.editor.markForRemoval(typePair); if (swap.findings.parserProps.length) { - const texts = swap.findings.parserProps.map((prop) => `${prop.name}: ${prop.valueText}`); - replacement += `${separator}parser: { ${texts.join(", ")} }`; + if (parser?.kind() === "object") { + const missing = swap.findings.parserProps.filter((prop) => !findPair(parser, prop.name)); + if (missing.length) { + this.editor.insertIntoObject(parser, () => missing.map((prop) => `${prop.name}: ${prop.valueText}`)); + } + } else if (!parser) { + const texts = swap.findings.parserProps.map((prop) => `${prop.name}: ${prop.valueText}`); + replacement += `${separator}parser: { ${texts.join(", ")} }`; + } } this.editor.replace(swap.pair, replacement); if (swap.optionsPair) this.editor.markForRemoval(swap.optionsPair); // A surviving rule that matches `.html` turns the "auto" default off. if (!ruleMatchesFiles(swap.ruleObject, HTML_SAMPLE_FILES)) return; - const config = findConfigObjectFor(swap.pair); if (config) this.requireExperimentsHtml(this.planFor(config)); } @@ -543,12 +515,29 @@ class HtmlMigration { if (instantiation) instances.push({ element, instantiation }); } if (!instances.length) return; + // A computed destination cannot safely receive the native settings. + if (["output", "experiments"].some((key) => { + const destination = findPair(configObject, key)?.field("value"); + return destination && destination.kind() !== "object"; + })) { + this.pluginRetained = true; + 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") { + if (optionsObject && ( + optionsObject.kind() !== "object" || + namedChildren(optionsObject).some((child) => child.kind() !== "pair" && child.kind() !== "comment") + )) { + this.pluginRetained = true; + return; + } + const template = optionsObject && findPair(optionsObject, "template")?.field("value"); + const entry = findPair(configObject, "entry")?.field("value"); + if (template && (template.kind() !== "string" || (entry && !this.replaceableEntryValue(entry)))) { this.pluginRetained = true; return; } @@ -762,6 +751,7 @@ class HtmlMigration { } const plan = this.planFor(configObject); const pages: { entryDescriptor: SgNode; htmlValue: string }[] = []; + const minification: Minification[] = []; const lost: string[] = []; let moduleScripts = false; for (const options of optionObjects) { @@ -783,6 +773,7 @@ class HtmlMigration { return; } const findings = this.collectFindings(options, MULTI_PAGE_SKIPPED_OPTIONS); + minification.push(findings.minification); // The page filename must fit the shared `htmlFilename: "[name].html"`. const filenameValue = findPair(options, "filename")?.field("value"); if (filenameValue) { @@ -798,6 +789,12 @@ class HtmlMigration { : "true"; pages.push({ entryDescriptor, htmlValue }); } + // Per-page minification cannot be represented by a compilation-wide option. + if (new Set(minification.map((request) => request.html ?? "{}")).size > 1) { + this.pluginRetained = true; + return; + } + plan.minification.push(...minification); const siblingRemoved = this.processSiblings( elements, instances.map((instance) => instance.element), @@ -843,19 +840,25 @@ class HtmlMigration { 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" } })) { + for (const property of this.pluginHookProperties()) { const renamed = HOOK_RENAMES.get(property.text()); if (!renamed) continue; - this.editor.replace(property, renamed); + this.editor.replace(property, property.kind() === "shorthand_property_identifier_pattern" + ? `${renamed}: ${property.text()}` : 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); + if (receivers.some((receiver) => receiver.range().start.index === statement.range().start.index)) { + // An insertion at the same position as the receiver replacement would + // be swallowed by commitEdits. A trailing comment avoids the overlap. + this.editor.insertAfter(statement, ` /* Review: ${review} */`); + continue; + } const lineStart = this.editor.source.lastIndexOf("\n", statement.range().start.index - 1) + 1; const indent = lineIndent(this.editor.source, statement.range().start.index); @@ -865,13 +868,6 @@ class HtmlMigration { 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. @@ -922,6 +918,10 @@ class HtmlMigration { skippedOptions?: Set, ): InstanceFindings { const findings: InstanceFindings = { + minification: collectMinification( + optionsObject ? findPair(optionsObject, "minify")?.field("value") : undefined, + "minify", + ), htmlProps: [], htmlFilename: null, templateValue: null, @@ -1007,9 +1007,7 @@ class HtmlMigration { } break; case "minify": - if (value.kind() !== "true" && literal !== "auto") { - findings.lost.push(this.describeLost(name)); - } + findings.lost.push(...findings.minification.lost); break; case "chunks": if (literal !== "all") findings.lost.push(this.describeLost(name)); @@ -1080,17 +1078,17 @@ class HtmlMigration { } // The template becomes the entry (native HTML entry point); the previous - // entry must be referenced from the template with a ` (added automatically when the template was found on disk)` + ? `The template is now the entry: it must load the previous default entry via (added by the HTML workflow step when the template is included)` : `The template is now the entry: reference the previous entry (webpack's default is ./src/index.js) from it, e.g. `, }; return; @@ -1101,9 +1099,9 @@ class HtmlMigration { findings.lost.push("template (make the template an .html entry that loads your JS)"); return; } - const injected = this.injectScriptIntoTemplate(templateValue, unquote(replaceable.text())); + const injected = queueTemplate(this.configFileName, unquote(templateValue), unquote(replaceable.text())); const comment = injected - ? `The template is now the entry: it must load the previous entry via (added automatically when the template was found on disk)` + ? `The template is now the entry: it must load the previous entry via (added by the HTML workflow step when the template is included)` : `The template is now the entry: reference the previous entry from it, e.g. `; const multiline = configObject.text().includes("\n"); if (multiline) { @@ -1119,71 +1117,6 @@ class HtmlMigration { } } - // Add a ``), - ); - } catch { - // Unreachable template — the review comment covers the manual step. - } - } - - // First candidate form the sandboxed runtime lets us read. - private readFirst( - fs: FsModule, - candidates: string[], - ): { path: string; content: string } | null { - for (const candidate of candidates) { - try { - return { path: candidate, content: fs.readFileSync(candidate, "utf8") }; - } catch { - // Try the next path form. - } - } - return null; - } - // A string entry value the template path can take over: the value itself, a // single-element array, or a single-key object's string value. private replaceableEntryValue(entryValue: SgNode): SgNode | null { @@ -1205,6 +1138,7 @@ class HtmlMigration { private mergeIntoPlan(configObject: SgNode, findings: InstanceFindings): void { const plan = this.planFor(configObject); + plan.minification.push(findings.minification); plan.pluginMigratedHere = true; this.requireExperimentsHtml(plan); if (findings.lost.length) { @@ -1224,6 +1158,8 @@ class HtmlMigration { private planConfigInsertions(): void { for (const plan of this.configPlans.values()) { const topProperties: ((indent: string, unit: string) => string)[] = []; + const optimization = planMinification(this.editor, plan.config, plan.minification, plan.commentLines); + if (optimization) topProperties.push(() => optimization); const pendingEntry = plan.pendingEntry; if (pendingEntry) { topProperties.push( @@ -1232,6 +1168,15 @@ class HtmlMigration { } const outputProps: HtmlProp[] = []; const htmlProps = dedupeProps(plan.htmlProps); + const existingOutput = findPair(plan.config, "output")?.field("value"); + const existingHtml = existingOutput?.kind() === "object" + ? findPair(existingOutput, "html")?.field("value") : undefined; + if (existingHtml?.kind() === "object") { + const missing = htmlProps.filter((prop) => !findPair(existingHtml, prop.name)); + if (missing.length) { + this.editor.insertIntoObject(existingHtml, () => missing.map((prop) => `${prop.name}: ${prop.valueText}`)); + } + } if (plan.htmlEnabled || htmlProps.length) { outputProps.push({ name: "html", @@ -1256,6 +1201,9 @@ class HtmlMigration { topProperties, ); } + if (!outputProps.length && !plan.experimentsProps.length && plan.commentLines.length) { + this.insertReviewComments(plan.config, plan.commentLines); + } if (topProperties.length) { // A fully-emptied config keeps its braces open for these properties. this.editor.keepBracesOpen(plan.config); @@ -1266,6 +1214,16 @@ class HtmlMigration { } } + private insertReviewComments(node: SgNode, lines: string[]): void { + if (!lines.length) return; + const position = node.range().start.index; + this.editor.addEdit({ + startPos: position, + endPos: position, + insertedText: `/* ${lines.join("; ").replace(/\*\//g, "* /")} */ `, + }); + } + // 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. @@ -1278,12 +1236,29 @@ class HtmlMigration { ): void { const withComments = (propertyText: string, indent: string, first: boolean): string => { if (!first || !commentLines.length) return propertyText; + if (!config.text().includes("\n")) { + return `/* ${commentLines.join("; ").replace(/\*\//g, "* /")} */ ${propertyText}`; + } return `${commentLines.map((line) => `// ${line}\n${indent}`).join("")}${propertyText}`; }; const value = findPair(config, key)?.field("value"); if (value && value.kind() === "object") { + for (const prop of props) { + const existing = findPair(value, prop.name)?.field("value"); + if (!existing) continue; + if ( + (key === "experiments" && (prop.name === "html" || prop.name === "outputModule")) || + (key === "output" && prop.name === "module") || + (key === "output" && prop.name === "html" && (existing.kind() === "false" || existing.kind() === "true")) + ) { + this.editor.replace(existing, prop.valueText); + } + } const missing = props.filter((prop) => !findPair(value, prop.name)); - if (!missing.length) return; + if (!missing.length) { + this.insertReviewComments(value, commentLines); + return; + } this.editor.insertIntoObject(value, (indent) => missing.map((prop, index) => withComments(`${prop.name}: ${prop.valueText}`, indent, index === 0), @@ -1302,13 +1277,9 @@ class HtmlMigration { } async function transform(root: SgRoot): Promise { - let fileSystem: FsModule | null = null; - try { - fileSystem = await import("node:fs"); - } catch { - // No filesystem access — template edits fall back to review comments. - } - return new HtmlMigration(root, fileSystem).run(); + const result = new HtmlMigration(root).run(); + recordSource(root.filename(), result ?? root.root().text()); + return result; } export default transform; diff --git a/codemods/html-plugins-to-native-html/tests/dynamic-output-untouched/expected.js b/codemods/html-plugins-to-native-html/tests/dynamic-output-untouched/expected.js new file mode 100644 index 0000000..a4ef5f7 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/dynamic-output-untouched/expected.js @@ -0,0 +1,2 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { output: getOutput(), plugins: [new HtmlWebpackPlugin()] }; diff --git a/codemods/html-plugins-to-native-html/tests/dynamic-output-untouched/input.js b/codemods/html-plugins-to-native-html/tests/dynamic-output-untouched/input.js new file mode 100644 index 0000000..a4ef5f7 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/dynamic-output-untouched/input.js @@ -0,0 +1,2 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { output: getOutput(), plugins: [new HtmlWebpackPlugin()] }; diff --git a/codemods/html-plugins-to-native-html/tests/dynamic-template-untouched/expected.js b/codemods/html-plugins-to-native-html/tests/dynamic-template-untouched/expected.js new file mode 100644 index 0000000..651dec6 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/dynamic-template-untouched/expected.js @@ -0,0 +1,2 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { plugins: [new HtmlWebpackPlugin({ template: getTemplate() })] }; diff --git a/codemods/html-plugins-to-native-html/tests/dynamic-template-untouched/input.js b/codemods/html-plugins-to-native-html/tests/dynamic-template-untouched/input.js new file mode 100644 index 0000000..651dec6 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/dynamic-template-untouched/input.js @@ -0,0 +1,2 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { plugins: [new HtmlWebpackPlugin({ template: getTemplate() })] }; diff --git a/codemods/html-plugins-to-native-html/tests/existing-native-html/expected.js b/codemods/html-plugins-to-native-html/tests/existing-native-html/expected.js new file mode 100644 index 0000000..825909e --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/existing-native-html/expected.js @@ -0,0 +1,4 @@ +module.exports = { + output: /* Removed html-webpack-plugin options without a native HTML equivalent: hash (use [contenthash] in output.htmlFilename) */ { html: { favicon: "./icon.png", title: "Native title" }, htmlFilename: "native.html" }, + experiments: { html: true }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/existing-native-html/input.js b/codemods/html-plugins-to-native-html/tests/existing-native-html/input.js new file mode 100644 index 0000000..0fb3d27 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/existing-native-html/input.js @@ -0,0 +1,6 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { + output: { html: { title: "Native title" }, htmlFilename: "native.html" }, + experiments: { html: true }, + plugins: [new HtmlWebpackPlugin({ title: "Plugin title", favicon: "./icon.png", hash: true })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/hooks-destructured/expected.js b/codemods/html-plugins-to-native-html/tests/hooks-destructured/expected.js new file mode 100644 index 0000000..4df2c60 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/hooks-destructured/expected.js @@ -0,0 +1,10 @@ +const { html } = require("webpack"); +// Review: transformHtml receives (html, { outputName }) and must return the html string +// Review: htmlEmitted receives ({ outputName }); nothing to return +const { transformHtml: beforeEmit, htmlEmitted: done } = html.HtmlModulesPlugin.getCompilationHooks(compilation); +module.exports = { output: { + html: true, + htmlFilename: "index.html", +}, experiments: { + html: true, +}, }; diff --git a/codemods/html-plugins-to-native-html/tests/hooks-destructured/input.js b/codemods/html-plugins-to-native-html/tests/hooks-destructured/input.js new file mode 100644 index 0000000..e80e567 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/hooks-destructured/input.js @@ -0,0 +1,3 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +const { beforeEmit, afterEmit: done } = HtmlWebpackPlugin.getHooks(compilation); +module.exports = { plugins: [new HtmlWebpackPlugin()] }; 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 index cfb76f3..eaf7690 100644 --- a/codemods/html-plugins-to-native-html/tests/hooks-migrated/expected.js +++ b/codemods/html-plugins-to-native-html/tests/hooks-migrated/expected.js @@ -3,8 +3,7 @@ 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)); + html.HtmlModulesPlugin.getCompilationHooks(compilation).transformHtml.tapAsync("MyPlugin", (data, callback) => callback(null, data)); /* Review: transformHtml receives (html, { outputName }) and must return the html string */ }); } } diff --git a/codemods/html-plugins-to-native-html/tests/hooks-unrelated-properties/expected.js b/codemods/html-plugins-to-native-html/tests/hooks-unrelated-properties/expected.js new file mode 100644 index 0000000..3ee7e66 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/hooks-unrelated-properties/expected.js @@ -0,0 +1,10 @@ +const { html } = require("webpack"); +const other = { beforeEmit: true, beforeAssetTagGeneration: true }; +const { beforeEmit } = other; +html.HtmlModulesPlugin.getCompilationHooks(compilation).transformHtml.tap("Example", (data) => data); /* Review: transformHtml receives (html, { outputName }) and must return the html string */ +module.exports = { output: { + html: true, + htmlFilename: "index.html", +}, experiments: { + html: true, +}, }; diff --git a/codemods/html-plugins-to-native-html/tests/hooks-unrelated-properties/input.js b/codemods/html-plugins-to-native-html/tests/hooks-unrelated-properties/input.js new file mode 100644 index 0000000..a5c7ff1 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/hooks-unrelated-properties/input.js @@ -0,0 +1,5 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +const other = { beforeEmit: true, beforeAssetTagGeneration: true }; +const { beforeEmit } = other; +HtmlWebpackPlugin.getHooks(compilation).beforeEmit.tap("Example", (data) => data); +module.exports = { plugins: [new HtmlWebpackPlugin()] }; diff --git a/codemods/html-plugins-to-native-html/tests/inline-hooks/expected.js b/codemods/html-plugins-to-native-html/tests/inline-hooks/expected.js new file mode 100644 index 0000000..9ca0341 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/inline-hooks/expected.js @@ -0,0 +1,8 @@ +const { html } = require("webpack"); +class Plugin { apply(compiler) { compiler.hooks.compilation.tap("Plugin", c => { html.HtmlModulesPlugin.getCompilationHooks(c).transformHtml.tap("Plugin", data => data); /* Review: transformHtml receives (html, { outputName }) and must return the html string */ }); } } +module.exports = { output: { + html: true, + htmlFilename: "index.html", +}, experiments: { + html: true, +}, plugins: [new Plugin()] }; diff --git a/codemods/html-plugins-to-native-html/tests/inline-hooks/input.js b/codemods/html-plugins-to-native-html/tests/inline-hooks/input.js new file mode 100644 index 0000000..50b0481 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/inline-hooks/input.js @@ -0,0 +1,3 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +class Plugin { apply(compiler) { compiler.hooks.compilation.tap("Plugin", c => { HtmlWebpackPlugin.getHooks(c).beforeEmit.tap("Plugin", data => data); }); } } +module.exports = { plugins: [new HtmlWebpackPlugin(), new Plugin()] }; diff --git a/codemods/html-plugins-to-native-html/tests/inline-lost-options/expected.js b/codemods/html-plugins-to-native-html/tests/inline-lost-options/expected.js new file mode 100644 index 0000000..f6c4ecf --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/inline-lost-options/expected.js @@ -0,0 +1,3 @@ +module.exports = { experiments: { + html: true, +}, output: { /* Removed html-webpack-plugin options without a native HTML equivalent: minify (configure optimization.minimizeOptions.html) */ html: true, htmlFilename: "index.html" } }; diff --git a/codemods/html-plugins-to-native-html/tests/inline-lost-options/input.js b/codemods/html-plugins-to-native-html/tests/inline-lost-options/input.js new file mode 100644 index 0000000..ab0b88c --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/inline-lost-options/input.js @@ -0,0 +1,2 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { output: {}, plugins: [new HtmlWebpackPlugin({ minify: settings })] }; diff --git a/codemods/html-plugins-to-native-html/tests/integration/project/package.json b/codemods/html-plugins-to-native-html/tests/integration/project/package.json new file mode 100644 index 0000000..977831d --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/integration/project/package.json @@ -0,0 +1,7 @@ +{ + "devDependencies": { + "html-webpack-plugin": "^5.6.0", + "html-loader": "^5.1.0", + "webpack": "^5.110.0" + } +} diff --git a/codemods/html-plugins-to-native-html/tests/integration/project/packages/migrated/package.json b/codemods/html-plugins-to-native-html/tests/integration/project/packages/migrated/package.json new file mode 100644 index 0000000..5c1144e --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/integration/project/packages/migrated/package.json @@ -0,0 +1,6 @@ +{ + "devDependencies": { + "html-webpack-plugin": "^5.6.0", + "webpack": "^5.110.0" + } +} diff --git a/codemods/html-plugins-to-native-html/tests/integration/project/packages/migrated/webpack.config.cjs b/codemods/html-plugins-to-native-html/tests/integration/project/packages/migrated/webpack.config.cjs new file mode 100644 index 0000000..d4f55e6 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/integration/project/packages/migrated/webpack.config.cjs @@ -0,0 +1,3 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +module.exports = { plugins: [new HtmlWebpackPlugin()] }; diff --git a/codemods/html-plugins-to-native-html/tests/integration/project/packages/skipped/webpack.config.cjs b/codemods/html-plugins-to-native-html/tests/integration/project/packages/skipped/webpack.config.cjs new file mode 100644 index 0000000..bd72e4b --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/integration/project/packages/skipped/webpack.config.cjs @@ -0,0 +1,3 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +module.exports = { plugins: [new HtmlWebpackPlugin(options)] }; diff --git a/codemods/html-plugins-to-native-html/tests/integration/project/src/index.html b/codemods/html-plugins-to-native-html/tests/integration/project/src/index.html new file mode 100644 index 0000000..bda7c5f --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/integration/project/src/index.html @@ -0,0 +1,7 @@ + + + + App + +
+ diff --git a/codemods/html-plugins-to-native-html/tests/integration/project/src/index.js b/codemods/html-plugins-to-native-html/tests/integration/project/src/index.js new file mode 100644 index 0000000..473bda4 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/integration/project/src/index.js @@ -0,0 +1 @@ +console.log("App"); diff --git a/codemods/html-plugins-to-native-html/tests/integration/project/unrelated.html b/codemods/html-plugins-to-native-html/tests/integration/project/unrelated.html new file mode 100644 index 0000000..d6424d1 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/integration/project/unrelated.html @@ -0,0 +1 @@ +Unrelated diff --git a/codemods/html-plugins-to-native-html/tests/integration/project/webpack.config.cjs b/codemods/html-plugins-to-native-html/tests/integration/project/webpack.config.cjs new file mode 100644 index 0000000..ee1420d --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/integration/project/webpack.config.cjs @@ -0,0 +1,6 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +module.exports = { + entry: "./src/index.js", + plugins: [new HtmlWebpackPlugin({ template: "./src/index.html", minify: false })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-dynamic-parser-untouched/expected.js b/codemods/html-plugins-to-native-html/tests/loader-dynamic-parser-untouched/expected.js new file mode 100644 index 0000000..4eab8fd --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-dynamic-parser-untouched/expected.js @@ -0,0 +1,3 @@ +module.exports = { + module: { rules: [{ test: /\.html$/, parser: getParser(), loader: "html-loader", options: { sources: false } }] }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-dynamic-parser-untouched/input.js b/codemods/html-plugins-to-native-html/tests/loader-dynamic-parser-untouched/input.js new file mode 100644 index 0000000..4eab8fd --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-dynamic-parser-untouched/input.js @@ -0,0 +1,3 @@ +module.exports = { + module: { rules: [{ test: /\.html$/, parser: getParser(), loader: "html-loader", options: { sources: false } }] }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-existing-type-parser/expected.js b/codemods/html-plugins-to-native-html/tests/loader-existing-type-parser/expected.js new file mode 100644 index 0000000..f5c3732 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-existing-type-parser/expected.js @@ -0,0 +1,11 @@ +module.exports = { + experiments: { html: true }, + module: { + rules: [{ + test: /\.html$/, + parser: { sources: false, custom: true }, + use: ["template-loader"], + type: "html", + }], + }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-existing-type-parser/input.js b/codemods/html-plugins-to-native-html/tests/loader-existing-type-parser/input.js new file mode 100644 index 0000000..16c5f75 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-existing-type-parser/input.js @@ -0,0 +1,11 @@ +module.exports = { + experiments: { html: false }, + module: { + rules: [{ + test: /\.html$/, + type: "javascript/auto", + parser: { custom: true }, + use: [{ loader: "html-loader", options: { sources: false } }, "template-loader"], + }], + }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-minify-conflict/expected.js b/codemods/html-plugins-to-native-html/tests/loader-minify-conflict/expected.js new file mode 100644 index 0000000..0f98d74 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-minify-conflict/expected.js @@ -0,0 +1,3 @@ +module.exports = /* Review: HTML minification differs between pages or loaders; configure optimization.minimizeOptions.html for the whole compilation */ { + module: { rules: [] }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-minify-conflict/input.js b/codemods/html-plugins-to-native-html/tests/loader-minify-conflict/input.js new file mode 100644 index 0000000..cd2eb17 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-minify-conflict/input.js @@ -0,0 +1,6 @@ +module.exports = { + module: { rules: [ + { test: /\.html$/, loader: "html-loader", options: { minimize: false } }, + { test: /\.html$/, loader: "html-loader", options: { minimize: true } }, + ] }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-minimize-false/expected.js b/codemods/html-plugins-to-native-html/tests/loader-minimize-false/expected.js new file mode 100644 index 0000000..1e0a918 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-minimize-false/expected.js @@ -0,0 +1,3 @@ +module.exports = { + optimization: { minimizeOptions: { html: false } }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-minimize-false/input.js b/codemods/html-plugins-to-native-html/tests/loader-minimize-false/input.js new file mode 100644 index 0000000..e8e4c1c --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-minimize-false/input.js @@ -0,0 +1,3 @@ +module.exports = { + module: { rules: [{ test: /\.html$/, loader: "html-loader", options: { minimize: false } }] }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-minimize-options/expected.js b/codemods/html-plugins-to-native-html/tests/loader-minimize-options/expected.js new file mode 100644 index 0000000..87e9352 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-minimize-options/expected.js @@ -0,0 +1,3 @@ +module.exports = { + optimization: { minimizeOptions: { html: { comments: true }, javascript: false } }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-minimize-options/input.js b/codemods/html-plugins-to-native-html/tests/loader-minimize-options/input.js new file mode 100644 index 0000000..190f28d --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-minimize-options/input.js @@ -0,0 +1,4 @@ +module.exports = { + optimization: { minimizeOptions: { javascript: false } }, + module: { rules: [{ test: /\.html$/, use: [{ loader: "html-loader", options: { minimize: { removeComments: false } } }] }] }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-other-extensions/expected.js b/codemods/html-plugins-to-native-html/tests/loader-other-extensions/expected.js new file mode 100644 index 0000000..4784de4 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-other-extensions/expected.js @@ -0,0 +1,6 @@ +module.exports = { + experiments: { + html: true, + }, + module: { rules: [{ test: /\.html?$/, type: "html" }] }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-other-extensions/input.js b/codemods/html-plugins-to-native-html/tests/loader-other-extensions/input.js new file mode 100644 index 0000000..884d993 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-other-extensions/input.js @@ -0,0 +1,3 @@ +module.exports = { + module: { rules: [{ test: /\.html?$/, loader: "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 index d8ea33c..e157ca0 100644 --- a/codemods/html-plugins-to-native-html/tests/loader-preprocessor/expected.js +++ b/codemods/html-plugins-to-native-html/tests/loader-preprocessor/expected.js @@ -1,9 +1,10 @@ module.exports = { + optimization: { minimizeOptions: { html: { comments: false } } }, module: { rules: [ { test: /\.md$/, - // Removed loader options without a native HTML equivalent: html-loader.preprocessor (move it to the rule's parser.template — synchronous (source, { module, resource }) => string), html-loader.postprocessor (tap HtmlModulesPlugin.getCompilationHooks(compilation).transformHtml for emitted pages), html-loader.minimize (customize via optimization.minimizer (minimizer-webpack-plugin)) + // Removed loader options without a native HTML equivalent: html-loader.preprocessor (move it to the rule's parser.template — synchronous (source, { module, resource }) => string), html-loader.postprocessor (tap HtmlModulesPlugin.getCompilationHooks(compilation).transformHtml for emitted pages) use: ["markdown-loader"], type: "html", }, 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 index 323c8e0..6203c8a 100644 --- a/codemods/html-plugins-to-native-html/tests/lost-options/expected.js +++ b/codemods/html-plugins-to-native-html/tests/lost-options/expected.js @@ -1,6 +1,7 @@ module.exports = { + optimization: { minimizeOptions: { html: { collapseWhitespace: true } } }, output: { - // Removed html-webpack-plugin options without a native HTML equivalent: hash (use [contenthash] in output.htmlFilename), minify (production HTML is minified by default; customize via optimization.minimizer (minimizer-webpack-plugin)), publicPath (set output.publicPath) + // Removed html-webpack-plugin options without a native HTML equivalent: hash (use [contenthash] in output.htmlFilename), 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", diff --git a/codemods/html-plugins-to-native-html/tests/minify-crlf/expected.js b/codemods/html-plugins-to-native-html/tests/minify-crlf/expected.js new file mode 100644 index 0000000..a5972e9 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-crlf/expected.js @@ -0,0 +1,10 @@ +module.exports = { + optimization: { minimizeOptions: { html: false } }, + output: { + html: true, + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-crlf/input.js b/codemods/html-plugins-to-native-html/tests/minify-crlf/input.js new file mode 100644 index 0000000..5f72ca8 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-crlf/input.js @@ -0,0 +1,4 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { + plugins: [new HtmlWebpackPlugin({ minify: false })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-custom-minimizer/expected.js b/codemods/html-plugins-to-native-html/tests/minify-custom-minimizer/expected.js new file mode 100644 index 0000000..5021864 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-custom-minimizer/expected.js @@ -0,0 +1,10 @@ +module.exports = { + output: { + html: true, + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, + optimization: { minimizer: [new CustomMinimizer(), "..."] }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-custom-minimizer/input.js b/codemods/html-plugins-to-native-html/tests/minify-custom-minimizer/input.js new file mode 100644 index 0000000..17bb536 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-custom-minimizer/input.js @@ -0,0 +1,5 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { + optimization: { minimizer: [new CustomMinimizer()] }, + plugins: [new HtmlWebpackPlugin()], +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-dynamic-minimizer/expected.js b/codemods/html-plugins-to-native-html/tests/minify-dynamic-minimizer/expected.js new file mode 100644 index 0000000..5e4b2ec --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-dynamic-minimizer/expected.js @@ -0,0 +1,12 @@ +module.exports = { + output: { + // Review: keep "..." in optimization.minimizer to enable native HTML minification + // Review: merge the HTML minification options into the existing optimization.minimizeOptions value + html: true, + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, + optimization: { minimizer: makeMinimizers(), minimizeOptions: getOptions() }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-dynamic-minimizer/input.js b/codemods/html-plugins-to-native-html/tests/minify-dynamic-minimizer/input.js new file mode 100644 index 0000000..a8bdec5 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-dynamic-minimizer/input.js @@ -0,0 +1,5 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { + optimization: { minimizer: makeMinimizers(), minimizeOptions: getOptions() }, + plugins: [new HtmlWebpackPlugin({ minify: { removeComments: true } })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-dynamic-optimization/expected.js b/codemods/html-plugins-to-native-html/tests/minify-dynamic-optimization/expected.js new file mode 100644 index 0000000..6565e96 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-dynamic-optimization/expected.js @@ -0,0 +1,11 @@ +module.exports = { + output: { + // Review: configure the existing optimization value to use webpack's native HTML minimizer (minimizeOptions.html and minimizer: ["..."]) + html: true, + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, + optimization: getOptimization(), +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-dynamic-optimization/input.js b/codemods/html-plugins-to-native-html/tests/minify-dynamic-optimization/input.js new file mode 100644 index 0000000..98f03fb --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-dynamic-optimization/input.js @@ -0,0 +1,5 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { + optimization: getOptimization(), + plugins: [new HtmlWebpackPlugin({ minify: false })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-existing-native/expected.js b/codemods/html-plugins-to-native-html/tests/minify-existing-native/expected.js new file mode 100644 index 0000000..dd7bdfe --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-existing-native/expected.js @@ -0,0 +1,14 @@ +module.exports = { + output: { + // Review: existing native HTML minification options take precedence over migrated minify/minimize options + html: true, + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, + optimization: { + minimize: { html: { comments: true }, javascript: false }, + minimizer: ["...", new CustomMinimizer()], + }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-existing-native/input.js b/codemods/html-plugins-to-native-html/tests/minify-existing-native/input.js new file mode 100644 index 0000000..ad02882 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-existing-native/input.js @@ -0,0 +1,8 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { + optimization: { + minimize: { html: { comments: true }, javascript: false }, + minimizer: ["...", new CustomMinimizer()], + }, + plugins: [new HtmlWebpackPlugin({ minify: false })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-existing-optimization/expected.js b/codemods/html-plugins-to-native-html/tests/minify-existing-optimization/expected.js new file mode 100644 index 0000000..46f54fe --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-existing-optimization/expected.js @@ -0,0 +1,14 @@ +module.exports = { + output: { + html: true, + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, + optimization: { + minimize: false, + minimizeOptions: { html: { comments: false }, javascript: { mangle: false }, css: false }, + minimizer: [new CustomMinimizer(), "...", /* keep this comment */], + }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-existing-optimization/input.js b/codemods/html-plugins-to-native-html/tests/minify-existing-optimization/input.js new file mode 100644 index 0000000..eb3b01b --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-existing-optimization/input.js @@ -0,0 +1,9 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { + optimization: { + minimize: false, + minimizeOptions: { javascript: { mangle: false }, css: false }, + minimizer: [new CustomMinimizer(), /* keep this comment */], + }, + plugins: [new HtmlWebpackPlugin({ minify: { removeComments: true } })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-false-custom-minimizer/expected.js b/codemods/html-plugins-to-native-html/tests/minify-false-custom-minimizer/expected.js new file mode 100644 index 0000000..1eebd30 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-false-custom-minimizer/expected.js @@ -0,0 +1,10 @@ +module.exports = { + output: { + html: true, + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, + optimization: { minimizeOptions: { html: false }, minimizer: [] }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-false-custom-minimizer/input.js b/codemods/html-plugins-to-native-html/tests/minify-false-custom-minimizer/input.js new file mode 100644 index 0000000..5951e9e --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-false-custom-minimizer/input.js @@ -0,0 +1,5 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { + optimization: { minimizer: [] }, + plugins: [new HtmlWebpackPlugin({ minify: false })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-false/expected.js b/codemods/html-plugins-to-native-html/tests/minify-false/expected.js new file mode 100644 index 0000000..f40170a --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-false/expected.js @@ -0,0 +1,11 @@ +module.exports = { + optimization: { minimizeOptions: { html: false } }, + output: { + html: true, + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, + mode: "production", +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-false/input.js b/codemods/html-plugins-to-native-html/tests/minify-false/input.js new file mode 100644 index 0000000..baced0c --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-false/input.js @@ -0,0 +1,5 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { + mode: "production", + plugins: [new HtmlWebpackPlugin({ minify: false })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-idempotent/expected.js b/codemods/html-plugins-to-native-html/tests/minify-idempotent/expected.js new file mode 100644 index 0000000..fcb9621 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-idempotent/expected.js @@ -0,0 +1,5 @@ +module.exports = { + output: { html: true, htmlFilename: "index.html" }, + experiments: { html: true }, + optimization: { minimizeOptions: { html: false }, minimizer: ["..."] }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-idempotent/input.js b/codemods/html-plugins-to-native-html/tests/minify-idempotent/input.js new file mode 100644 index 0000000..fcb9621 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-idempotent/input.js @@ -0,0 +1,5 @@ +module.exports = { + output: { html: true, htmlFilename: "index.html" }, + experiments: { html: true }, + optimization: { minimizeOptions: { html: false }, minimizer: ["..."] }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-options/expected.mjs b/codemods/html-plugins-to-native-html/tests/minify-options/expected.mjs new file mode 100644 index 0000000..b46d636 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-options/expected.mjs @@ -0,0 +1,12 @@ +export default { + optimization: { minimizeOptions: { html: { collapseWhitespace: false, collapseBooleanAttributes: true, comments: true, normalizeAttributeQuotes: true, removeOptionalTags: false, removeImpliedTags: false, removeEmptyAttributes: true, removeRedundantAttributes: false, sortAttributes: true } } }, + output: { + // Removed html-webpack-plugin options without a native HTML equivalent: minify.minifyJS (configure optimization.minimizeOptions.html), minify.options (configure optimization.minimizeOptions.html) + html: true, + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, + mode: "development", +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-options/input.mjs b/codemods/html-plugins-to-native-html/tests/minify-options/input.mjs new file mode 100644 index 0000000..5f32d7d --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-options/input.mjs @@ -0,0 +1,17 @@ +import HtmlWebpackPlugin from "html-webpack-plugin"; +export default { + mode: "development", + plugins: [new HtmlWebpackPlugin({ minify: { + collapseWhitespace: false, + conservativeCollapse: true, + collapseBooleanAttributes: true, + removeComments: false, + removeAttributeQuotes: true, + removeOptionalTags: false, + removeEmptyAttributes: true, + removeRedundantAttributes: false, + sortAttributes: true, + minifyJS: false, + ...extraOptions, + } })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-shorthand/expected.js b/codemods/html-plugins-to-native-html/tests/minify-shorthand/expected.js new file mode 100644 index 0000000..83c2ec2 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-shorthand/expected.js @@ -0,0 +1,10 @@ +module.exports = { + output: { + html: true, + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, + optimization: { minimize: { html: { collapseWhitespace: false }, javascript: false }, minimizer: [ "..."] }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-shorthand/input.js b/codemods/html-plugins-to-native-html/tests/minify-shorthand/input.js new file mode 100644 index 0000000..7f81406 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-shorthand/input.js @@ -0,0 +1,5 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { + optimization: { minimize: { javascript: false }, minimizer: [] }, + plugins: [new HtmlWebpackPlugin({ minify: { collapseWhitespace: false } })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-template/expected.js b/codemods/html-plugins-to-native-html/tests/minify-template/expected.js new file mode 100644 index 0000000..89a50b9 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-template/expected.js @@ -0,0 +1,11 @@ +module.exports = { + optimization: { minimizeOptions: { html: false } }, + output: { + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, + // The template is now the entry: it must load the previous entry via (added by the HTML workflow step when the template is included) + entry: "./src/app.html", +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-template/input.js b/codemods/html-plugins-to-native-html/tests/minify-template/input.js new file mode 100644 index 0000000..6b982b3 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-template/input.js @@ -0,0 +1,5 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { + entry: "./src/app.js", + plugins: [new HtmlWebpackPlugin({ template: "./src/app.html", minify: false })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-unknown-options/expected.js b/codemods/html-plugins-to-native-html/tests/minify-unknown-options/expected.js new file mode 100644 index 0000000..6a957af --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-unknown-options/expected.js @@ -0,0 +1,10 @@ +module.exports = { + output: { + // Removed html-webpack-plugin options without a native HTML equivalent: minify (configure optimization.minimizeOptions.html) + html: true, + htmlFilename: "index.html", + }, + experiments: { + html: true, + }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-unknown-options/input.js b/codemods/html-plugins-to-native-html/tests/minify-unknown-options/input.js new file mode 100644 index 0000000..e096169 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/minify-unknown-options/input.js @@ -0,0 +1,4 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { + plugins: [new HtmlWebpackPlugin({ minify: settings })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/multi-entry-template-untouched/expected.js b/codemods/html-plugins-to-native-html/tests/multi-entry-template-untouched/expected.js new file mode 100644 index 0000000..694ec8c --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/multi-entry-template-untouched/expected.js @@ -0,0 +1,5 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { + entry: ["./polyfill.js", "./app.js"], + plugins: [new HtmlWebpackPlugin({ template: "./index.html" })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/multi-entry-template-untouched/input.js b/codemods/html-plugins-to-native-html/tests/multi-entry-template-untouched/input.js new file mode 100644 index 0000000..694ec8c --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/multi-entry-template-untouched/input.js @@ -0,0 +1,5 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { + entry: ["./polyfill.js", "./app.js"], + plugins: [new HtmlWebpackPlugin({ template: "./index.html" })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/multi-page-minify-conflict-untouched/expected.js b/codemods/html-plugins-to-native-html/tests/multi-page-minify-conflict-untouched/expected.js new file mode 100644 index 0000000..e541cb4 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/multi-page-minify-conflict-untouched/expected.js @@ -0,0 +1,8 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { + entry: { app: "./app.js", admin: "./admin.js" }, + plugins: [ + new HtmlWebpackPlugin({ chunks: ["app"], minify: true }), + new HtmlWebpackPlugin({ chunks: ["admin"], minify: false }), + ], +}; diff --git a/codemods/html-plugins-to-native-html/tests/multi-page-minify-conflict-untouched/input.js b/codemods/html-plugins-to-native-html/tests/multi-page-minify-conflict-untouched/input.js new file mode 100644 index 0000000..e541cb4 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/multi-page-minify-conflict-untouched/input.js @@ -0,0 +1,8 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { + entry: { app: "./app.js", admin: "./admin.js" }, + plugins: [ + new HtmlWebpackPlugin({ chunks: ["app"], minify: true }), + new HtmlWebpackPlugin({ chunks: ["admin"], minify: false }), + ], +}; diff --git a/codemods/html-plugins-to-native-html/tests/multi-page-minify/expected.js b/codemods/html-plugins-to-native-html/tests/multi-page-minify/expected.js new file mode 100644 index 0000000..d522db7 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/multi-page-minify/expected.js @@ -0,0 +1,10 @@ +module.exports = { + optimization: { minimizeOptions: { html: false } }, + output: { + htmlFilename: "[name].html", + }, + experiments: { + html: true, + }, + entry: { app: { import: "./app.js", html: true }, admin: { import: "./admin.js", html: true } }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/multi-page-minify/input.js b/codemods/html-plugins-to-native-html/tests/multi-page-minify/input.js new file mode 100644 index 0000000..bec012c --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/multi-page-minify/input.js @@ -0,0 +1,8 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { + entry: { app: "./app.js", admin: "./admin.js" }, + plugins: [ + new HtmlWebpackPlugin({ chunks: ["app"], minify: false }), + new HtmlWebpackPlugin({ chunks: ["admin"], minify: false }), + ], +}; diff --git a/codemods/html-plugins-to-native-html/tests/native-html-disabled/expected.js b/codemods/html-plugins-to-native-html/tests/native-html-disabled/expected.js new file mode 100644 index 0000000..dfb1ae4 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/native-html-disabled/expected.js @@ -0,0 +1,4 @@ +module.exports = { + experiments: { html: true, outputModule: true }, + output: { htmlFilename: "index.html", html: true, module: true }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/native-html-disabled/input.js b/codemods/html-plugins-to-native-html/tests/native-html-disabled/input.js new file mode 100644 index 0000000..1081a02 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/native-html-disabled/input.js @@ -0,0 +1,6 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { + experiments: { html: false, outputModule: false }, + output: { html: false, module: false }, + plugins: [new HtmlWebpackPlugin({ scriptLoading: "module" })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/remove-dependencies.ts b/codemods/html-plugins-to-native-html/tests/remove-dependencies.ts new file mode 100644 index 0000000..0c0f2a6 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/remove-dependencies.ts @@ -0,0 +1,9 @@ +import type Json from "@codemod.com/jssg-types/langs/json"; +import type { SgRoot } from "@codemod.com/jssg-types/main"; +import { removeUnusedDependencies } from "../src/remove-dependencies"; + +// Unit fixtures describe packages with no remaining source references. +// Workflow integration tests cover collection and retention across files. +export default async function transform(root: SgRoot): Promise { + return removeUnusedDependencies(root, new Set()); +} diff --git a/codemods/html-plugins-to-native-html/tests/remove-dependencies/manifest-reference/expected.json b/codemods/html-plugins-to-native-html/tests/remove-dependencies/manifest-reference/expected.json new file mode 100644 index 0000000..1083271 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/remove-dependencies/manifest-reference/expected.json @@ -0,0 +1,4 @@ +{ + "scripts": { "inspect": "node -p 'require.resolve(\"html-loader\")'" }, + "devDependencies": { "html-loader": "^5.1.0" } +} diff --git a/codemods/html-plugins-to-native-html/tests/remove-dependencies/manifest-reference/input.json b/codemods/html-plugins-to-native-html/tests/remove-dependencies/manifest-reference/input.json new file mode 100644 index 0000000..d03522f --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/remove-dependencies/manifest-reference/input.json @@ -0,0 +1,4 @@ +{ + "scripts": { "inspect": "node -p 'require.resolve(\"html-loader\")'" }, + "devDependencies": { "html-loader": "^5.1.0", "html-webpack-plugin": "^5.6.0" } +} diff --git a/codemods/html-plugins-to-native-html/tests/spread-options-untouched/expected.js b/codemods/html-plugins-to-native-html/tests/spread-options-untouched/expected.js new file mode 100644 index 0000000..a5a77c1 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/spread-options-untouched/expected.js @@ -0,0 +1,2 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { plugins: [new HtmlWebpackPlugin({ ...options, title: "App" })] }; diff --git a/codemods/html-plugins-to-native-html/tests/spread-options-untouched/input.js b/codemods/html-plugins-to-native-html/tests/spread-options-untouched/input.js new file mode 100644 index 0000000..a5a77c1 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/spread-options-untouched/input.js @@ -0,0 +1,2 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { plugins: [new HtmlWebpackPlugin({ ...options, title: "App" })] }; 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 2a9119e..b41f577 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: it must load the previous entry via (added automatically when the template was found on disk) + // The template is now the entry: it must load the previous entry via (added by the HTML workflow step when the template is included) 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 4a3d47c..9cd2d8e 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: it must load the previous default entry via (added automatically when the template was found on disk) + // The template is now the entry: it must load the previous default entry via (added by the HTML workflow step when the template is included) 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 52918df..26ce6f4 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: it must load the previous entry via (added automatically when the template was found on disk) + // The template is now the entry: it must load the previous entry via (added by the HTML workflow step when the template is included) 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 0d61dfa..7b6aed0 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: it must load the previous entry via (added automatically when the template was found on disk) + // The template is now the entry: it must load the previous entry via (added by the HTML workflow step when the template is included) entry: "./src/index.html", output: { // Removed html-webpack-plugin options without a native HTML equivalent: title (add it to the template) diff --git a/codemods/html-plugins-to-native-html/tests/templates.ts b/codemods/html-plugins-to-native-html/tests/templates.ts new file mode 100644 index 0000000..82c1055 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/templates.ts @@ -0,0 +1,7 @@ +import type Html from "@codemod.com/jssg-types/langs/html"; +import type { SgRoot } from "@codemod.com/jssg-types/main"; +import { addTemplateScripts } from "../src/templates"; + +export default async function transform(root: SgRoot): Promise { + return addTemplateScripts(root, ["./index.js"]); +} diff --git a/codemods/html-plugins-to-native-html/tests/templates/body/expected.html b/codemods/html-plugins-to-native-html/tests/templates/body/expected.html new file mode 100644 index 0000000..57afd75 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/templates/body/expected.html @@ -0,0 +1 @@ +App \ No newline at end of file diff --git a/codemods/html-plugins-to-native-html/tests/templates/body/input.html b/codemods/html-plugins-to-native-html/tests/templates/body/input.html new file mode 100644 index 0000000..f817e34 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/templates/body/input.html @@ -0,0 +1 @@ +App \ No newline at end of file diff --git a/codemods/html-plugins-to-native-html/tests/templates/comment-is-not-script/expected.html b/codemods/html-plugins-to-native-html/tests/templates/comment-is-not-script/expected.html new file mode 100644 index 0000000..196433f --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/templates/comment-is-not-script/expected.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/codemods/html-plugins-to-native-html/tests/templates/comment-is-not-script/input.html b/codemods/html-plugins-to-native-html/tests/templates/comment-is-not-script/input.html new file mode 100644 index 0000000..4ab41c0 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/templates/comment-is-not-script/input.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/codemods/html-plugins-to-native-html/tests/templates/crlf/expected.html b/codemods/html-plugins-to-native-html/tests/templates/crlf/expected.html new file mode 100644 index 0000000..5f0c12f --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/templates/crlf/expected.html @@ -0,0 +1,3 @@ + + + diff --git a/codemods/html-plugins-to-native-html/tests/templates/crlf/input.html b/codemods/html-plugins-to-native-html/tests/templates/crlf/input.html new file mode 100644 index 0000000..1a9d3d9 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/templates/crlf/input.html @@ -0,0 +1,2 @@ + + diff --git a/codemods/html-plugins-to-native-html/tests/templates/existing-unquoted/expected.html b/codemods/html-plugins-to-native-html/tests/templates/existing-unquoted/expected.html new file mode 100644 index 0000000..f7efb8a --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/templates/existing-unquoted/expected.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/codemods/html-plugins-to-native-html/tests/templates/existing-unquoted/input.html b/codemods/html-plugins-to-native-html/tests/templates/existing-unquoted/input.html new file mode 100644 index 0000000..f7efb8a --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/templates/existing-unquoted/input.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/codemods/html-plugins-to-native-html/tests/templates/existing/expected.html b/codemods/html-plugins-to-native-html/tests/templates/existing/expected.html new file mode 100644 index 0000000..3f269fd --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/templates/existing/expected.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/codemods/html-plugins-to-native-html/tests/templates/existing/input.html b/codemods/html-plugins-to-native-html/tests/templates/existing/input.html new file mode 100644 index 0000000..3f269fd --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/templates/existing/input.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/codemods/html-plugins-to-native-html/tests/templates/fragment/expected.html b/codemods/html-plugins-to-native-html/tests/templates/fragment/expected.html new file mode 100644 index 0000000..af9728c --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/templates/fragment/expected.html @@ -0,0 +1,2 @@ +

App

+ diff --git a/codemods/html-plugins-to-native-html/tests/templates/fragment/input.html b/codemods/html-plugins-to-native-html/tests/templates/fragment/input.html new file mode 100644 index 0000000..3c756d9 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/templates/fragment/input.html @@ -0,0 +1 @@ +

App

\ No newline at end of file diff --git a/codemods/html-plugins-to-native-html/tests/templates/head/expected.html b/codemods/html-plugins-to-native-html/tests/templates/head/expected.html new file mode 100644 index 0000000..587f389 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/templates/head/expected.html @@ -0,0 +1,8 @@ + + + + App + + + + diff --git a/codemods/html-plugins-to-native-html/tests/templates/head/input.html b/codemods/html-plugins-to-native-html/tests/templates/head/input.html new file mode 100644 index 0000000..5d5729e --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/templates/head/input.html @@ -0,0 +1,7 @@ + + + + App + + + diff --git a/codemods/html-plugins-to-native-html/tests/templates/inline/expected.html b/codemods/html-plugins-to-native-html/tests/templates/inline/expected.html new file mode 100644 index 0000000..f8b9c12 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/templates/inline/expected.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/codemods/html-plugins-to-native-html/tests/templates/inline/input.html b/codemods/html-plugins-to-native-html/tests/templates/inline/input.html new file mode 100644 index 0000000..1707182 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/templates/inline/input.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/codemods/html-plugins-to-native-html/tests/workflow.test.mjs b/codemods/html-plugins-to-native-html/tests/workflow.test.mjs new file mode 100644 index 0000000..ea45543 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/workflow.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { cpSync, mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +const workspace = fileURLToPath(new URL("../", import.meta.url)); +const fixture = path.join(workspace, "tests/integration/project"); +const files = readdirSync(fixture, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile()) + .map((entry) => path.relative(fixture, path.join(entry.parentPath, entry.name))); + +function runWorkflow(target, dryRun = false) { + assert.ok(process.env.npm_execpath, "Run this test through npm run test:integration"); + execFileSync(process.execPath, [ + process.env.npm_execpath, "exec", "--", "codemod", "workflow", "run", + "--workflow", path.join(workspace, "workflow.yaml"), "--target", target, + "--allow-dirty", "--no-interactive", "--disable-analytics", + ...(dryRun ? ["--dry-run"] : []), + ], { cwd: workspace, encoding: "utf8", timeout: 120_000, stdio: "pipe" }); +} + +test("workflow applies HTML and dependency edits through the runner, without fs permission", () => { + const target = mkdtempSync(path.join(tmpdir(), "webpack-html-workflow-")); + try { + cpSync(fixture, target, { recursive: true }); + execFileSync("git", ["init", "--quiet", target]); + runWorkflow(target, true); + for (const file of files) { + assert.deepEqual(readFileSync(path.join(target, file)), readFileSync(path.join(fixture, file)), + `dry run modified ${file}`); + } + + runWorkflow(target); + const html = readFileSync(path.join(target, "src/index.html"), "utf8"); + assert.equal(html.match(/` before `` (or ``), preserving line endings and avoiding duplicate script references. Comments containing script markup do not count as existing scripts. +`output.html` generates each page from scratch, so an authored template maps to webpack's other native mode instead: the **HTML entry point**. The configuration transform makes the template the entry and records the previous JS entry in workflow state. A separate HTML AST transform adds the corresponding `` before `` (or ``), preserving line endings and avoiding duplicate script references. Comments containing script markup do not count as existing scripts. A `webpackChunkName` comment preserves the previous entry name (`main` for unnamed entries). Existing matching scripts are annotated without duplication, while existing webpack magic comments take precedence. If several configs request different names for the same script in a shared template, webpack's default naming is kept for manual review. All changes are returned to the codemod runner; the transforms do not use filesystem APIs. This keeps template changes inside the normal workflow and makes `--dry-run` safe. Only `.html` templates included in the workflow are edited. The configuration keeps a review comment with the script reference for missing/excluded templates and when running the configuration transform alone. Since head tags are only injected into webpack-generated pages, `title`/`meta`/`favicon`/`base` are flagged to be added to the template instead. @@ -119,7 +119,7 @@ module.exports = { experiments: { html: true, }, - // The template is now the entry: it must load the previous entry via (added by the HTML workflow step when the template is included) + // The template is now the entry: it must load the previous entry via (added by the HTML workflow step when the template is included) entry: "./src/index.html", output: { htmlFilename: "index.html", diff --git a/codemods/html-plugins-to-native-html/package.json b/codemods/html-plugins-to-native-html/package.json index cd317f2..39b9d80 100644 --- a/codemods/html-plugins-to-native-html/package.json +++ b/codemods/html-plugins-to-native-html/package.json @@ -8,7 +8,7 @@ "test": "npm run test:workflow && npm run test:templates && npm run test:dependencies && npm run test:integration", "test:workflow": "npx codemod jssg test -l typescript ./src/workflow.ts", "test:dependencies": "npx codemod jssg test -l json ./tests/remove-dependencies.ts ./tests/remove-dependencies", - "test:templates": "npx codemod jssg test -l html ./tests/templates.ts ./tests/templates", + "test:templates": "npx codemod jssg test -l html ./tests/templates.ts ./tests/templates && npx codemod jssg test -l html ./tests/template-chunks.ts ./tests/template-chunks", "test:integration": "node --test ./tests/workflow.test.mjs" }, "repository": { diff --git a/codemods/html-plugins-to-native-html/src/migration-state.ts b/codemods/html-plugins-to-native-html/src/migration-state.ts index c676151..b48a832 100644 --- a/codemods/html-plugins-to-native-html/src/migration-state.ts +++ b/codemods/html-plugins-to-native-html/src/migration-state.ts @@ -4,6 +4,11 @@ import { acquireLock, getState, setState } from "codemod:workflow"; const SOURCES_KEY = "webpack-native-html:sources"; const TEMPLATES_KEY = "webpack-native-html:templates"; +export interface TemplateScript { + src: string; + chunkName?: string; +} + export const HTML_PACKAGES = [ "html-webpack-plugin", "html-loader", @@ -54,7 +59,7 @@ export function remainingPackages(manifestFilename: string): Set | null return remaining; } -export function queueTemplate(configFilename: string, template: string, entry: string): string | null { +export function queueTemplate(configFilename: string, template: string, entry: string, chunkName: string): string | null { if (!template.startsWith(".") || !entry.startsWith(".")) return null; const directory = directoryOf(configFilename); const templateFilename = normalizeFilename(`${directory}/${template}`); @@ -67,8 +72,12 @@ export function queueTemplate(configFilename: string, template: string, entry: s const script = `${up ? "../".repeat(up) : "./"}${to.slice(common).join("/")}`; const release = acquireLock(TEMPLATES_KEY); try { - const templates = getState>(TEMPLATES_KEY) ?? {}; - templates[templateFilename] = [...new Set([...(templates[templateFilename] ?? []), script])].sort(); + const templates = getState>(TEMPLATES_KEY) ?? {}; + const scripts = templates[templateFilename] ?? []; + if (!scripts.some((item) => item.src === script && item.chunkName === chunkName)) { + scripts.push({ src: script, chunkName }); + } + templates[templateFilename] = scripts.sort((a, b) => a.src.localeCompare(b.src) || (a.chunkName ?? "").localeCompare(b.chunkName ?? "")); setState(TEMPLATES_KEY, templates); } finally { release(); @@ -76,6 +85,6 @@ export function queueTemplate(configFilename: string, template: string, entry: s return script; } -export function scriptsForTemplate(filename: string): string[] { - return getState>(TEMPLATES_KEY)?.[normalizeFilename(filename)] ?? []; +export function scriptsForTemplate(filename: string): TemplateScript[] { + return getState>(TEMPLATES_KEY)?.[normalizeFilename(filename)] ?? []; } diff --git a/codemods/html-plugins-to-native-html/src/templates.ts b/codemods/html-plugins-to-native-html/src/templates.ts index 30e499f..66e65e5 100644 --- a/codemods/html-plugins-to-native-html/src/templates.ts +++ b/codemods/html-plugins-to-native-html/src/templates.ts @@ -1,16 +1,39 @@ import type Html from "@codemod.com/jssg-types/langs/html"; -import type { SgRoot } from "@codemod.com/jssg-types/main"; -import { normalizeFilename, scriptsForTemplate } from "./migration-state"; +import type { Edit, SgRoot } from "@codemod.com/jssg-types/main"; +import { normalizeFilename, scriptsForTemplate, type TemplateScript } from "./migration-state"; function attributeValue(text: string): string { return text.replace(/&/g, "&").replace(/"/g, """).replace(/, scripts: string[]): string | null { +function chunkComment(name: string): string { + // JSON escapes are understood by webpack's magic-comment parser. Escape + // HTML delimiters too so an entry name cannot terminate the comment. + const escaped = JSON.stringify(name).replace(//g, "\\u003e").replace(/-/g, "\\u002d"); + return ``; +} + +export function templateScriptTag(script: TemplateScript): string { + return `${script.chunkName ? `${chunkComment(script.chunkName)} ` : ""}`; +} + +export function addTemplateScripts(root: SgRoot, scripts: TemplateScript[]): string | null { if (!scripts.length) return null; const node = root.root(); const source = node.text(); const eol = source.includes("\r\n") ? "\r\n" : "\n"; + const edits: Edit[] = []; + const requested = new Map(); + const names = new Map>(); + for (const script of scripts) { + const key = normalizeFilename(attributeValue(script.src)); + const chunkNames = names.get(key) ?? new Set(); + chunkNames.add(script.chunkName); + names.set(key, chunkNames); + // Shared templates may be requested by configs with different entry names. + // Keep webpack's default naming when there is no unambiguous choice. + requested.set(key, { src: script.src, chunkName: chunkNames.size === 1 ? script.chunkName : undefined }); + } const existing = new Set(); for (const script of node.findAll({ rule: { kind: "script_element" } })) { const start = script.children().find((child) => child.kind() === "start_tag"); @@ -19,12 +42,22 @@ export function addTemplateScripts(root: SgRoot, scripts: string[]): strin const name = attribute.children().find((child) => child.kind() === "attribute_name"); if (name?.text().toLowerCase() !== "src") continue; const value = attribute.children().find((child) => child.kind() === "quoted_attribute_value" || child.kind() === "attribute_value"); - if (value) existing.add(normalizeFilename(value.text().replace(/^["']|["']$/g, ""))); + if (!value) continue; + const key = normalizeFilename(value.text().replace(/^["']|["']$/g, "")); + existing.add(key); + const chunkName = requested.get(key)?.chunkName; + const previous = script.prev(); + // A new comment would override existing webpackIgnore/Preload/etc. + if (!chunkName || (previous?.kind() === "comment" && /\bwebpack\w+\s*:/.test(previous.text()))) continue; + const position = script.range().start.index; + const indent = source.slice(source.lastIndexOf("\n", position - 1) + 1, position); + const separator = /^[ \t]*$/.test(indent) ? `${eol}${indent}` : " "; + edits.push({ startPos: position, endPos: position, insertedText: `${chunkComment(chunkName)}${separator}` }); } } - const missing = [...new Set(scripts)].filter((script) => !existing.has(normalizeFilename(attributeValue(script)))); - if (!missing.length) return null; - const tags = missing.map((script) => ``); + const missing = [...requested].filter(([key]) => !existing.has(key)).map(([, script]) => script); + if (!missing.length) return edits.length ? node.commitEdits(edits) : null; + const tags = missing.map(templateScriptTag); const endings = node.findAll({ rule: { kind: "end_tag" } }); const closing = endings.find((tag) => /^<\/head\s*>$/i.test(tag.text())) ?? endings.find((tag) => /^<\/body\s*>$/i.test(tag.text())); @@ -41,7 +74,8 @@ export function addTemplateScripts(root: SgRoot, scripts: string[]): strin text = tags.join(""); } } - return node.commitEdits([{ startPos: position, endPos: position, insertedText: text }]); + edits.push({ startPos: position, endPos: position, insertedText: text }); + return node.commitEdits(edits); } export default 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 08e789a..a1d1a8a 100644 --- a/codemods/html-plugins-to-native-html/src/workflow.ts +++ b/codemods/html-plugins-to-native-html/src/workflow.ts @@ -22,6 +22,7 @@ import { import { collectMinification, planMinification, type Minification } from "./minification"; import { queueTemplate, recordSource } from "./migration-state"; import { nativeSources, nativeTemplate } from "./loader-options"; +import { templateScriptTag } from "./templates"; const PLUGIN_MODULE = "html-webpack-plugin"; const LOADER_NAME = "html-loader"; @@ -1134,11 +1135,11 @@ class HtmlMigration { const templateValue = findings.templateValue as string; const entryPair = findPair(configObject, "entry"); if (!entryPair) { - const injected = queueTemplate(this.configFileName, unquote(templateValue), "./src/index.js"); + const injected = queueTemplate(this.configFileName, unquote(templateValue), "./src/index.js", "main"); findings.pendingEntry = { text: templateValue, comment: injected - ? `The template is now the entry: it must load the previous default entry via (added by the HTML workflow step when the template is included)` + ? `The template is now the entry: it must load the previous default entry via ${templateScriptTag({ src: injected, chunkName: "main" })} (added by the HTML workflow step when the template is included)` : `The template is now the entry: reference the previous entry (webpack's default is ./src/index.js) from it, e.g. `, }; return; @@ -1149,9 +1150,10 @@ class HtmlMigration { findings.lost.push("template (make the template an .html entry that loads your JS)"); return; } - const injected = queueTemplate(this.configFileName, unquote(templateValue), unquote(replaceable.text())); + const chunkName = entryValue?.kind() === "object" ? keyName(pairsOf(entryValue)[0]) ?? "main" : "main"; + const injected = queueTemplate(this.configFileName, unquote(templateValue), unquote(replaceable.text()), chunkName); const comment = injected - ? `The template is now the entry: it must load the previous entry via (added by the HTML workflow step when the template is included)` + ? `The template is now the entry: it must load the previous entry via ${templateScriptTag({ src: injected, chunkName })} (added by the HTML workflow step when the template is included)` : `The template is now the entry: reference the previous entry from it, e.g. `; const multiline = configObject.text().includes("\n"); if (multiline) { @@ -1163,7 +1165,7 @@ class HtmlMigration { }); this.editor.replace(replaceable, findings.templateValue as string); } else { - this.editor.replace(replaceable, `/* ${comment} */ ${findings.templateValue}`); + this.editor.replace(replaceable, `/* ${comment.replace(/\*\//g, "* /")} */ ${findings.templateValue}`); } } @@ -1178,6 +1180,9 @@ class HtmlMigration { if (entryValue.kind() === "object") { const entryPairs = pairsOf(entryValue); if (entryPairs.length !== 1) return null; + if (namedChildren(entryValue).some((child) => child.kind() !== "pair" && child.kind() !== "comment")) return null; + const key = entryPairs[0].field("key"); + if (!key || key.kind() === "computed_property_name" || key.text().includes("\\")) return null; const inner = entryPairs[0].field("value"); return inner && inner.kind() === "string" ? inner : null; } diff --git a/codemods/html-plugins-to-native-html/tests/integration/project/package.json b/codemods/html-plugins-to-native-html/tests/integration/project/package.json index 4f711e9..7923849 100644 --- a/codemods/html-plugins-to-native-html/tests/integration/project/package.json +++ b/codemods/html-plugins-to-native-html/tests/integration/project/package.json @@ -2,6 +2,6 @@ "devDependencies": { "html-webpack-plugin": "^5.6.0", "html-loader": "^5.1.0", - "webpack": "^5.111.0" + "webpack": "^5.112.0" } } diff --git a/codemods/html-plugins-to-native-html/tests/integration/project/packages/migrated/package.json b/codemods/html-plugins-to-native-html/tests/integration/project/packages/migrated/package.json index fc0ca17..47bfd8f 100644 --- a/codemods/html-plugins-to-native-html/tests/integration/project/packages/migrated/package.json +++ b/codemods/html-plugins-to-native-html/tests/integration/project/packages/migrated/package.json @@ -2,6 +2,6 @@ "devDependencies": { "html-webpack-plugin": "^5.6.0", "favicons-webpack-plugin": "^6.0.1", - "webpack": "^5.111.0" + "webpack": "^5.112.0" } } diff --git a/codemods/html-plugins-to-native-html/tests/integration/project/packages/named/index.html b/codemods/html-plugins-to-native-html/tests/integration/project/packages/named/index.html new file mode 100644 index 0000000..5f0c12f --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/integration/project/packages/named/index.html @@ -0,0 +1,3 @@ + + + diff --git a/codemods/html-plugins-to-native-html/tests/integration/project/packages/named/index.js b/codemods/html-plugins-to-native-html/tests/integration/project/packages/named/index.js new file mode 100644 index 0000000..702645f --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/integration/project/packages/named/index.js @@ -0,0 +1 @@ +console.log("app"); diff --git a/codemods/html-plugins-to-native-html/tests/integration/project/packages/named/webpack.config.cjs b/codemods/html-plugins-to-native-html/tests/integration/project/packages/named/webpack.config.cjs new file mode 100644 index 0000000..2b66326 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/integration/project/packages/named/webpack.config.cjs @@ -0,0 +1,5 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = { + entry: { app: "./index.js" }, + plugins: [new HtmlWebpackPlugin({ template: "./index.html" })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/minify-template/expected.js b/codemods/html-plugins-to-native-html/tests/minify-template/expected.js index 89a50b9..e2f1c7e 100644 --- a/codemods/html-plugins-to-native-html/tests/minify-template/expected.js +++ b/codemods/html-plugins-to-native-html/tests/minify-template/expected.js @@ -6,6 +6,6 @@ module.exports = { experiments: { html: true, }, - // The template is now the entry: it must load the previous entry via (added by the HTML workflow step when the template is included) + // The template is now the entry: it must load the previous entry via (added by the HTML workflow step when the template is included) entry: "./src/app.html", }; diff --git a/codemods/html-plugins-to-native-html/tests/template-chunks.ts b/codemods/html-plugins-to-native-html/tests/template-chunks.ts new file mode 100644 index 0000000..18f493f --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-chunks.ts @@ -0,0 +1,11 @@ +import type Html from "@codemod.com/jssg-types/langs/html"; +import type { SgRoot } from "@codemod.com/jssg-types/main"; +import { addTemplateScripts } from "../src/templates"; + +export default async function transform(root: SgRoot): Promise { + if (root.filename().includes("conflicting")) { + return addTemplateScripts(root, [{ src: "./index.js", chunkName: "app" }, { src: "./index.js", chunkName: "admin" }]); + } + const chunkName = root.filename().includes("escaped") ? 'app--> diff --git a/codemods/html-plugins-to-native-html/tests/template-chunks/conflicting/input.html b/codemods/html-plugins-to-native-html/tests/template-chunks/conflicting/input.html new file mode 100644 index 0000000..1cc3525 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-chunks/conflicting/input.html @@ -0,0 +1 @@ + diff --git a/codemods/html-plugins-to-native-html/tests/template-chunks/crlf/expected.html b/codemods/html-plugins-to-native-html/tests/template-chunks/crlf/expected.html new file mode 100644 index 0000000..8facaec --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-chunks/crlf/expected.html @@ -0,0 +1,4 @@ + + + + diff --git a/codemods/html-plugins-to-native-html/tests/template-chunks/crlf/input.html b/codemods/html-plugins-to-native-html/tests/template-chunks/crlf/input.html new file mode 100644 index 0000000..40418e2 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-chunks/crlf/input.html @@ -0,0 +1,3 @@ + + + diff --git a/codemods/html-plugins-to-native-html/tests/template-chunks/escaped/expected.html b/codemods/html-plugins-to-native-html/tests/template-chunks/escaped/expected.html new file mode 100644 index 0000000..d4c423d --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-chunks/escaped/expected.html @@ -0,0 +1 @@ + diff --git a/codemods/html-plugins-to-native-html/tests/template-chunks/escaped/input.html b/codemods/html-plugins-to-native-html/tests/template-chunks/escaped/input.html new file mode 100644 index 0000000..1cc3525 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-chunks/escaped/input.html @@ -0,0 +1 @@ + diff --git a/codemods/html-plugins-to-native-html/tests/template-chunks/existing-hints/expected.html b/codemods/html-plugins-to-native-html/tests/template-chunks/existing-hints/expected.html new file mode 100644 index 0000000..000acde --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-chunks/existing-hints/expected.html @@ -0,0 +1 @@ + diff --git a/codemods/html-plugins-to-native-html/tests/template-chunks/existing-hints/input.html b/codemods/html-plugins-to-native-html/tests/template-chunks/existing-hints/input.html new file mode 100644 index 0000000..000acde --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-chunks/existing-hints/input.html @@ -0,0 +1 @@ + diff --git a/codemods/html-plugins-to-native-html/tests/template-chunks/existing-name/expected.html b/codemods/html-plugins-to-native-html/tests/template-chunks/existing-name/expected.html new file mode 100644 index 0000000..0c71405 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-chunks/existing-name/expected.html @@ -0,0 +1 @@ + diff --git a/codemods/html-plugins-to-native-html/tests/template-chunks/existing-name/input.html b/codemods/html-plugins-to-native-html/tests/template-chunks/existing-name/input.html new file mode 100644 index 0000000..0c71405 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-chunks/existing-name/input.html @@ -0,0 +1 @@ + diff --git a/codemods/html-plugins-to-native-html/tests/template-chunks/existing/expected.html b/codemods/html-plugins-to-native-html/tests/template-chunks/existing/expected.html new file mode 100644 index 0000000..0543305 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-chunks/existing/expected.html @@ -0,0 +1,4 @@ + + + + diff --git a/codemods/html-plugins-to-native-html/tests/template-chunks/existing/input.html b/codemods/html-plugins-to-native-html/tests/template-chunks/existing/input.html new file mode 100644 index 0000000..5f0c12f --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-chunks/existing/input.html @@ -0,0 +1,3 @@ + + + diff --git a/codemods/html-plugins-to-native-html/tests/template-chunks/idempotent/expected.html b/codemods/html-plugins-to-native-html/tests/template-chunks/idempotent/expected.html new file mode 100644 index 0000000..994baff --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-chunks/idempotent/expected.html @@ -0,0 +1 @@ + diff --git a/codemods/html-plugins-to-native-html/tests/template-chunks/idempotent/input.html b/codemods/html-plugins-to-native-html/tests/template-chunks/idempotent/input.html new file mode 100644 index 0000000..994baff --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-chunks/idempotent/input.html @@ -0,0 +1 @@ + diff --git a/codemods/html-plugins-to-native-html/tests/template-chunks/ignored/expected.html b/codemods/html-plugins-to-native-html/tests/template-chunks/ignored/expected.html new file mode 100644 index 0000000..785d5ed --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-chunks/ignored/expected.html @@ -0,0 +1 @@ + diff --git a/codemods/html-plugins-to-native-html/tests/template-chunks/ignored/input.html b/codemods/html-plugins-to-native-html/tests/template-chunks/ignored/input.html new file mode 100644 index 0000000..785d5ed --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-chunks/ignored/input.html @@ -0,0 +1 @@ + diff --git a/codemods/html-plugins-to-native-html/tests/template-chunks/insert/expected.html b/codemods/html-plugins-to-native-html/tests/template-chunks/insert/expected.html new file mode 100644 index 0000000..82d5d2d --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-chunks/insert/expected.html @@ -0,0 +1,6 @@ + + + App + + + diff --git a/codemods/html-plugins-to-native-html/tests/template-chunks/insert/input.html b/codemods/html-plugins-to-native-html/tests/template-chunks/insert/input.html new file mode 100644 index 0000000..19a082e --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-chunks/insert/input.html @@ -0,0 +1,5 @@ + + + App + + diff --git a/codemods/html-plugins-to-native-html/tests/template-dynamic-entry-name-untouched/expected.js b/codemods/html-plugins-to-native-html/tests/template-dynamic-entry-name-untouched/expected.js new file mode 100644 index 0000000..655bccf --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-dynamic-entry-name-untouched/expected.js @@ -0,0 +1,5 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = [ + { entry: { [getName()]: "./src/index.js" }, plugins: [new HtmlWebpackPlugin({ template: "./src/index.html" })] }, + { entry: { ...entries, app: "./src/index.js" }, plugins: [new HtmlWebpackPlugin({ template: "./src/index.html" })] }, +]; diff --git a/codemods/html-plugins-to-native-html/tests/template-dynamic-entry-name-untouched/input.js b/codemods/html-plugins-to-native-html/tests/template-dynamic-entry-name-untouched/input.js new file mode 100644 index 0000000..655bccf --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-dynamic-entry-name-untouched/input.js @@ -0,0 +1,5 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); +module.exports = [ + { entry: { [getName()]: "./src/index.js" }, plugins: [new HtmlWebpackPlugin({ template: "./src/index.html" })] }, + { entry: { ...entries, app: "./src/index.js" }, plugins: [new HtmlWebpackPlugin({ template: "./src/index.html" })] }, +]; 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 b41f577..65310f3 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: it must load the previous entry via (added by the HTML workflow step when the template is included) + // The template is now the entry: it must load the previous entry via (added by the HTML workflow step when the template is included) 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 9cd2d8e..e8f18c6 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: it must load the previous default entry via (added by the HTML workflow step when the template is included) + // The template is now the entry: it must load the previous default entry via (added by the HTML workflow step when the template is included) 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 26ce6f4..badbdfd 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: it must load the previous entry via (added by the HTML workflow step when the template is included) + // The template is now the entry: it must load the previous entry via (added by the HTML workflow step when the template is included) 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 7b6aed0..4f40073 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: it must load the previous entry via (added by the HTML workflow step when the template is included) + // The template is now the entry: it must load the previous entry via (added by the HTML workflow step when the template is included) entry: "./src/index.html", output: { // Removed html-webpack-plugin options without a native HTML equivalent: title (add it to the template) diff --git a/codemods/html-plugins-to-native-html/tests/templates.ts b/codemods/html-plugins-to-native-html/tests/templates.ts index 82c1055..c2e1451 100644 --- a/codemods/html-plugins-to-native-html/tests/templates.ts +++ b/codemods/html-plugins-to-native-html/tests/templates.ts @@ -3,5 +3,5 @@ import type { SgRoot } from "@codemod.com/jssg-types/main"; import { addTemplateScripts } from "../src/templates"; export default async function transform(root: SgRoot): Promise { - return addTemplateScripts(root, ["./index.js"]); + return addTemplateScripts(root, [{ src: "./index.js" }]); } diff --git a/codemods/html-plugins-to-native-html/tests/workflow.test.mjs b/codemods/html-plugins-to-native-html/tests/workflow.test.mjs index 9843c4c..dd9c0c5 100644 --- a/codemods/html-plugins-to-native-html/tests/workflow.test.mjs +++ b/codemods/html-plugins-to-native-html/tests/workflow.test.mjs @@ -35,6 +35,10 @@ test("workflow applies HTML and dependency edits through the runner, without fs runWorkflow(target); const html = readFileSync(path.join(target, "src/index.html"), "utf8"); + assert.match(html, //); + const namedHtml = readFileSync(path.join(target, "packages/named/index.html"), "utf8"); + assert.match(namedHtml, //); + assert.equal(namedHtml.match(/ (added by the HTML workflow step when the template is included) - entry: "./src/index.html", - output: { - htmlFilename: "index.html", - }, -}; +```diff +-const HtmlWebpackPlugin = require("html-webpack-plugin"); +- + module.exports = { +- entry: "./src/index.js", +- plugins: [new HtmlWebpackPlugin({ template: "./src/index.html" })], ++ experiments: { ++ html: true, ++ }, ++ // The template is now the entry: it must load the previous entry via (added by the HTML workflow step when the template is included) ++ entry: "./src/index.html", ++ output: { ++ htmlFilename: "index.html", ++ }, + }; ``` After migration, the codemod removes unused `html-webpack-plugin`, `html-loader`, and supported companion plugins from `dependencies` and `devDependencies`. The configuration step records remaining package references in workflow state, scoped by source filename. The JSON transform combines those references with other manifest fields, preserving packages still referenced beneath that manifest by skipped configs or other tooling. Running the JSON transform alone keeps dependencies because no source-reference scan is available. Run your package manager afterward to update the lockfile. From 2375e7b961d47e7c9e000ee01613fa8eb184a280 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 7 Nov 2026 12:48:22 -0500 Subject: [PATCH 35/42] refactor: use node.is in HTML codemod transforms --- .../src/loader-options.ts | 96 +++++----- .../src/minification.ts | 40 ++-- .../src/remove-dependencies.ts | 4 +- .../src/templates.ts | 10 +- .../src/workflow.ts | 173 +++++++++--------- 5 files changed, 161 insertions(+), 162 deletions(-) diff --git a/codemods/html-plugins-to-native-html/src/loader-options.ts b/codemods/html-plugins-to-native-html/src/loader-options.ts index 93c6042..f3a279a 100644 --- a/codemods/html-plugins-to-native-html/src/loader-options.ts +++ b/codemods/html-plugins-to-native-html/src/loader-options.ts @@ -3,27 +3,27 @@ import type { SgNode } from "@codemod.com/jssg-types/main"; import { findPair, keyName, namedChildren, unquote } from "@webpack/codemod-utils"; function callbackParameters(value: SgNode): SgNode[] | null { - if (!["arrow_function", "function_expression"].includes(value.kind())) return null; - if (value.children().some((node) => node.kind() === "async")) return null; + if (!["arrow_function", "function_expression"].some((kind) => value.is(kind))) return null; + if (value.children().some((node) => node.is("async"))) return null; if (value.findAll({ rule: { any: [{ kind: "this" }, { pattern: "arguments" }] } }).length) return null; const parameters = value.field("parameters"); const parameter = value.field("parameter"); - const params = parameters ? namedChildren(parameters).filter((node) => node.kind() !== "comment") : parameter ? [parameter] : []; - if (params.some((param) => param.field("value") || param.kind() === "assignment_pattern" || param.kind() === "rest_pattern")) return null; + const params = parameters ? namedChildren(parameters).filter((node) => !node.is("comment")) : parameter ? [parameter] : []; + if (params.some((param) => param.field("value") || param.is("assignment_pattern") || param.is("rest_pattern"))) return null; const names = params.map((param) => param.field("pattern") ?? param); - return names.every((name) => name.kind() === "identifier") ? names : null; + return names.every((name) => name.is("identifier")) ? names : null; } export function isIdentityCallback(value: SgNode): boolean { const params = callbackParameters(value); if (!params?.length) return false; let body = value.field("body"); - if (body?.kind() === "statement_block") { - const statements = namedChildren(body).filter((node) => node.kind() !== "comment"); - if (statements.length !== 1 || statements[0].kind() !== "return_statement") return false; - body = namedChildren(statements[0]).find((node) => node.kind() !== "comment") ?? null; + if (body?.is("statement_block")) { + const statements = namedChildren(body).filter((node) => !node.is("comment")); + if (statements.length !== 1 || !statements[0].is("return_statement")) return false; + body = namedChildren(statements[0]).find((node) => !node.is("comment")) ?? null; } - return body?.kind() === "identifier" && body.text() === params[0].text(); + return !!body?.is("identifier") && body.text() === params[0].text(); } function usesParameter(value: SgNode, params: SgNode[], index: number): boolean { @@ -34,12 +34,12 @@ function usesParameter(value: SgNode, params: SgNode[], index: number): function constantBoolean(value: SgNode): string | null { if (!callbackParameters(value)) return null; let body = value.field("body"); - if (body?.kind() === "statement_block") { - const statements = namedChildren(body).filter((node) => node.kind() !== "comment"); - if (statements.length !== 1 || statements[0].kind() !== "return_statement") return null; + if (body?.is("statement_block")) { + const statements = namedChildren(body).filter((node) => !node.is("comment")); + if (statements.length !== 1 || !statements[0].is("return_statement")) return null; body = namedChildren(statements[0])[0] ?? null; } - return body?.kind() === "true" || body?.kind() === "false" ? body.text() : null; + return body?.is("true") || body?.is("false") ? body.text() : null; } function freshName(source: string, base: string): string { @@ -60,31 +60,31 @@ function sourceFilter(value: SgNode, tag: string | null, attribute: string, } export function nativeSources(value: SgNode, typescript = false): { text: string; extendsDefaults: boolean } | null { - if (value.kind() === "true" || value.kind() === "false") return { text: value.text(), extendsDefaults: false }; - if (value.kind() !== "object") return null; - const options = namedChildren(value).filter((node) => node.kind() !== "comment"); + if (value.is("true") || value.is("false")) return { text: value.text(), extendsDefaults: false }; + if (!value.is("object")) return null; + const options = namedChildren(value).filter((node) => !node.is("comment")); if (!options.length) return { text: "true", extendsDefaults: false }; const keys = options.map(keyName); if (new Set(keys).size !== keys.length || keys.some((key) => !key || !["list", "urlFilter", "scriptingEnabled"].includes(key))) return null; const scripting = findPair(value, "scriptingEnabled")?.field("value"); - if (scripting && scripting.kind() !== "true") return null; + if (scripting && !scripting.is("true")) return null; const urlFilter = findPair(value, "urlFilter")?.field("value"); const urlConstant = urlFilter ? constantBoolean(urlFilter) : "true"; if (urlConstant === "false") return { text: "false", extendsDefaults: false }; const list = findPair(value, "list")?.field("value"); if (!list) return urlConstant === "true" ? { text: "true", extendsDefaults: false } : null; - if (list.kind() !== "array") return null; + if (!list.is("array")) return null; const urlParams = urlFilter ? callbackParameters(urlFilter) : []; if (urlConstant === null && (!urlParams || urlParams.length > 3 || usesParameter(urlFilter!, urlParams, 2))) return null; const edits = []; for (const source of namedChildren(list)) { - if (source.kind() === "comment") continue; - if (source.kind() === "string" && unquote(source.text()) === "...") { + if (source.is("comment")) continue; + if (source.is("string") && unquote(source.text()) === "...") { if (urlConstant === null) return null; continue; } - if (source.kind() !== "object") return null; - const properties = namedChildren(source).filter((node) => node.kind() !== "comment"); + if (!source.is("object")) return null; + const properties = namedChildren(source).filter((node) => !node.is("comment")); const names = properties.map(keyName); if (new Set(names).size !== names.length) return null; if (!findPair(source, "attribute") || !findPair(source, "type")) return null; @@ -94,8 +94,8 @@ export function nativeSources(value: SgNode, typescript = false): { text: st if (!name || !["tag", "attribute", "type", "filter"].includes(name) || !option) return null; if (name === "filter") continue; if (name === "type") { - if (option.kind() !== "string" || !["src", "srcset"].includes(unquote(option.text()))) return null; - } else if (option.kind() !== "string") return null; + if (!option.is("string") || !["src", "srcset"].includes(unquote(option.text()))) return null; + } else if (!option.is("string")) return null; } const filter = findPair(source, "filter")?.field("value"); if (!filter && urlConstant === "true") continue; @@ -128,7 +128,7 @@ export function nativeSources(value: SgNode, typescript = false): { text: st } return { text: edits.length ? list.commitEdits(edits) : list.text(), - extendsDefaults: namedChildren(list).some((node) => node.kind() === "string" && unquote(node.text()) === "..."), + extendsDefaults: namedChildren(list).some((node) => node.is("string") && unquote(node.text()) === "..."), }; } @@ -154,7 +154,7 @@ function adaptTemplateContext(value: SgNode, context: SgNode, typescript const resourcePattern = '/^((?:\\0.|[^?#\\0])*)(\\?(?:\\0.|[^#\\0])*)?(#.*)?$/'; for (const reference of body.findAll({ rule: { kind: "identifier" } }).filter((node) => node.text() === name)) { const member = reference.parent(); - if (member?.kind() !== "member_expression" || member.field("object")?.range().start.index !== reference.range().start.index) return null; + if (!member?.is("member_expression") || member.field("object")?.range().start.index !== reference.range().start.index) return null; const property = member.field("property")?.text(); if (!property) return null; if (CONTEXT_METHODS.has(property)) properties.set(property, CONTEXT_METHODS.get(property)!); @@ -173,7 +173,7 @@ function adaptTemplateContext(value: SgNode, context: SgNode, typescript } else return null; } if (typescript) { - const parameter = namedChildren(value.field("parameters")!).filter((node) => node.kind() !== "comment")[1]; + const parameter = namedChildren(value.field("parameters")!).filter((node) => !node.is("comment"))[1]; const type = parameter.field("type"); const text = `: { ${[...properties].map(([key, type]) => `${key}: ${type}`).join("; ")} }`; if (type) edits.push(type.replace(text)); @@ -184,18 +184,18 @@ function adaptTemplateContext(value: SgNode, context: SgNode, typescript function returnsString(node: SgNode | null, strings: Set): boolean { if (!node) return false; - if (node.kind() === "string" || node.kind() === "template_string") return true; - if (node.kind() === "identifier" || node.kind() === "member_expression") return strings.has(node.text()); - if (node.kind() === "parenthesized_expression") return returnsString(namedChildren(node)[0], strings); - if (node.kind() === "binary_expression" && node.field("operator")?.text() === "+") { + if (node.is("string") || node.is("template_string")) return true; + if (node.is("identifier") || node.is("member_expression")) return strings.has(node.text()); + if (node.is("parenthesized_expression")) return returnsString(namedChildren(node)[0], strings); + if (node.is("binary_expression") && node.field("operator")?.text() === "+") { return returnsString(node.field("left"), strings) || returnsString(node.field("right"), strings); } - if (node.kind() === "ternary_expression") { + if (node.is("ternary_expression")) { return returnsString(node.field("consequence"), strings) && returnsString(node.field("alternative"), strings); } - if (node.kind() === "call_expression") { - const callee = node.field("function"); - return callee?.kind() === "member_expression" + if (node.is("call_expression")) { + const callee: SgNode = node.field("function"); + return callee?.is("member_expression") && STRING_METHODS.has(callee.field("property")?.text() ?? "") && returnsString(callee.field("object"), strings); } @@ -206,8 +206,8 @@ function returnsString(node: SgNode | null, strings: Set): boolean { // to inline string transforms whose result we can establish without executing // user code. Unsupported context APIs and async/unknown returns need adaptation. export function nativeTemplate(value: SgNode, typescript = false): string | null { - if (!["arrow_function", "function_expression"].includes(value.kind())) return null; - if (value.children().some((node) => node.kind() === "async")) return null; + if (!["arrow_function", "function_expression"].some((kind) => value.is(kind))) return null; + if (value.children().some((node) => node.is("async"))) return null; if (value.findAll({ rule: { any: [ { kind: "await_expression" }, { kind: "this" }, { kind: "assignment_expression" }, { kind: "augmented_assignment_expression" }, @@ -223,20 +223,20 @@ export function nativeTemplate(value: SgNode, typescript = false): string | if (context) for (const property of ["resource", "resourcePath", "resourceQuery", "resourceFragment", "context"]) strings.add(`${context.text()}.${property}`); const body = value.field("body"); if (!body) return null; - if (body.kind() !== "statement_block") return returnsString(body, strings) ? text : null; - const statements = namedChildren(body).filter((node) => node.kind() !== "comment"); + if (!body.is("statement_block")) return returnsString(body, strings) ? text : null; + const statements = namedChildren(body).filter((node) => !node.is("comment")); const last = statements.pop(); - if (last?.kind() !== "return_statement") return null; + if (!last?.is("return_statement")) return null; for (const statement of statements) { - const expression = statement.kind() === "expression_statement" ? namedChildren(statement)[0] : undefined; - const callee = expression?.kind() === "call_expression" ? expression.field("function") : undefined; - if (context && callee?.kind() === "member_expression" && callee.field("object")?.text() === context.text() && CONTEXT_METHODS.has(callee.field("property")?.text() ?? "")) continue; - if (statement.kind() !== "lexical_declaration" || !statement.text().startsWith("const ")) return null; - for (const declaration of namedChildren(statement).filter((node) => node.kind() !== "comment")) { + const expression = statement.is("expression_statement") ? namedChildren(statement)[0] : undefined; + const callee: SgNode | undefined = expression?.is("call_expression") ? expression.field("function") : undefined; + if (context && callee?.is("member_expression") && callee.field("object")?.text() === context.text() && CONTEXT_METHODS.has(callee.field("property")?.text() ?? "")) continue; + if (!statement.is("lexical_declaration") || !statement.text().startsWith("const ")) return null; + for (const declaration of namedChildren(statement).filter((node) => !node.is("comment"))) { const binding = declaration.field("name"); - if (binding?.kind() !== "identifier" || !returnsString(declaration.field("value"), strings)) return null; + if (!binding?.is("identifier") || !returnsString(declaration.field("value"), strings)) return null; strings.add(binding.text()); } } - return returnsString(namedChildren(last).find((node) => node.kind() !== "comment") ?? null, strings) ? text : null; + return returnsString(namedChildren(last).find((node) => !node.is("comment")) ?? null, strings) ? text : null; } diff --git a/codemods/html-plugins-to-native-html/src/minification.ts b/codemods/html-plugins-to-native-html/src/minification.ts index 11528a8..b251412 100644 --- a/codemods/html-plugins-to-native-html/src/minification.ts +++ b/codemods/html-plugins-to-native-html/src/minification.ts @@ -22,11 +22,11 @@ const BOOLEAN_OPTIONS = new Set([ // Only translate known literal values; retain a review note for everything else. export function collectMinification(value: SgNode | null | undefined, name: string): Minification { const result: Minification = { html: null, lost: [] }; - if (!value || value.kind() === "true" || (value.kind() === "string" && unquote(value.text()) === "auto")) { + if (!value || value.is("true") || (value.is("string") && unquote(value.text()) === "auto")) { return result; } - if (value.kind() === "false") return { html: "false", lost: [] }; - if (value.kind() !== "object") { + if (value.is("false")) return { html: "false", lost: [] }; + if (!value.is("object")) { result.lost.push(`${name} (configure optimization.minimizeOptions.html)`); return result; } @@ -34,22 +34,22 @@ export function collectMinification(value: SgNode | null | undefined, name: const removeComments = findPair(value, "removeComments")?.field("value"); const ignoredComments = findPair(value, "ignoreCustomComments")?.field("value"); for (const child of namedChildren(value)) { - if (child.kind() === "comment") continue; + if (child.is("comment")) continue; const key = keyName(child); const option = child.field("value"); if (key === "ignoreCustomComments" && option) { - if (removeComments?.kind() === "false") continue; - const patterns = option.kind() === "array" ? namedChildren(option).filter((node) => node.kind() !== "comment") : null; - if (!patterns || patterns.some((node) => node.kind() !== "regex")) { + if (removeComments?.is("false")) continue; + const patterns = option.is("array") ? namedChildren(option).filter((node) => !node.is("comment")) : null; + if (!patterns || patterns.some((node) => !node.is("regex"))) { result.lost.push(`${name}.${key} (configure optimization.minimizeOptions.html.comments)`); } continue; } - if (!key || !option || (option.kind() !== "true" && option.kind() !== "false")) { + if (!key || !option || (!option.is("true") && !option.is("false"))) { result.lost.push(`${name}.${key ?? "options"} (configure optimization.minimizeOptions.html)`); continue; } - const enabled = option.kind() === "true"; + const enabled = option.is("true"); if (BOOLEAN_OPTIONS.has(key)) { props.set(key, option.text()); } else if (key === "removeComments") { @@ -67,12 +67,12 @@ export function collectMinification(value: SgNode | null | undefined, name: result.lost.push(`${name}.${key} (configure optimization.minimizeOptions.html)`); } } - if (props.get("collapseWhitespace") === "true" && findPair(value, "conservativeCollapse")?.field("value")?.kind() === "false") { + if (props.get("collapseWhitespace") === "true" && findPair(value, "conservativeCollapse")?.field("value")?.is("false")) { props.set("collapseWhitespace", '"all"'); } - if (ignoredComments?.kind() === "array" && removeComments?.kind() === "true") { - const patterns = namedChildren(ignoredComments).filter((node) => node.kind() !== "comment"); - if (patterns.every((node) => node.kind() === "regex")) { + if (ignoredComments?.is("array") && removeComments?.is("true")) { + const patterns = namedChildren(ignoredComments).filter((node) => !node.is("comment")); + if (patterns.every((node) => node.is("regex"))) { const parameter = /\.[cm]?tsx?$/.test(value.getRoot().filename()) ? "comment: string" : "comment"; props.set("comments", patterns.length ? `(${parameter}) => [${patterns.map((node) => node.text()).join(", ")}].some((pattern) => pattern.test(comment))` : "false"); props.set("extractComments", "false"); @@ -103,14 +103,14 @@ export function planMinification( if (!optimization) { return html === null ? null : `optimization: { minimizeOptions: { html: ${html} } }`; } - if (optimization.kind() !== "object") { + if (!optimization.is("object")) { notes.push("Review: configure the existing optimization value to use webpack's native HTML minimizer (minimizeOptions.html and minimizer: [\"...\"])"); return null; } const minimizer = findPair(optimization, "minimizer")?.field("value"); - if (minimizer?.kind() === "array" && html !== "false") { - const elements = namedChildren(minimizer).filter((element) => element.kind() !== "comment"); - if (!elements.some((element) => element.kind() === "string" && unquote(element.text()) === "...")) { + if (minimizer?.is("array") && html !== "false") { + const elements = namedChildren(minimizer).filter((element) => !element.is("comment")); + if (!elements.some((element) => element.is("string") && unquote(element.text()) === "...")) { // Append after custom minimizers, so assets they marked minimized stay so. const end = elements.at(-1)?.range().end.index ?? minimizer.range().start.index + 1; editor.addEdit({ @@ -119,15 +119,15 @@ export function planMinification( insertedText: `${elements.length ? "," : ""} "..."`, }); } - } else if (minimizer && minimizer.kind() !== "array") { + } else if (minimizer && !minimizer.is("array")) { notes.push('Review: keep "..." in optimization.minimizer to enable native HTML minification'); } if (html === null) return null; const minimize = findPair(optimization, "minimize")?.field("value"); const separateOptions = findPair(optimization, "minimizeOptions")?.field("value"); // The shorthand takes precedence during webpack normalization. - const options = minimize?.kind() === "object" ? minimize : separateOptions; - if (options?.kind() === "object") { + const options = minimize?.is("object") ? minimize : separateOptions; + if (options?.is("object")) { if (!findPair(options, "html")) { editor.insertIntoObject(options, () => [`html: ${html}`]); } else { 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 4dedd4f..38e66e5 100644 --- a/codemods/html-plugins-to-native-html/src/remove-dependencies.ts +++ b/codemods/html-plugins-to-native-html/src/remove-dependencies.ts @@ -13,7 +13,7 @@ export function removeUnusedDependencies(root: SgRoot, remaining: Set; const editor = new ConfigEditor(rootNode); const manifest = namedChildren(rootNode)[0]; - if (!manifest || manifest.kind() !== "object") return null; + if (!manifest || !manifest.is("object")) return null; if (!remaining) return null; // Scripts and other manifest fields can invoke these packages as well. for (const pair of pairsOf(manifest)) { @@ -24,7 +24,7 @@ export function removeUnusedDependencies(root: SgRoot, remaining: Set, scripts: TemplateScript[] } const existing = new Set(); for (const script of node.findAll({ rule: { kind: "script_element" } })) { - const start = script.children().find((child) => child.kind() === "start_tag"); + const start = script.children().find((child) => child.is("start_tag")); for (const attribute of start?.children() ?? []) { - if (attribute.kind() !== "attribute") continue; - const name = attribute.children().find((child) => child.kind() === "attribute_name"); + if (!attribute.is("attribute")) continue; + const name = attribute.children().find((child) => child.is("attribute_name")); if (name?.text().toLowerCase() !== "src") continue; - const value = attribute.children().find((child) => child.kind() === "quoted_attribute_value" || child.kind() === "attribute_value"); + const value = attribute.children().find((child) => child.is("quoted_attribute_value") || child.is("attribute_value")); if (!value) continue; const key = normalizeFilename(value.text().replace(/^["']|["']$/g, "")); existing.add(key); const chunkName = requested.get(key)?.chunkName; const previous = script.prev(); // A new comment would override existing webpackIgnore/Preload/etc. - if (!chunkName || (previous?.kind() === "comment" && /\bwebpack\w+\s*:/.test(previous.text()))) continue; + if (!chunkName || (previous?.is("comment") && /\bwebpack\w+\s*:/.test(previous.text()))) continue; const position = script.range().start.index; const indent = source.slice(source.lastIndexOf("\n", position - 1) + 1, position); const separator = /^[ \t]*$/.test(indent) ? `${eol}${indent}` : " "; diff --git a/codemods/html-plugins-to-native-html/src/workflow.ts b/codemods/html-plugins-to-native-html/src/workflow.ts index a4febcb..aa83c4c 100644 --- a/codemods/html-plugins-to-native-html/src/workflow.ts +++ b/codemods/html-plugins-to-native-html/src/workflow.ts @@ -83,12 +83,12 @@ function dedupeProps(props: HtmlProp[]): HtmlProp[] { } function isRequireOf(node: SgNode, moduleName: string): boolean { - if (node.kind() !== "call_expression") return false; + if (!node.is("call_expression")) return false; const callee = node.field("function"); - if (!callee || callee.kind() !== "identifier" || callee.text() !== "require") return false; + if (!callee || !callee.is("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; + return args.length === 1 && args[0].is("string") && unquote(args[0].text()) === moduleName; } // Everything one plugin instance contributes to its enclosing config. @@ -189,7 +189,7 @@ class HtmlMigration { // too, because the companion relies on its hooks. for (const pair of pluginsPairs) { const value = unwrapFilterCall(pair.field("value") ?? pair); - if (value.kind() !== "array") continue; + if (!value.is("array")) continue; if (namedChildren(value).some((element) => { const sibling = this.siblingInstantiationOf(element); return sibling?.module === FAVICONS_MODULE && !this.faviconLogo(sibling.instantiation); @@ -239,17 +239,17 @@ class HtmlMigration { for (const receiver of this.pluginHookReceivers()) { const call = receiver.parent(); const access = call?.parent(); - if (access?.kind() === "member_expression") { + if (access?.is("member_expression")) { const property = access.field("property"); if (property) properties.push(property); - } else if (access?.kind() === "variable_declarator") { + } else if (access?.is("variable_declarator")) { const name = access.field("name"); - if (name?.kind() === "object_pattern") { + if (name?.is("object_pattern")) { for (const member of namedChildren(name)) { - const key = member.kind() === "pair_pattern" ? member.field("key") : member; + const key = member.is("pair_pattern") ? member.field("key") : member; if (key) properties.push(key); } - } else if (name?.kind() === "identifier") { + } else if (name?.is("identifier")) { for (const member of this.editor.rootNode.findAll({ rule: { kind: "member_expression" } })) { if (member.field("object")?.text() !== name.text()) continue; const property = member.field("property"); @@ -303,9 +303,9 @@ class HtmlMigration { // ESM output is stable since webpack 5.111. Remove the obsolete flag // only when its value is a literal, preserving any expression side effects. const experiments = findPair(plan.config, "experiments")?.field("value"); - const flag = experiments?.kind() === "object" ? findPair(experiments, "outputModule") : undefined; + const flag = experiments?.is("object") ? findPair(experiments, "outputModule") : undefined; const value = flag?.field("value"); - if (flag && (value?.kind() === "true" || value?.kind() === "false")) { + if (flag && (value?.is("true") || value?.is("false"))) { this.editor.keepBracesOpen(experiments!); this.editor.markForRemoval(flag); } @@ -330,20 +330,20 @@ class HtmlMigration { for (const branch of branches) this.collectLoaderFindings(branch, findings); return; } - if (node.kind() !== "object" || loaderNameOf(node) !== LOADER_NAME) return; + if (!node.is("object") || loaderNameOf(node) !== LOADER_NAME) return; const optionsValue = findPair(node, "options")?.field("value"); const minification = collectMinification( - optionsValue?.kind() === "object" ? findPair(optionsValue, "minimize")?.field("value") : undefined, + optionsValue?.is("object") ? findPair(optionsValue, "minimize")?.field("value") : undefined, `${LOADER_NAME}.minimize`, ); findings.minification.push(minification); if (minification.lost.length) findings.supported = false; if (!optionsValue) return; - if (optionsValue.kind() !== "object") { + if (!optionsValue.is("object")) { findings.supported = false; return; } - if (namedChildren(optionsValue).some((child) => child.kind() !== "pair" && child.kind() !== "comment")) { + if (namedChildren(optionsValue).some((child) => !child.is("pair") && !child.is("comment"))) { findings.supported = false; return; } @@ -353,7 +353,7 @@ class HtmlMigration { if (name === "esModule") { // Native HTML exposes a default string export and its CJS shim also // returns that string directly, so both literal settings can migrate. - if (value?.kind() !== "true" && value?.kind() !== "false") findings.supported = false; + if (!value?.is("true") && !value?.is("false")) findings.supported = false; continue; } if (name === "minimize") continue; @@ -382,7 +382,7 @@ class HtmlMigration { // tool's config (Storybook, craco, …). private isWebpackRuleContext(pair: SgNode, arrayNode: SgNode): boolean { const owner = arrayNode.parent(); - if (owner && owner.kind() === "pair") { + if (owner && owner.is("pair")) { const name = keyName(owner); if (name === "rules" || name === "oneOf") return true; } @@ -398,13 +398,13 @@ class HtmlMigration { const originalValue = pair.field("value"); if (!originalValue) continue; const value = unwrapFilterCall(originalValue); - const elements = value.kind() === "array" ? namedChildren(value) : [value]; + const elements = value.is("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 (!ruleObject || !ruleObject.is("object")) continue; if (!this.isWebpackRuleContext(pair, ruleObject.parent() ?? ruleObject)) continue; const findings: LoaderFindings = { supported: true, sourceDefaults: false, parserProps: [], minification: [] }; for (const element of elements) this.collectLoaderFindings(element, findings); @@ -425,7 +425,7 @@ class HtmlMigration { // Rule-level `loader:`/`options:` shorthand. for (const pair of loaderPairs) { const ruleObject = pair.parent(); - if (!ruleObject || ruleObject.kind() !== "object") continue; + if (!ruleObject || !ruleObject.is("object")) continue; if (!findPair(ruleObject, "test") || findPair(ruleObject, "use")) continue; const value = pair.field("value"); if (!value || !this.isRemovableUseElement(value)) continue; @@ -466,13 +466,13 @@ class HtmlMigration { const experiments = config && findPair(config, "experiments")?.field("value"); const parser = findPair(swap.ruleObject, "parser")?.field("value"); if ( - (experiments && experiments.kind() !== "object") || - (swap.findings.parserProps.length && parser && parser.kind() !== "object") + (experiments && !experiments.is("object")) || + (swap.findings.parserProps.length && parser && !parser.is("object")) ) return false; // Do not replace loader behavior with a conflicting existing parser option, // or flatten differently configured loader branches into one parser. for (const prop of swap.findings.parserProps) { - const existing = parser?.kind() === "object" ? findPair(parser, prop.name)?.field("value") : undefined; + const existing = parser?.is("object") ? findPair(parser, prop.name)?.field("value") : undefined; if (existing && existing.text() !== prop.valueText) return false; if (swap.findings.parserProps.some((other) => other.name === prop.name && other.valueText !== prop.valueText)) return false; } @@ -520,7 +520,7 @@ class HtmlMigration { replacement += `type: "html"`; if (typePair) this.editor.markForRemoval(typePair); if (swap.findings.parserProps.length) { - if (parser?.kind() === "object") { + if (parser?.is("object")) { const missing = swap.findings.parserProps.filter((prop) => !findPair(parser, prop.name)); if (missing.length) { this.editor.insertIntoObject(parser, () => missing.map((prop) => `${prop.name}: ${prop.valueText}`)); @@ -549,13 +549,13 @@ class HtmlMigration { } return null; } - if (element.kind() !== "new_expression") return null; + if (!element.is("new_expression")) return null; const constructorNode = element.field("constructor"); if (!constructorNode) return null; if (this.pluginNames.has(constructorNode.text())) return element; // `new (require("html-webpack-plugin"))(...)` without a binding. const inner = - constructorNode.kind() === "parenthesized_expression" + constructorNode.is("parenthesized_expression") ? (namedChildren(constructorNode)[0] ?? constructorNode) : constructorNode; return isRequireOf(inner, PLUGIN_MODULE) ? element : null; @@ -563,10 +563,10 @@ class HtmlMigration { private transformPluginsPair(pluginsPair: SgNode): void { const configObject = pluginsPair.parent(); - if (!configObject || configObject.kind() !== "object") return; + if (!configObject || !configObject.is("object")) return; if (this.blockedConfigs.has(configObject.range().start.index)) return; const value = unwrapFilterCall(pluginsPair.field("value") ?? pluginsPair); - if (value.kind() !== "array") return; + if (!value.is("array")) return; const elements = namedChildren(value); const instances: { element: SgNode; instantiation: SgNode }[] = []; for (const element of elements) { @@ -577,7 +577,7 @@ class HtmlMigration { // A computed destination cannot safely receive the native settings. if (["output", "experiments"].some((key) => { const destination = findPair(configObject, key)?.field("value"); - return destination && destination.kind() !== "object"; + return destination && !destination.is("object"); })) { this.pluginRetained = true; return; @@ -588,15 +588,15 @@ class HtmlMigration { const argumentsNode = instantiation.field("arguments"); const optionsObject = argumentsNode ? namedChildren(argumentsNode)[0] : undefined; if (optionsObject && ( - optionsObject.kind() !== "object" || - namedChildren(optionsObject).some((child) => child.kind() !== "pair" && child.kind() !== "comment") + !optionsObject.is("object") || + namedChildren(optionsObject).some((child) => !child.is("pair") && !child.is("comment")) )) { this.pluginRetained = true; return; } const template = optionsObject && findPair(optionsObject, "template")?.field("value"); const entry = findPair(configObject, "entry")?.field("value"); - if (template && (template.kind() !== "string" || (entry && !this.replaceableEntryValue(entry)))) { + if (template && (!template.is("string") || (entry && !this.replaceableEntryValue(entry)))) { this.pluginRetained = true; return; } @@ -608,7 +608,7 @@ class HtmlMigration { const chunkRestricted = instances.length > 1 || (firstOptions !== undefined && - findPair(firstOptions, "chunks")?.field("value")?.kind() === "array"); + findPair(firstOptions, "chunks")?.field("value")?.is("array")); if (chunkRestricted) { this.migrateMultiPage(configObject, pluginsPair, elements, instances, optionObjects); return; @@ -640,13 +640,13 @@ class HtmlMigration { private faviconLogo(instantiation: SgNode): SgNode | null { const argsNode = instantiation.field("arguments"); - const args = argsNode ? namedChildren(argsNode).filter((node) => node.kind() !== "comment") : []; + const args = argsNode ? namedChildren(argsNode).filter((node) => !node.is("comment")) : []; if (args.length !== 1) return null; const option = args[0]; - if (option.kind() === "string") return option; - if (option.kind() !== "object" || namedChildren(option).some((node) => node.kind() !== "pair" && node.kind() !== "comment")) return null; + if (option.is("string")) return option; + if (!option.is("object") || namedChildren(option).some((node) => !node.is("pair") && !node.is("comment"))) return null; const logo = findPair(option, "logo")?.field("value"); - return logo?.kind() === "string" ? logo : null; + return logo?.is("string") ? logo : null; } // Named bindings (`import { X }` / `const { X } = require(...)`) — e.g. @@ -666,19 +666,19 @@ class HtmlMigration { 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", + (child) => child.is("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 (!pattern || !pattern.is("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") { + if (property.is("shorthand_property_identifier_pattern")) { bindings.push({ name: property.text(), statement }); - } else if (property.kind() === "pair_pattern") { + } else if (property.is("pair_pattern")) { const local = property.field("value"); if (local) bindings.push({ name: local.text(), statement }); } @@ -698,10 +698,10 @@ class HtmlMigration { } return null; } - if (element.kind() !== "new_expression") return null; + if (!element.is("new_expression")) return null; const constructorNode = element.field("constructor"); if (!constructorNode) return null; - const inner = constructorNode.kind() === "parenthesized_expression" + const inner = constructorNode.is("parenthesized_expression") ? namedChildren(constructorNode)[0] : constructorNode; const module = this.siblingNameToModule.get(constructorNode.text()) ?? SIBLING_PLUGIN_MODULES.find((name) => inner && isRequireOf(inner, name)); @@ -747,11 +747,11 @@ class HtmlMigration { if (module === CSP_MODULE) { const policy = args[0]; if (!policy) props.push({ name: "csp", valueText: "true" }); - else if (policy.kind() === "object") { + else if (policy.is("object")) { props.push({ name: "csp", valueText: `{ policy: ${policy.text()} }` }); } else return false; const options = args[1]; - if (options && options.kind() === "object") { + if (options && options.is("object")) { for (const optionPair of pairsOf(options)) lost.push(keyName(optionPair) ?? "options"); } else if (options) { lost.push("options"); @@ -760,13 +760,13 @@ class HtmlMigration { let integrity = "true"; let enabled = true; const options = args[0]; - if (options && options.kind() !== "object") return false; + if (options && !options.is("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; + if (name === "hashFuncNames" && value?.is("array")) integrity = value.text(); + else if (name === "enabled" && value?.is("false")) enabled = false; else if (name === "enabled") continue; else lost.push(name ?? "options"); } @@ -776,7 +776,7 @@ class HtmlMigration { const logo = this.faviconLogo(instantiation); if (!logo) return false; props.push({ name: "favicon", valueText: logo.text() }); - if (args[0]?.kind() === "object") { + if (args[0]?.is("object")) { for (const pair of pairsOf(args[0])) { if (keyName(pair) !== "logo") lost.push(keyName(pair) ?? "options"); } @@ -813,7 +813,7 @@ class HtmlMigration { optionObjects: (SgNode | undefined)[], ): void { const entryValue = findPair(configObject, "entry")?.field("value"); - if (!entryValue || entryValue.kind() !== "object") { + if (!entryValue || !entryValue.is("object")) { this.pluginRetained = true; return; } @@ -825,8 +825,8 @@ class HtmlMigration { 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") { + const chunkNames = chunksValue?.is("array") ? namedChildren(chunksValue) : []; + if (!options || chunkNames.length !== 1 || !chunkNames[0].is("string")) { this.pluginRetained = true; return; } @@ -845,7 +845,7 @@ class HtmlMigration { // 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; + const filename = filenameValue.is("string") ? unquote(filenameValue.text()) : null; if (filename !== `${chunkName}.html` && filename !== "[name].html") { lost.push(`filename "${filename ?? "?"}" (output.htmlFilename is "[name].html")`); } @@ -875,7 +875,7 @@ class HtmlMigration { ]); for (const page of pages) { const node = page.entryDescriptor; - if (node.kind() === "object") { + if (node.is("object")) { if (!findPair(node, "html")) { this.editor.insertIntoObject(node, () => [`html: ${page.htmlValue}`]); } @@ -913,7 +913,7 @@ class HtmlMigration { for (const property of this.pluginHookProperties()) { const renamed = HOOK_RENAMES.get(property.text()); if (!renamed) continue; - this.editor.replace(property, property.kind() === "shorthand_property_identifier_pattern" + this.editor.replace(property, property.is("shorthand_property_identifier_pattern") ? `${renamed}: ${property.text()}` : renamed); const statement = this.statementOf(property); const review = HOOK_REVIEW_COMMENTS.get(renamed); @@ -944,8 +944,7 @@ class HtmlMigration { 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") { + if (parent.is("statement_block") || parent.is("program") || parent.is("class_body")) { return current; } current = parent; @@ -961,7 +960,7 @@ class HtmlMigration { if (webpackBindings.length) return `${webpackBindings[0].name}.html.HtmlModulesPlugin`; const binding = this.pluginBindings[0]; if (binding) { - const isEsm = binding.statement.kind() === "import_statement"; + const isEsm = binding.statement.is("import_statement"); this.editor.replace( binding.statement, isEsm ? 'import { html } from "webpack";' : 'const { html } = require("webpack");', @@ -1002,7 +1001,7 @@ class HtmlMigration { const templateValue = findPair(optionsObject, "template")?.field("value"); const templateMode = Boolean(templateValue); if (templateValue) { - if (templateValue.kind() === "string") findings.templateValue = templateValue.text(); + if (templateValue.is("string")) findings.templateValue = templateValue.text(); else findings.lost.push("template"); } for (const optionPair of pairsOf(optionsObject)) { @@ -1025,7 +1024,7 @@ class HtmlMigration { templateMode: boolean, findings: InstanceFindings, ): void { - const literal = value.kind() === "string" ? unquote(value.text()) : null; + const literal = value.is("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) { @@ -1034,7 +1033,7 @@ class HtmlMigration { } switch (name) { case "filename": - if (value.kind() === "string") findings.htmlFilename = value.text(); + if (value.is("string")) findings.htmlFilename = value.text(); else findings.lost.push(this.describeLost(name)); break; case "title": @@ -1047,11 +1046,11 @@ class HtmlMigration { 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") { + if (templateMode || value.is("true")) break; + if (value.is("false") || literal === "body" || literal === "head") { findings.htmlProps.push({ name, - valueText: value.kind() === "false" ? "false" : `"${literal}"`, + valueText: value.is("false") ? "false" : `"${literal}"`, }); } else { findings.lost.push(this.describeLost(name)); @@ -1085,7 +1084,7 @@ class HtmlMigration { break; case "hash": case "xhtml": - if (value.kind() !== "false") findings.lost.push(this.describeLost(name)); + if (!value.is("false")) findings.lost.push(this.describeLost(name)); break; default: findings.lost.push(this.describeLost(name)); @@ -1096,7 +1095,7 @@ class HtmlMigration { // property, content }` objects (native keys `og:*` use `property` on their // own); anything else — extra attributes, `http-equiv` — is flagged per key. private collectMetaOption(value: SgNode, findings: InstanceFindings): void { - if (value.kind() !== "object") { + if (!value.is("object")) { findings.lost.push("meta"); return; } @@ -1108,11 +1107,11 @@ class HtmlMigration { findings.lost.push("meta"); continue; } - if (metaValue.kind() === "string") { + if (metaValue.is("string")) { parts.push(metaPair.text()); continue; } - if (metaValue.kind() === "object") { + if (metaValue.is("object")) { const nameValue = (findPair(metaValue, "name") ?? findPair(metaValue, "property"))?.field( "value", ); @@ -1120,7 +1119,7 @@ class HtmlMigration { const extraKeys = pairsOf(metaValue) .map((pair) => keyName(pair)) .filter((key) => key !== "name" && key !== "property" && key !== "content"); - if (nameValue?.kind() === "string" && contentValue && !extraKeys.length) { + if (nameValue?.is("string") && contentValue && !extraKeys.length) { parts.push(`"${unquote(nameValue.text())}": ${contentValue.text()}`); continue; } @@ -1138,7 +1137,7 @@ class HtmlMigration { // 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) { + if (entryValue && entryValue.is("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", ); @@ -1167,7 +1166,7 @@ class HtmlMigration { findings.lost.push("template (make the template an .html entry that loads your JS)"); return; } - const chunkName = entryValue?.kind() === "object" ? keyName(pairsOf(entryValue)[0]) ?? "main" : "main"; + const chunkName = entryValue?.is("object") ? keyName(pairsOf(entryValue)[0]) ?? "main" : "main"; const injected = queueTemplate(this.configFileName, unquote(templateValue), unquote(replaceable.text()), chunkName); const comment = injected ? `The template is now the entry: it must load the previous entry via ${templateScriptTag({ src: injected, chunkName })} (added by the HTML workflow step when the template is included)` @@ -1189,19 +1188,19 @@ class HtmlMigration { // 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") { + if (entryValue.is("string")) return entryValue; + if (entryValue.is("array")) { const elements = namedChildren(entryValue); - return elements.length === 1 && elements[0].kind() === "string" ? elements[0] : null; + return elements.length === 1 && elements[0].is("string") ? elements[0] : null; } - if (entryValue.kind() === "object") { + if (entryValue.is("object")) { const entryPairs = pairsOf(entryValue); if (entryPairs.length !== 1) return null; - if (namedChildren(entryValue).some((child) => child.kind() !== "pair" && child.kind() !== "comment")) return null; + if (namedChildren(entryValue).some((child) => !child.is("pair") && !child.is("comment"))) return null; const key = entryPairs[0].field("key"); - if (!key || key.kind() === "computed_property_name" || key.text().includes("\\")) return null; + if (!key || key.is("computed_property_name") || key.text().includes("\\")) return null; const inner = entryPairs[0].field("value"); - return inner && inner.kind() === "string" ? inner : null; + return inner && inner.is("string") ? inner : null; } return null; } @@ -1213,13 +1212,13 @@ class HtmlMigration { for (const name of ["module", "parser", "html"]) { const value = findPair(current, name)?.field("value"); if (!value) return true; - if (value.kind() !== "object" || namedChildren(value).some((child) => child.kind() !== "pair" && child.kind() !== "comment")) return false; + if (!value.is("object") || namedChildren(value).some((child) => !child.is("pair") && !child.is("comment"))) return false; current = value; } const sources = findPair(current, "sources")?.field("value"); - if (!sources || sources.kind() === "true") return true; - const elements = sources.kind() === "array" ? namedChildren(sources).filter((node) => node.kind() !== "comment") : []; - return elements.length === 1 && elements[0].kind() === "string" && unquote(elements[0].text()) === "..."; + if (!sources || sources.is("true")) return true; + const elements = sources.is("array") ? namedChildren(sources).filter((node) => !node.is("comment")) : []; + return elements.length === 1 && elements[0].is("string") && unquote(elements[0].text()) === "..."; } private normalizeSourceDefaults(config: SgNode): void { @@ -1239,7 +1238,7 @@ class HtmlMigration { current = value; } const sources = findPair(current, "sources")?.field("value"); - if (sources?.kind() === "true") this.editor.replace(sources, '["..."]'); + if (sources?.is("true")) this.editor.replace(sources, '["..."]'); else if (!sources) this.editor.insertIntoObject(current, () => ['sources: ["..."]']); } @@ -1277,9 +1276,9 @@ class HtmlMigration { const outputProps: HtmlProp[] = []; const htmlProps = dedupeProps(plan.htmlProps); const existingOutput = findPair(plan.config, "output")?.field("value"); - const existingHtml = existingOutput?.kind() === "object" + const existingHtml = existingOutput?.is("object") ? findPair(existingOutput, "html")?.field("value") : undefined; - if (existingHtml?.kind() === "object") { + if (existingHtml?.is("object")) { const missing = htmlProps.filter((prop) => !findPair(existingHtml, prop.name)); if (missing.length) { this.editor.insertIntoObject(existingHtml, () => missing.map((prop) => `${prop.name}: ${prop.valueText}`)); @@ -1350,14 +1349,14 @@ class HtmlMigration { return `${commentLines.map((line) => `// ${line}\n${indent}`).join("")}${propertyText}`; }; const value = findPair(config, key)?.field("value"); - if (value && value.kind() === "object") { + if (value && value.is("object")) { for (const prop of props) { const existing = findPair(value, prop.name)?.field("value"); if (!existing) continue; if ( (key === "experiments" && prop.name === "html") || (key === "output" && prop.name === "module") || - (key === "output" && prop.name === "html" && (existing.kind() === "false" || existing.kind() === "true")) + (key === "output" && prop.name === "html" && (existing.is("false") || existing.is("true"))) ) { this.editor.replace(existing, prop.valueText); } From e6e1987b3b1fa510907ce78bc9a5ce20b88a37a8 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 7 Nov 2026 12:48:38 -0500 Subject: [PATCH 36/42] test: cover HTML dependency removal edge cases --- .../tests/remove-dependencies.ts | 8 +++++--- .../remove-dependencies/missing-scan/expected.json | 4 ++++ .../remove-dependencies/missing-scan/input.json | 4 ++++ .../remove-dependencies/no-sections/expected.json | 5 +++++ .../remove-dependencies/no-sections/input.json | 5 +++++ .../production-and-development/expected.json | 10 ++++++++++ .../production-and-development/input.json | 13 +++++++++++++ .../remove-dependencies/removes-all/expected.json | 4 ++++ .../remove-dependencies/removes-all/input.json | 9 +++++++++ .../source-reference/expected.json | 4 ++++ .../remove-dependencies/source-reference/input.json | 4 ++++ 11 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/missing-scan/expected.json create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/missing-scan/input.json create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/no-sections/expected.json create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/no-sections/input.json create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/production-and-development/expected.json create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/production-and-development/input.json create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/removes-all/expected.json create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/removes-all/input.json create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/source-reference/expected.json create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/source-reference/input.json diff --git a/codemods/html-plugins-to-native-html/tests/remove-dependencies.ts b/codemods/html-plugins-to-native-html/tests/remove-dependencies.ts index 0c0f2a6..77a46c8 100644 --- a/codemods/html-plugins-to-native-html/tests/remove-dependencies.ts +++ b/codemods/html-plugins-to-native-html/tests/remove-dependencies.ts @@ -2,8 +2,10 @@ import type Json from "@codemod.com/jssg-types/langs/json"; import type { SgRoot } from "@codemod.com/jssg-types/main"; import { removeUnusedDependencies } from "../src/remove-dependencies"; -// Unit fixtures describe packages with no remaining source references. -// Workflow integration tests cover collection and retention across files. +// Simulate absent, partial, and completed reference scans. The workflow +// integration tests cover collecting those references across source files. export default async function transform(root: SgRoot): Promise { - return removeUnusedDependencies(root, new Set()); + if (root.filename().includes("missing-scan")) return removeUnusedDependencies(root, null); + const remaining = root.filename().includes("source-reference") ? new Set(["html-loader"]) : new Set(); + return removeUnusedDependencies(root, remaining); } diff --git a/codemods/html-plugins-to-native-html/tests/remove-dependencies/missing-scan/expected.json b/codemods/html-plugins-to-native-html/tests/remove-dependencies/missing-scan/expected.json new file mode 100644 index 0000000..b94d9d6 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/remove-dependencies/missing-scan/expected.json @@ -0,0 +1,4 @@ +{ + "dependencies": { "html-loader": "^5.1.0" }, + "devDependencies": { "html-webpack-plugin": "^5.6.0" } +} diff --git a/codemods/html-plugins-to-native-html/tests/remove-dependencies/missing-scan/input.json b/codemods/html-plugins-to-native-html/tests/remove-dependencies/missing-scan/input.json new file mode 100644 index 0000000..b94d9d6 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/remove-dependencies/missing-scan/input.json @@ -0,0 +1,4 @@ +{ + "dependencies": { "html-loader": "^5.1.0" }, + "devDependencies": { "html-webpack-plugin": "^5.6.0" } +} diff --git a/codemods/html-plugins-to-native-html/tests/remove-dependencies/no-sections/expected.json b/codemods/html-plugins-to-native-html/tests/remove-dependencies/no-sections/expected.json new file mode 100644 index 0000000..ed85159 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/remove-dependencies/no-sections/expected.json @@ -0,0 +1,5 @@ +{ + "name": "app", + "version": "1.0.0", + "private": true +} diff --git a/codemods/html-plugins-to-native-html/tests/remove-dependencies/no-sections/input.json b/codemods/html-plugins-to-native-html/tests/remove-dependencies/no-sections/input.json new file mode 100644 index 0000000..ed85159 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/remove-dependencies/no-sections/input.json @@ -0,0 +1,5 @@ +{ + "name": "app", + "version": "1.0.0", + "private": true +} diff --git a/codemods/html-plugins-to-native-html/tests/remove-dependencies/production-and-development/expected.json b/codemods/html-plugins-to-native-html/tests/remove-dependencies/production-and-development/expected.json new file mode 100644 index 0000000..7523076 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/remove-dependencies/production-and-development/expected.json @@ -0,0 +1,10 @@ +{ + "name": "app", + "dependencies": { + "react": "^18.0.0" + }, + "devDependencies": { + "typescript": "^5.9.0", + "webpack": "^5.112.0" + } +} diff --git a/codemods/html-plugins-to-native-html/tests/remove-dependencies/production-and-development/input.json b/codemods/html-plugins-to-native-html/tests/remove-dependencies/production-and-development/input.json new file mode 100644 index 0000000..1dc0df6 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/remove-dependencies/production-and-development/input.json @@ -0,0 +1,13 @@ +{ + "name": "app", + "dependencies": { + "html-loader": "^5.1.0", + "react": "^18.0.0", + "html-webpack-plugin": "^5.6.0" + }, + "devDependencies": { + "typescript": "^5.9.0", + "favicons-webpack-plugin": "^6.0.0", + "webpack": "^5.112.0" + } +} diff --git a/codemods/html-plugins-to-native-html/tests/remove-dependencies/removes-all/expected.json b/codemods/html-plugins-to-native-html/tests/remove-dependencies/removes-all/expected.json new file mode 100644 index 0000000..9b55ba8 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/remove-dependencies/removes-all/expected.json @@ -0,0 +1,4 @@ +{ + "dependencies": {}, + "devDependencies": {} +} diff --git a/codemods/html-plugins-to-native-html/tests/remove-dependencies/removes-all/input.json b/codemods/html-plugins-to-native-html/tests/remove-dependencies/removes-all/input.json new file mode 100644 index 0000000..97d8c42 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/remove-dependencies/removes-all/input.json @@ -0,0 +1,9 @@ +{ + "dependencies": { + "html-loader": "^5.1.0" + }, + "devDependencies": { + "html-webpack-plugin": "^5.6.0", + "favicons-webpack-plugin": "^6.0.0" + } +} diff --git a/codemods/html-plugins-to-native-html/tests/remove-dependencies/source-reference/expected.json b/codemods/html-plugins-to-native-html/tests/remove-dependencies/source-reference/expected.json new file mode 100644 index 0000000..8a0b41f --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/remove-dependencies/source-reference/expected.json @@ -0,0 +1,4 @@ +{ + "dependencies": { "html-loader": "^5.1.0" }, + "devDependencies": { "html-loader": "^5.1.0" } +} diff --git a/codemods/html-plugins-to-native-html/tests/remove-dependencies/source-reference/input.json b/codemods/html-plugins-to-native-html/tests/remove-dependencies/source-reference/input.json new file mode 100644 index 0000000..1b3f200 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/remove-dependencies/source-reference/input.json @@ -0,0 +1,4 @@ +{ + "dependencies": { "html-loader": "^5.1.0", "html-webpack-plugin": "^5.6.0" }, + "devDependencies": { "html-loader": "^5.1.0", "favicons-webpack-plugin": "^6.0.0" } +} From 854cf2697d060dadfa1612a31643f3a5445ba5dc Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 7 Nov 2026 12:52:30 -0500 Subject: [PATCH 37/42] refactor: separate HTML loader callback adapters --- .../src/loader-options.ts | 242 ------------------ .../src/loaders/callbacks.ts | 43 ++++ .../src/loaders/sources.ts | 94 +++++++ .../src/loaders/template.ts | 113 ++++++++ .../src/workflow.ts | 4 +- 5 files changed, 253 insertions(+), 243 deletions(-) delete mode 100644 codemods/html-plugins-to-native-html/src/loader-options.ts create mode 100644 codemods/html-plugins-to-native-html/src/loaders/callbacks.ts create mode 100644 codemods/html-plugins-to-native-html/src/loaders/sources.ts create mode 100644 codemods/html-plugins-to-native-html/src/loaders/template.ts diff --git a/codemods/html-plugins-to-native-html/src/loader-options.ts b/codemods/html-plugins-to-native-html/src/loader-options.ts deleted file mode 100644 index f3a279a..0000000 --- a/codemods/html-plugins-to-native-html/src/loader-options.ts +++ /dev/null @@ -1,242 +0,0 @@ -import type Js from "@codemod.com/jssg-types/langs/javascript"; -import type { SgNode } from "@codemod.com/jssg-types/main"; -import { findPair, keyName, namedChildren, unquote } from "@webpack/codemod-utils"; - -function callbackParameters(value: SgNode): SgNode[] | null { - if (!["arrow_function", "function_expression"].some((kind) => value.is(kind))) return null; - if (value.children().some((node) => node.is("async"))) return null; - if (value.findAll({ rule: { any: [{ kind: "this" }, { pattern: "arguments" }] } }).length) return null; - const parameters = value.field("parameters"); - const parameter = value.field("parameter"); - const params = parameters ? namedChildren(parameters).filter((node) => !node.is("comment")) : parameter ? [parameter] : []; - if (params.some((param) => param.field("value") || param.is("assignment_pattern") || param.is("rest_pattern"))) return null; - const names = params.map((param) => param.field("pattern") ?? param); - return names.every((name) => name.is("identifier")) ? names : null; -} - -export function isIdentityCallback(value: SgNode): boolean { - const params = callbackParameters(value); - if (!params?.length) return false; - let body = value.field("body"); - if (body?.is("statement_block")) { - const statements = namedChildren(body).filter((node) => !node.is("comment")); - if (statements.length !== 1 || !statements[0].is("return_statement")) return false; - body = namedChildren(statements[0]).find((node) => !node.is("comment")) ?? null; - } - return !!body?.is("identifier") && body.text() === params[0].text(); -} - -function usesParameter(value: SgNode, params: SgNode[], index: number): boolean { - const param = params[index]; - return !!param && !!value.field("body")?.findAll({ rule: { pattern: param.text() } }).length; -} - -function constantBoolean(value: SgNode): string | null { - if (!callbackParameters(value)) return null; - let body = value.field("body"); - if (body?.is("statement_block")) { - const statements = namedChildren(body).filter((node) => !node.is("comment")); - if (statements.length !== 1 || !statements[0].is("return_statement")) return null; - body = namedChildren(statements[0])[0] ?? null; - } - return body?.is("true") || body?.is("false") ? body.text() : null; -} - -function freshName(source: string, base: string): string { - while (source.includes(base)) base += "_"; - return base; -} - -// Source filters run per attribute in both APIs, but native attributes are a -// Map and the native callback has no tag name or resource path arguments. -function sourceFilter(value: SgNode, tag: string | null, attribute: string, attrs: string, typescript: boolean): string | null { - const params = callbackParameters(value); - if (!params || params.length > 4 || usesParameter(value, params, 3)) return null; - if ((!tag || tag === "*") && usesParameter(value, params, 0)) return null; - // parse5's foreign attribute namespace metadata is not exposed by native HTML. - if (usesParameter(value, params, 2) && /\b(namespace|prefix)\b/.test(value.text())) return null; - const args = [JSON.stringify(tag ?? ""), JSON.stringify(attribute), `Array.from(${attrs}, ([name, value]) => ({ name, value }))`, typescript ? "undefined as never" : "undefined"]; - return `(${value.text()})(${args.slice(0, params.length).join(", ")})`; -} - -export function nativeSources(value: SgNode, typescript = false): { text: string; extendsDefaults: boolean } | null { - if (value.is("true") || value.is("false")) return { text: value.text(), extendsDefaults: false }; - if (!value.is("object")) return null; - const options = namedChildren(value).filter((node) => !node.is("comment")); - if (!options.length) return { text: "true", extendsDefaults: false }; - const keys = options.map(keyName); - if (new Set(keys).size !== keys.length || keys.some((key) => !key || !["list", "urlFilter", "scriptingEnabled"].includes(key))) return null; - const scripting = findPair(value, "scriptingEnabled")?.field("value"); - if (scripting && !scripting.is("true")) return null; - const urlFilter = findPair(value, "urlFilter")?.field("value"); - const urlConstant = urlFilter ? constantBoolean(urlFilter) : "true"; - if (urlConstant === "false") return { text: "false", extendsDefaults: false }; - const list = findPair(value, "list")?.field("value"); - if (!list) return urlConstant === "true" ? { text: "true", extendsDefaults: false } : null; - if (!list.is("array")) return null; - const urlParams = urlFilter ? callbackParameters(urlFilter) : []; - if (urlConstant === null && (!urlParams || urlParams.length > 3 || usesParameter(urlFilter!, urlParams, 2))) return null; - const edits = []; - for (const source of namedChildren(list)) { - if (source.is("comment")) continue; - if (source.is("string") && unquote(source.text()) === "...") { - if (urlConstant === null) return null; - continue; - } - if (!source.is("object")) return null; - const properties = namedChildren(source).filter((node) => !node.is("comment")); - const names = properties.map(keyName); - if (new Set(names).size !== names.length) return null; - if (!findPair(source, "attribute") || !findPair(source, "type")) return null; - for (const property of properties) { - const name = keyName(property); - const option = property.field("value"); - if (!name || !["tag", "attribute", "type", "filter"].includes(name) || !option) return null; - if (name === "filter") continue; - if (name === "type") { - if (!option.is("string") || !["src", "srcset"].includes(unquote(option.text()))) return null; - } else if (!option.is("string")) return null; - } - const filter = findPair(source, "filter")?.field("value"); - if (!filter && urlConstant === "true") continue; - const tagNode = findPair(source, "tag")?.field("value"); - const tag = tagNode ? unquote(tagNode.text()).toLowerCase() : null; - const attribute = unquote(findPair(source, "attribute")!.field("value")!.text()).toLowerCase(); - const type = findPair(source, "type")!.field("value")!; - // urlFilter sees each parsed srcset candidate, while a native source filter - // sees the entire attribute. Do not turn a per-URL decision into all-or-none. - if (urlConstant === null && unquote(type.text()) !== "src") return null; - const attrs = freshName(value.text(), "htmlAttributes"); - const url = freshName(value.text(), "htmlUrl"); - const conditions: string[] = []; - if (filter) { - const condition = sourceFilter(filter, tag, attribute, attrs, typescript); - if (condition === null) return null; - conditions.push(condition); - } - if (urlFilter && urlConstant === null) { - const normalized = `${url}.replace(/^[\\t\\n\\f\\r ]+|[\\t\\n\\f\\r ]+$/g, "").replace(/^[\\u0001-\\u0019\\u00a0]+|[\\u0001-\\u0019\\u00a0]+$/g, "")`; - const args = [JSON.stringify(attribute), normalized, typescript ? "undefined as never" : "undefined"]; - conditions.push(`(${urlFilter.text()})(${args.slice(0, urlParams!.length).join(", ")})`); - } - const parameters = urlConstant === null - ? `${attrs}${typescript ? ": Map" : ""}, ${url}${typescript ? ": string" : ""}` - : `${attrs}${typescript ? ": Map" : ""}`; - const mapped = properties.filter((property) => keyName(property) !== "filter").map((property) => property.text()); - mapped.push(`filter: (${parameters}) => ${conditions.join(" && ")}`); - edits.push(source.replace(`{ ${mapped.join(", ")} }`)); - } - return { - text: edits.length ? list.commitEdits(edits) : list.text(), - extendsDefaults: namedChildren(list).some((node) => node.is("string") && unquote(node.text()) === "..."), - }; -} - -const STRING_METHODS = new Set([ - "concat", "normalize", "padEnd", "padStart", "repeat", "replace", "replaceAll", - "slice", "substring", "toLowerCase", "toUpperCase", "trim", "trimEnd", "trimStart", -]); - -const CONTEXT_METHODS = new Map([ - ["addDependency", "(dependency: string) => void"], - ["addContextDependency", "(dependency: string) => void"], - ["addMissingDependency", "(dependency: string) => void"], - ["addBuildDependency", "(dependency: string) => void"], - ["emitWarning", "(warning: Error) => void"], - ["emitError", "(error: Error) => void"], -]); - -function adaptTemplateContext(value: SgNode, context: SgNode, typescript: boolean): string | null { - const body = value.field("body")!; - const edits = []; - const properties = new Map(); - const name = context.text(); - const resourcePattern = '/^((?:\\0.|[^?#\\0])*)(\\?(?:\\0.|[^#\\0])*)?(#.*)?$/'; - for (const reference of body.findAll({ rule: { kind: "identifier" } }).filter((node) => node.text() === name)) { - const member = reference.parent(); - if (!member?.is("member_expression") || member.field("object")?.range().start.index !== reference.range().start.index) return null; - const property = member.field("property")?.text(); - if (!property) return null; - if (CONTEXT_METHODS.has(property)) properties.set(property, CONTEXT_METHODS.get(property)!); - else if (property === "context") { - properties.set("module", "{ context: string }"); - edits.push(member.replace(`${name}.module.context`)); - } else if (["resource", "resourcePath", "resourceQuery", "resourceFragment"].includes(property)) { - properties.set("resource", "string"); - if (property === "resourcePath") { - edits.push(member.replace(`${name}.resource.replace(${resourcePattern}, "$1").replace(/\\0(.)/g, "$1")`)); - } else if (property === "resourceQuery") { - edits.push(member.replace(`(${name}.resource.match(${resourcePattern})?.[2]?.replace(/\\0(.)/g, "$1") ?? "")`)); - } else if (property === "resourceFragment") { - edits.push(member.replace(`(${name}.resource.match(${resourcePattern})?.[3] ?? "")`)); - } - } else return null; - } - if (typescript) { - const parameter = namedChildren(value.field("parameters")!).filter((node) => !node.is("comment"))[1]; - const type = parameter.field("type"); - const text = `: { ${[...properties].map(([key, type]) => `${key}: ${type}`).join("; ")} }`; - if (type) edits.push(type.replace(text)); - else edits.push({ startPos: context.range().end.index, endPos: context.range().end.index, insertedText: text }); - } - return edits.length ? value.commitEdits(edits) : value.text(); -} - -function returnsString(node: SgNode | null, strings: Set): boolean { - if (!node) return false; - if (node.is("string") || node.is("template_string")) return true; - if (node.is("identifier") || node.is("member_expression")) return strings.has(node.text()); - if (node.is("parenthesized_expression")) return returnsString(namedChildren(node)[0], strings); - if (node.is("binary_expression") && node.field("operator")?.text() === "+") { - return returnsString(node.field("left"), strings) || returnsString(node.field("right"), strings); - } - if (node.is("ternary_expression")) { - return returnsString(node.field("consequence"), strings) && returnsString(node.field("alternative"), strings); - } - if (node.is("call_expression")) { - const callee: SgNode = node.field("function"); - return callee?.is("member_expression") - && STRING_METHODS.has(callee.field("property")?.text() ?? "") - && returnsString(callee.field("object"), strings); - } - return false; -} - -// Native templates must synchronously return a string. Limit automatic migration -// to inline string transforms whose result we can establish without executing -// user code. Unsupported context APIs and async/unknown returns need adaptation. -export function nativeTemplate(value: SgNode, typescript = false): string | null { - if (!["arrow_function", "function_expression"].some((kind) => value.is(kind))) return null; - if (value.children().some((node) => node.is("async"))) return null; - if (value.findAll({ rule: { any: [ - { kind: "await_expression" }, { kind: "this" }, - { kind: "assignment_expression" }, { kind: "augmented_assignment_expression" }, - { kind: "update_expression" }, { pattern: "arguments" }, - ] } }).length) return null; - const params = callbackParameters(value); - if (!params || params.length > 2) return null; - const name = params[0]; - const context = params[1]; - const text = context ? adaptTemplateContext(value, context, typescript) : value.text(); - if (text === null) return null; - const strings = new Set(name ? [name.text()] : []); - if (context) for (const property of ["resource", "resourcePath", "resourceQuery", "resourceFragment", "context"]) strings.add(`${context.text()}.${property}`); - const body = value.field("body"); - if (!body) return null; - if (!body.is("statement_block")) return returnsString(body, strings) ? text : null; - const statements = namedChildren(body).filter((node) => !node.is("comment")); - const last = statements.pop(); - if (!last?.is("return_statement")) return null; - for (const statement of statements) { - const expression = statement.is("expression_statement") ? namedChildren(statement)[0] : undefined; - const callee: SgNode | undefined = expression?.is("call_expression") ? expression.field("function") : undefined; - if (context && callee?.is("member_expression") && callee.field("object")?.text() === context.text() && CONTEXT_METHODS.has(callee.field("property")?.text() ?? "")) continue; - if (!statement.is("lexical_declaration") || !statement.text().startsWith("const ")) return null; - for (const declaration of namedChildren(statement).filter((node) => !node.is("comment"))) { - const binding = declaration.field("name"); - if (!binding?.is("identifier") || !returnsString(declaration.field("value"), strings)) return null; - strings.add(binding.text()); - } - } - return returnsString(namedChildren(last).find((node) => !node.is("comment")) ?? null, strings) ? text : null; -} diff --git a/codemods/html-plugins-to-native-html/src/loaders/callbacks.ts b/codemods/html-plugins-to-native-html/src/loaders/callbacks.ts new file mode 100644 index 0000000..f496aa4 --- /dev/null +++ b/codemods/html-plugins-to-native-html/src/loaders/callbacks.ts @@ -0,0 +1,43 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode } from "@codemod.com/jssg-types/main"; +import { namedChildren } from "@webpack/codemod-utils"; + +export function callbackParameters(value: SgNode): SgNode[] | null { + if (!["arrow_function", "function_expression"].some((kind) => value.is(kind))) return null; + if (value.children().some((node) => node.is("async"))) return null; + if (value.findAll({ rule: { any: [{ kind: "this" }, { pattern: "arguments" }] } }).length) return null; + const parameters = value.field("parameters"); + const parameter = value.field("parameter"); + const params = parameters ? namedChildren(parameters).filter((node) => !node.is("comment")) : parameter ? [parameter] : []; + if (params.some((param) => param.field("value") || param.is("assignment_pattern") || param.is("rest_pattern"))) return null; + const names = params.map((param) => param.field("pattern") ?? param); + return names.every((name) => name.is("identifier")) ? names : null; +} + +export function isIdentityCallback(value: SgNode): boolean { + const params = callbackParameters(value); + if (!params?.length) return false; + let body = value.field("body"); + if (body?.is("statement_block")) { + const statements = namedChildren(body).filter((node) => !node.is("comment")); + if (statements.length !== 1 || !statements[0].is("return_statement")) return false; + body = namedChildren(statements[0]).find((node) => !node.is("comment")) ?? null; + } + return !!body?.is("identifier") && body.text() === params[0].text(); +} + +export function usesParameter(value: SgNode, params: SgNode[], index: number): boolean { + const param = params[index]; + return !!param && !!value.field("body")?.findAll({ rule: { pattern: param.text() } }).length; +} + +export function constantBoolean(value: SgNode): string | null { + if (!callbackParameters(value)) return null; + let body = value.field("body"); + if (body?.is("statement_block")) { + const statements = namedChildren(body).filter((node) => !node.is("comment")); + if (statements.length !== 1 || !statements[0].is("return_statement")) return null; + body = namedChildren(statements[0])[0] ?? null; + } + return body?.is("true") || body?.is("false") ? body.text() : null; +} diff --git a/codemods/html-plugins-to-native-html/src/loaders/sources.ts b/codemods/html-plugins-to-native-html/src/loaders/sources.ts new file mode 100644 index 0000000..760e8be --- /dev/null +++ b/codemods/html-plugins-to-native-html/src/loaders/sources.ts @@ -0,0 +1,94 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode } from "@codemod.com/jssg-types/main"; +import { findPair, keyName, namedChildren, unquote } from "@webpack/codemod-utils"; +import { callbackParameters, constantBoolean, usesParameter } from "./callbacks"; + +function freshName(source: string, base: string): string { + while (source.includes(base)) base += "_"; + return base; +} + +// Source filters run per attribute in both APIs, but native attributes are a +// Map and the native callback has no tag name or resource path arguments. +function sourceFilter(value: SgNode, tag: string | null, attribute: string, attrs: string, typescript: boolean): string | null { + const params = callbackParameters(value); + if (!params || params.length > 4 || usesParameter(value, params, 3)) return null; + if ((!tag || tag === "*") && usesParameter(value, params, 0)) return null; + // parse5's foreign attribute namespace metadata is not exposed by native HTML. + if (usesParameter(value, params, 2) && /\b(namespace|prefix)\b/.test(value.text())) return null; + const args = [JSON.stringify(tag ?? ""), JSON.stringify(attribute), `Array.from(${attrs}, ([name, value]) => ({ name, value }))`, typescript ? "undefined as never" : "undefined"]; + return `(${value.text()})(${args.slice(0, params.length).join(", ")})`; +} + +export function nativeSources(value: SgNode, typescript = false): { text: string; extendsDefaults: boolean } | null { + if (value.is("true") || value.is("false")) return { text: value.text(), extendsDefaults: false }; + if (!value.is("object")) return null; + const options = namedChildren(value).filter((node) => !node.is("comment")); + if (!options.length) return { text: "true", extendsDefaults: false }; + const keys = options.map(keyName); + if (new Set(keys).size !== keys.length || keys.some((key) => !key || !["list", "urlFilter", "scriptingEnabled"].includes(key))) return null; + const scripting = findPair(value, "scriptingEnabled")?.field("value"); + if (scripting && !scripting.is("true")) return null; + const urlFilter = findPair(value, "urlFilter")?.field("value"); + const urlConstant = urlFilter ? constantBoolean(urlFilter) : "true"; + if (urlConstant === "false") return { text: "false", extendsDefaults: false }; + const list = findPair(value, "list")?.field("value"); + if (!list) return urlConstant === "true" ? { text: "true", extendsDefaults: false } : null; + if (!list.is("array")) return null; + const urlParams = urlFilter ? callbackParameters(urlFilter) : []; + if (urlConstant === null && (!urlParams || urlParams.length > 3 || usesParameter(urlFilter!, urlParams, 2))) return null; + const edits = []; + for (const source of namedChildren(list)) { + if (source.is("comment")) continue; + if (source.is("string") && unquote(source.text()) === "...") { + if (urlConstant === null) return null; + continue; + } + if (!source.is("object")) return null; + const properties = namedChildren(source).filter((node) => !node.is("comment")); + const names = properties.map(keyName); + if (new Set(names).size !== names.length) return null; + if (!findPair(source, "attribute") || !findPair(source, "type")) return null; + for (const property of properties) { + const name = keyName(property); + const option = property.field("value"); + if (!name || !["tag", "attribute", "type", "filter"].includes(name) || !option) return null; + if (name === "filter") continue; + if (name === "type") { + if (!option.is("string") || !["src", "srcset"].includes(unquote(option.text()))) return null; + } else if (!option.is("string")) return null; + } + const filter = findPair(source, "filter")?.field("value"); + if (!filter && urlConstant === "true") continue; + const tagNode = findPair(source, "tag")?.field("value"); + const tag = tagNode ? unquote(tagNode.text()).toLowerCase() : null; + const attribute = unquote(findPair(source, "attribute")!.field("value")!.text()).toLowerCase(); + const type = findPair(source, "type")!.field("value")!; + // urlFilter sees each parsed srcset candidate, while a native source filter + // sees the entire attribute. Do not turn a per-URL decision into all-or-none. + if (urlConstant === null && unquote(type.text()) !== "src") return null; + const attrs = freshName(value.text(), "htmlAttributes"); + const url = freshName(value.text(), "htmlUrl"); + const conditions: string[] = []; + if (filter) { + const condition = sourceFilter(filter, tag, attribute, attrs, typescript); + if (condition === null) return null; + conditions.push(condition); + } + if (urlFilter && urlConstant === null) { + const normalized = `${url}.replace(/^[\\t\\n\\f\\r ]+|[\\t\\n\\f\\r ]+$/g, "").replace(/^[\\u0001-\\u0019\\u00a0]+|[\\u0001-\\u0019\\u00a0]+$/g, "")`; + const args = [JSON.stringify(attribute), normalized, typescript ? "undefined as never" : "undefined"]; + conditions.push(`(${urlFilter.text()})(${args.slice(0, urlParams!.length).join(", ")})`); + } + const parameters = urlConstant === null + ? `${attrs}${typescript ? ": Map" : ""}, ${url}${typescript ? ": string" : ""}` + : `${attrs}${typescript ? ": Map" : ""}`; + const mapped = properties.filter((property) => keyName(property) !== "filter").map((property) => property.text()); + mapped.push(`filter: (${parameters}) => ${conditions.join(" && ")}`); + edits.push(source.replace(`{ ${mapped.join(", ")} }`)); + } + return { + text: edits.length ? list.commitEdits(edits) : list.text(), + extendsDefaults: namedChildren(list).some((node) => node.is("string") && unquote(node.text()) === "..."), + }; +} diff --git a/codemods/html-plugins-to-native-html/src/loaders/template.ts b/codemods/html-plugins-to-native-html/src/loaders/template.ts new file mode 100644 index 0000000..bfa190a --- /dev/null +++ b/codemods/html-plugins-to-native-html/src/loaders/template.ts @@ -0,0 +1,113 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode } from "@codemod.com/jssg-types/main"; +import { namedChildren } from "@webpack/codemod-utils"; +import { callbackParameters } from "./callbacks"; + +const STRING_METHODS = new Set([ + "concat", "normalize", "padEnd", "padStart", "repeat", "replace", "replaceAll", + "slice", "substring", "toLowerCase", "toUpperCase", "trim", "trimEnd", "trimStart", +]); + +const CONTEXT_METHODS = new Map([ + ["addDependency", "(dependency: string) => void"], + ["addContextDependency", "(dependency: string) => void"], + ["addMissingDependency", "(dependency: string) => void"], + ["addBuildDependency", "(dependency: string) => void"], + ["emitWarning", "(warning: Error) => void"], + ["emitError", "(error: Error) => void"], +]); + +function adaptTemplateContext(value: SgNode, context: SgNode, typescript: boolean): string | null { + const body = value.field("body")!; + const edits = []; + const properties = new Map(); + const name = context.text(); + const resourcePattern = '/^((?:\\0.|[^?#\\0])*)(\\?(?:\\0.|[^#\\0])*)?(#.*)?$/'; + for (const reference of body.findAll({ rule: { kind: "identifier" } }).filter((node) => node.text() === name)) { + const member = reference.parent(); + if (!member?.is("member_expression") || member.field("object")?.range().start.index !== reference.range().start.index) return null; + const property = member.field("property")?.text(); + if (!property) return null; + if (CONTEXT_METHODS.has(property)) properties.set(property, CONTEXT_METHODS.get(property)!); + else if (property === "context") { + properties.set("module", "{ context: string }"); + edits.push(member.replace(`${name}.module.context`)); + } else if (["resource", "resourcePath", "resourceQuery", "resourceFragment"].includes(property)) { + properties.set("resource", "string"); + if (property === "resourcePath") { + edits.push(member.replace(`${name}.resource.replace(${resourcePattern}, "$1").replace(/\\0(.)/g, "$1")`)); + } else if (property === "resourceQuery") { + edits.push(member.replace(`(${name}.resource.match(${resourcePattern})?.[2]?.replace(/\\0(.)/g, "$1") ?? "")`)); + } else if (property === "resourceFragment") { + edits.push(member.replace(`(${name}.resource.match(${resourcePattern})?.[3] ?? "")`)); + } + } else return null; + } + if (typescript) { + const parameter = namedChildren(value.field("parameters")!).filter((node) => !node.is("comment"))[1]; + const type = parameter.field("type"); + const text = `: { ${[...properties].map(([key, type]) => `${key}: ${type}`).join("; ")} }`; + if (type) edits.push(type.replace(text)); + else edits.push({ startPos: context.range().end.index, endPos: context.range().end.index, insertedText: text }); + } + return edits.length ? value.commitEdits(edits) : value.text(); +} + +function returnsString(node: SgNode | null, strings: Set): boolean { + if (!node) return false; + if (node.is("string") || node.is("template_string")) return true; + if (node.is("identifier") || node.is("member_expression")) return strings.has(node.text()); + if (node.is("parenthesized_expression")) return returnsString(namedChildren(node)[0], strings); + if (node.is("binary_expression") && node.field("operator")?.text() === "+") { + return returnsString(node.field("left"), strings) || returnsString(node.field("right"), strings); + } + if (node.is("ternary_expression")) { + return returnsString(node.field("consequence"), strings) && returnsString(node.field("alternative"), strings); + } + if (node.is("call_expression")) { + const callee: SgNode = node.field("function"); + return callee?.is("member_expression") + && STRING_METHODS.has(callee.field("property")?.text() ?? "") + && returnsString(callee.field("object"), strings); + } + return false; +} + +// Native templates must synchronously return a string. Limit automatic migration +// to inline string transforms whose result we can establish without executing +// user code. Unsupported context APIs and async/unknown returns need adaptation. +export function nativeTemplate(value: SgNode, typescript = false): string | null { + if (!["arrow_function", "function_expression"].some((kind) => value.is(kind))) return null; + if (value.children().some((node) => node.is("async"))) return null; + if (value.findAll({ rule: { any: [ + { kind: "await_expression" }, { kind: "this" }, + { kind: "assignment_expression" }, { kind: "augmented_assignment_expression" }, + { kind: "update_expression" }, { pattern: "arguments" }, + ] } }).length) return null; + const params = callbackParameters(value); + if (!params || params.length > 2) return null; + const name = params[0]; + const context = params[1]; + const text = context ? adaptTemplateContext(value, context, typescript) : value.text(); + if (text === null) return null; + const strings = new Set(name ? [name.text()] : []); + if (context) for (const property of ["resource", "resourcePath", "resourceQuery", "resourceFragment", "context"]) strings.add(`${context.text()}.${property}`); + const body = value.field("body"); + if (!body) return null; + if (!body.is("statement_block")) return returnsString(body, strings) ? text : null; + const statements = namedChildren(body).filter((node) => !node.is("comment")); + const last = statements.pop(); + if (!last?.is("return_statement")) return null; + for (const statement of statements) { + const expression = statement.is("expression_statement") ? namedChildren(statement)[0] : undefined; + const callee: SgNode | undefined = expression?.is("call_expression") ? expression.field("function") : undefined; + if (context && callee?.is("member_expression") && callee.field("object")?.text() === context.text() && CONTEXT_METHODS.has(callee.field("property")?.text() ?? "")) continue; + if (!statement.is("lexical_declaration") || !statement.text().startsWith("const ")) return null; + for (const declaration of namedChildren(statement).filter((node) => !node.is("comment"))) { + const binding = declaration.field("name"); + if (!binding?.is("identifier") || !returnsString(declaration.field("value"), strings)) return null; + strings.add(binding.text()); + } + } + return returnsString(namedChildren(last).find((node) => !node.is("comment")) ?? null, strings) ? text : null; +} diff --git a/codemods/html-plugins-to-native-html/src/workflow.ts b/codemods/html-plugins-to-native-html/src/workflow.ts index aa83c4c..a442116 100644 --- a/codemods/html-plugins-to-native-html/src/workflow.ts +++ b/codemods/html-plugins-to-native-html/src/workflow.ts @@ -21,7 +21,9 @@ import { } from "@webpack/codemod-utils"; import { collectMinification, planMinification, type Minification } from "./minification"; import { queueTemplate, recordSource } from "./migration-state"; -import { isIdentityCallback, nativeSources, nativeTemplate } from "./loader-options"; +import { isIdentityCallback } from "./loaders/callbacks"; +import { nativeSources } from "./loaders/sources"; +import { nativeTemplate } from "./loaders/template"; import { templateScriptTag } from "./templates"; const PLUGIN_MODULE = "html-webpack-plugin"; From c58d2fd19f7066d39221648618435db2d26ea5eb Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 7 Nov 2026 12:54:16 -0500 Subject: [PATCH 38/42] refactor: split HTML migration responsibilities into modules --- .../src/config-plan.ts | 251 +++ .../src/loaders/options.ts | 73 + .../src/loaders/rules.ts | 207 +++ .../src/plugins/bindings.ts | 52 + .../src/plugins/companions.ts | 183 +++ .../src/plugins/entries.ts | 78 + .../src/plugins/hooks.ts | 174 ++ .../src/plugins/migration.ts | 253 +++ .../src/plugins/options.ts | 193 +++ .../src/workflow.ts | 1413 +---------------- 10 files changed, 1508 insertions(+), 1369 deletions(-) create mode 100644 codemods/html-plugins-to-native-html/src/config-plan.ts create mode 100644 codemods/html-plugins-to-native-html/src/loaders/options.ts create mode 100644 codemods/html-plugins-to-native-html/src/loaders/rules.ts create mode 100644 codemods/html-plugins-to-native-html/src/plugins/bindings.ts create mode 100644 codemods/html-plugins-to-native-html/src/plugins/companions.ts create mode 100644 codemods/html-plugins-to-native-html/src/plugins/entries.ts create mode 100644 codemods/html-plugins-to-native-html/src/plugins/hooks.ts create mode 100644 codemods/html-plugins-to-native-html/src/plugins/migration.ts create mode 100644 codemods/html-plugins-to-native-html/src/plugins/options.ts diff --git a/codemods/html-plugins-to-native-html/src/config-plan.ts b/codemods/html-plugins-to-native-html/src/config-plan.ts new file mode 100644 index 0000000..3ad5a96 --- /dev/null +++ b/codemods/html-plugins-to-native-html/src/config-plan.ts @@ -0,0 +1,251 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode } from "@codemod.com/jssg-types/main"; +import { ConfigEditor, findPair, namedChildren, unquote } from "@webpack/codemod-utils"; +import { planMinification, type Minification } from "./minification"; + +export interface HtmlProp { + name: string; + valueText: string; +} + +export function dedupeProps(props: HtmlProp[]): HtmlProp[] { + const seen = new Set(); + return props.filter((prop) => !seen.has(prop.name) && seen.add(prop.name)); +} + +// Properties to add to one webpack config object once all removals are known. +export interface ConfigPlan { + config: SgNode; + sourceDefaults: boolean; + minification: Minification[]; + commentLines: string[]; + // Emit a global `output.html` (single-page mode; per-entry pages skip it). + htmlEnabled: boolean; + // Props composing the `output.html` object (plugin options, sibling plugins). + htmlProps: HtmlProp[]; + htmlFilename: string | null; + // Other output-level props (e.g. `module` for ESM script loading). + outputProps: HtmlProp[]; + experimentsProps: HtmlProp[]; + pendingEntry: { text: string; comment: string } | null; + // A html-webpack-plugin instance in this config was migrated. + pluginMigratedHere: boolean; +} + +export class ConfigPlans { + private readonly configPlans = new Map(); + + private readonly editor: ConfigEditor; + + constructor(editor: ConfigEditor) { + this.editor = editor; +} + + + planFor(config: SgNode): ConfigPlan { + const key = config.range().start.index; + let plan = this.configPlans.get(key); + if (!plan) { + plan = { + config, + sourceDefaults: false, + minification: [], + commentLines: [], + htmlEnabled: false, + htmlProps: [], + htmlFilename: null, + outputProps: [], + experimentsProps: [], + pendingEntry: null, + pluginMigratedHere: false, + }; + this.configPlans.set(key, plan); + } + return plan; + } + + + requireExperimentsHtml(plan: ConfigPlan): void { + if (!plan.experimentsProps.some((prop) => prop.name === "html")) { + plan.experimentsProps.push({ name: "html", valueText: "true" }); + } + } + + + // `scriptLoading: "module"` means ESM output — a config-wide switch. + applyModuleScripts(plan: ConfigPlan, moduleScripts: boolean): void { + if (!moduleScripts) return; + if (!plan.outputProps.some((prop) => prop.name === "module")) { + plan.outputProps.push({ name: "module", valueText: "true" }); + } + // ESM output is stable since webpack 5.111. Remove the obsolete flag + // only when its value is a literal, preserving any expression side effects. + const experiments = findPair(plan.config, "experiments")?.field("value"); + const flag = experiments?.is("object") ? findPair(experiments, "outputModule") : undefined; + const value = flag?.field("value"); + if (flag && (value?.is("true") || value?.is("false"))) { + this.editor.keepBracesOpen(experiments!); + this.editor.markForRemoval(flag); + } + } + + canNormalizeSourceDefaults(config: SgNode): boolean { + let current: SgNode = config; + for (const name of ["module", "parser", "html"]) { + const value = findPair(current, name)?.field("value"); + if (!value) return true; + if (!value.is("object") || namedChildren(value).some((child) => !child.is("pair") && !child.is("comment"))) return false; + current = value; + } + const sources = findPair(current, "sources")?.field("value"); + if (!sources || sources.is("true")) return true; + const elements = sources.is("array") ? namedChildren(sources).filter((node) => !node.is("comment")) : []; + return elements.length === 1 && elements[0].is("string") && unquote(elements[0].text()) === "..."; + } + + + private normalizeSourceDefaults(config: SgNode): void { + // Rule parser options are clever-merged with global HTML parser defaults. + // Its default `sources: true` would expand a rule's "..." to `[true, ...]`, + // which fails validation. `["..."]` is equivalent but survives that merge. + let current = findPair(config, "module")!.field("value")!; + for (const [name, text] of [ + ["parser", 'parser: { html: { sources: ["..."] } }'], + ["html", 'html: { sources: ["..."] }'], + ]) { + const value = findPair(current, name)?.field("value"); + if (!value) { + this.editor.insertIntoObject(current, () => [text]); + return; + } + current = value; + } + const sources = findPair(current, "sources")?.field("value"); + if (sources?.is("true")) this.editor.replace(sources, '["..."]'); + else if (!sources) this.editor.insertIntoObject(current, () => ['sources: ["..."]']); + } + + + planConfigInsertions(): void { + for (const plan of this.configPlans.values()) { + if (plan.sourceDefaults) this.normalizeSourceDefaults(plan.config); + const topProperties: ((indent: string, unit: string) => string)[] = []; + const optimization = planMinification(this.editor, plan.config, plan.minification, plan.commentLines); + if (optimization) topProperties.push(() => optimization); + const pendingEntry = plan.pendingEntry; + if (pendingEntry) { + topProperties.push( + (indent) => `// ${pendingEntry.comment}\n${indent}entry: ${pendingEntry.text}`, + ); + } + const outputProps: HtmlProp[] = []; + const htmlProps = dedupeProps(plan.htmlProps); + const existingOutput = findPair(plan.config, "output")?.field("value"); + const existingHtml = existingOutput?.is("object") + ? findPair(existingOutput, "html")?.field("value") : undefined; + if (existingHtml?.is("object")) { + const missing = htmlProps.filter((prop) => !findPair(existingHtml, prop.name)); + if (missing.length) { + this.editor.insertIntoObject(existingHtml, () => missing.map((prop) => `${prop.name}: ${prop.valueText}`)); + } + } + if (plan.htmlEnabled || htmlProps.length) { + outputProps.push({ + name: "html", + valueText: htmlProps.length + ? `{ ${htmlProps.map((prop) => `${prop.name}: ${prop.valueText}`).join(", ")} }` + : "true", + }); + } + if (plan.htmlFilename !== null) { + outputProps.push({ name: "htmlFilename", valueText: plan.htmlFilename }); + } + outputProps.push(...plan.outputProps); + if (outputProps.length) { + this.planObjectProps(plan.config, "output", outputProps, plan.commentLines, topProperties); + } + if (plan.experimentsProps.length) { + this.planObjectProps( + plan.config, + "experiments", + plan.experimentsProps, + outputProps.length ? [] : plan.commentLines, + topProperties, + ); + } + if (!outputProps.length && !plan.experimentsProps.length && plan.commentLines.length) { + this.insertReviewComments(plan.config, plan.commentLines); + } + 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)), + ); + } + } + } + + + private insertReviewComments(node: SgNode, lines: string[]): void { + if (!lines.length) return; + const position = node.range().start.index; + this.editor.addEdit({ + startPos: position, + endPos: position, + insertedText: `/* ${lines.join("; ").replace(/\*\//g, "* /")} */ `, + }); + } + + + // Insert props into the config's `key` object, creating it when absent; an + // existing non-object value (e.g. a variable) is left alone. Comment lines + // ride on the first inserted property. + private planObjectProps( + config: SgNode, + key: string, + props: HtmlProp[], + commentLines: string[], + topProperties: ((indent: string, unit: string) => string)[], + ): void { + const withComments = (propertyText: string, indent: string, first: boolean): string => { + if (!first || !commentLines.length) return propertyText; + if (!config.text().includes("\n")) { + return `/* ${commentLines.join("; ").replace(/\*\//g, "* /")} */ ${propertyText}`; + } + return `${commentLines.map((line) => `// ${line}\n${indent}`).join("")}${propertyText}`; + }; + const value = findPair(config, key)?.field("value"); + if (value && value.is("object")) { + for (const prop of props) { + const existing = findPair(value, prop.name)?.field("value"); + if (!existing) continue; + if ( + (key === "experiments" && prop.name === "html") || + (key === "output" && prop.name === "module") || + (key === "output" && prop.name === "html" && (existing.is("false") || existing.is("true"))) + ) { + this.editor.replace(existing, prop.valueText); + } + } + const missing = props.filter((prop) => !findPair(value, prop.name)); + if (!missing.length) { + this.insertReviewComments(value, commentLines); + 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}}`; + }); + } + } +} diff --git a/codemods/html-plugins-to-native-html/src/loaders/options.ts b/codemods/html-plugins-to-native-html/src/loaders/options.ts new file mode 100644 index 0000000..c5cf032 --- /dev/null +++ b/codemods/html-plugins-to-native-html/src/loaders/options.ts @@ -0,0 +1,73 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode } from "@codemod.com/jssg-types/main"; +import { findPair, guardBranchesOf, keyName, loaderNameOf, namedChildren, pairsOf } from "@webpack/codemod-utils"; +import { collectMinification, type Minification } from "../minification"; +import type { HtmlProp } from "../config-plan"; +import { isIdentityCallback } from "./callbacks"; +import { nativeSources } from "./sources"; +import { nativeTemplate } from "./template"; + +export const LOADER_NAME = "html-loader"; + +// Loader options translated into the surviving rule and migration eligibility. +export interface LoaderFindings { + supported: boolean; + sourceDefaults: boolean; + parserProps: HtmlProp[]; + minification: Minification[]; +} + +// Translate known equivalents; retain the config when an option would lose +// executable behavior or change the module's export contract. +export function collectLoaderFindings(node: SgNode, findings: LoaderFindings, typescript: boolean): void { + const branches = guardBranchesOf(node); + if (branches) { + for (const branch of branches) collectLoaderFindings(branch, findings, typescript); + return; + } + if (!node.is("object") || loaderNameOf(node) !== LOADER_NAME) return; + const optionsValue = findPair(node, "options")?.field("value"); + const minification = collectMinification( + optionsValue?.is("object") ? findPair(optionsValue, "minimize")?.field("value") : undefined, + `${LOADER_NAME}.minimize`, + ); + findings.minification.push(minification); + if (minification.lost.length) findings.supported = false; + if (!optionsValue) return; + if (!optionsValue.is("object")) { + findings.supported = false; + return; + } + if (namedChildren(optionsValue).some((child) => !child.is("pair") && !child.is("comment"))) { + findings.supported = false; + return; + } + for (const optionPair of pairsOf(optionsValue)) { + const name = keyName(optionPair); + const value = optionPair.field("value"); + if (name === "esModule") { + // Native HTML exposes a default string export and its CJS shim also + // returns that string directly, so both literal settings can migrate. + if (!value?.is("true") && !value?.is("false")) findings.supported = false; + continue; + } + if (name === "minimize") continue; + if (name === "sources" && value) { + const sources = nativeSources(value, typescript); + if (sources === null) findings.supported = false; + else { + findings.parserProps.push({ name: "sources", valueText: sources.text }); + findings.sourceDefaults ||= sources.extendsDefaults; + } + continue; + } + if (name === "preprocessor" && value) { + const template = nativeTemplate(value, typescript); + if (template === null) findings.supported = false; + else findings.parserProps.push({ name: "template", valueText: template }); + continue; + } + if (name === "postprocessor" && value && isIdentityCallback(value)) continue; + findings.supported = false; + } +} diff --git a/codemods/html-plugins-to-native-html/src/loaders/rules.ts b/codemods/html-plugins-to-native-html/src/loaders/rules.ts new file mode 100644 index 0000000..2dac964 --- /dev/null +++ b/codemods/html-plugins-to-native-html/src/loaders/rules.ts @@ -0,0 +1,207 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode } from "@codemod.com/jssg-types/main"; +import { ConfigEditor, cascadeRemovalTarget, filterSuffixOf, findConfigObjectFor, findPair, guardBranchesOf, keyName, lineIndent, loaderNameOf, namedChildren, pairsOf, ruleMatchesFiles, unwrapFilterCall } from "@webpack/codemod-utils"; +import { ConfigPlans, dedupeProps } from "../config-plan"; +import { collectLoaderFindings, LOADER_NAME, type LoaderFindings } from "./options"; + +const HTML_SAMPLE_FILES = ["/file.html"]; + +const DEFAULT_HTML_TESTS = new Set(["/\\.html$/", "/\\.html$/i"]); + +interface RuleMigration { + pair: SgNode; + ruleObject: SgNode; + kept: SgNode[]; + filterSuffix: string; + findings: LoaderFindings; + trivial: boolean; + optionsPair: SgNode | null; +} + +export class HtmlLoaderRules { + private readonly typescript: boolean; + + private readonly editor: ConfigEditor; + private readonly plans: ConfigPlans; + private readonly blockedConfigs: Set; + + constructor(editor: ConfigEditor, plans: ConfigPlans, blockedConfigs: Set) { + this.editor = editor; + this.plans = plans; + this.blockedConfigs = blockedConfigs; + + this.typescript = /\.[cm]?tsx?$/.test(editor.rootNode.getRoot().filename()); + } + + // 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; + } + + + // 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.is("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. + transformRules(usePairs: SgNode[], loaderPairs: SgNode[]): void { + const migrations: RuleMigration[] = []; + for (const pair of usePairs) { + const originalValue = pair.field("value"); + if (!originalValue) continue; + const value = unwrapFilterCall(originalValue); + const elements = value.is("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.is("object")) continue; + if (!this.isWebpackRuleContext(pair, ruleObject.parent() ?? ruleObject)) continue; + const findings: LoaderFindings = { supported: true, sourceDefaults: false, parserProps: [], minification: [] }; + for (const element of elements) collectLoaderFindings(element, findings, this.typescript); + const trivial = pairsOf(ruleObject).every((rulePair) => { + const name = keyName(rulePair); + return name === "test" || name === "use"; + }); + migrations.push({ + 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.is("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 = { supported: true, sourceDefaults: false, parserProps: [], minification: [] }; + collectLoaderFindings(ruleObject, findings, this.typescript); + const trivial = pairsOf(ruleObject).every((rulePair) => { + const name = keyName(rulePair); + return name === "test" || name === "loader" || name === "options"; + }); + migrations.push({ + pair, + ruleObject, + kept: [], + filterSuffix: "", + findings, + trivial, + optionsPair: findPair(ruleObject, "options") ?? null, + }); + } + // A retained html-loader emits JavaScript. Enabling native HTML or replacing + // an HTML plugin's template beside it would parse that JavaScript as HTML. + // Decide this before making edits to any of the owning config's rules. + for (const migration of migrations) { + if (this.canMigrateRule(migration)) continue; + const config = findConfigObjectFor(migration.pair); + if (config) { + this.blockedConfigs.add(config.range().start.index); + } + } + for (const migration of migrations) this.applyRuleMigration(migration); + } + + + private canMigrateRule(swap: RuleMigration): boolean { + if (!swap.findings.supported) return false; + const config = findConfigObjectFor(swap.pair); + const experiments = config && findPair(config, "experiments")?.field("value"); + const parser = findPair(swap.ruleObject, "parser")?.field("value"); + if ( + (experiments && !experiments.is("object")) || + (swap.findings.parserProps.length && parser && !parser.is("object")) + ) return false; + // Do not replace loader behavior with a conflicting existing parser option, + // or flatten differently configured loader branches into one parser. + for (const prop of swap.findings.parserProps) { + const existing = parser?.is("object") ? findPair(parser, prop.name)?.field("value") : undefined; + if (existing && existing.text() !== prop.valueText) return false; + if (swap.findings.parserProps.some((other) => other.name === prop.name && other.valueText !== prop.valueText)) return false; + } + return !swap.findings.sourceDefaults || (!!config && this.plans.canNormalizeSourceDefaults(config)); + } + + + private applyRuleMigration(swap: RuleMigration): void { + if (!this.canMigrateRule(swap)) return; + const config = findConfigObjectFor(swap.pair); + if (config && this.blockedConfigs.has(config.range().start.index)) return; + const experiments = config && findPair(config, "experiments")?.field("value"); + const parser = findPair(swap.ruleObject, "parser")?.field("value"); + swap.findings.parserProps = dedupeProps(swap.findings.parserProps); + if (swap.findings.sourceDefaults && config) this.plans.planFor(config).sourceDefaults = true; + if (config) { + this.plans.planFor(config).minification.push( + ...(swap.findings.minification.length ? swap.findings.minification : [{ html: null, lost: [] }]), + ); + } + const survives = + !swap.trivial || + !DEFAULT_HTML_TESTS.has(findPair(swap.ruleObject, "test")?.field("value")?.text() ?? "") || + swap.kept.length > 0 || + swap.findings.parserProps.length > 0; + if (!survives) { + // Explicitly disabled HTML must be enabled even when its loader rule goes. + if (config && experiments && findPair(experiments, "html")) { + this.plans.requireExperimentsHtml(this.plans.planFor(config)); + } + 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"); + const separator = multiline ? `,\n${indent}` : ", "; + let replacement = ""; + 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}`; + } + const typePair = findPair(swap.ruleObject, "type"); + replacement += `type: "html"`; + if (typePair) this.editor.markForRemoval(typePair); + if (swap.findings.parserProps.length) { + if (parser?.is("object")) { + const missing = swap.findings.parserProps.filter((prop) => !findPair(parser, prop.name)); + if (missing.length) { + this.editor.insertIntoObject(parser, () => missing.map((prop) => `${prop.name}: ${prop.valueText}`)); + } + } else if (!parser) { + 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; + if (config) this.plans.requireExperimentsHtml(this.plans.planFor(config)); + } +} diff --git a/codemods/html-plugins-to-native-html/src/plugins/bindings.ts b/codemods/html-plugins-to-native-html/src/plugins/bindings.ts new file mode 100644 index 0000000..8f50684 --- /dev/null +++ b/codemods/html-plugins-to-native-html/src/plugins/bindings.ts @@ -0,0 +1,52 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode } from "@codemod.com/jssg-types/main"; +import { type ModuleBinding, namedChildren, unquote } from "@webpack/codemod-utils"; + +export const PLUGIN_MODULE = "html-webpack-plugin"; + +export function isRequireOf(node: SgNode, moduleName: string): boolean { + if (!node.is("call_expression")) return false; + const callee = node.field("function"); + if (!callee || !callee.is("identifier") || callee.text() !== "require") return false; + const argumentsNode = node.field("arguments"); + const args = argumentsNode ? namedChildren(argumentsNode) : []; + return args.length === 1 && args[0].is("string") && unquote(args[0].text()) === moduleName; +} + +// 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. +export function collectNamedBindings(rootNode: SgNode, moduleName: string): ModuleBinding[] { + const bindings: ModuleBinding[] = []; + for (const statement of 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 rootNode.findAll({ rule: { kind } })) { + const declarators = namedChildren(statement).filter( + (child) => child.is("variable_declarator"), + ); + if (declarators.length !== 1) continue; + const pattern = declarators[0].field("name"); + const valueNode = declarators[0].field("value"); + if (!pattern || !pattern.is("object_pattern")) continue; + if (!valueNode || !isRequireOf(valueNode, moduleName)) continue; + const properties = namedChildren(pattern); + if (properties.length !== 1) continue; + const property = properties[0]; + if (property.is("shorthand_property_identifier_pattern")) { + bindings.push({ name: property.text(), statement }); + } else if (property.is("pair_pattern")) { + const local = property.field("value"); + if (local) bindings.push({ name: local.text(), statement }); + } + } + } + return bindings; +} diff --git a/codemods/html-plugins-to-native-html/src/plugins/companions.ts b/codemods/html-plugins-to-native-html/src/plugins/companions.ts new file mode 100644 index 0000000..3644e4c --- /dev/null +++ b/codemods/html-plugins-to-native-html/src/plugins/companions.ts @@ -0,0 +1,183 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode } from "@codemod.com/jssg-types/main"; +import { ConfigEditor, type ModuleBinding, collectModuleBindings, findPair, guardBranchesOf, keyName, namedChildren, pairsOf, unwrapFilterCall } from "@webpack/codemod-utils"; +import type { ConfigPlan, HtmlProp } from "../config-plan"; +import type { PageMode } from "./options"; +import { collectNamedBindings, isRequireOf } from "./bindings"; + +// 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]; + +export class CompanionPlugins { + readonly bindings: ModuleBinding[] = []; + private readonly siblingNameToModule = new Map(); + + private readonly editor: ConfigEditor; + + constructor(editor: ConfigEditor) { + this.editor = editor; + + for (const moduleName of SIBLING_PLUGIN_MODULES) { + const named = collectNamedBindings(editor.rootNode, moduleName); + for (const binding of [...collectModuleBindings(editor.rootNode, moduleName), ...named]) { + if (this.siblingNameToModule.has(binding.name)) continue; + this.bindings.push(binding); + this.siblingNameToModule.set(binding.name, moduleName); + } + } + } + + findUnsupportedConfigs(pluginsPairs: SgNode[]): Set { + const blockedConfigs = new Set(); + // Dynamic favicon inputs need manual migration. Keep their HTML plugin + // too, because the companion relies on its hooks. + for (const pair of pluginsPairs) { + const value = unwrapFilterCall(pair.field("value") ?? pair); + if (!value.is("array")) continue; + if (namedChildren(value).some((element) => { + const sibling = this.siblingInstantiationOf(element); + return sibling?.module === FAVICONS_MODULE && !this.faviconLogo(sibling.instantiation); + })) { + const config = pair.parent(); + if (config) blockedConfigs.add(config.range().start.index); + } + } + return blockedConfigs; + } + + private faviconLogo(instantiation: SgNode): SgNode | null { + const argsNode = instantiation.field("arguments"); + const args = argsNode ? namedChildren(argsNode).filter((node) => !node.is("comment")) : []; + if (args.length !== 1) return null; + const option = args[0]; + if (option.is("string")) return option; + if (!option.is("object") || namedChildren(option).some((node) => !node.is("pair") && !node.is("comment"))) return null; + const logo = findPair(option, "logo")?.field("value"); + return logo?.is("string") ? logo : null; + } + + + 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.is("new_expression")) return null; + const constructorNode = element.field("constructor"); + if (!constructorNode) return null; + const inner = constructorNode.is("parenthesized_expression") + ? namedChildren(constructorNode)[0] : constructorNode; + const module = this.siblingNameToModule.get(constructorNode.text()) + ?? SIBLING_PLUGIN_MODULES.find((name) => inner && isRequireOf(inner, name)); + return 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. + 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.is("object")) { + props.push({ name: "csp", valueText: `{ policy: ${policy.text()} }` }); + } else return false; + const options = args[1]; + if (options && options.is("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.is("object")) return false; + if (options) { + for (const optionPair of pairsOf(options)) { + const name = keyName(optionPair); + const value = optionPair.field("value"); + if (name === "hashFuncNames" && value?.is("array")) integrity = value.text(); + else if (name === "enabled" && value?.is("false")) enabled = false; + else if (name === "enabled") continue; + else lost.push(name ?? "options"); + } + } + if (enabled) props.push({ name: "integrity", valueText: integrity }); + } else if (module === FAVICONS_MODULE) { + const logo = this.faviconLogo(instantiation); + if (!logo) return false; + props.push({ name: "favicon", valueText: logo.text() }); + if (args[0]?.is("object")) { + for (const pair of pairsOf(args[0])) { + if (keyName(pair) !== "logo") lost.push(keyName(pair) ?? "options"); + } + } + plan.commentLines.push("Review: native favicon emits the source icon only; generate the required icon sizes/formats and web app manifest manually, then configure their links in output.html or the HTML template"); + } 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 === FAVICONS_MODULE ? "favicon generator" : module} options without a native HTML equivalent: ${[...new Set(lost)].join(", ")}`, + ); + } + return true; + } +} diff --git a/codemods/html-plugins-to-native-html/src/plugins/entries.ts b/codemods/html-plugins-to-native-html/src/plugins/entries.ts new file mode 100644 index 0000000..0bff59c --- /dev/null +++ b/codemods/html-plugins-to-native-html/src/plugins/entries.ts @@ -0,0 +1,78 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode } from "@codemod.com/jssg-types/main"; +import { ConfigEditor, findPair, keyName, lineIndent, namedChildren, pairsOf, unquote } from "@webpack/codemod-utils"; +import type { InstanceFindings } from "./options"; +import { queueTemplate } from "../migration-state"; +import { templateScriptTag } from "../templates"; + +// 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. +export function noteMultiPageEntry(configObject: SgNode, findings: InstanceFindings): void { + const entryValue = findPair(configObject, "entry")?.field("value"); + if (entryValue && entryValue.is("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 ? replaceableEntryValue(entryValue) : null; + if (!replaceable) { + findings.lost.push("template (make the template an .html entry that loads your JS)"); + return; + } + const chunkName = entryValue?.is("object") ? keyName(pairsOf(entryValue)[0]) ?? "main" : "main"; + const injected = queueTemplate(configFileName, unquote(templateValue), unquote(replaceable.text()), chunkName); + const comment = injected + ? `The template is now the entry: it must load the previous entry via ${templateScriptTag({ src: injected, chunkName })} (added by the HTML workflow step when the template is included)` + : `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(editor.source, entryPair.range().start.index); + editor.addEdit({ + startPos: entryPair.range().start.index, + endPos: entryPair.range().start.index, + insertedText: `// ${comment}\n${indent}`, + }); + editor.replace(replaceable, findings.templateValue as string); + } else { + editor.replace(replaceable, `/* ${comment.replace(/\*\//g, "* /")} */ ${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. +export function replaceableEntryValue(entryValue: SgNode): SgNode | null { + if (entryValue.is("string")) return entryValue; + if (entryValue.is("array")) { + const elements = namedChildren(entryValue); + return elements.length === 1 && elements[0].is("string") ? elements[0] : null; + } + if (entryValue.is("object")) { + const entryPairs = pairsOf(entryValue); + if (entryPairs.length !== 1) return null; + if (namedChildren(entryValue).some((child) => !child.is("pair") && !child.is("comment"))) return null; + const key = entryPairs[0].field("key"); + if (!key || key.is("computed_property_name") || key.text().includes("\\")) return null; + const inner = entryPairs[0].field("value"); + return inner && inner.is("string") ? inner : null; + } + return null; +} diff --git a/codemods/html-plugins-to-native-html/src/plugins/hooks.ts b/codemods/html-plugins-to-native-html/src/plugins/hooks.ts new file mode 100644 index 0000000..b9a7431 --- /dev/null +++ b/codemods/html-plugins-to-native-html/src/plugins/hooks.ts @@ -0,0 +1,174 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode } from "@codemod.com/jssg-types/main"; +import { ConfigEditor, type ModuleBinding, addImport, collectModuleBindings, lineIndent, namedChildren } from "@webpack/codemod-utils"; + +// 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"]); + +export class HtmlPluginHooks { + readonly repurposedStatements = new Set(); + private readonly pluginNames: Set; + + private readonly editor: ConfigEditor; + private readonly pluginBindings: ModuleBinding[]; + + constructor(editor: ConfigEditor, pluginBindings: ModuleBinding[]) { + this.editor = editor; + this.pluginBindings = pluginBindings; + + this.pluginNames = new Set(pluginBindings.map((binding) => binding.name)); + } + + + usesUnmappableHooks(): boolean { + if (!this.pluginNames.size || !this.pluginHookReceivers().length) return false; + for (const node of this.pluginHookProperties()) { + if (UNMAPPABLE_HOOKS.has(node.text())) return true; + } + return false; + } + + + // `X.getHooks(...)` / `X.getCompilationHooks(...)` receivers on the plugin. + private pluginHookReceivers(): SgNode[] { + const receivers: SgNode[] = []; + for (const node of this.editor.rootNode.findAll({ rule: { kind: "member_expression" } })) { + const property = node.field("property")?.text(); + if (property !== "getHooks" && property !== "getCompilationHooks") continue; + const objectPart = node.field("object"); + if (objectPart && this.pluginNames.has(objectPart.text())) receivers.push(node); + } + return receivers; + } + + + // Only keys read from this plugin's hook object belong to its API. Other + // plugins and ordinary application properties may use exactly the same names. + private pluginHookProperties(): SgNode[] { + const properties: SgNode[] = []; + for (const receiver of this.pluginHookReceivers()) { + const call = receiver.parent(); + const access = call?.parent(); + if (access?.is("member_expression")) { + const property = access.field("property"); + if (property) properties.push(property); + } else if (access?.is("variable_declarator")) { + const name = access.field("name"); + if (name?.is("object_pattern")) { + for (const member of namedChildren(name)) { + const key = member.is("pair_pattern") ? member.field("key") : member; + if (key) properties.push(key); + } + } else if (name?.is("identifier")) { + for (const member of this.editor.rootNode.findAll({ rule: { kind: "member_expression" } })) { + if (member.field("object")?.text() !== name.text()) continue; + const property = member.field("property"); + if (property) properties.push(property); + } + } + } + } + return properties; + } + + // 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. + migrateHooks(): void { + const receivers = this.pluginHookReceivers(); + if (!receivers.length) return; + const nativeReceiver = this.nativeHooksReceiver(); + for (const node of receivers) { + this.editor.replace(node, `${nativeReceiver}.getCompilationHooks`); + } + // One review comment per statement holding a renamed tap. + const commentedStatements = new Set(); + for (const property of this.pluginHookProperties()) { + const renamed = HOOK_RENAMES.get(property.text()); + if (!renamed) continue; + this.editor.replace(property, property.is("shorthand_property_identifier_pattern") + ? `${renamed}: ${property.text()}` : 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); + if (receivers.some((receiver) => receiver.range().start.index === statement.range().start.index)) { + // An insertion at the same position as the receiver replacement would + // be swallowed by commitEdits. A trailing comment avoids the overlap. + this.editor.insertAfter(statement, ` /* Review: ${review} */`); + continue; + } + 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`, + }); + } + } + + + // 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; + if (parent.is("statement_block") || parent.is("program") || parent.is("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.is("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"; + } +} diff --git a/codemods/html-plugins-to-native-html/src/plugins/migration.ts b/codemods/html-plugins-to-native-html/src/plugins/migration.ts new file mode 100644 index 0000000..06d31cf --- /dev/null +++ b/codemods/html-plugins-to-native-html/src/plugins/migration.ts @@ -0,0 +1,253 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode } from "@codemod.com/jssg-types/main"; +import { ConfigEditor, type ModuleBinding, findPair, guardBranchesOf, namedChildren, unquote, unwrapFilterCall } from "@webpack/codemod-utils"; +import { ConfigPlans } from "../config-plan"; +import type { Minification } from "../minification"; +import { CompanionPlugins } from "./companions"; +import { PLUGIN_MODULE, isRequireOf } from "./bindings"; +import { collectFindings, type InstanceFindings, type PageMode } from "./options"; +import { migrateEntry, noteMultiPageEntry, replaceableEntryValue } from "./entries"; + +// Handled by the per-entry migration itself rather than the option mapping. +const MULTI_PAGE_SKIPPED_OPTIONS = new Set(["chunks", "filename"]); + +export class HtmlPluginMigration { + migrated = false; + retained = false; + private readonly pluginNames: Set; + private readonly configFileName: string; + + private readonly editor: ConfigEditor; + private readonly plans: ConfigPlans; + private readonly companions: CompanionPlugins; + private readonly blockedConfigs: Set; + + constructor( + editor: ConfigEditor, + plans: ConfigPlans, + companions: CompanionPlugins, + blockedConfigs: Set, + pluginBindings: ModuleBinding[], + ) { + this.editor = editor; + this.plans = plans; + this.companions = companions; + this.blockedConfigs = blockedConfigs; + + this.pluginNames = new Set(pluginBindings.map((binding) => binding.name)); + this.configFileName = editor.rootNode.getRoot().filename(); + } + + // 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.is("new_expression")) return null; + const constructorNode = element.field("constructor"); + if (!constructorNode) return null; + if (this.pluginNames.has(constructorNode.text())) return element; + // `new (require("html-webpack-plugin"))(...)` without a binding. + const inner = + constructorNode.is("parenthesized_expression") + ? (namedChildren(constructorNode)[0] ?? constructorNode) + : constructorNode; + return isRequireOf(inner, PLUGIN_MODULE) ? element : null; + } + + + transformPluginsPair(pluginsPair: SgNode): void { + const configObject = pluginsPair.parent(); + if (!configObject || !configObject.is("object")) return; + if (this.blockedConfigs.has(configObject.range().start.index)) return; + const value = unwrapFilterCall(pluginsPair.field("value") ?? pluginsPair); + if (!value.is("array")) return; + const elements = namedChildren(value); + const instances: { element: SgNode; instantiation: SgNode }[] = []; + for (const element of elements) { + const instantiation = this.pluginInstantiationOf(element); + if (instantiation) instances.push({ element, instantiation }); + } + if (!instances.length) return; + // A computed destination cannot safely receive the native settings. + if (["output", "experiments"].some((key) => { + const destination = findPair(configObject, key)?.field("value"); + return destination && !destination.is("object"); + })) { + this.retained = true; + 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.is("object") || + namedChildren(optionsObject).some((child) => !child.is("pair") && !child.is("comment")) + )) { + this.retained = true; + return; + } + const template = optionsObject && findPair(optionsObject, "template")?.field("value"); + const entry = findPair(configObject, "entry")?.field("value"); + if (template && (!template.is("string") || (entry && !replaceableEntryValue(entry)))) { + this.retained = 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")?.is("array")); + if (chunkRestricted) { + this.migrateMultiPage(configObject, pluginsPair, elements, instances, optionObjects); + return; + } + const findings = collectFindings(firstOptions); + const plan = this.plans.planFor(configObject); + const mode: PageMode = findings.templateValue !== null ? "template" : "single"; + if (findings.templateValue !== null) migrateEntry(this.editor, this.configFileName, configObject, findings); + else noteMultiPageEntry(configObject, findings); + this.mergeIntoPlan(configObject, findings); + const siblingRemoved = this.companions.processSiblings(elements, [instances[0].element], mode, plan); + this.removeInstances(pluginsPair, elements, [instances[0].element, ...siblingRemoved]); + this.migrated = 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.is("object")) { + this.retained = true; + return; + } + const plan = this.plans.planFor(configObject); + const pages: { entryDescriptor: SgNode; htmlValue: string }[] = []; + const minification: Minification[] = []; + const lost: string[] = []; + let moduleScripts = false; + for (const options of optionObjects) { + // Each page needs exactly one owning entry in `chunks`. + const chunksValue = options ? findPair(options, "chunks")?.field("value") : undefined; + const chunkNames = chunksValue?.is("array") ? namedChildren(chunksValue) : []; + if (!options || chunkNames.length !== 1 || !chunkNames[0].is("string")) { + this.retained = true; + return; + } + const chunkName = unquote(chunkNames[0].text()); + const entryDescriptor = findPair(entryValue, chunkName)?.field("value"); + if (!entryDescriptor) { + this.retained = true; + return; + } + if (findPair(options, "template") || findPair(options, "templateContent")) { + this.retained = true; + return; + } + const findings = collectFindings(options, MULTI_PAGE_SKIPPED_OPTIONS); + minification.push(findings.minification); + // The page filename must fit the shared `htmlFilename: "[name].html"`. + const filenameValue = findPair(options, "filename")?.field("value"); + if (filenameValue) { + const filename = filenameValue.is("string") ? unquote(filenameValue.text()) : null; + if (filename !== `${chunkName}.html` && filename !== "[name].html") { + lost.push(`filename "${filename ?? "?"}" (output.htmlFilename is "[name].html")`); + } + } + lost.push(...findings.lost); + moduleScripts ||= findings.scriptLoadingModule; + const htmlValue = findings.htmlProps.length + ? `{ ${findings.htmlProps.map((prop) => `${prop.name}: ${prop.valueText}`).join(", ")} }` + : "true"; + pages.push({ entryDescriptor, htmlValue }); + } + // Per-page minification cannot be represented by a compilation-wide option. + if (new Set(minification.map((request) => request.html ?? "{}")).size > 1) { + this.retained = true; + return; + } + plan.minification.push(...minification); + const siblingRemoved = this.companions.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.is("object")) { + if (!findPair(node, "html")) { + this.editor.insertIntoObject(node, () => [`html: ${page.htmlValue}`]); + } + } else { + this.editor.replace(node, `{ import: ${node.text()}, html: ${page.htmlValue} }`); + } + } + plan.pluginMigratedHere = true; + this.plans.requireExperimentsHtml(plan); + this.plans.applyModuleScripts(plan, moduleScripts); + plan.htmlFilename ??= '"[name].html"'; + if (lost.length) { + plan.commentLines.push( + `Removed html-webpack-plugin options without a native HTML equivalent: ${[...new Set(lost)].join(", ")}`, + ); + } + this.migrated = true; + } + + + private mergeIntoPlan(configObject: SgNode, findings: InstanceFindings): void { + const plan = this.plans.planFor(configObject); + plan.minification.push(findings.minification); + plan.pluginMigratedHere = true; + this.plans.requireExperimentsHtml(plan); + if (findings.lost.length) { + plan.commentLines.push( + `Removed html-webpack-plugin options without a native HTML equivalent: ${[...new Set(findings.lost)].join(", ")}`, + ); + } + plan.commentLines.push(...findings.notes); + if (findings.templateValue === null) plan.htmlEnabled = true; + plan.htmlProps.push(...findings.htmlProps); + this.plans.applyModuleScripts(plan, findings.scriptLoadingModule); + // The plugin emitted `index.html` by default; native defaults to `[name].html`. + plan.htmlFilename ??= findings.htmlFilename ?? '"index.html"'; + plan.pendingEntry = findings.pendingEntry; + } +} diff --git a/codemods/html-plugins-to-native-html/src/plugins/options.ts b/codemods/html-plugins-to-native-html/src/plugins/options.ts new file mode 100644 index 0000000..f1a5e39 --- /dev/null +++ b/codemods/html-plugins-to-native-html/src/plugins/options.ts @@ -0,0 +1,193 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode } from "@codemod.com/jssg-types/main"; +import { findPair, keyName, pairsOf, unquote } from "@webpack/codemod-utils"; +import { collectMinification, type Minification } from "../minification"; +import type { HtmlProp } from "../config-plan"; + +// 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 output.module"], + ["templateContent", "author the page as an .html entry file"], +]); + +export type PageMode = "single" | "template" | "multi"; + +// Everything one plugin instance contributes to its enclosing config. +export interface InstanceFindings { + minification: Minification; + htmlProps: HtmlProp[]; + htmlFilename: string | null; + templateValue: string | null; + lost: string[]; + notes: string[]; + // `entry` property to create when the config had none (template mode). + pendingEntry: { text: string; comment: string } | null; + // `scriptLoading: "module"` — ESM output, set config-wide. + scriptLoadingModule: boolean; +} + +function describeLost(name: string): string { + const hint = LOST_OPTION_HINTS.get(name); + return hint ? `${name} (${hint})` : name; +} + +export function collectFindings( + optionsObject: SgNode | undefined, + skippedOptions?: Set, +): InstanceFindings { + const findings: InstanceFindings = { + minification: collectMinification( + optionsObject ? findPair(optionsObject, "minify")?.field("value") : undefined, + "minify", + ), + htmlProps: [], + htmlFilename: null, + templateValue: null, + lost: [], + notes: [], + pendingEntry: null, + scriptLoadingModule: false, + }; + if (!optionsObject) return findings; + const templateValue = findPair(optionsObject, "template")?.field("value"); + const templateMode = Boolean(templateValue); + if (templateValue) { + if (templateValue.is("string")) findings.templateValue = templateValue.text(); + else findings.lost.push("template"); + } + for (const optionPair of pairsOf(optionsObject)) { + const name = keyName(optionPair); + const optionValue = optionPair.field("value"); + if (!name || !optionValue) { + findings.lost.push("options"); + continue; + } + if (name === "template" || DROPPABLE_OPTIONS.has(name)) continue; + if (skippedOptions && skippedOptions.has(name)) continue; + collectOption(name, optionValue, templateMode, findings); + } + return findings; +} + +function collectOption( + name: string, + value: SgNode, + templateMode: boolean, + findings: InstanceFindings, +): void { + const literal = value.is("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.is("string")) findings.htmlFilename = value.text(); + else findings.lost.push(describeLost(name)); + break; + case "title": + case "favicon": + case "base": + findings.htmlProps.push({ name, valueText: value.text() }); + break; + case "meta": + collectMetaOption(value, findings); + break; + case "inject": + // `true` is the native default placement; the template authors its own tags. + if (templateMode || value.is("true")) break; + if (value.is("false") || literal === "body" || literal === "head") { + findings.htmlProps.push({ + name, + valueText: value.is("false") ? "false" : `"${literal}"`, + }); + } else { + findings.lost.push(describeLost(name)); + } + break; + case "scriptLoading": + // Native `"auto"` already defers classic scripts. + if (literal === "defer") break; + if (templateMode) { + if (literal === "module") { + findings.lost.push( + 'scriptLoading (enable output.module and use `, - }; - 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 chunkName = entryValue?.is("object") ? keyName(pairsOf(entryValue)[0]) ?? "main" : "main"; - const injected = queueTemplate(this.configFileName, unquote(templateValue), unquote(replaceable.text()), chunkName); - const comment = injected - ? `The template is now the entry: it must load the previous entry via ${templateScriptTag({ src: injected, chunkName })} (added by the HTML workflow step when the template is included)` - : `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.replace(/\*\//g, "* /")} */ ${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.is("string")) return entryValue; - if (entryValue.is("array")) { - const elements = namedChildren(entryValue); - return elements.length === 1 && elements[0].is("string") ? elements[0] : null; - } - if (entryValue.is("object")) { - const entryPairs = pairsOf(entryValue); - if (entryPairs.length !== 1) return null; - if (namedChildren(entryValue).some((child) => !child.is("pair") && !child.is("comment"))) return null; - const key = entryPairs[0].field("key"); - if (!key || key.is("computed_property_name") || key.text().includes("\\")) return null; - const inner = entryPairs[0].field("value"); - return inner && inner.is("string") ? inner : null; - } - return null; - } - - // ---------- config-level insertions ---------- - - private canNormalizeSourceDefaults(config: SgNode): boolean { - let current: SgNode = config; - for (const name of ["module", "parser", "html"]) { - const value = findPair(current, name)?.field("value"); - if (!value) return true; - if (!value.is("object") || namedChildren(value).some((child) => !child.is("pair") && !child.is("comment"))) return false; - current = value; - } - const sources = findPair(current, "sources")?.field("value"); - if (!sources || sources.is("true")) return true; - const elements = sources.is("array") ? namedChildren(sources).filter((node) => !node.is("comment")) : []; - return elements.length === 1 && elements[0].is("string") && unquote(elements[0].text()) === "..."; - } - - private normalizeSourceDefaults(config: SgNode): void { - // Rule parser options are clever-merged with global HTML parser defaults. - // Its default `sources: true` would expand a rule's "..." to `[true, ...]`, - // which fails validation. `["..."]` is equivalent but survives that merge. - let current = findPair(config, "module")!.field("value")!; - for (const [name, text] of [ - ["parser", 'parser: { html: { sources: ["..."] } }'], - ["html", 'html: { sources: ["..."] }'], - ]) { - const value = findPair(current, name)?.field("value"); - if (!value) { - this.editor.insertIntoObject(current, () => [text]); - return; - } - current = value; - } - const sources = findPair(current, "sources")?.field("value"); - if (sources?.is("true")) this.editor.replace(sources, '["..."]'); - else if (!sources) this.editor.insertIntoObject(current, () => ['sources: ["..."]']); - } - - private mergeIntoPlan(configObject: SgNode, findings: InstanceFindings): void { - const plan = this.planFor(configObject); - plan.minification.push(findings.minification); - plan.pluginMigratedHere = true; - this.requireExperimentsHtml(plan); - if (findings.lost.length) { - plan.commentLines.push( - `Removed html-webpack-plugin options without a native HTML equivalent: ${[...new Set(findings.lost)].join(", ")}`, - ); - } - plan.commentLines.push(...findings.notes); - if (findings.templateValue === null) plan.htmlEnabled = true; - plan.htmlProps.push(...findings.htmlProps); - this.applyModuleScripts(plan, findings.scriptLoadingModule); - // The plugin emitted `index.html` by default; native defaults to `[name].html`. - plan.htmlFilename ??= findings.htmlFilename ?? '"index.html"'; - plan.pendingEntry = findings.pendingEntry; - } - - private planConfigInsertions(): void { - for (const plan of this.configPlans.values()) { - if (plan.sourceDefaults) this.normalizeSourceDefaults(plan.config); - const topProperties: ((indent: string, unit: string) => string)[] = []; - const optimization = planMinification(this.editor, plan.config, plan.minification, plan.commentLines); - if (optimization) topProperties.push(() => optimization); - const pendingEntry = plan.pendingEntry; - if (pendingEntry) { - topProperties.push( - (indent) => `// ${pendingEntry.comment}\n${indent}entry: ${pendingEntry.text}`, - ); - } - const outputProps: HtmlProp[] = []; - const htmlProps = dedupeProps(plan.htmlProps); - const existingOutput = findPair(plan.config, "output")?.field("value"); - const existingHtml = existingOutput?.is("object") - ? findPair(existingOutput, "html")?.field("value") : undefined; - if (existingHtml?.is("object")) { - const missing = htmlProps.filter((prop) => !findPair(existingHtml, prop.name)); - if (missing.length) { - this.editor.insertIntoObject(existingHtml, () => missing.map((prop) => `${prop.name}: ${prop.valueText}`)); - } - } - if (plan.htmlEnabled || htmlProps.length) { - outputProps.push({ - name: "html", - valueText: htmlProps.length - ? `{ ${htmlProps.map((prop) => `${prop.name}: ${prop.valueText}`).join(", ")} }` - : "true", - }); - } - if (plan.htmlFilename !== null) { - outputProps.push({ name: "htmlFilename", valueText: plan.htmlFilename }); - } - outputProps.push(...plan.outputProps); - if (outputProps.length) { - this.planObjectProps(plan.config, "output", outputProps, plan.commentLines, topProperties); - } - if (plan.experimentsProps.length) { - this.planObjectProps( - plan.config, - "experiments", - plan.experimentsProps, - outputProps.length ? [] : plan.commentLines, - topProperties, - ); - } - if (!outputProps.length && !plan.experimentsProps.length && plan.commentLines.length) { - this.insertReviewComments(plan.config, plan.commentLines); - } - 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)), - ); - } - } - } - - private insertReviewComments(node: SgNode, lines: string[]): void { - if (!lines.length) return; - const position = node.range().start.index; - this.editor.addEdit({ - startPos: position, - endPos: position, - insertedText: `/* ${lines.join("; ").replace(/\*\//g, "* /")} */ `, - }); - } - - // Insert props into the config's `key` object, creating it when absent; an - // existing non-object value (e.g. a variable) is left alone. Comment lines - // ride on the first inserted property. - private planObjectProps( - config: SgNode, - key: string, - props: HtmlProp[], - commentLines: string[], - topProperties: ((indent: string, unit: string) => string)[], - ): void { - const withComments = (propertyText: string, indent: string, first: boolean): string => { - if (!first || !commentLines.length) return propertyText; - if (!config.text().includes("\n")) { - return `/* ${commentLines.join("; ").replace(/\*\//g, "* /")} */ ${propertyText}`; - } - return `${commentLines.map((line) => `// ${line}\n${indent}`).join("")}${propertyText}`; - }; - const value = findPair(config, key)?.field("value"); - if (value && value.is("object")) { - for (const prop of props) { - const existing = findPair(value, prop.name)?.field("value"); - if (!existing) continue; - if ( - (key === "experiments" && prop.name === "html") || - (key === "output" && prop.name === "module") || - (key === "output" && prop.name === "html" && (existing.is("false") || existing.is("true"))) - ) { - this.editor.replace(existing, prop.valueText); - } - } - const missing = props.filter((prop) => !findPair(value, prop.name)); - if (!missing.length) { - this.insertReviewComments(value, commentLines); - 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}}`; - }); - } - } + return editor.commit(); } -async function transform(root: SgRoot): Promise { - const result = new HtmlMigration(root).run(); +export default async function transform(root: SgRoot): Promise { + const result = migrateConfiguration(root); + recordSource(root.filename(), result ?? root.root().text()); + return result; } - -export default transform; From 695bc920c13199eb20b99e31d5e2e607b77e4967 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 7 Nov 2026 12:56:18 -0500 Subject: [PATCH 39/42] style: expand HTML codemod blocks and spacing --- .../src/config-plan.ts | 127 ++++++++-- .../src/loaders/callbacks.ts | 60 ++++- .../src/loaders/options.ts | 78 +++++- .../src/loaders/rules.ts | 219 +++++++++++++--- .../src/loaders/sources.ts | 194 +++++++++++--- .../src/loaders/template.ts | 239 ++++++++++++++---- .../src/migration-state.ts | 69 ++++- .../src/minification.ts | 103 ++++++-- .../src/plugins/bindings.ts | 65 ++++- .../src/plugins/companions.ts | 176 ++++++++++--- .../src/plugins/entries.ts | 81 +++++- .../src/plugins/hooks.ts | 140 ++++++++-- .../src/plugins/migration.ts | 179 ++++++++++--- .../src/plugins/options.ts | 83 +++++- .../src/remove-dependencies.ts | 44 +++- .../src/templates.ts | 78 +++++- .../src/workflow.ts | 26 +- 17 files changed, 1609 insertions(+), 352 deletions(-) diff --git a/codemods/html-plugins-to-native-html/src/config-plan.ts b/codemods/html-plugins-to-native-html/src/config-plan.ts index 3ad5a96..2edc1d3 100644 --- a/codemods/html-plugins-to-native-html/src/config-plan.ts +++ b/codemods/html-plugins-to-native-html/src/config-plan.ts @@ -10,6 +10,7 @@ export interface HtmlProp { export function dedupeProps(props: HtmlProp[]): HtmlProp[] { const seen = new Set(); + return props.filter((prop) => !seen.has(prop.name) && seen.add(prop.name)); } @@ -39,12 +40,12 @@ export class ConfigPlans { constructor(editor: ConfigEditor) { this.editor = editor; -} - + } planFor(config: SgNode): ConfigPlan { const key = config.range().start.index; let plan = this.configPlans.get(key); + if (!plan) { plan = { config, @@ -61,28 +62,32 @@ export class ConfigPlans { }; this.configPlans.set(key, plan); } + return plan; } - requireExperimentsHtml(plan: ConfigPlan): void { if (!plan.experimentsProps.some((prop) => prop.name === "html")) { plan.experimentsProps.push({ name: "html", valueText: "true" }); } } - // `scriptLoading: "module"` means ESM output — a config-wide switch. applyModuleScripts(plan: ConfigPlan, moduleScripts: boolean): void { - if (!moduleScripts) return; + if (!moduleScripts) { + return; + } + if (!plan.outputProps.some((prop) => prop.name === "module")) { plan.outputProps.push({ name: "module", valueText: "true" }); } + // ESM output is stable since webpack 5.111. Remove the obsolete flag // only when its value is a literal, preserving any expression side effects. const experiments = findPair(plan.config, "experiments")?.field("value"); const flag = experiments?.is("object") ? findPair(experiments, "outputModule") : undefined; const value = flag?.field("value"); + if (flag && (value?.is("true") || value?.is("false"))) { this.editor.keepBracesOpen(experiments!); this.editor.markForRemoval(flag); @@ -91,64 +96,112 @@ export class ConfigPlans { canNormalizeSourceDefaults(config: SgNode): boolean { let current: SgNode = config; + for (const name of ["module", "parser", "html"]) { const value = findPair(current, name)?.field("value"); - if (!value) return true; - if (!value.is("object") || namedChildren(value).some((child) => !child.is("pair") && !child.is("comment"))) return false; + + if (!value) { + return true; + } + + if ( + !value.is("object") || + namedChildren(value).some((child) => !child.is("pair") && !child.is("comment")) + ) { + return false; + } + current = value; } + const sources = findPair(current, "sources")?.field("value"); - if (!sources || sources.is("true")) return true; - const elements = sources.is("array") ? namedChildren(sources).filter((node) => !node.is("comment")) : []; - return elements.length === 1 && elements[0].is("string") && unquote(elements[0].text()) === "..."; - } + if (!sources || sources.is("true")) { + return true; + } + + const elements = sources.is("array") + ? namedChildren(sources).filter((node) => !node.is("comment")) + : []; + + return ( + elements.length === 1 && elements[0].is("string") && unquote(elements[0].text()) === "..." + ); + } private normalizeSourceDefaults(config: SgNode): void { // Rule parser options are clever-merged with global HTML parser defaults. // Its default `sources: true` would expand a rule's "..." to `[true, ...]`, // which fails validation. `["..."]` is equivalent but survives that merge. let current = findPair(config, "module")!.field("value")!; + for (const [name, text] of [ ["parser", 'parser: { html: { sources: ["..."] } }'], ["html", 'html: { sources: ["..."] }'], ]) { const value = findPair(current, name)?.field("value"); + if (!value) { this.editor.insertIntoObject(current, () => [text]); + return; } + current = value; } + const sources = findPair(current, "sources")?.field("value"); - if (sources?.is("true")) this.editor.replace(sources, '["..."]'); - else if (!sources) this.editor.insertIntoObject(current, () => ['sources: ["..."]']); - } + if (sources?.is("true")) { + this.editor.replace(sources, '["..."]'); + } else if (!sources) { + this.editor.insertIntoObject(current, () => ['sources: ["..."]']); + } + } planConfigInsertions(): void { for (const plan of this.configPlans.values()) { - if (plan.sourceDefaults) this.normalizeSourceDefaults(plan.config); + if (plan.sourceDefaults) { + this.normalizeSourceDefaults(plan.config); + } + const topProperties: ((indent: string, unit: string) => string)[] = []; - const optimization = planMinification(this.editor, plan.config, plan.minification, plan.commentLines); - if (optimization) topProperties.push(() => optimization); + const optimization = planMinification( + this.editor, + plan.config, + plan.minification, + plan.commentLines, + ); + + if (optimization) { + topProperties.push(() => optimization); + } + const pendingEntry = plan.pendingEntry; + if (pendingEntry) { topProperties.push( (indent) => `// ${pendingEntry.comment}\n${indent}entry: ${pendingEntry.text}`, ); } + const outputProps: HtmlProp[] = []; const htmlProps = dedupeProps(plan.htmlProps); const existingOutput = findPair(plan.config, "output")?.field("value"); const existingHtml = existingOutput?.is("object") - ? findPair(existingOutput, "html")?.field("value") : undefined; + ? findPair(existingOutput, "html")?.field("value") + : undefined; + if (existingHtml?.is("object")) { const missing = htmlProps.filter((prop) => !findPair(existingHtml, prop.name)); + if (missing.length) { - this.editor.insertIntoObject(existingHtml, () => missing.map((prop) => `${prop.name}: ${prop.valueText}`)); + this.editor.insertIntoObject(existingHtml, () => + missing.map((prop) => `${prop.name}: ${prop.valueText}`), + ); } } + if (plan.htmlEnabled || htmlProps.length) { outputProps.push({ name: "html", @@ -157,13 +210,17 @@ export class ConfigPlans { : "true", }); } + if (plan.htmlFilename !== null) { outputProps.push({ name: "htmlFilename", valueText: plan.htmlFilename }); } + outputProps.push(...plan.outputProps); + if (outputProps.length) { this.planObjectProps(plan.config, "output", outputProps, plan.commentLines, topProperties); } + if (plan.experimentsProps.length) { this.planObjectProps( plan.config, @@ -173,9 +230,11 @@ export class ConfigPlans { topProperties, ); } + if (!outputProps.length && !plan.experimentsProps.length && plan.commentLines.length) { this.insertReviewComments(plan.config, plan.commentLines); } + if (topProperties.length) { // A fully-emptied config keeps its braces open for these properties. this.editor.keepBracesOpen(plan.config); @@ -186,10 +245,13 @@ export class ConfigPlans { } } - private insertReviewComments(node: SgNode, lines: string[]): void { - if (!lines.length) return; + if (!lines.length) { + return; + } + const position = node.range().start.index; + this.editor.addEdit({ startPos: position, endPos: position, @@ -197,7 +259,6 @@ export class ConfigPlans { }); } - // 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. @@ -209,30 +270,45 @@ export class ConfigPlans { topProperties: ((indent: string, unit: string) => string)[], ): void { const withComments = (propertyText: string, indent: string, first: boolean): string => { - if (!first || !commentLines.length) return propertyText; + if (!first || !commentLines.length) { + return propertyText; + } + if (!config.text().includes("\n")) { return `/* ${commentLines.join("; ").replace(/\*\//g, "* /")} */ ${propertyText}`; } + return `${commentLines.map((line) => `// ${line}\n${indent}`).join("")}${propertyText}`; }; const value = findPair(config, key)?.field("value"); + if (value && value.is("object")) { for (const prop of props) { const existing = findPair(value, prop.name)?.field("value"); - if (!existing) continue; + + if (!existing) { + continue; + } + if ( (key === "experiments" && prop.name === "html") || (key === "output" && prop.name === "module") || - (key === "output" && prop.name === "html" && (existing.is("false") || existing.is("true"))) + (key === "output" && + prop.name === "html" && + (existing.is("false") || existing.is("true"))) ) { this.editor.replace(existing, prop.valueText); } } + const missing = props.filter((prop) => !findPair(value, prop.name)); + if (!missing.length) { this.insertReviewComments(value, commentLines); + return; } + this.editor.insertIntoObject(value, (indent) => missing.map((prop, index) => withComments(`${prop.name}: ${prop.valueText}`, indent, index === 0), @@ -244,6 +320,7 @@ export class ConfigPlans { 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}}`; }); } diff --git a/codemods/html-plugins-to-native-html/src/loaders/callbacks.ts b/codemods/html-plugins-to-native-html/src/loaders/callbacks.ts index f496aa4..af7b1c9 100644 --- a/codemods/html-plugins-to-native-html/src/loaders/callbacks.ts +++ b/codemods/html-plugins-to-native-html/src/loaders/callbacks.ts @@ -3,41 +3,83 @@ import type { SgNode } from "@codemod.com/jssg-types/main"; import { namedChildren } from "@webpack/codemod-utils"; export function callbackParameters(value: SgNode): SgNode[] | null { - if (!["arrow_function", "function_expression"].some((kind) => value.is(kind))) return null; - if (value.children().some((node) => node.is("async"))) return null; - if (value.findAll({ rule: { any: [{ kind: "this" }, { pattern: "arguments" }] } }).length) return null; + if (!["arrow_function", "function_expression"].some((kind) => value.is(kind))) { + return null; + } + + if (value.children().some((node) => node.is("async"))) { + return null; + } + + if (value.findAll({ rule: { any: [{ kind: "this" }, { pattern: "arguments" }] } }).length) { + return null; + } + const parameters = value.field("parameters"); const parameter = value.field("parameter"); - const params = parameters ? namedChildren(parameters).filter((node) => !node.is("comment")) : parameter ? [parameter] : []; - if (params.some((param) => param.field("value") || param.is("assignment_pattern") || param.is("rest_pattern"))) return null; + const params = parameters + ? namedChildren(parameters).filter((node) => !node.is("comment")) + : parameter + ? [parameter] + : []; + + if ( + params.some( + (param) => param.field("value") || param.is("assignment_pattern") || param.is("rest_pattern"), + ) + ) { + return null; + } + const names = params.map((param) => param.field("pattern") ?? param); + return names.every((name) => name.is("identifier")) ? names : null; } export function isIdentityCallback(value: SgNode): boolean { const params = callbackParameters(value); - if (!params?.length) return false; + + if (!params?.length) { + return false; + } + let body = value.field("body"); + if (body?.is("statement_block")) { const statements = namedChildren(body).filter((node) => !node.is("comment")); - if (statements.length !== 1 || !statements[0].is("return_statement")) return false; + + if (statements.length !== 1 || !statements[0].is("return_statement")) { + return false; + } + body = namedChildren(statements[0]).find((node) => !node.is("comment")) ?? null; } + return !!body?.is("identifier") && body.text() === params[0].text(); } export function usesParameter(value: SgNode, params: SgNode[], index: number): boolean { const param = params[index]; + return !!param && !!value.field("body")?.findAll({ rule: { pattern: param.text() } }).length; } export function constantBoolean(value: SgNode): string | null { - if (!callbackParameters(value)) return null; + if (!callbackParameters(value)) { + return null; + } + let body = value.field("body"); + if (body?.is("statement_block")) { const statements = namedChildren(body).filter((node) => !node.is("comment")); - if (statements.length !== 1 || !statements[0].is("return_statement")) return null; + + if (statements.length !== 1 || !statements[0].is("return_statement")) { + return null; + } + body = namedChildren(statements[0])[0] ?? null; } + return body?.is("true") || body?.is("false") ? body.text() : null; } diff --git a/codemods/html-plugins-to-native-html/src/loaders/options.ts b/codemods/html-plugins-to-native-html/src/loaders/options.ts index c5cf032..03ebbcd 100644 --- a/codemods/html-plugins-to-native-html/src/loaders/options.ts +++ b/codemods/html-plugins-to-native-html/src/loaders/options.ts @@ -1,6 +1,13 @@ import type Js from "@codemod.com/jssg-types/langs/javascript"; import type { SgNode } from "@codemod.com/jssg-types/main"; -import { findPair, guardBranchesOf, keyName, loaderNameOf, namedChildren, pairsOf } from "@webpack/codemod-utils"; +import { + findPair, + guardBranchesOf, + keyName, + loaderNameOf, + namedChildren, + pairsOf, +} from "@webpack/codemod-utils"; import { collectMinification, type Minification } from "../minification"; import type { HtmlProp } from "../config-plan"; import { isIdentityCallback } from "./callbacks"; @@ -19,55 +26,100 @@ export interface LoaderFindings { // Translate known equivalents; retain the config when an option would lose // executable behavior or change the module's export contract. -export function collectLoaderFindings(node: SgNode, findings: LoaderFindings, typescript: boolean): void { +export function collectLoaderFindings( + node: SgNode, + findings: LoaderFindings, + typescript: boolean, +): void { const branches = guardBranchesOf(node); + if (branches) { - for (const branch of branches) collectLoaderFindings(branch, findings, typescript); + for (const branch of branches) { + collectLoaderFindings(branch, findings, typescript); + } + return; } - if (!node.is("object") || loaderNameOf(node) !== LOADER_NAME) return; + + if (!node.is("object") || loaderNameOf(node) !== LOADER_NAME) { + return; + } + const optionsValue = findPair(node, "options")?.field("value"); const minification = collectMinification( optionsValue?.is("object") ? findPair(optionsValue, "minimize")?.field("value") : undefined, `${LOADER_NAME}.minimize`, ); + findings.minification.push(minification); - if (minification.lost.length) findings.supported = false; - if (!optionsValue) return; + + if (minification.lost.length) { + findings.supported = false; + } + + if (!optionsValue) { + return; + } + if (!optionsValue.is("object")) { findings.supported = false; + return; } + if (namedChildren(optionsValue).some((child) => !child.is("pair") && !child.is("comment"))) { findings.supported = false; + return; } + for (const optionPair of pairsOf(optionsValue)) { const name = keyName(optionPair); const value = optionPair.field("value"); + if (name === "esModule") { // Native HTML exposes a default string export and its CJS shim also // returns that string directly, so both literal settings can migrate. - if (!value?.is("true") && !value?.is("false")) findings.supported = false; + if (!value?.is("true") && !value?.is("false")) { + findings.supported = false; + } + continue; } - if (name === "minimize") continue; + + if (name === "minimize") { + continue; + } + if (name === "sources" && value) { const sources = nativeSources(value, typescript); - if (sources === null) findings.supported = false; - else { + + if (sources === null) { + findings.supported = false; + } else { findings.parserProps.push({ name: "sources", valueText: sources.text }); findings.sourceDefaults ||= sources.extendsDefaults; } + continue; } + if (name === "preprocessor" && value) { const template = nativeTemplate(value, typescript); - if (template === null) findings.supported = false; - else findings.parserProps.push({ name: "template", valueText: template }); + + if (template === null) { + findings.supported = false; + } else { + findings.parserProps.push({ name: "template", valueText: template }); + } + continue; } - if (name === "postprocessor" && value && isIdentityCallback(value)) continue; + + if (name === "postprocessor" && value && isIdentityCallback(value)) { + continue; + } + findings.supported = false; } } diff --git a/codemods/html-plugins-to-native-html/src/loaders/rules.ts b/codemods/html-plugins-to-native-html/src/loaders/rules.ts index 2dac964..296fe20 100644 --- a/codemods/html-plugins-to-native-html/src/loaders/rules.ts +++ b/codemods/html-plugins-to-native-html/src/loaders/rules.ts @@ -1,6 +1,20 @@ import type Js from "@codemod.com/jssg-types/langs/javascript"; import type { SgNode } from "@codemod.com/jssg-types/main"; -import { ConfigEditor, cascadeRemovalTarget, filterSuffixOf, findConfigObjectFor, findPair, guardBranchesOf, keyName, lineIndent, loaderNameOf, namedChildren, pairsOf, ruleMatchesFiles, unwrapFilterCall } from "@webpack/codemod-utils"; +import { + ConfigEditor, + cascadeRemovalTarget, + filterSuffixOf, + findConfigObjectFor, + findPair, + guardBranchesOf, + keyName, + lineIndent, + loaderNameOf, + namedChildren, + pairsOf, + ruleMatchesFiles, + unwrapFilterCall, +} from "@webpack/codemod-utils"; import { ConfigPlans, dedupeProps } from "../config-plan"; import { collectLoaderFindings, LOADER_NAME, type LoaderFindings } from "./options"; @@ -36,49 +50,85 @@ export class HtmlLoaderRules { // 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; } - // 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.is("pair")) { const name = keyName(owner); - if (name === "rules" || name === "oneOf") return true; + + 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. transformRules(usePairs: SgNode[], loaderPairs: SgNode[]): void { const migrations: RuleMigration[] = []; + for (const pair of usePairs) { const originalValue = pair.field("value"); - if (!originalValue) continue; + + if (!originalValue) { + continue; + } + const value = unwrapFilterCall(originalValue); const elements = value.is("array") ? namedChildren(value) : [value]; - if (!elements.length) continue; + + 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; + + if (kept.length === elements.length) { + continue; + } + const ruleObject = pair.parent(); - if (!ruleObject || !ruleObject.is("object")) continue; - if (!this.isWebpackRuleContext(pair, ruleObject.parent() ?? ruleObject)) continue; - const findings: LoaderFindings = { supported: true, sourceDefaults: false, parserProps: [], minification: [] }; - for (const element of elements) collectLoaderFindings(element, findings, this.typescript); + + if (!ruleObject || !ruleObject.is("object")) { + continue; + } + + if (!this.isWebpackRuleContext(pair, ruleObject.parent() ?? ruleObject)) { + continue; + } + + const findings: LoaderFindings = { + supported: true, + sourceDefaults: false, + parserProps: [], + minification: [], + }; + + for (const element of elements) { + collectLoaderFindings(element, findings, this.typescript); + } + const trivial = pairsOf(ruleObject).every((rulePair) => { const name = keyName(rulePair); + return name === "test" || name === "use"; }); + migrations.push({ pair, ruleObject, @@ -89,20 +139,44 @@ export class HtmlLoaderRules { optionsPair: null, }); } + // Rule-level `loader:`/`options:` shorthand. for (const pair of loaderPairs) { const ruleObject = pair.parent(); - if (!ruleObject || !ruleObject.is("object")) continue; - if (!findPair(ruleObject, "test") || findPair(ruleObject, "use")) continue; + + if (!ruleObject || !ruleObject.is("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 = { supported: true, sourceDefaults: false, parserProps: [], minification: [] }; + + if (!value || !this.isRemovableUseElement(value)) { + continue; + } + + if (!this.isWebpackRuleContext(pair, ruleObject.parent() ?? ruleObject)) { + continue; + } + + const findings: LoaderFindings = { + supported: true, + sourceDefaults: false, + parserProps: [], + minification: [], + }; + collectLoaderFindings(ruleObject, findings, this.typescript); + const trivial = pairsOf(ruleObject).every((rulePair) => { const name = keyName(rulePair); + return name === "test" || name === "loader" || name === "options"; }); + migrations.push({ pair, ruleObject, @@ -113,95 +187,166 @@ export class HtmlLoaderRules { optionsPair: findPair(ruleObject, "options") ?? null, }); } + // A retained html-loader emits JavaScript. Enabling native HTML or replacing // an HTML plugin's template beside it would parse that JavaScript as HTML. // Decide this before making edits to any of the owning config's rules. for (const migration of migrations) { - if (this.canMigrateRule(migration)) continue; + if (this.canMigrateRule(migration)) { + continue; + } + const config = findConfigObjectFor(migration.pair); + if (config) { this.blockedConfigs.add(config.range().start.index); } } - for (const migration of migrations) this.applyRuleMigration(migration); - } + for (const migration of migrations) { + this.applyRuleMigration(migration); + } + } private canMigrateRule(swap: RuleMigration): boolean { - if (!swap.findings.supported) return false; + if (!swap.findings.supported) { + return false; + } + const config = findConfigObjectFor(swap.pair); const experiments = config && findPair(config, "experiments")?.field("value"); const parser = findPair(swap.ruleObject, "parser")?.field("value"); + if ( (experiments && !experiments.is("object")) || (swap.findings.parserProps.length && parser && !parser.is("object")) - ) return false; + ) { + return false; + } + // Do not replace loader behavior with a conflicting existing parser option, // or flatten differently configured loader branches into one parser. for (const prop of swap.findings.parserProps) { - const existing = parser?.is("object") ? findPair(parser, prop.name)?.field("value") : undefined; - if (existing && existing.text() !== prop.valueText) return false; - if (swap.findings.parserProps.some((other) => other.name === prop.name && other.valueText !== prop.valueText)) return false; + const existing = parser?.is("object") + ? findPair(parser, prop.name)?.field("value") + : undefined; + + if (existing && existing.text() !== prop.valueText) { + return false; + } + + if ( + swap.findings.parserProps.some( + (other) => other.name === prop.name && other.valueText !== prop.valueText, + ) + ) { + return false; + } } - return !swap.findings.sourceDefaults || (!!config && this.plans.canNormalizeSourceDefaults(config)); - } + return ( + !swap.findings.sourceDefaults || (!!config && this.plans.canNormalizeSourceDefaults(config)) + ); + } private applyRuleMigration(swap: RuleMigration): void { - if (!this.canMigrateRule(swap)) return; + if (!this.canMigrateRule(swap)) { + return; + } + const config = findConfigObjectFor(swap.pair); - if (config && this.blockedConfigs.has(config.range().start.index)) return; + + if (config && this.blockedConfigs.has(config.range().start.index)) { + return; + } + const experiments = config && findPair(config, "experiments")?.field("value"); const parser = findPair(swap.ruleObject, "parser")?.field("value"); + swap.findings.parserProps = dedupeProps(swap.findings.parserProps); - if (swap.findings.sourceDefaults && config) this.plans.planFor(config).sourceDefaults = true; + + if (swap.findings.sourceDefaults && config) { + this.plans.planFor(config).sourceDefaults = true; + } + if (config) { - this.plans.planFor(config).minification.push( - ...(swap.findings.minification.length ? swap.findings.minification : [{ html: null, lost: [] }]), - ); + this.plans + .planFor(config) + .minification.push( + ...(swap.findings.minification.length + ? swap.findings.minification + : [{ html: null, lost: [] }]), + ); } + const survives = !swap.trivial || !DEFAULT_HTML_TESTS.has(findPair(swap.ruleObject, "test")?.field("value")?.text() ?? "") || swap.kept.length > 0 || swap.findings.parserProps.length > 0; + if (!survives) { // Explicitly disabled HTML must be enabled even when its loader rule goes. if (config && experiments && findPair(experiments, "html")) { this.plans.requireExperimentsHtml(this.plans.planFor(config)); } + 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"); const separator = multiline ? `,\n${indent}` : ", "; let replacement = ""; + 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}`; } + const typePair = findPair(swap.ruleObject, "type"); + replacement += `type: "html"`; - if (typePair) this.editor.markForRemoval(typePair); + + if (typePair) { + this.editor.markForRemoval(typePair); + } + if (swap.findings.parserProps.length) { if (parser?.is("object")) { const missing = swap.findings.parserProps.filter((prop) => !findPair(parser, prop.name)); + if (missing.length) { - this.editor.insertIntoObject(parser, () => missing.map((prop) => `${prop.name}: ${prop.valueText}`)); + this.editor.insertIntoObject(parser, () => + missing.map((prop) => `${prop.name}: ${prop.valueText}`), + ); } } else if (!parser) { 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); + + 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; - if (config) this.plans.requireExperimentsHtml(this.plans.planFor(config)); + if (!ruleMatchesFiles(swap.ruleObject, HTML_SAMPLE_FILES)) { + return; + } + + if (config) { + this.plans.requireExperimentsHtml(this.plans.planFor(config)); + } } } diff --git a/codemods/html-plugins-to-native-html/src/loaders/sources.ts b/codemods/html-plugins-to-native-html/src/loaders/sources.ts index 760e8be..5612b98 100644 --- a/codemods/html-plugins-to-native-html/src/loaders/sources.ts +++ b/codemods/html-plugins-to-native-html/src/loaders/sources.ts @@ -4,91 +4,217 @@ import { findPair, keyName, namedChildren, unquote } from "@webpack/codemod-util import { callbackParameters, constantBoolean, usesParameter } from "./callbacks"; function freshName(source: string, base: string): string { - while (source.includes(base)) base += "_"; + while (source.includes(base)) { + base += "_"; + } + return base; } // Source filters run per attribute in both APIs, but native attributes are a // Map and the native callback has no tag name or resource path arguments. -function sourceFilter(value: SgNode, tag: string | null, attribute: string, attrs: string, typescript: boolean): string | null { +function sourceFilter( + value: SgNode, + tag: string | null, + attribute: string, + attrs: string, + typescript: boolean, +): string | null { const params = callbackParameters(value); - if (!params || params.length > 4 || usesParameter(value, params, 3)) return null; - if ((!tag || tag === "*") && usesParameter(value, params, 0)) return null; + + if (!params || params.length > 4 || usesParameter(value, params, 3)) { + return null; + } + + if ((!tag || tag === "*") && usesParameter(value, params, 0)) { + return null; + } + // parse5's foreign attribute namespace metadata is not exposed by native HTML. - if (usesParameter(value, params, 2) && /\b(namespace|prefix)\b/.test(value.text())) return null; - const args = [JSON.stringify(tag ?? ""), JSON.stringify(attribute), `Array.from(${attrs}, ([name, value]) => ({ name, value }))`, typescript ? "undefined as never" : "undefined"]; + if (usesParameter(value, params, 2) && /\b(namespace|prefix)\b/.test(value.text())) { + return null; + } + + const args = [ + JSON.stringify(tag ?? ""), + JSON.stringify(attribute), + `Array.from(${attrs}, ([name, value]) => ({ name, value }))`, + typescript ? "undefined as never" : "undefined", + ]; + return `(${value.text()})(${args.slice(0, params.length).join(", ")})`; } -export function nativeSources(value: SgNode, typescript = false): { text: string; extendsDefaults: boolean } | null { - if (value.is("true") || value.is("false")) return { text: value.text(), extendsDefaults: false }; - if (!value.is("object")) return null; +export function nativeSources( + value: SgNode, + typescript = false, +): { text: string; extendsDefaults: boolean } | null { + if (value.is("true") || value.is("false")) { + return { text: value.text(), extendsDefaults: false }; + } + + if (!value.is("object")) { + return null; + } + const options = namedChildren(value).filter((node) => !node.is("comment")); - if (!options.length) return { text: "true", extendsDefaults: false }; + + if (!options.length) { + return { text: "true", extendsDefaults: false }; + } + const keys = options.map(keyName); - if (new Set(keys).size !== keys.length || keys.some((key) => !key || !["list", "urlFilter", "scriptingEnabled"].includes(key))) return null; + + if ( + new Set(keys).size !== keys.length || + keys.some((key) => !key || !["list", "urlFilter", "scriptingEnabled"].includes(key)) + ) { + return null; + } + const scripting = findPair(value, "scriptingEnabled")?.field("value"); - if (scripting && !scripting.is("true")) return null; + + if (scripting && !scripting.is("true")) { + return null; + } + const urlFilter = findPair(value, "urlFilter")?.field("value"); const urlConstant = urlFilter ? constantBoolean(urlFilter) : "true"; - if (urlConstant === "false") return { text: "false", extendsDefaults: false }; + + if (urlConstant === "false") { + return { text: "false", extendsDefaults: false }; + } + const list = findPair(value, "list")?.field("value"); - if (!list) return urlConstant === "true" ? { text: "true", extendsDefaults: false } : null; - if (!list.is("array")) return null; + + if (!list) { + return urlConstant === "true" ? { text: "true", extendsDefaults: false } : null; + } + + if (!list.is("array")) { + return null; + } + const urlParams = urlFilter ? callbackParameters(urlFilter) : []; - if (urlConstant === null && (!urlParams || urlParams.length > 3 || usesParameter(urlFilter!, urlParams, 2))) return null; + + if ( + urlConstant === null && + (!urlParams || urlParams.length > 3 || usesParameter(urlFilter!, urlParams, 2)) + ) { + return null; + } + const edits = []; + for (const source of namedChildren(list)) { - if (source.is("comment")) continue; + if (source.is("comment")) { + continue; + } + if (source.is("string") && unquote(source.text()) === "...") { - if (urlConstant === null) return null; + if (urlConstant === null) { + return null; + } + continue; } - if (!source.is("object")) return null; + + if (!source.is("object")) { + return null; + } + const properties = namedChildren(source).filter((node) => !node.is("comment")); const names = properties.map(keyName); - if (new Set(names).size !== names.length) return null; - if (!findPair(source, "attribute") || !findPair(source, "type")) return null; + + if (new Set(names).size !== names.length) { + return null; + } + + if (!findPair(source, "attribute") || !findPair(source, "type")) { + return null; + } + for (const property of properties) { const name = keyName(property); const option = property.field("value"); - if (!name || !["tag", "attribute", "type", "filter"].includes(name) || !option) return null; - if (name === "filter") continue; + + if (!name || !["tag", "attribute", "type", "filter"].includes(name) || !option) { + return null; + } + + if (name === "filter") { + continue; + } + if (name === "type") { - if (!option.is("string") || !["src", "srcset"].includes(unquote(option.text()))) return null; - } else if (!option.is("string")) return null; + if (!option.is("string") || !["src", "srcset"].includes(unquote(option.text()))) { + return null; + } + } else if (!option.is("string")) { + return null; + } } + const filter = findPair(source, "filter")?.field("value"); - if (!filter && urlConstant === "true") continue; + + if (!filter && urlConstant === "true") { + continue; + } + const tagNode = findPair(source, "tag")?.field("value"); const tag = tagNode ? unquote(tagNode.text()).toLowerCase() : null; const attribute = unquote(findPair(source, "attribute")!.field("value")!.text()).toLowerCase(); const type = findPair(source, "type")!.field("value")!; + // urlFilter sees each parsed srcset candidate, while a native source filter // sees the entire attribute. Do not turn a per-URL decision into all-or-none. - if (urlConstant === null && unquote(type.text()) !== "src") return null; + if (urlConstant === null && unquote(type.text()) !== "src") { + return null; + } + const attrs = freshName(value.text(), "htmlAttributes"); const url = freshName(value.text(), "htmlUrl"); const conditions: string[] = []; + if (filter) { const condition = sourceFilter(filter, tag, attribute, attrs, typescript); - if (condition === null) return null; + + if (condition === null) { + return null; + } + conditions.push(condition); } + if (urlFilter && urlConstant === null) { const normalized = `${url}.replace(/^[\\t\\n\\f\\r ]+|[\\t\\n\\f\\r ]+$/g, "").replace(/^[\\u0001-\\u0019\\u00a0]+|[\\u0001-\\u0019\\u00a0]+$/g, "")`; - const args = [JSON.stringify(attribute), normalized, typescript ? "undefined as never" : "undefined"]; + const args = [ + JSON.stringify(attribute), + normalized, + typescript ? "undefined as never" : "undefined", + ]; + conditions.push(`(${urlFilter.text()})(${args.slice(0, urlParams!.length).join(", ")})`); } - const parameters = urlConstant === null - ? `${attrs}${typescript ? ": Map" : ""}, ${url}${typescript ? ": string" : ""}` - : `${attrs}${typescript ? ": Map" : ""}`; - const mapped = properties.filter((property) => keyName(property) !== "filter").map((property) => property.text()); + + const parameters = + urlConstant === null + ? `${attrs}${typescript ? ": Map" : ""}, ${url}${ + typescript ? ": string" : "" + }` + : `${attrs}${typescript ? ": Map" : ""}`; + const mapped = properties + .filter((property) => keyName(property) !== "filter") + .map((property) => property.text()); + mapped.push(`filter: (${parameters}) => ${conditions.join(" && ")}`); edits.push(source.replace(`{ ${mapped.join(", ")} }`)); } + return { text: edits.length ? list.commitEdits(edits) : list.text(), - extendsDefaults: namedChildren(list).some((node) => node.is("string") && unquote(node.text()) === "..."), + extendsDefaults: namedChildren(list).some( + (node) => node.is("string") && unquote(node.text()) === "...", + ), }; } diff --git a/codemods/html-plugins-to-native-html/src/loaders/template.ts b/codemods/html-plugins-to-native-html/src/loaders/template.ts index bfa190a..a7746c4 100644 --- a/codemods/html-plugins-to-native-html/src/loaders/template.ts +++ b/codemods/html-plugins-to-native-html/src/loaders/template.ts @@ -4,8 +4,20 @@ import { namedChildren } from "@webpack/codemod-utils"; import { callbackParameters } from "./callbacks"; const STRING_METHODS = new Set([ - "concat", "normalize", "padEnd", "padStart", "repeat", "replace", "replaceAll", - "slice", "substring", "toLowerCase", "toUpperCase", "trim", "trimEnd", "trimStart", + "concat", + "normalize", + "padEnd", + "padStart", + "repeat", + "replace", + "replaceAll", + "slice", + "substring", + "toLowerCase", + "toUpperCase", + "trim", + "trimEnd", + "trimStart", ]); const CONTEXT_METHODS = new Map([ @@ -17,59 +29,126 @@ const CONTEXT_METHODS = new Map([ ["emitError", "(error: Error) => void"], ]); -function adaptTemplateContext(value: SgNode, context: SgNode, typescript: boolean): string | null { +function adaptTemplateContext( + value: SgNode, + context: SgNode, + typescript: boolean, +): string | null { const body = value.field("body")!; const edits = []; const properties = new Map(); const name = context.text(); - const resourcePattern = '/^((?:\\0.|[^?#\\0])*)(\\?(?:\\0.|[^#\\0])*)?(#.*)?$/'; - for (const reference of body.findAll({ rule: { kind: "identifier" } }).filter((node) => node.text() === name)) { + const resourcePattern = "/^((?:\\0.|[^?#\\0])*)(\\?(?:\\0.|[^#\\0])*)?(#.*)?$/"; + + for (const reference of body + .findAll({ rule: { kind: "identifier" } }) + .filter((node) => node.text() === name)) { const member = reference.parent(); - if (!member?.is("member_expression") || member.field("object")?.range().start.index !== reference.range().start.index) return null; + + if ( + !member?.is("member_expression") || + member.field("object")?.range().start.index !== reference.range().start.index + ) { + return null; + } + const property = member.field("property")?.text(); - if (!property) return null; - if (CONTEXT_METHODS.has(property)) properties.set(property, CONTEXT_METHODS.get(property)!); - else if (property === "context") { + + if (!property) { + return null; + } + + if (CONTEXT_METHODS.has(property)) { + properties.set(property, CONTEXT_METHODS.get(property)!); + } else if (property === "context") { properties.set("module", "{ context: string }"); edits.push(member.replace(`${name}.module.context`)); - } else if (["resource", "resourcePath", "resourceQuery", "resourceFragment"].includes(property)) { + } else if ( + ["resource", "resourcePath", "resourceQuery", "resourceFragment"].includes(property) + ) { properties.set("resource", "string"); + if (property === "resourcePath") { - edits.push(member.replace(`${name}.resource.replace(${resourcePattern}, "$1").replace(/\\0(.)/g, "$1")`)); + edits.push( + member.replace( + `${name}.resource.replace(${resourcePattern}, "$1").replace(/\\0(.)/g, "$1")`, + ), + ); } else if (property === "resourceQuery") { - edits.push(member.replace(`(${name}.resource.match(${resourcePattern})?.[2]?.replace(/\\0(.)/g, "$1") ?? "")`)); + edits.push( + member.replace( + `(${name}.resource.match(${resourcePattern})?.[2]?.replace(/\\0(.)/g, "$1") ?? "")`, + ), + ); } else if (property === "resourceFragment") { edits.push(member.replace(`(${name}.resource.match(${resourcePattern})?.[3] ?? "")`)); } - } else return null; + } else { + return null; + } } + if (typescript) { - const parameter = namedChildren(value.field("parameters")!).filter((node) => !node.is("comment"))[1]; + const parameter = namedChildren(value.field("parameters")!).filter( + (node) => !node.is("comment"), + )[1]; const type = parameter.field("type"); const text = `: { ${[...properties].map(([key, type]) => `${key}: ${type}`).join("; ")} }`; - if (type) edits.push(type.replace(text)); - else edits.push({ startPos: context.range().end.index, endPos: context.range().end.index, insertedText: text }); + + if (type) { + edits.push(type.replace(text)); + } else { + edits.push({ + startPos: context.range().end.index, + endPos: context.range().end.index, + insertedText: text, + }); + } } + return edits.length ? value.commitEdits(edits) : value.text(); } function returnsString(node: SgNode | null, strings: Set): boolean { - if (!node) return false; - if (node.is("string") || node.is("template_string")) return true; - if (node.is("identifier") || node.is("member_expression")) return strings.has(node.text()); - if (node.is("parenthesized_expression")) return returnsString(namedChildren(node)[0], strings); + if (!node) { + return false; + } + + if (node.is("string") || node.is("template_string")) { + return true; + } + + if (node.is("identifier") || node.is("member_expression")) { + return strings.has(node.text()); + } + + if (node.is("parenthesized_expression")) { + return returnsString(namedChildren(node)[0], strings); + } + if (node.is("binary_expression") && node.field("operator")?.text() === "+") { - return returnsString(node.field("left"), strings) || returnsString(node.field("right"), strings); + return ( + returnsString(node.field("left"), strings) || returnsString(node.field("right"), strings) + ); } + if (node.is("ternary_expression")) { - return returnsString(node.field("consequence"), strings) && returnsString(node.field("alternative"), strings); + return ( + returnsString(node.field("consequence"), strings) && + returnsString(node.field("alternative"), strings) + ); } + if (node.is("call_expression")) { const callee: SgNode = node.field("function"); - return callee?.is("member_expression") - && STRING_METHODS.has(callee.field("property")?.text() ?? "") - && returnsString(callee.field("object"), strings); + + return ( + callee?.is("member_expression") && + STRING_METHODS.has(callee.field("property")?.text() ?? "") && + returnsString(callee.field("object"), strings) + ); } + return false; } @@ -77,37 +156,109 @@ function returnsString(node: SgNode | null, strings: Set): boolean { // to inline string transforms whose result we can establish without executing // user code. Unsupported context APIs and async/unknown returns need adaptation. export function nativeTemplate(value: SgNode, typescript = false): string | null { - if (!["arrow_function", "function_expression"].some((kind) => value.is(kind))) return null; - if (value.children().some((node) => node.is("async"))) return null; - if (value.findAll({ rule: { any: [ - { kind: "await_expression" }, { kind: "this" }, - { kind: "assignment_expression" }, { kind: "augmented_assignment_expression" }, - { kind: "update_expression" }, { pattern: "arguments" }, - ] } }).length) return null; + if (!["arrow_function", "function_expression"].some((kind) => value.is(kind))) { + return null; + } + + if (value.children().some((node) => node.is("async"))) { + return null; + } + + if ( + value.findAll({ + rule: { + any: [ + { kind: "await_expression" }, + { kind: "this" }, + { kind: "assignment_expression" }, + { kind: "augmented_assignment_expression" }, + { kind: "update_expression" }, + { pattern: "arguments" }, + ], + }, + }).length + ) { + return null; + } + const params = callbackParameters(value); - if (!params || params.length > 2) return null; + + if (!params || params.length > 2) { + return null; + } + const name = params[0]; const context = params[1]; const text = context ? adaptTemplateContext(value, context, typescript) : value.text(); - if (text === null) return null; + + if (text === null) { + return null; + } + const strings = new Set(name ? [name.text()] : []); - if (context) for (const property of ["resource", "resourcePath", "resourceQuery", "resourceFragment", "context"]) strings.add(`${context.text()}.${property}`); + + if (context) { + for (const property of [ + "resource", + "resourcePath", + "resourceQuery", + "resourceFragment", + "context", + ]) { + strings.add(`${context.text()}.${property}`); + } + } + const body = value.field("body"); - if (!body) return null; - if (!body.is("statement_block")) return returnsString(body, strings) ? text : null; + + if (!body) { + return null; + } + + if (!body.is("statement_block")) { + return returnsString(body, strings) ? text : null; + } + const statements = namedChildren(body).filter((node) => !node.is("comment")); const last = statements.pop(); - if (!last?.is("return_statement")) return null; + + if (!last?.is("return_statement")) { + return null; + } + for (const statement of statements) { - const expression = statement.is("expression_statement") ? namedChildren(statement)[0] : undefined; - const callee: SgNode | undefined = expression?.is("call_expression") ? expression.field("function") : undefined; - if (context && callee?.is("member_expression") && callee.field("object")?.text() === context.text() && CONTEXT_METHODS.has(callee.field("property")?.text() ?? "")) continue; - if (!statement.is("lexical_declaration") || !statement.text().startsWith("const ")) return null; + const expression = statement.is("expression_statement") + ? namedChildren(statement)[0] + : undefined; + const callee: SgNode | undefined = expression?.is("call_expression") + ? expression.field("function") + : undefined; + + if ( + context && + callee?.is("member_expression") && + callee.field("object")?.text() === context.text() && + CONTEXT_METHODS.has(callee.field("property")?.text() ?? "") + ) { + continue; + } + + if (!statement.is("lexical_declaration") || !statement.text().startsWith("const ")) { + return null; + } + for (const declaration of namedChildren(statement).filter((node) => !node.is("comment"))) { const binding = declaration.field("name"); - if (!binding?.is("identifier") || !returnsString(declaration.field("value"), strings)) return null; + + if (!binding?.is("identifier") || !returnsString(declaration.field("value"), strings)) { + return null; + } + strings.add(binding.text()); } } - return returnsString(namedChildren(last).find((node) => !node.is("comment")) ?? null, strings) ? text : null; + + return returnsString(namedChildren(last).find((node) => !node.is("comment")) ?? null, strings) + ? text + : null; } diff --git a/codemods/html-plugins-to-native-html/src/migration-state.ts b/codemods/html-plugins-to-native-html/src/migration-state.ts index b48a832..fd5364b 100644 --- a/codemods/html-plugins-to-native-html/src/migration-state.ts +++ b/codemods/html-plugins-to-native-html/src/migration-state.ts @@ -21,24 +21,38 @@ export const HTML_PACKAGES = [ // Windows filenames. Resolving these paths never opens or writes a file. export function normalizeFilename(filename: string): string { const segments: string[] = []; - for (const part of filename.replace(/^\\\\\?\\/, "").replace(/\\/g, "/").split("/")) { - if (part === ".") continue; - if (part === ".." && segments.length && segments.at(-1) !== "..") segments.pop(); - else segments.push(part); + + for (const part of filename + .replace(/^\\\\\?\\/, "") + .replace(/\\/g, "/") + .split("/")) { + if (part === ".") { + continue; + } + + if (part === ".." && segments.length && segments.at(-1) !== "..") { + segments.pop(); + } else { + segments.push(part); + } } + return segments.join("/"); } export function directoryOf(filename: string): string { const normalized = normalizeFilename(filename); const slash = normalized.lastIndexOf("/"); + return slash < 0 ? "." : normalized.slice(0, slash); } export function recordSource(filename: string, source: string): void { const release = acquireLock(SOURCES_KEY); + try { const sources = getState>(SOURCES_KEY) ?? {}; + sources[normalizeFilename(filename)] = HTML_PACKAGES.filter((name) => source.includes(name)); setState(SOURCES_KEY, sources); } finally { @@ -48,43 +62,74 @@ export function recordSource(filename: string, source: string): void { export function remainingPackages(manifestFilename: string): Set | null { const sources = getState>(SOURCES_KEY); + // Running the JSON transform alone provides no evidence for safe removal. - if (!sources) return null; + if (!sources) { + return null; + } + const directory = directoryOf(manifestFilename); const remaining = new Set(); + for (const [filename, packages] of Object.entries(sources)) { - if (directory !== "." && !filename.startsWith(`${directory}/`)) continue; - for (const name of packages) remaining.add(name); + if (directory !== "." && !filename.startsWith(`${directory}/`)) { + continue; + } + + for (const name of packages) { + remaining.add(name); + } } + return remaining; } -export function queueTemplate(configFilename: string, template: string, entry: string, chunkName: string): string | null { - if (!template.startsWith(".") || !entry.startsWith(".")) return null; +export function queueTemplate( + configFilename: string, + template: string, + entry: string, + chunkName: string, +): string | null { + if (!template.startsWith(".") || !entry.startsWith(".")) { + return null; + } + const directory = directoryOf(configFilename); const templateFilename = normalizeFilename(`${directory}/${template}`); const entryFilename = normalizeFilename(`${directory}/${entry}`); const from = directoryOf(templateFilename).split("/"); const to = entryFilename.split("/"); let common = 0; - while (common < from.length && common < to.length - 1 && from[common] === to[common]) common += 1; + + while (common < from.length && common < to.length - 1 && from[common] === to[common]) { + common += 1; + } + const up = from.length - common; const script = `${up ? "../".repeat(up) : "./"}${to.slice(common).join("/")}`; const release = acquireLock(TEMPLATES_KEY); + try { const templates = getState>(TEMPLATES_KEY) ?? {}; const scripts = templates[templateFilename] ?? []; + if (!scripts.some((item) => item.src === script && item.chunkName === chunkName)) { scripts.push({ src: script, chunkName }); } - templates[templateFilename] = scripts.sort((a, b) => a.src.localeCompare(b.src) || (a.chunkName ?? "").localeCompare(b.chunkName ?? "")); + + templates[templateFilename] = scripts.sort( + (a, b) => a.src.localeCompare(b.src) || (a.chunkName ?? "").localeCompare(b.chunkName ?? ""), + ); setState(TEMPLATES_KEY, templates); } finally { release(); } + return script; } export function scriptsForTemplate(filename: string): TemplateScript[] { - return getState>(TEMPLATES_KEY)?.[normalizeFilename(filename)] ?? []; + return ( + getState>(TEMPLATES_KEY)?.[normalizeFilename(filename)] ?? [] + ); } diff --git a/codemods/html-plugins-to-native-html/src/minification.ts b/codemods/html-plugins-to-native-html/src/minification.ts index b251412..df97140 100644 --- a/codemods/html-plugins-to-native-html/src/minification.ts +++ b/codemods/html-plugins-to-native-html/src/minification.ts @@ -20,42 +20,71 @@ const BOOLEAN_OPTIONS = new Set([ // html-minifier-terser and webpack do not accept the same option object. // Only translate known literal values; retain a review note for everything else. -export function collectMinification(value: SgNode | null | undefined, name: string): Minification { +export function collectMinification( + value: SgNode | null | undefined, + name: string, +): Minification { const result: Minification = { html: null, lost: [] }; + if (!value || value.is("true") || (value.is("string") && unquote(value.text()) === "auto")) { return result; } - if (value.is("false")) return { html: "false", lost: [] }; + + if (value.is("false")) { + return { html: "false", lost: [] }; + } + if (!value.is("object")) { result.lost.push(`${name} (configure optimization.minimizeOptions.html)`); + return result; } + const props = new Map(); const removeComments = findPair(value, "removeComments")?.field("value"); const ignoredComments = findPair(value, "ignoreCustomComments")?.field("value"); + for (const child of namedChildren(value)) { - if (child.is("comment")) continue; + if (child.is("comment")) { + continue; + } + const key = keyName(child); const option = child.field("value"); + if (key === "ignoreCustomComments" && option) { - if (removeComments?.is("false")) continue; - const patterns = option.is("array") ? namedChildren(option).filter((node) => !node.is("comment")) : null; + if (removeComments?.is("false")) { + continue; + } + + const patterns = option.is("array") + ? namedChildren(option).filter((node) => !node.is("comment")) + : null; + if (!patterns || patterns.some((node) => !node.is("regex"))) { result.lost.push(`${name}.${key} (configure optimization.minimizeOptions.html.comments)`); } + continue; } + if (!key || !option || (!option.is("true") && !option.is("false"))) { result.lost.push(`${name}.${key ?? "options"} (configure optimization.minimizeOptions.html)`); + continue; } + const enabled = option.is("true"); + if (BOOLEAN_OPTIONS.has(key)) { props.set(key, option.text()); } else if (key === "removeComments") { props.set("comments", enabled ? "false" : "true"); + // Keeping a comment in the HTML also means not moving it to LICENSE.txt. - if (!enabled) props.set("extractComments", "false"); + if (!enabled) { + props.set("extractComments", "false"); + } } else if (key === "removeAttributeQuotes") { props.set("normalizeAttributeQuotes", option.text()); } else if (key === "removeOptionalTags") { @@ -67,20 +96,38 @@ export function collectMinification(value: SgNode | null | undefined, name: result.lost.push(`${name}.${key} (configure optimization.minimizeOptions.html)`); } } - if (props.get("collapseWhitespace") === "true" && findPair(value, "conservativeCollapse")?.field("value")?.is("false")) { + + if ( + props.get("collapseWhitespace") === "true" && + findPair(value, "conservativeCollapse")?.field("value")?.is("false") + ) { props.set("collapseWhitespace", '"all"'); } + if (ignoredComments?.is("array") && removeComments?.is("true")) { const patterns = namedChildren(ignoredComments).filter((node) => !node.is("comment")); + if (patterns.every((node) => node.is("regex"))) { - const parameter = /\.[cm]?tsx?$/.test(value.getRoot().filename()) ? "comment: string" : "comment"; - props.set("comments", patterns.length ? `(${parameter}) => [${patterns.map((node) => node.text()).join(", ")}].some((pattern) => pattern.test(comment))` : "false"); + const parameter = /\.[cm]?tsx?$/.test(value.getRoot().filename()) + ? "comment: string" + : "comment"; + + props.set( + "comments", + patterns.length + ? `(${parameter}) => [${patterns + .map((node) => node.text()) + .join(", ")}].some((pattern) => pattern.test(comment))` + : "false", + ); props.set("extractComments", "false"); } } + if (props.size) { result.html = `{ ${[...props].map(([key, text]) => `${key}: ${text}`).join(", ")} }`; } + return result; } @@ -92,27 +139,43 @@ export function planMinification( requests: Minification[], notes: string[], ): string | null { - if (!requests.length) return null; + if (!requests.length) { + return null; + } + const values = new Set(requests.map((request) => request.html ?? "{}")); let html = requests.find((request) => request.html !== null)?.html ?? null; + if (values.size > 1) { - notes.push("Review: HTML minification differs between pages or loaders; configure optimization.minimizeOptions.html for the whole compilation"); + notes.push( + "Review: HTML minification differs between pages or loaders; configure optimization.minimizeOptions.html for the whole compilation", + ); html = null; } + const optimization = findPair(config, "optimization")?.field("value"); + if (!optimization) { return html === null ? null : `optimization: { minimizeOptions: { html: ${html} } }`; } + if (!optimization.is("object")) { - notes.push("Review: configure the existing optimization value to use webpack's native HTML minimizer (minimizeOptions.html and minimizer: [\"...\"])"); + notes.push( + 'Review: configure the existing optimization value to use webpack\'s native HTML minimizer (minimizeOptions.html and minimizer: ["..."])', + ); + return null; } + const minimizer = findPair(optimization, "minimizer")?.field("value"); + if (minimizer?.is("array") && html !== "false") { const elements = namedChildren(minimizer).filter((element) => !element.is("comment")); + if (!elements.some((element) => element.is("string") && unquote(element.text()) === "...")) { // Append after custom minimizers, so assets they marked minimized stay so. const end = elements.at(-1)?.range().end.index ?? minimizer.range().start.index + 1; + editor.addEdit({ startPos: end, endPos: end, @@ -122,21 +185,31 @@ export function planMinification( } else if (minimizer && !minimizer.is("array")) { notes.push('Review: keep "..." in optimization.minimizer to enable native HTML minification'); } - if (html === null) return null; + + if (html === null) { + return null; + } + const minimize = findPair(optimization, "minimize")?.field("value"); const separateOptions = findPair(optimization, "minimizeOptions")?.field("value"); // The shorthand takes precedence during webpack normalization. const options = minimize?.is("object") ? minimize : separateOptions; + if (options?.is("object")) { if (!findPair(options, "html")) { editor.insertIntoObject(options, () => [`html: ${html}`]); } else { - notes.push("Review: existing native HTML minification options take precedence over migrated minify/minimize options"); + notes.push( + "Review: existing native HTML minification options take precedence over migrated minify/minimize options", + ); } } else if (options) { - notes.push("Review: merge the HTML minification options into the existing optimization.minimizeOptions value"); + notes.push( + "Review: merge the HTML minification options into the existing optimization.minimizeOptions value", + ); } else { editor.insertIntoObject(optimization, () => [`minimizeOptions: { html: ${html} }`]); } + return null; } diff --git a/codemods/html-plugins-to-native-html/src/plugins/bindings.ts b/codemods/html-plugins-to-native-html/src/plugins/bindings.ts index 8f50684..9378143 100644 --- a/codemods/html-plugins-to-native-html/src/plugins/bindings.ts +++ b/codemods/html-plugins-to-native-html/src/plugins/bindings.ts @@ -5,11 +5,19 @@ import { type ModuleBinding, namedChildren, unquote } from "@webpack/codemod-uti export const PLUGIN_MODULE = "html-webpack-plugin"; export function isRequireOf(node: SgNode, moduleName: string): boolean { - if (!node.is("call_expression")) return false; + if (!node.is("call_expression")) { + return false; + } + const callee = node.field("function"); - if (!callee || !callee.is("identifier") || callee.text() !== "require") return false; + + if (!callee || !callee.is("identifier") || callee.text() !== "require") { + return false; + } + const argumentsNode = node.field("arguments"); const args = argumentsNode ? namedChildren(argumentsNode) : []; + return args.length === 1 && args[0].is("string") && unquote(args[0].text()) === moduleName; } @@ -19,34 +27,67 @@ export function isRequireOf(node: SgNode, moduleName: string): boolean { // statement can never drop another binding. export function collectNamedBindings(rootNode: SgNode, moduleName: string): ModuleBinding[] { const bindings: ModuleBinding[] = []; + for (const statement of rootNode.findAll({ rule: { kind: "import_statement" } })) { const source = statement.field("source"); - if (!source || unquote(source.text()) !== moduleName) continue; + + if (!source || unquote(source.text()) !== moduleName) { + continue; + } + const specifiers = statement.findAll({ rule: { kind: "import_specifier" } }); - if (specifiers.length !== 1) continue; + + if (specifiers.length !== 1) { + continue; + } + const local = specifiers[0].field("alias") ?? specifiers[0].field("name"); - if (local) bindings.push({ name: local.text(), statement }); + + if (local) { + bindings.push({ name: local.text(), statement }); + } } + for (const kind of ["lexical_declaration", "variable_declaration"] as const) { for (const statement of rootNode.findAll({ rule: { kind } })) { - const declarators = namedChildren(statement).filter( - (child) => child.is("variable_declarator"), + const declarators = namedChildren(statement).filter((child) => + child.is("variable_declarator"), ); - if (declarators.length !== 1) continue; + + if (declarators.length !== 1) { + continue; + } + const pattern = declarators[0].field("name"); const valueNode = declarators[0].field("value"); - if (!pattern || !pattern.is("object_pattern")) continue; - if (!valueNode || !isRequireOf(valueNode, moduleName)) continue; + + if (!pattern || !pattern.is("object_pattern")) { + continue; + } + + if (!valueNode || !isRequireOf(valueNode, moduleName)) { + continue; + } + const properties = namedChildren(pattern); - if (properties.length !== 1) continue; + + if (properties.length !== 1) { + continue; + } + const property = properties[0]; + if (property.is("shorthand_property_identifier_pattern")) { bindings.push({ name: property.text(), statement }); } else if (property.is("pair_pattern")) { const local = property.field("value"); - if (local) bindings.push({ name: local.text(), statement }); + + if (local) { + bindings.push({ name: local.text(), statement }); + } } } } + return bindings; } diff --git a/codemods/html-plugins-to-native-html/src/plugins/companions.ts b/codemods/html-plugins-to-native-html/src/plugins/companions.ts index 3644e4c..ef8da72 100644 --- a/codemods/html-plugins-to-native-html/src/plugins/companions.ts +++ b/codemods/html-plugins-to-native-html/src/plugins/companions.ts @@ -1,6 +1,16 @@ import type Js from "@codemod.com/jssg-types/langs/javascript"; import type { SgNode } from "@codemod.com/jssg-types/main"; -import { ConfigEditor, type ModuleBinding, collectModuleBindings, findPair, guardBranchesOf, keyName, namedChildren, pairsOf, unwrapFilterCall } from "@webpack/codemod-utils"; +import { + ConfigEditor, + type ModuleBinding, + collectModuleBindings, + findPair, + guardBranchesOf, + keyName, + namedChildren, + pairsOf, + unwrapFilterCall, +} from "@webpack/codemod-utils"; import type { ConfigPlan, HtmlProp } from "../config-plan"; import type { PageMode } from "./options"; import { collectNamedBindings, isRequireOf } from "./bindings"; @@ -26,8 +36,12 @@ export class CompanionPlugins { for (const moduleName of SIBLING_PLUGIN_MODULES) { const named = collectNamedBindings(editor.rootNode, moduleName); + for (const binding of [...collectModuleBindings(editor.rootNode, moduleName), ...named]) { - if (this.siblingNameToModule.has(binding.name)) continue; + if (this.siblingNameToModule.has(binding.name)) { + continue; + } + this.bindings.push(binding); this.siblingNameToModule.set(binding.name, moduleName); } @@ -36,56 +50,97 @@ export class CompanionPlugins { findUnsupportedConfigs(pluginsPairs: SgNode[]): Set { const blockedConfigs = new Set(); + // Dynamic favicon inputs need manual migration. Keep their HTML plugin // too, because the companion relies on its hooks. for (const pair of pluginsPairs) { const value = unwrapFilterCall(pair.field("value") ?? pair); - if (!value.is("array")) continue; - if (namedChildren(value).some((element) => { - const sibling = this.siblingInstantiationOf(element); - return sibling?.module === FAVICONS_MODULE && !this.faviconLogo(sibling.instantiation); - })) { + + if (!value.is("array")) { + continue; + } + + if ( + namedChildren(value).some((element) => { + const sibling = this.siblingInstantiationOf(element); + + return sibling?.module === FAVICONS_MODULE && !this.faviconLogo(sibling.instantiation); + }) + ) { const config = pair.parent(); - if (config) blockedConfigs.add(config.range().start.index); + + if (config) { + blockedConfigs.add(config.range().start.index); + } } } + return blockedConfigs; } private faviconLogo(instantiation: SgNode): SgNode | null { const argsNode = instantiation.field("arguments"); const args = argsNode ? namedChildren(argsNode).filter((node) => !node.is("comment")) : []; - if (args.length !== 1) return null; + + if (args.length !== 1) { + return null; + } + const option = args[0]; - if (option.is("string")) return option; - if (!option.is("object") || namedChildren(option).some((node) => !node.is("pair") && !node.is("comment"))) return null; + + if (option.is("string")) { + return option; + } + + if ( + !option.is("object") || + namedChildren(option).some((node) => !node.is("pair") && !node.is("comment")) + ) { + return null; + } + const logo = findPair(option, "logo")?.field("value"); + return logo?.is("string") ? logo : null; } - 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; + + if (found) { + return found; + } } + return null; } - if (!element.is("new_expression")) return null; + + if (!element.is("new_expression")) { + return null; + } + const constructorNode = element.field("constructor"); - if (!constructorNode) return null; + + if (!constructorNode) { + return null; + } + const inner = constructorNode.is("parenthesized_expression") - ? namedChildren(constructorNode)[0] : constructorNode; - const module = this.siblingNameToModule.get(constructorNode.text()) - ?? SIBLING_PLUGIN_MODULES.find((name) => inner && isRequireOf(inner, name)); + ? namedChildren(constructorNode)[0] + : constructorNode; + const module = + this.siblingNameToModule.get(constructorNode.text()) ?? + SIBLING_PLUGIN_MODULES.find((name) => inner && isRequireOf(inner, name)); + return 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. @@ -97,10 +152,18 @@ export class CompanionPlugins { ): 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; + if (hwpStarts.has(element.range().start.index)) { + continue; + } + const sibling = this.siblingInstantiationOf(element); - if (!sibling) continue; + + if (!sibling) { + continue; + } + if (this.migrateSibling(sibling.module, sibling.instantiation, mode, plan)) { removed.push(element); } else { @@ -109,10 +172,10 @@ export class CompanionPlugins { ); } } + return removed; } - private migrateSibling( module: string, instantiation: SgNode, @@ -123,15 +186,24 @@ export class CompanionPlugins { 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.is("object")) { + + if (!policy) { + props.push({ name: "csp", valueText: "true" }); + } else if (policy.is("object")) { props.push({ name: "csp", valueText: `{ policy: ${policy.text()} }` }); - } else return false; + } else { + return false; + } + const options = args[1]; + if (options && options.is("object")) { - for (const optionPair of pairsOf(options)) lost.push(keyName(optionPair) ?? "options"); + for (const optionPair of pairsOf(options)) { + lost.push(keyName(optionPair) ?? "options"); + } } else if (options) { lost.push("options"); } @@ -139,29 +211,55 @@ export class CompanionPlugins { let integrity = "true"; let enabled = true; const options = args[0]; - if (options && !options.is("object")) return false; + + if (options && !options.is("object")) { + return false; + } + if (options) { for (const optionPair of pairsOf(options)) { const name = keyName(optionPair); const value = optionPair.field("value"); - if (name === "hashFuncNames" && value?.is("array")) integrity = value.text(); - else if (name === "enabled" && value?.is("false")) enabled = false; - else if (name === "enabled") continue; - else lost.push(name ?? "options"); + + if (name === "hashFuncNames" && value?.is("array")) { + integrity = value.text(); + } else if (name === "enabled" && value?.is("false")) { + enabled = false; + } else if (name === "enabled") { + continue; + } else { + lost.push(name ?? "options"); + } } } - if (enabled) props.push({ name: "integrity", valueText: integrity }); + + if (enabled) { + props.push({ name: "integrity", valueText: integrity }); + } } else if (module === FAVICONS_MODULE) { const logo = this.faviconLogo(instantiation); - if (!logo) return false; + + if (!logo) { + return false; + } + props.push({ name: "favicon", valueText: logo.text() }); + if (args[0]?.is("object")) { for (const pair of pairsOf(args[0])) { - if (keyName(pair) !== "logo") lost.push(keyName(pair) ?? "options"); + if (keyName(pair) !== "logo") { + lost.push(keyName(pair) ?? "options"); + } } } - plan.commentLines.push("Review: native favicon emits the source icon only; generate the required icon sizes/formats and web app manifest manually, then configure their links in output.html or the HTML template"); - } else return false; + + plan.commentLines.push( + "Review: native favicon emits the source icon only; generate the required icon sizes/formats and web app manifest manually, then configure their links in output.html or the HTML template", + ); + } else { + return false; + } + for (const prop of props) { if (mode === "multi") { // A global `output.html` would generate a page for every entry. @@ -173,11 +271,15 @@ export class CompanionPlugins { plan.htmlProps.push(prop); } } + if (lost.length) { plan.commentLines.push( - `Removed ${module === FAVICONS_MODULE ? "favicon generator" : module} options without a native HTML equivalent: ${[...new Set(lost)].join(", ")}`, + `Removed ${ + module === FAVICONS_MODULE ? "favicon generator" : module + } options without a native HTML equivalent: ${[...new Set(lost)].join(", ")}`, ); } + return true; } } diff --git a/codemods/html-plugins-to-native-html/src/plugins/entries.ts b/codemods/html-plugins-to-native-html/src/plugins/entries.ts index 0bff59c..abd8c14 100644 --- a/codemods/html-plugins-to-native-html/src/plugins/entries.ts +++ b/codemods/html-plugins-to-native-html/src/plugins/entries.ts @@ -1,6 +1,14 @@ import type Js from "@codemod.com/jssg-types/langs/javascript"; import type { SgNode } from "@codemod.com/jssg-types/main"; -import { ConfigEditor, findPair, keyName, lineIndent, namedChildren, pairsOf, unquote } from "@webpack/codemod-utils"; +import { + ConfigEditor, + findPair, + keyName, + lineIndent, + namedChildren, + pairsOf, + unquote, +} from "@webpack/codemod-utils"; import type { InstanceFindings } from "./options"; import { queueTemplate } from "../migration-state"; import { templateScriptTag } from "../templates"; @@ -9,6 +17,7 @@ import { templateScriptTag } from "../templates"; // single page referencing every chunk — worth a note on multi-entry configs. export function noteMultiPageEntry(configObject: SgNode, findings: InstanceFindings): void { const entryValue = findPair(configObject, "entry")?.field("value"); + if (entryValue && entryValue.is("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", @@ -19,33 +28,62 @@ export function noteMultiPageEntry(configObject: SgNode, findings: InstanceF // 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 ? replaceableEntryValue(entryValue) : null; + if (!replaceable) { findings.lost.push("template (make the template an .html entry that loads your JS)"); + return; } + const chunkName = entryValue?.is("object") ? keyName(pairsOf(entryValue)[0]) ?? "main" : "main"; - const injected = queueTemplate(configFileName, unquote(templateValue), unquote(replaceable.text()), chunkName); + const injected = queueTemplate( + configFileName, + unquote(templateValue), + unquote(replaceable.text()), + chunkName, + ); const comment = injected - ? `The template is now the entry: it must load the previous entry via ${templateScriptTag({ src: injected, chunkName })} (added by the HTML workflow step when the template is included)` + ? `The template is now the entry: it must load the previous entry via ${templateScriptTag({ + src: injected, + chunkName, + })} (added by the HTML workflow step when the template is included)` : `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(editor.source, entryPair.range().start.index); + editor.addEdit({ startPos: entryPair.range().start.index, endPos: entryPair.range().start.index, @@ -53,26 +91,47 @@ export function migrateEntry(editor: ConfigEditor, configFileName: string, confi }); editor.replace(replaceable, findings.templateValue as string); } else { - editor.replace(replaceable, `/* ${comment.replace(/\*\//g, "* /")} */ ${findings.templateValue}`); + editor.replace( + replaceable, + `/* ${comment.replace(/\*\//g, "* /")} */ ${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. export function replaceableEntryValue(entryValue: SgNode): SgNode | null { - if (entryValue.is("string")) return entryValue; + if (entryValue.is("string")) { + return entryValue; + } + if (entryValue.is("array")) { const elements = namedChildren(entryValue); + return elements.length === 1 && elements[0].is("string") ? elements[0] : null; } + if (entryValue.is("object")) { const entryPairs = pairsOf(entryValue); - if (entryPairs.length !== 1) return null; - if (namedChildren(entryValue).some((child) => !child.is("pair") && !child.is("comment"))) return null; + + if (entryPairs.length !== 1) { + return null; + } + + if (namedChildren(entryValue).some((child) => !child.is("pair") && !child.is("comment"))) { + return null; + } + const key = entryPairs[0].field("key"); - if (!key || key.is("computed_property_name") || key.text().includes("\\")) return null; + + if (!key || key.is("computed_property_name") || key.text().includes("\\")) { + return null; + } + const inner = entryPairs[0].field("value"); + return inner && inner.is("string") ? inner : null; } + return null; } diff --git a/codemods/html-plugins-to-native-html/src/plugins/hooks.ts b/codemods/html-plugins-to-native-html/src/plugins/hooks.ts index b9a7431..caebd0b 100644 --- a/codemods/html-plugins-to-native-html/src/plugins/hooks.ts +++ b/codemods/html-plugins-to-native-html/src/plugins/hooks.ts @@ -1,6 +1,13 @@ import type Js from "@codemod.com/jssg-types/langs/javascript"; import type { SgNode } from "@codemod.com/jssg-types/main"; -import { ConfigEditor, type ModuleBinding, addImport, collectModuleBindings, lineIndent, namedChildren } from "@webpack/codemod-utils"; +import { + ConfigEditor, + type ModuleBinding, + addImport, + collectModuleBindings, + lineIndent, + namedChildren, +} from "@webpack/codemod-utils"; // Plugin hooks renamed to the native `HtmlModulesPlugin.getCompilationHooks` // stage covering the same moment; arguments differ, hence the review comments. @@ -16,7 +23,10 @@ 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"], + [ + "transformHtml", + "transformHtml receives (html, { outputName }) and must return the html string", + ], ["htmlEmitted", "htmlEmitted receives ({ outputName }); nothing to return"], ]); @@ -38,55 +48,85 @@ export class HtmlPluginHooks { this.pluginNames = new Set(pluginBindings.map((binding) => binding.name)); } - usesUnmappableHooks(): boolean { - if (!this.pluginNames.size || !this.pluginHookReceivers().length) return false; + if (!this.pluginNames.size || !this.pluginHookReceivers().length) { + return false; + } + for (const node of this.pluginHookProperties()) { - if (UNMAPPABLE_HOOKS.has(node.text())) return true; + if (UNMAPPABLE_HOOKS.has(node.text())) { + return true; + } } + return false; } - // `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; + + if (property !== "getHooks" && property !== "getCompilationHooks") { + continue; + } + const objectPart = node.field("object"); - if (objectPart && this.pluginNames.has(objectPart.text())) receivers.push(node); + + if (objectPart && this.pluginNames.has(objectPart.text())) { + receivers.push(node); + } } + return receivers; } - // Only keys read from this plugin's hook object belong to its API. Other // plugins and ordinary application properties may use exactly the same names. private pluginHookProperties(): SgNode[] { const properties: SgNode[] = []; + for (const receiver of this.pluginHookReceivers()) { const call = receiver.parent(); const access = call?.parent(); + if (access?.is("member_expression")) { const property = access.field("property"); - if (property) properties.push(property); + + if (property) { + properties.push(property); + } } else if (access?.is("variable_declarator")) { const name = access.field("name"); + if (name?.is("object_pattern")) { for (const member of namedChildren(name)) { const key = member.is("pair_pattern") ? member.field("key") : member; - if (key) properties.push(key); + + if (key) { + properties.push(key); + } } } else if (name?.is("identifier")) { - for (const member of this.editor.rootNode.findAll({ rule: { kind: "member_expression" } })) { - if (member.field("object")?.text() !== name.text()) continue; + for (const member of this.editor.rootNode.findAll({ + rule: { kind: "member_expression" }, + })) { + if (member.field("object")?.text() !== name.text()) { + continue; + } + const property = member.field("property"); - if (property) properties.push(property); + + if (property) { + properties.push(property); + } } } } } + return properties; } @@ -95,33 +135,62 @@ export class HtmlPluginHooks { // file's plugin setup was actually migrated and no instance survives. migrateHooks(): void { const receivers = this.pluginHookReceivers(); - if (!receivers.length) return; + + if (!receivers.length) { + return; + } + const nativeReceiver = this.nativeHooksReceiver(); + for (const node of receivers) { this.editor.replace(node, `${nativeReceiver}.getCompilationHooks`); } + // One review comment per statement holding a renamed tap. const commentedStatements = new Set(); + for (const property of this.pluginHookProperties()) { const renamed = HOOK_RENAMES.get(property.text()); - if (!renamed) continue; - this.editor.replace(property, property.is("shorthand_property_identifier_pattern") - ? `${renamed}: ${property.text()}` : renamed); + + if (!renamed) { + continue; + } + + this.editor.replace( + property, + property.is("shorthand_property_identifier_pattern") + ? `${renamed}: ${property.text()}` + : renamed, + ); + const statement = this.statementOf(property); const review = HOOK_REVIEW_COMMENTS.get(renamed); - if (!statement || !review) continue; + + if (!statement || !review) { + continue; + } + const key = `${statement.range().start.index}:${renamed}`; - if (commentedStatements.has(key)) continue; + + if (commentedStatements.has(key)) { + continue; + } + commentedStatements.add(key); - if (receivers.some((receiver) => receiver.range().start.index === statement.range().start.index)) { + + if ( + receivers.some((receiver) => receiver.range().start.index === statement.range().start.index) + ) { // An insertion at the same position as the receiver replacement would // be swallowed by commitEdits. A trailing comment avoids the overlap. this.editor.insertAfter(statement, ` /* Review: ${review} */`); + continue; } - const lineStart = - this.editor.source.lastIndexOf("\n", statement.range().start.index - 1) + 1; + + 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, @@ -130,31 +199,42 @@ export class HtmlPluginHooks { } } - // 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; + + if (!parent) { + return null; + } + if (parent.is("statement_block") || parent.is("program") || parent.is("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`; + + if (webpackBindings.length) { + return `${webpackBindings[0].name}.html.HtmlModulesPlugin`; + } + const binding = this.pluginBindings[0]; + if (binding) { const isEsm = binding.statement.is("import_statement"); + this.editor.replace( binding.statement, isEsm ? 'import { html } from "webpack";' : 'const { html } = require("webpack");', @@ -167,8 +247,12 @@ export class HtmlPluginHooks { from: "webpack", moduleType: "cjs", }); - if (edit) this.editor.addEdit(edit); + + if (edit) { + this.editor.addEdit(edit); + } } + return "html.HtmlModulesPlugin"; } } diff --git a/codemods/html-plugins-to-native-html/src/plugins/migration.ts b/codemods/html-plugins-to-native-html/src/plugins/migration.ts index 06d31cf..c5330eb 100644 --- a/codemods/html-plugins-to-native-html/src/plugins/migration.ts +++ b/codemods/html-plugins-to-native-html/src/plugins/migration.ts @@ -1,6 +1,14 @@ import type Js from "@codemod.com/jssg-types/langs/javascript"; import type { SgNode } from "@codemod.com/jssg-types/main"; -import { ConfigEditor, type ModuleBinding, findPair, guardBranchesOf, namedChildren, unquote, unwrapFilterCall } from "@webpack/codemod-utils"; +import { + ConfigEditor, + type ModuleBinding, + findPair, + guardBranchesOf, + namedChildren, + unquote, + unwrapFilterCall, +} from "@webpack/codemod-utils"; import { ConfigPlans } from "../config-plan"; import type { Minification } from "../minification"; import { CompanionPlugins } from "./companions"; @@ -41,90 +49,151 @@ export class HtmlPluginMigration { // 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; + + if (found) { + return found; + } } + + return null; + } + + if (!element.is("new_expression")) { return null; } - if (!element.is("new_expression")) return null; + const constructorNode = element.field("constructor"); - if (!constructorNode) return null; - if (this.pluginNames.has(constructorNode.text())) return element; + + if (!constructorNode) { + return null; + } + + if (this.pluginNames.has(constructorNode.text())) { + return element; + } + // `new (require("html-webpack-plugin"))(...)` without a binding. - const inner = - constructorNode.is("parenthesized_expression") - ? (namedChildren(constructorNode)[0] ?? constructorNode) - : constructorNode; + const inner = constructorNode.is("parenthesized_expression") + ? namedChildren(constructorNode)[0] ?? constructorNode + : constructorNode; + return isRequireOf(inner, PLUGIN_MODULE) ? element : null; } - transformPluginsPair(pluginsPair: SgNode): void { const configObject = pluginsPair.parent(); - if (!configObject || !configObject.is("object")) return; - if (this.blockedConfigs.has(configObject.range().start.index)) return; + + if (!configObject || !configObject.is("object")) { + return; + } + + if (this.blockedConfigs.has(configObject.range().start.index)) { + return; + } + const value = unwrapFilterCall(pluginsPair.field("value") ?? pluginsPair); - if (!value.is("array")) return; + + if (!value.is("array")) { + return; + } + const elements = namedChildren(value); const instances: { element: SgNode; instantiation: SgNode }[] = []; + for (const element of elements) { const instantiation = this.pluginInstantiationOf(element); - if (instantiation) instances.push({ element, instantiation }); + + if (instantiation) { + instances.push({ element, instantiation }); + } + } + + if (!instances.length) { + return; } - if (!instances.length) return; + // A computed destination cannot safely receive the native settings. - if (["output", "experiments"].some((key) => { - const destination = findPair(configObject, key)?.field("value"); - return destination && !destination.is("object"); - })) { + if ( + ["output", "experiments"].some((key) => { + const destination = findPair(configObject, key)?.field("value"); + + return destination && !destination.is("object"); + }) + ) { this.retained = true; + 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.is("object") || - namedChildren(optionsObject).some((child) => !child.is("pair") && !child.is("comment")) - )) { + + if ( + optionsObject && + (!optionsObject.is("object") || + namedChildren(optionsObject).some((child) => !child.is("pair") && !child.is("comment"))) + ) { this.retained = true; + return; } + const template = optionsObject && findPair(optionsObject, "template")?.field("value"); const entry = findPair(configObject, "entry")?.field("value"); + if (template && (!template.is("string") || (entry && !replaceableEntryValue(entry)))) { this.retained = 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")?.is("array")); + (firstOptions !== undefined && findPair(firstOptions, "chunks")?.field("value")?.is("array")); + if (chunkRestricted) { this.migrateMultiPage(configObject, pluginsPair, elements, instances, optionObjects); + return; } + const findings = collectFindings(firstOptions); const plan = this.plans.planFor(configObject); const mode: PageMode = findings.templateValue !== null ? "template" : "single"; - if (findings.templateValue !== null) migrateEntry(this.editor, this.configFileName, configObject, findings); - else noteMultiPageEntry(configObject, findings); + + if (findings.templateValue !== null) { + migrateEntry(this.editor, this.configFileName, configObject, findings); + } else { + noteMultiPageEntry(configObject, findings); + } + this.mergeIntoPlan(configObject, findings); - const siblingRemoved = this.companions.processSiblings(elements, [instances[0].element], mode, plan); + + const siblingRemoved = this.companions.processSiblings( + elements, + [instances[0].element], + mode, + plan, + ); + this.removeInstances(pluginsPair, elements, [instances[0].element, ...siblingRemoved]); this.migrated = true; } - private removeInstances( pluginsPair: SgNode, elements: SgNode[], @@ -133,11 +202,12 @@ export class HtmlPluginMigration { if (removedElements.length === elements.length) { this.editor.markForRemoval(pluginsPair); } else { - for (const element of removedElements) this.editor.markForRemoval(element); + 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. @@ -149,68 +219,94 @@ export class HtmlPluginMigration { optionObjects: (SgNode | undefined)[], ): void { const entryValue = findPair(configObject, "entry")?.field("value"); + if (!entryValue || !entryValue.is("object")) { this.retained = true; + return; } + const plan = this.plans.planFor(configObject); const pages: { entryDescriptor: SgNode; htmlValue: string }[] = []; const minification: Minification[] = []; const lost: string[] = []; let moduleScripts = false; + for (const options of optionObjects) { // Each page needs exactly one owning entry in `chunks`. const chunksValue = options ? findPair(options, "chunks")?.field("value") : undefined; const chunkNames = chunksValue?.is("array") ? namedChildren(chunksValue) : []; + if (!options || chunkNames.length !== 1 || !chunkNames[0].is("string")) { this.retained = true; + return; } + const chunkName = unquote(chunkNames[0].text()); const entryDescriptor = findPair(entryValue, chunkName)?.field("value"); + if (!entryDescriptor) { this.retained = true; + return; } + if (findPair(options, "template") || findPair(options, "templateContent")) { this.retained = true; + return; } + const findings = collectFindings(options, MULTI_PAGE_SKIPPED_OPTIONS); + minification.push(findings.minification); + // The page filename must fit the shared `htmlFilename: "[name].html"`. const filenameValue = findPair(options, "filename")?.field("value"); + if (filenameValue) { const filename = filenameValue.is("string") ? unquote(filenameValue.text()) : null; + if (filename !== `${chunkName}.html` && filename !== "[name].html") { lost.push(`filename "${filename ?? "?"}" (output.htmlFilename is "[name].html")`); } } + lost.push(...findings.lost); moduleScripts ||= findings.scriptLoadingModule; + const htmlValue = findings.htmlProps.length ? `{ ${findings.htmlProps.map((prop) => `${prop.name}: ${prop.valueText}`).join(", ")} }` : "true"; + pages.push({ entryDescriptor, htmlValue }); } + // Per-page minification cannot be represented by a compilation-wide option. if (new Set(minification.map((request) => request.html ?? "{}")).size > 1) { this.retained = true; + return; } + plan.minification.push(...minification); + const siblingRemoved = this.companions.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.is("object")) { if (!findPair(node, "html")) { this.editor.insertIntoObject(node, () => [`html: ${page.htmlValue}`]); @@ -219,31 +315,44 @@ export class HtmlPluginMigration { this.editor.replace(node, `{ import: ${node.text()}, html: ${page.htmlValue} }`); } } + plan.pluginMigratedHere = true; this.plans.requireExperimentsHtml(plan); this.plans.applyModuleScripts(plan, moduleScripts); plan.htmlFilename ??= '"[name].html"'; + if (lost.length) { plan.commentLines.push( - `Removed html-webpack-plugin options without a native HTML equivalent: ${[...new Set(lost)].join(", ")}`, + `Removed html-webpack-plugin options without a native HTML equivalent: ${[ + ...new Set(lost), + ].join(", ")}`, ); } + this.migrated = true; } - private mergeIntoPlan(configObject: SgNode, findings: InstanceFindings): void { const plan = this.plans.planFor(configObject); + plan.minification.push(findings.minification); plan.pluginMigratedHere = true; this.plans.requireExperimentsHtml(plan); + if (findings.lost.length) { plan.commentLines.push( - `Removed html-webpack-plugin options without a native HTML equivalent: ${[...new Set(findings.lost)].join(", ")}`, + `Removed html-webpack-plugin options without a native HTML equivalent: ${[ + ...new Set(findings.lost), + ].join(", ")}`, ); } + plan.commentLines.push(...findings.notes); - if (findings.templateValue === null) plan.htmlEnabled = true; + + if (findings.templateValue === null) { + plan.htmlEnabled = true; + } + plan.htmlProps.push(...findings.htmlProps); this.plans.applyModuleScripts(plan, findings.scriptLoadingModule); // The plugin emitted `index.html` by default; native defaults to `[name].html`. diff --git a/codemods/html-plugins-to-native-html/src/plugins/options.ts b/codemods/html-plugins-to-native-html/src/plugins/options.ts index f1a5e39..976f56c 100644 --- a/codemods/html-plugins-to-native-html/src/plugins/options.ts +++ b/codemods/html-plugins-to-native-html/src/plugins/options.ts @@ -38,6 +38,7 @@ export interface InstanceFindings { function describeLost(name: string): string { const hint = LOST_OPTION_HINTS.get(name); + return hint ? `${name} (${hint})` : name; } @@ -58,24 +59,43 @@ export function collectFindings( pendingEntry: null, scriptLoadingModule: false, }; - if (!optionsObject) return findings; + + if (!optionsObject) { + return findings; + } + const templateValue = findPair(optionsObject, "template")?.field("value"); const templateMode = Boolean(templateValue); + if (templateValue) { - if (templateValue.is("string")) findings.templateValue = templateValue.text(); - else findings.lost.push("template"); + if (templateValue.is("string")) { + findings.templateValue = templateValue.text(); + } else { + findings.lost.push("template"); + } } + for (const optionPair of pairsOf(optionsObject)) { const name = keyName(optionPair); const optionValue = optionPair.field("value"); + if (!name || !optionValue) { findings.lost.push("options"); + continue; } - if (name === "template" || DROPPABLE_OPTIONS.has(name)) continue; - if (skippedOptions && skippedOptions.has(name)) continue; + + if (name === "template" || DROPPABLE_OPTIONS.has(name)) { + continue; + } + + if (skippedOptions && skippedOptions.has(name)) { + continue; + } + collectOption(name, optionValue, templateMode, findings); } + return findings; } @@ -86,28 +106,40 @@ function collectOption( findings: InstanceFindings, ): void { const literal = value.is("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.is("string")) findings.htmlFilename = value.text(); - else findings.lost.push(describeLost(name)); + if (value.is("string")) { + findings.htmlFilename = value.text(); + } else { + findings.lost.push(describeLost(name)); + } + break; case "title": case "favicon": case "base": findings.htmlProps.push({ name, valueText: value.text() }); + break; case "meta": collectMetaOption(value, findings); + break; case "inject": // `true` is the native default placement; the template authors its own tags. - if (templateMode || value.is("true")) break; + if (templateMode || value.is("true")) { + break; + } + if (value.is("false") || literal === "body" || literal === "head") { findings.htmlProps.push({ name, @@ -116,10 +148,14 @@ function collectOption( } else { findings.lost.push(describeLost(name)); } + break; case "scriptLoading": // Native `"auto"` already defers classic scripts. - if (literal === "defer") break; + if (literal === "defer") { + break; + } + if (templateMode) { if (literal === "module") { findings.lost.push( @@ -133,19 +169,30 @@ function collectOption( } else { findings.lost.push(describeLost(name)); } + break; case "minify": findings.lost.push(...findings.minification.lost); + break; case "chunks": - if (literal !== "all") findings.lost.push(describeLost(name)); + if (literal !== "all") { + findings.lost.push(describeLost(name)); + } + break; case "publicPath": - if (literal !== "auto") findings.lost.push(describeLost(name)); + if (literal !== "auto") { + findings.lost.push(describeLost(name)); + } + break; case "hash": case "xhtml": - if (!value.is("false")) findings.lost.push(describeLost(name)); + if (!value.is("false")) { + findings.lost.push(describeLost(name)); + } + break; default: findings.lost.push(describeLost(name)); @@ -158,20 +205,28 @@ function collectOption( function collectMetaOption(value: SgNode, findings: InstanceFindings): void { if (!value.is("object")) { findings.lost.push("meta"); + return; } + const parts: string[] = []; + for (const metaPair of pairsOf(value)) { const metaKey = keyName(metaPair); const metaValue = metaPair.field("value"); + if (!metaKey || !metaValue) { findings.lost.push("meta"); + continue; } + if (metaValue.is("string")) { parts.push(metaPair.text()); + continue; } + if (metaValue.is("object")) { const nameValue = (findPair(metaValue, "name") ?? findPair(metaValue, "property"))?.field( "value", @@ -180,13 +235,17 @@ function collectMetaOption(value: SgNode, findings: InstanceFindings): void const extraKeys = pairsOf(metaValue) .map((pair) => keyName(pair)) .filter((key) => key !== "name" && key !== "property" && key !== "content"); + if (nameValue?.is("string") && contentValue && !extraKeys.length) { parts.push(`"${unquote(nameValue.text())}": ${contentValue.text()}`); + continue; } } + findings.lost.push(`meta.${metaKey}`); } + if (parts.length) { findings.htmlProps.push({ name: "meta", valueText: `{ ${parts.join(", ")} }` }); } 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 38e66e5..ef532ff 100644 --- a/codemods/html-plugins-to-native-html/src/remove-dependencies.ts +++ b/codemods/html-plugins-to-native-html/src/remove-dependencies.ts @@ -8,30 +8,58 @@ import { HTML_PACKAGES, remainingPackages } from "./migration-state"; const REMOVED_PACKAGES = new Set(HTML_PACKAGES); const DEPENDENCY_KEYS = ["dependencies", "devDependencies"]; -export function removeUnusedDependencies(root: SgRoot, remaining: Set | null): string | null { +export function removeUnusedDependencies( + root: SgRoot, + remaining: Set | null, +): string | null { // 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.is("object")) return null; - if (!remaining) return null; + + if (!manifest || !manifest.is("object")) { + return null; + } + + if (!remaining) { + return null; + } + // Scripts and other manifest fields can invoke these packages as well. for (const pair of pairsOf(manifest)) { - if (DEPENDENCY_KEYS.includes(keyName(pair) ?? "")) continue; + if (DEPENDENCY_KEYS.includes(keyName(pair) ?? "")) { + continue; + } + for (const name of REMOVED_PACKAGES) { - if (pair.text().includes(name)) remaining.add(name); + if (pair.text().includes(name)) { + remaining.add(name); + } } } + for (const key of DEPENDENCY_KEYS) { const value = findPair(manifest, key)?.field("value"); - if (!value || !value.is("object")) continue; + + if (!value || !value.is("object")) { + continue; + } + for (const pair of pairsOf(value)) { const name = keyName(pair); - if (name && REMOVED_PACKAGES.has(name) && !remaining.has(name)) editor.markForRemoval(pair); + + if (name && REMOVED_PACKAGES.has(name) && !remaining.has(name)) { + editor.markForRemoval(pair); + } } } + editor.finalizeRemovals(); - if (!editor.hasEdits) return null; + + if (!editor.hasEdits) { + return null; + } + return editor.commit(); } diff --git a/codemods/html-plugins-to-native-html/src/templates.ts b/codemods/html-plugins-to-native-html/src/templates.ts index e4385d6..97cc5ad 100644 --- a/codemods/html-plugins-to-native-html/src/templates.ts +++ b/codemods/html-plugins-to-native-html/src/templates.ts @@ -9,64 +9,114 @@ function attributeValue(text: string): string { function chunkComment(name: string): string { // JSON escapes are understood by webpack's magic-comment parser. Escape // HTML delimiters too so an entry name cannot terminate the comment. - const escaped = JSON.stringify(name).replace(//g, "\\u003e").replace(/-/g, "\\u002d"); + const escaped = JSON.stringify(name) + .replace(//g, "\\u003e") + .replace(/-/g, "\\u002d"); + return ``; } export function templateScriptTag(script: TemplateScript): string { - return `${script.chunkName ? `${chunkComment(script.chunkName)} ` : ""}`; + return `${ + script.chunkName ? `${chunkComment(script.chunkName)} ` : "" + }`; } export function addTemplateScripts(root: SgRoot, scripts: TemplateScript[]): string | null { - if (!scripts.length) return null; + if (!scripts.length) { + return null; + } + const node = root.root(); const source = node.text(); const eol = source.includes("\r\n") ? "\r\n" : "\n"; const edits: Edit[] = []; const requested = new Map(); const names = new Map>(); + for (const script of scripts) { const key = normalizeFilename(attributeValue(script.src)); const chunkNames = names.get(key) ?? new Set(); + chunkNames.add(script.chunkName); names.set(key, chunkNames); // Shared templates may be requested by configs with different entry names. // Keep webpack's default naming when there is no unambiguous choice. - requested.set(key, { src: script.src, chunkName: chunkNames.size === 1 ? script.chunkName : undefined }); + requested.set(key, { + src: script.src, + chunkName: chunkNames.size === 1 ? script.chunkName : undefined, + }); } + const existing = new Set(); + for (const script of node.findAll({ rule: { kind: "script_element" } })) { const start = script.children().find((child) => child.is("start_tag")); + for (const attribute of start?.children() ?? []) { - if (!attribute.is("attribute")) continue; + if (!attribute.is("attribute")) { + continue; + } + const name = attribute.children().find((child) => child.is("attribute_name")); - if (name?.text().toLowerCase() !== "src") continue; - const value = attribute.children().find((child) => child.is("quoted_attribute_value") || child.is("attribute_value")); - if (!value) continue; + + if (name?.text().toLowerCase() !== "src") { + continue; + } + + const value = attribute + .children() + .find((child) => child.is("quoted_attribute_value") || child.is("attribute_value")); + + if (!value) { + continue; + } + const key = normalizeFilename(value.text().replace(/^["']|["']$/g, "")); + existing.add(key); + const chunkName = requested.get(key)?.chunkName; const previous = script.prev(); + // A new comment would override existing webpackIgnore/Preload/etc. - if (!chunkName || (previous?.is("comment") && /\bwebpack\w+\s*:/.test(previous.text()))) continue; + if (!chunkName || (previous?.is("comment") && /\bwebpack\w+\s*:/.test(previous.text()))) { + continue; + } + const position = script.range().start.index; const indent = source.slice(source.lastIndexOf("\n", position - 1) + 1, position); const separator = /^[ \t]*$/.test(indent) ? `${eol}${indent}` : " "; - edits.push({ startPos: position, endPos: position, insertedText: `${chunkComment(chunkName)}${separator}` }); + + edits.push({ + startPos: position, + endPos: position, + insertedText: `${chunkComment(chunkName)}${separator}`, + }); } } + const missing = [...requested].filter(([key]) => !existing.has(key)).map(([, script]) => script); - if (!missing.length) return edits.length ? node.commitEdits(edits) : null; + + if (!missing.length) { + return edits.length ? node.commitEdits(edits) : null; + } + const tags = missing.map(templateScriptTag); const endings = node.findAll({ rule: { kind: "end_tag" } }); - const closing = endings.find((tag) => /^<\/head\s*>$/i.test(tag.text())) - ?? endings.find((tag) => /^<\/body\s*>$/i.test(tag.text())); + const closing = + endings.find((tag) => /^<\/head\s*>$/i.test(tag.text())) ?? + endings.find((tag) => /^<\/body\s*>$/i.test(tag.text())); let position = source.length; let text = `${source.endsWith("\n") || !source ? "" : eol}${tags.join(eol)}${eol}`; + if (closing) { position = closing.range().start.index; + const lineStart = source.lastIndexOf("\n", position - 1) + 1; const indent = source.slice(lineStart, position); + if (/^[ \t]*$/.test(indent)) { position = lineStart; text = tags.map((tag) => `${indent} ${tag}${eol}`).join(""); @@ -74,7 +124,9 @@ export function addTemplateScripts(root: SgRoot, scripts: TemplateScript[] text = tags.join(""); } } + edits.push({ startPos: position, endPos: position, insertedText: text }); + return node.commitEdits(edits); } diff --git a/codemods/html-plugins-to-native-html/src/workflow.ts b/codemods/html-plugins-to-native-html/src/workflow.ts index b651a18..adf1316 100644 --- a/codemods/html-plugins-to-native-html/src/workflow.ts +++ b/codemods/html-plugins-to-native-html/src/workflow.ts @@ -16,7 +16,9 @@ function migrateConfiguration(root: SgRoot): string | null { const hooks = new HtmlPluginHooks(editor, bindings); // A tap on an unavailable native stage keeps the entire file unchanged. - if (hooks.usesUnmappableHooks()) return null; + if (hooks.usesUnmappableHooks()) { + return null; + } const usePairs: SgNode[] = []; const loaderPairs: SgNode[] = []; @@ -25,9 +27,13 @@ function migrateConfiguration(root: SgRoot): string | null { for (const pair of editor.rootNode.findAll({ rule: { kind: "pair" } })) { const name = keyName(pair); - if (name === "use") usePairs.push(pair); - else if (name === "loader") loaderPairs.push(pair); - else if (name === "plugins") pluginsPairs.push(pair); + if (name === "use") { + usePairs.push(pair); + } else if (name === "loader") { + loaderPairs.push(pair); + } else if (name === "plugins") { + pluginsPairs.push(pair); + } } const plans = new ConfigPlans(editor); @@ -40,7 +46,9 @@ function migrateConfiguration(root: SgRoot): string | null { // or templates are changed in their owning configuration. rules.transformRules(usePairs, loaderPairs); - for (const pair of pluginsPairs) plugins.transformPluginsPair(pair); + for (const pair of pluginsPairs) { + plugins.transformPluginsPair(pair); + } if (plugins.migrated && !plugins.retained && !blockedConfigs.size) { hooks.migrateHooks(); @@ -49,10 +57,14 @@ function migrateConfiguration(root: SgRoot): string | null { plans.planConfigInsertions(); editor.finalizeRemovals(); - if (!editor.hasEdits) return null; + if (!editor.hasEdits) { + return null; + } for (const binding of [...bindings, ...companions.bindings]) { - if (hooks.repurposedStatements.has(binding.statement.range().start.index)) continue; + if (hooks.repurposedStatements.has(binding.statement.range().start.index)) { + continue; + } editor.removeBindingIfUnused(binding); } From fc06c896d9b2ed2f7477e5d3348f27a2f3a6d0cb Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 7 Nov 2026 14:00:33 -0500 Subject: [PATCH 40/42] refactor: inspect HTML loader options through AST nodes --- .../src/loaders/rules.ts | 22 ++- .../src/loaders/sources.ts | 49 +++++- .../src/loaders/template.ts | 63 +++++--- .../expected.js | 6 + .../loader-parser-equivalent-syntax/input.js | 3 + .../loader-preprocessor-trivia/expected.js | 10 ++ .../tests/loader-preprocessor-trivia/input.js | 6 + .../expected.js | 3 + .../input.js | 3 + .../expected.js | 6 + .../loader-sources-filter-comments/input.js | 3 + packages/codemod-utils/src/index.ts | 1 + packages/codemod-utils/src/syntax.ts | 139 ++++++++++++++++++ 13 files changed, 279 insertions(+), 35 deletions(-) create mode 100644 codemods/html-plugins-to-native-html/tests/loader-parser-equivalent-syntax/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-parser-equivalent-syntax/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-preprocessor-trivia/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-preprocessor-trivia/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-sources-computed-metadata-untouched/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-sources-computed-metadata-untouched/input.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-sources-filter-comments/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/loader-sources-filter-comments/input.js create mode 100644 packages/codemod-utils/src/syntax.ts diff --git a/codemods/html-plugins-to-native-html/src/loaders/rules.ts b/codemods/html-plugins-to-native-html/src/loaders/rules.ts index 296fe20..8071e8c 100644 --- a/codemods/html-plugins-to-native-html/src/loaders/rules.ts +++ b/codemods/html-plugins-to-native-html/src/loaders/rules.ts @@ -12,7 +12,9 @@ import { loaderNameOf, namedChildren, pairsOf, + parseExpression, ruleMatchesFiles, + sameSyntax, unwrapFilterCall, } from "@webpack/codemod-utils"; import { ConfigPlans, dedupeProps } from "../config-plan"; @@ -20,7 +22,15 @@ import { collectLoaderFindings, LOADER_NAME, type LoaderFindings } from "./optio const HTML_SAMPLE_FILES = ["/file.html"]; -const DEFAULT_HTML_TESTS = new Set(["/\\.html$/", "/\\.html$/i"]); +function isDefaultHtmlTest(node: SgNode | null | undefined): boolean { + if (!node?.is("regex")) { + return false; + } + + const flags = node.field("flags")?.text() ?? ""; + + return node.field("pattern")?.text() === "\\.html$" && (flags === "" || flags === "i"); +} interface RuleMigration { pair: SgNode; @@ -230,14 +240,16 @@ export class HtmlLoaderRules { const existing = parser?.is("object") ? findPair(parser, prop.name)?.field("value") : undefined; + const replacement = parseExpression(prop.valueText); - if (existing && existing.text() !== prop.valueText) { + if (existing && !sameSyntax(existing, replacement)) { return false; } if ( swap.findings.parserProps.some( - (other) => other.name === prop.name && other.valueText !== prop.valueText, + (other) => + other.name === prop.name && !sameSyntax(parseExpression(other.valueText), replacement), ) ) { return false; @@ -281,7 +293,7 @@ export class HtmlLoaderRules { const survives = !swap.trivial || - !DEFAULT_HTML_TESTS.has(findPair(swap.ruleObject, "test")?.field("value")?.text() ?? "") || + !isDefaultHtmlTest(findPair(swap.ruleObject, "test")?.field("value")) || swap.kept.length > 0 || swap.findings.parserProps.length > 0; @@ -297,7 +309,7 @@ export class HtmlLoaderRules { } const indent = lineIndent(this.editor.source, swap.pair.range().start.index); - const multiline = swap.ruleObject.text().includes("\n"); + const multiline = swap.ruleObject.range().start.line !== swap.ruleObject.range().end.line; const separator = multiline ? `,\n${indent}` : ", "; let replacement = ""; diff --git a/codemods/html-plugins-to-native-html/src/loaders/sources.ts b/codemods/html-plugins-to-native-html/src/loaders/sources.ts index 5612b98..9cd289b 100644 --- a/codemods/html-plugins-to-native-html/src/loaders/sources.ts +++ b/codemods/html-plugins-to-native-html/src/loaders/sources.ts @@ -1,10 +1,10 @@ import type Js from "@codemod.com/jssg-types/langs/javascript"; import type { SgNode } from "@codemod.com/jssg-types/main"; -import { findPair, keyName, namedChildren, unquote } from "@webpack/codemod-utils"; +import { findPair, keyName, namedChildren, staticString, unquote } from "@webpack/codemod-utils"; import { callbackParameters, constantBoolean, usesParameter } from "./callbacks"; -function freshName(source: string, base: string): string { - while (source.includes(base)) { +function freshName(identifiers: Set, base: string): string { + while (identifiers.has(base)) { base += "_"; } @@ -31,7 +31,37 @@ function sourceFilter( } // parse5's foreign attribute namespace metadata is not exposed by native HTML. - if (usesParameter(value, params, 2) && /\b(namespace|prefix)\b/.test(value.text())) { + const metadataAccesses = value.findAll({ + rule: { + any: [ + { kind: "property_identifier" }, + { kind: "shorthand_property_identifier_pattern" }, + { kind: "string" }, + { kind: "template_string" }, + { kind: "binary_expression" }, + { kind: "subscript_expression" }, + ], + }, + }); + + if ( + usesParameter(value, params, 2) && + metadataAccesses.some((node) => { + if (node.is("subscript_expression")) { + const index = node.field("index"); + const name = staticString(index); + + return name === "namespace" || name === "prefix" || (name === null && !index.is("number")); + } + + const name = + node.is("property_identifier") || node.is("shorthand_property_identifier_pattern") + ? node.text() + : staticString(node); + + return name === "namespace" || name === "prefix"; + }) + ) { return null; } @@ -105,6 +135,13 @@ export function nativeSources( } const edits = []; + const identifiers = new Set( + value + .findAll({ + rule: { any: [{ kind: "identifier" }, { kind: "shorthand_property_identifier" }] }, + }) + .map((node) => node.text()), + ); for (const source of namedChildren(list)) { if (source.is("comment")) { @@ -172,8 +209,8 @@ export function nativeSources( return null; } - const attrs = freshName(value.text(), "htmlAttributes"); - const url = freshName(value.text(), "htmlUrl"); + const attrs = freshName(identifiers, "htmlAttributes"); + const url = freshName(identifiers, "htmlUrl"); const conditions: string[] = []; if (filter) { diff --git a/codemods/html-plugins-to-native-html/src/loaders/template.ts b/codemods/html-plugins-to-native-html/src/loaders/template.ts index a7746c4..0f37d69 100644 --- a/codemods/html-plugins-to-native-html/src/loaders/template.ts +++ b/codemods/html-plugins-to-native-html/src/loaders/template.ts @@ -29,6 +29,14 @@ const CONTEXT_METHODS = new Map([ ["emitError", "(error: Error) => void"], ]); +const STRING_CONTEXT_PROPERTIES = new Set([ + "resource", + "resourcePath", + "resourceQuery", + "resourceFragment", + "context", +]); + function adaptTemplateContext( value: SgNode, context: SgNode, @@ -109,7 +117,7 @@ function adaptTemplateContext( return edits.length ? value.commitEdits(edits) : value.text(); } -function returnsString(node: SgNode | null, strings: Set): boolean { +function returnsString(node: SgNode | null, strings: Set, context?: string): boolean { if (!node) { return false; } @@ -118,24 +126,36 @@ function returnsString(node: SgNode | null, strings: Set): boolean { return true; } - if (node.is("identifier") || node.is("member_expression")) { + if (node.is("identifier")) { return strings.has(node.text()); } + if (node.is("member_expression")) { + const object = node.field("object"); + const property = node.field("property"); + + return ( + !!object?.is("identifier") && + object.text() === context && + STRING_CONTEXT_PROPERTIES.has(property?.text() ?? "") + ); + } + if (node.is("parenthesized_expression")) { - return returnsString(namedChildren(node)[0], strings); + return returnsString(namedChildren(node)[0], strings, context); } - if (node.is("binary_expression") && node.field("operator")?.text() === "+") { + if (node.is("binary_expression") && node.field("operator")?.is("+")) { return ( - returnsString(node.field("left"), strings) || returnsString(node.field("right"), strings) + returnsString(node.field("left"), strings, context) || + returnsString(node.field("right"), strings, context) ); } if (node.is("ternary_expression")) { return ( - returnsString(node.field("consequence"), strings) && - returnsString(node.field("alternative"), strings) + returnsString(node.field("consequence"), strings, context) && + returnsString(node.field("alternative"), strings, context) ); } @@ -145,7 +165,7 @@ function returnsString(node: SgNode | null, strings: Set): boolean { return ( callee?.is("member_expression") && STRING_METHODS.has(callee.field("property")?.text() ?? "") && - returnsString(callee.field("object"), strings) + returnsString(callee.field("object"), strings, context) ); } @@ -197,18 +217,6 @@ export function nativeTemplate(value: SgNode, typescript = false): string | const strings = new Set(name ? [name.text()] : []); - if (context) { - for (const property of [ - "resource", - "resourcePath", - "resourceQuery", - "resourceFragment", - "context", - ]) { - strings.add(`${context.text()}.${property}`); - } - } - const body = value.field("body"); if (!body) { @@ -216,7 +224,7 @@ export function nativeTemplate(value: SgNode, typescript = false): string | } if (!body.is("statement_block")) { - return returnsString(body, strings) ? text : null; + return returnsString(body, strings, context?.text()) ? text : null; } const statements = namedChildren(body).filter((node) => !node.is("comment")); @@ -243,14 +251,17 @@ export function nativeTemplate(value: SgNode, typescript = false): string | continue; } - if (!statement.is("lexical_declaration") || !statement.text().startsWith("const ")) { + if (!statement.is("lexical_declaration") || !statement.field("kind")?.is("const")) { return null; } for (const declaration of namedChildren(statement).filter((node) => !node.is("comment"))) { const binding = declaration.field("name"); - if (!binding?.is("identifier") || !returnsString(declaration.field("value"), strings)) { + if ( + !binding?.is("identifier") || + !returnsString(declaration.field("value"), strings, context?.text()) + ) { return null; } @@ -258,7 +269,11 @@ export function nativeTemplate(value: SgNode, typescript = false): string | } } - return returnsString(namedChildren(last).find((node) => !node.is("comment")) ?? null, strings) + return returnsString( + namedChildren(last).find((node) => !node.is("comment")) ?? null, + strings, + context?.text(), + ) ? text : null; } diff --git a/codemods/html-plugins-to-native-html/tests/loader-parser-equivalent-syntax/expected.js b/codemods/html-plugins-to-native-html/tests/loader-parser-equivalent-syntax/expected.js new file mode 100644 index 0000000..2ea3f69 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-parser-equivalent-syntax/expected.js @@ -0,0 +1,6 @@ +module.exports = { + experiments: { + html: true, + }, + module: { rules: [{ test: /\.html$/, parser: { sources: [ /* existing */ { attribute: 'src', type: 'src' } ] }, type: "html" }] }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-parser-equivalent-syntax/input.js b/codemods/html-plugins-to-native-html/tests/loader-parser-equivalent-syntax/input.js new file mode 100644 index 0000000..970d990 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-parser-equivalent-syntax/input.js @@ -0,0 +1,3 @@ +module.exports = { + module: { rules: [{ test: /\.html$/, parser: { sources: [ /* existing */ { attribute: 'src', type: 'src' } ] }, loader: "html-loader", options: { sources: { list: [{ attribute: "src", type: "src" }] } } }] }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-preprocessor-trivia/expected.js b/codemods/html-plugins-to-native-html/tests/loader-preprocessor-trivia/expected.js new file mode 100644 index 0000000..86929eb --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-preprocessor-trivia/expected.js @@ -0,0 +1,10 @@ +module.exports = { + experiments: { + html: true, + }, + module: { rules: [{ test: /\.html$/, type: "html", + parser: { template: (content, context) => { + const/* keep this comment */result = content.trim(); + return result + context /* original resource */ . resource; + } } }] }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-preprocessor-trivia/input.js b/codemods/html-plugins-to-native-html/tests/loader-preprocessor-trivia/input.js new file mode 100644 index 0000000..55fa130 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-preprocessor-trivia/input.js @@ -0,0 +1,6 @@ +module.exports = { + module: { rules: [{ test: /\.html$/, loader: "html-loader", options: { preprocessor: (content, context) => { + const/* keep this comment */result = content.trim(); + return result + context /* original resource */ . resource; + } } }] }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-sources-computed-metadata-untouched/expected.js b/codemods/html-plugins-to-native-html/tests/loader-sources-computed-metadata-untouched/expected.js new file mode 100644 index 0000000..ce08d45 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-sources-computed-metadata-untouched/expected.js @@ -0,0 +1,3 @@ +module.exports = { + module: { rules: [{ test: /\.html$/, loader: "html-loader", options: { sources: { list: [{ tag: "img", attribute: "src", type: "src", filter: (tag, attribute, attrs) => attrs.some((item) => item["name" + "space"]) }] } } }] }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-sources-computed-metadata-untouched/input.js b/codemods/html-plugins-to-native-html/tests/loader-sources-computed-metadata-untouched/input.js new file mode 100644 index 0000000..ce08d45 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-sources-computed-metadata-untouched/input.js @@ -0,0 +1,3 @@ +module.exports = { + module: { rules: [{ test: /\.html$/, loader: "html-loader", options: { sources: { list: [{ tag: "img", attribute: "src", type: "src", filter: (tag, attribute, attrs) => attrs.some((item) => item["name" + "space"]) }] } } }] }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-sources-filter-comments/expected.js b/codemods/html-plugins-to-native-html/tests/loader-sources-filter-comments/expected.js new file mode 100644 index 0000000..f22cc74 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-sources-filter-comments/expected.js @@ -0,0 +1,6 @@ +module.exports = { + experiments: { + html: true, + }, + module: { rules: [{ test: /\.html$/, type: "html", parser: { sources: [{ tag: "img", attribute: "src", type: "src", filter: (htmlAttributes) => ((tag, attribute, attrs) => /* namespace prefix htmlAttributes */ attrs.some((item) => item.name === "data-local"))("img", "src", Array.from(htmlAttributes, ([name, value]) => ({ name, value }))) }] } }] }, +}; diff --git a/codemods/html-plugins-to-native-html/tests/loader-sources-filter-comments/input.js b/codemods/html-plugins-to-native-html/tests/loader-sources-filter-comments/input.js new file mode 100644 index 0000000..a1a12ce --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/loader-sources-filter-comments/input.js @@ -0,0 +1,3 @@ +module.exports = { + module: { rules: [{ test: /\.html$/, loader: "html-loader", options: { sources: { list: [{ tag: "img", attribute: "src", type: "src", filter: (tag, attribute, attrs) => /* namespace prefix htmlAttributes */ attrs.some((item) => item.name === "data-local") }] } } }] }, +}; diff --git a/packages/codemod-utils/src/index.ts b/packages/codemod-utils/src/index.ts index 70beff8..61b4478 100644 --- a/packages/codemod-utils/src/index.ts +++ b/packages/codemod-utils/src/index.ts @@ -15,6 +15,7 @@ import type { ModuleBinding } from "./imports"; export * from "./ast"; export * from "./imports"; +export * from "./syntax"; // ---------- webpack config helpers ---------- diff --git a/packages/codemod-utils/src/syntax.ts b/packages/codemod-utils/src/syntax.ts new file mode 100644 index 0000000..ce2cce5 --- /dev/null +++ b/packages/codemod-utils/src/syntax.ts @@ -0,0 +1,139 @@ +import type Js from "@codemod.com/jssg-types/langs/javascript"; +import type { SgNode } from "@codemod.com/jssg-types/main"; +import { parse } from "codemod:ast-grep"; + +import { namedChildren } from "./ast"; + +function decodeEscape(text: string): string | null { + const escaped = text.slice(1); + const simple: Record = { + n: "\n", + r: "\r", + t: "\t", + b: "\b", + f: "\f", + v: "\v", + "\n": "", + "\r": "", + "\r\n": "", + }; + + if (Object.hasOwn(simple, escaped)) { + return simple[escaped]; + } + + if (/^[0-7]{1,3}$/.test(escaped)) { + return String.fromCharCode(Number.parseInt(escaped, 8)); + } + + if (/^x[\da-f]{2}$/i.test(escaped)) { + return String.fromCharCode(Number.parseInt(escaped.slice(1), 16)); + } + + if (/^u[\da-f]{4}$/i.test(escaped)) { + return String.fromCharCode(Number.parseInt(escaped.slice(1), 16)); + } + + if (/^u\{[\da-f]+\}$/i.test(escaped)) { + const codePoint = Number.parseInt(escaped.slice(2, -1), 16); + + return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : null; + } + + return escaped.length === 1 ? escaped : null; +} + +// Read literal values from syntax nodes without evaluating user code. +// Escape decoding operates on individual escape tokens, not source patterns. +export function staticString(node: SgNode | null | undefined): string | null { + if (!node) { + return null; + } + + if (node.is("parenthesized_expression")) { + return staticString(namedChildren(node).find((child) => !child.is("comment"))); + } + + if (node.is("binary_expression") && node.field("operator")?.is("+")) { + const left = staticString(node.field("left")); + const right = staticString(node.field("right")); + + return left === null || right === null ? null : left + right; + } + + if (!node.is("string") && !node.is("template_string")) { + return null; + } + + let result = ""; + + for (const child of namedChildren(node)) { + if (child.is("string_fragment")) { + result += child.text(); + } else if (child.is("escape_sequence")) { + const value = decodeEscape(child.text()); + + if (value === null) { + return null; + } + + result += value; + } else { + return null; + } + } + + return result; +} + +function unwrapParentheses(node: SgNode): SgNode { + while (node.is("parenthesized_expression")) { + const inner = namedChildren(node).find((child) => !child.is("comment")); + + if (!inner) { + break; + } + + node = inner; + } + + return node; +} + +// Compare syntax while ignoring trivia and redundant parentheses. Operators, +// property order, optional chaining, and other executable tokens still matter. +export function sameSyntax(left: SgNode, right: SgNode): boolean { + left = unwrapParentheses(left); + right = unwrapParentheses(right); + + if (!left.is(right.kind())) { + return false; + } + + if (left.is("string")) { + const value = staticString(left); + + if (value !== null) { + return value === staticString(right); + } + } + + const leftChildren = left.children().filter((node) => !node.is("comment")); + const rightChildren = right.children().filter((node) => !node.is("comment")); + + if (!leftChildren.length && !rightChildren.length) { + return left.text() === right.text(); + } + + return ( + leftChildren.length === rightChildren.length && + leftChildren.every((node, index) => sameSyntax(node, rightChildren[index])) + ); +} + +export function parseExpression(source: string): SgNode { + const root = parse("typescript", `(${source})`); + const statement = namedChildren(root.root())[0]; + + return unwrapParentheses(namedChildren(statement)[0]); +} From f04031a3cdf19bc8a1c313f32ada621a6a17305a Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 7 Nov 2026 14:00:33 -0500 Subject: [PATCH 41/42] refactor: inspect HTML tags and source layout through AST --- .../html-plugins-to-native-html/src/config-plan.ts | 2 +- .../src/plugins/entries.ts | 10 ++++++++-- .../html-plugins-to-native-html/src/templates.ts | 13 +++++++++---- .../expected.js | 6 ++++++ .../template-escaped-identifier-untouched/input.js | 6 ++++++ 5 files changed, 30 insertions(+), 7 deletions(-) create mode 100644 codemods/html-plugins-to-native-html/tests/template-escaped-identifier-untouched/expected.js create mode 100644 codemods/html-plugins-to-native-html/tests/template-escaped-identifier-untouched/input.js diff --git a/codemods/html-plugins-to-native-html/src/config-plan.ts b/codemods/html-plugins-to-native-html/src/config-plan.ts index 2edc1d3..42ea1da 100644 --- a/codemods/html-plugins-to-native-html/src/config-plan.ts +++ b/codemods/html-plugins-to-native-html/src/config-plan.ts @@ -274,7 +274,7 @@ export class ConfigPlans { return propertyText; } - if (!config.text().includes("\n")) { + if (config.range().start.line === config.range().end.line) { return `/* ${commentLines.join("; ").replace(/\*\//g, "* /")} */ ${propertyText}`; } diff --git a/codemods/html-plugins-to-native-html/src/plugins/entries.ts b/codemods/html-plugins-to-native-html/src/plugins/entries.ts index abd8c14..98de036 100644 --- a/codemods/html-plugins-to-native-html/src/plugins/entries.ts +++ b/codemods/html-plugins-to-native-html/src/plugins/entries.ts @@ -79,7 +79,7 @@ export function migrateEntry( chunkName, })} (added by the HTML workflow step when the template is included)` : `The template is now the entry: reference the previous entry from it, e.g. `; - const multiline = configObject.text().includes("\n"); + const multiline = configObject.range().start.line !== configObject.range().end.line; if (multiline) { const indent = lineIndent(editor.source, entryPair.range().start.index); @@ -124,7 +124,13 @@ export function replaceableEntryValue(entryValue: SgNode): SgNode | null const key = entryPairs[0].field("key"); - if (!key || key.is("computed_property_name") || key.text().includes("\\")) { + if ( + !key || + key.is("computed_property_name") || + key.findAll({ rule: { kind: "escape_sequence" } }).length || + // Escaped identifiers are leaf tokens; the parser exposes no escape child. + (key.is("property_identifier") && key.text().includes("\\")) + ) { return null; } diff --git a/codemods/html-plugins-to-native-html/src/templates.ts b/codemods/html-plugins-to-native-html/src/templates.ts index 97cc5ad..b2a1582 100644 --- a/codemods/html-plugins-to-native-html/src/templates.ts +++ b/codemods/html-plugins-to-native-html/src/templates.ts @@ -73,7 +73,10 @@ export function addTemplateScripts(root: SgRoot, scripts: TemplateScript[] continue; } - const key = normalizeFilename(value.text().replace(/^["']|["']$/g, "")); + const content = value.is("quoted_attribute_value") + ? value.children().find((child) => child.is("attribute_value")) + : value; + const key = normalizeFilename(content?.text() ?? ""); existing.add(key); @@ -105,9 +108,11 @@ export function addTemplateScripts(root: SgRoot, scripts: TemplateScript[] const tags = missing.map(templateScriptTag); const endings = node.findAll({ rule: { kind: "end_tag" } }); - const closing = - endings.find((tag) => /^<\/head\s*>$/i.test(tag.text())) ?? - endings.find((tag) => /^<\/body\s*>$/i.test(tag.text())); + const closingTag = (name: string) => + endings.find((tag) => + tag.children().some((child) => child.is("tag_name") && child.text().toLowerCase() === name), + ); + const closing = closingTag("head") ?? closingTag("body"); let position = source.length; let text = `${source.endsWith("\n") || !source ? "" : eol}${tags.join(eol)}${eol}`; diff --git a/codemods/html-plugins-to-native-html/tests/template-escaped-identifier-untouched/expected.js b/codemods/html-plugins-to-native-html/tests/template-escaped-identifier-untouched/expected.js new file mode 100644 index 0000000..4769770 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-escaped-identifier-untouched/expected.js @@ -0,0 +1,6 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +module.exports = { + entry: { app\u0041: "./src/index.js" }, + plugins: [new HtmlWebpackPlugin({ template: "./src/index.html" })], +}; diff --git a/codemods/html-plugins-to-native-html/tests/template-escaped-identifier-untouched/input.js b/codemods/html-plugins-to-native-html/tests/template-escaped-identifier-untouched/input.js new file mode 100644 index 0000000..4769770 --- /dev/null +++ b/codemods/html-plugins-to-native-html/tests/template-escaped-identifier-untouched/input.js @@ -0,0 +1,6 @@ +const HtmlWebpackPlugin = require("html-webpack-plugin"); + +module.exports = { + entry: { app\u0041: "./src/index.js" }, + plugins: [new HtmlWebpackPlugin({ template: "./src/index.html" })], +}; From 91310b8d936ab2f302ed4ddb3ff2b3c2a417810f Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 7 Nov 2026 14:05:25 -0500 Subject: [PATCH 42/42] fix: collect HTML package references from AST nodes --- .../html-plugins-to-native-html/README.md | 4 + .../src/migration-state.ts | 12 +- .../src/package-references.ts | 122 ++++++++++++++++++ .../src/remove-dependencies.ts | 15 ++- .../src/workflow.ts | 7 +- .../packages/dynamic-literal/package.json | 6 + .../packages/dynamic-literal/tooling.mjs | 1 + .../packages/dynamic-reference/package.json | 6 + .../packages/dynamic-reference/tooling.cjs | 1 + .../packages/inline-loader/package.json | 3 + .../project/packages/inline-loader/view.js | 2 + .../packages/migrated/webpack.config.cjs | 4 + .../project/packages/references/package.json | 9 ++ .../project/packages/references/tooling.mjs | 5 + .../escaped-manifest-reference/expected.json | 4 + .../escaped-manifest-reference/input.json | 4 + .../metadata-mentions/expected.json | 4 + .../metadata-mentions/input.json | 4 + .../nested-tool-reference/expected.json | 5 + .../nested-tool-reference/input.json | 5 + .../tests/workflow.test.mjs | 11 ++ 21 files changed, 218 insertions(+), 16 deletions(-) create mode 100644 codemods/html-plugins-to-native-html/src/package-references.ts create mode 100644 codemods/html-plugins-to-native-html/tests/integration/project/packages/dynamic-literal/package.json create mode 100644 codemods/html-plugins-to-native-html/tests/integration/project/packages/dynamic-literal/tooling.mjs create mode 100644 codemods/html-plugins-to-native-html/tests/integration/project/packages/dynamic-reference/package.json create mode 100644 codemods/html-plugins-to-native-html/tests/integration/project/packages/dynamic-reference/tooling.cjs create mode 100644 codemods/html-plugins-to-native-html/tests/integration/project/packages/inline-loader/package.json create mode 100644 codemods/html-plugins-to-native-html/tests/integration/project/packages/inline-loader/view.js create mode 100644 codemods/html-plugins-to-native-html/tests/integration/project/packages/references/package.json create mode 100644 codemods/html-plugins-to-native-html/tests/integration/project/packages/references/tooling.mjs create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/escaped-manifest-reference/expected.json create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/escaped-manifest-reference/input.json create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/metadata-mentions/expected.json create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/metadata-mentions/input.json create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/nested-tool-reference/expected.json create mode 100644 codemods/html-plugins-to-native-html/tests/remove-dependencies/nested-tool-reference/input.json diff --git a/codemods/html-plugins-to-native-html/README.md b/codemods/html-plugins-to-native-html/README.md index 9fe0ad4..49c4e8a 100644 --- a/codemods/html-plugins-to-native-html/README.md +++ b/codemods/html-plugins-to-native-html/README.md @@ -144,3 +144,7 @@ With a `template`, the template becomes the entry point and gets a `