Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
dcfd19c
feat: add html-plugins-to-native-html codemod
bjohansebas Aug 2, 2026
316d457
feat: migrate html-loader rules in html-plugins-to-native-html
bjohansebas Aug 2, 2026
bacb4a2
feat: add the entry script tag to the template file directly
bjohansebas Aug 2, 2026
cc265e5
docs: map html-webpack-plugin hooks to their native counterparts
bjohansebas Aug 2, 2026
ab2a803
feat: migrate html-webpack-plugin hook taps to the native hooks
bjohansebas Aug 2, 2026
3dfe1b3
feat: map multi-page chunks setups to per-entry html descriptors
bjohansebas Aug 2, 2026
1134c40
feat: migrate csp/sri/favicons companion plugins to output.html options
bjohansebas Aug 2, 2026
ac1dda3
feat: map scriptLoading module and structured meta values
bjohansebas Aug 2, 2026
8eb0c8a
feat: support inline require instantiation, cover ts/function/merge c…
bjohansebas Aug 2, 2026
1e7ed56
fix: flag html-loader postprocessor and object minimize options
bjohansebas Aug 2, 2026
598c7a7
docs: point minify hints at minimizer-webpack-plugin
bjohansebas Aug 2, 2026
132bb34
debug: surface template injection failure reason (temporary)
bjohansebas Aug 2, 2026
0a5af62
fix: resolve template paths by segments, drop the runtime path module
bjohansebas Aug 2, 2026
215a3e1
debug: probe existsSync variants on windows (temporary)
bjohansebas Aug 2, 2026
aad5b56
debug: probe readFileSync on windows (temporary)
bjohansebas Aug 2, 2026
0cddfb8
fix: probe sandbox-accepted path forms with readFileSync
bjohansebas Aug 2, 2026
fec910f
fix: make the template edit best-effort with a platform-stable comment
bjohansebas Aug 2, 2026
7d86405
feat: improve native HTML migration and minification
bjohansebas Nov 7, 2026
641d2ba
fix: preserve favicon generation during native HTML migration
bjohansebas Nov 7, 2026
d5c8c1d
fix: use stable ESM output for native HTML scripts
bjohansebas Nov 7, 2026
5830114
feat: migrate favicon logos with manual asset generation guidance
bjohansebas Nov 7, 2026
37bf91d
feat: migrate HTML loader source lists to native parser options
bjohansebas Nov 7, 2026
7989900
feat: migrate synchronous HTML preprocessors to parser templates
bjohansebas Nov 7, 2026
a68728e
feat: preserve entry chunk names in migrated HTML templates
bjohansebas Nov 7, 2026
210c4df
fix: preserve HTML source defaults during parser option merging
bjohansebas Nov 7, 2026
cfbf55b
docs: align HTML migration notes with final behavior
bjohansebas Nov 7, 2026
06a12bc
feat: migrate compatible HTML loader source filters
bjohansebas Nov 7, 2026
daa6a70
feat: adapt HTML preprocessor contexts to native templates
bjohansebas Nov 7, 2026
ce12a55
feat: migrate HTML minifier comment and whitespace options
bjohansebas Nov 7, 2026
46b8ced
fix: preserve unsupported HTML loader behavior during migration
bjohansebas Nov 7, 2026
e42dc4b
chore: remove HTML codemod changesets
bjohansebas Nov 7, 2026
307e44d
chore: restore initial HTML codemod changeset
bjohansebas Nov 7, 2026
4193dc5
chore: simplify HTML codemod package metadata
bjohansebas Nov 7, 2026
07f86f2
docs: show HTML migration examples as diffs
bjohansebas Nov 7, 2026
2375e7b
refactor: use node.is in HTML codemod transforms
bjohansebas Nov 7, 2026
e6e1987
test: cover HTML dependency removal edge cases
bjohansebas Nov 7, 2026
854cf26
refactor: separate HTML loader callback adapters
bjohansebas Nov 7, 2026
c58d2fd
refactor: split HTML migration responsibilities into modules
bjohansebas Nov 7, 2026
695bc92
style: expand HTML codemod blocks and spacing
bjohansebas Nov 7, 2026
fc06c89
refactor: inspect HTML loader options through AST nodes
bjohansebas Nov 7, 2026
f04031a
refactor: inspect HTML tags and source layout through AST
bjohansebas Nov 7, 2026
91310b8
fix: collect HTML package references from AST nodes
bjohansebas Nov 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
7 changes: 7 additions & 0 deletions .changeset/html-plugins-to-native-html.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@webpack/html-plugins-to-native-html": major
---

Add a codemod migrating html-webpack-plugin and html-loader to webpack 5.112.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.
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@

# CRLF fixtures exercise the editor's EOL detection — keep their bytes as-is.
codemods/*/tests/crlf/* -text
codemods/html-plugins-to-native-html/tests/template-chunks/crlf/* -text
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ npx codemod run @webpack/<codemod-name>
| 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` to native HTML support and minification. |

## Contributing

Expand Down
150 changes: 150 additions & 0 deletions codemods/html-plugins-to-native-html/README.md

Large diffs are not rendered by default.

23 changes: 23 additions & 0 deletions codemods/html-plugins-to-native-html/codemod.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
schema_version: "1.0"
name: "@webpack/html-plugins-to-native-html"
version: "0.0.0"
description: Migrate html-webpack-plugin and html-loader to webpack's native HTML support and minification
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
19 changes: 19 additions & 0 deletions codemods/html-plugins-to-native-html/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "@webpack/html-plugins-to-native-html",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"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 && npx codemod jssg test -l html ./tests/template-chunks.ts ./tests/template-chunks",
"test:integration": "node --test ./tests/*.test.mjs"
},
"dependencies": {
"@webpack/codemod-utils": "*"
},
"devDependencies": {
"@codemod.com/jssg-types": "^1.6.2"
}
}
328 changes: 328 additions & 0 deletions codemods/html-plugins-to-native-html/src/config-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,328 @@
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<string>();

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<Js>;
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<number, ConfigPlan>();

private readonly editor: ConfigEditor;

constructor(editor: ConfigEditor) {
this.editor = editor;
}

planFor(config: SgNode<Js>): 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<Js>): boolean {
let current: SgNode<Js> = 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<Js>): 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<Js>, 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<Js>,
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.range().start.line === config.range().end.line) {
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}}`;
});
}
}
}
Loading
Loading