feat(rspack): use custom WriteIndexHtmlPlugin to handle variable interpolation (#30805)

## Current Behavior
The `HtmlRspackPlugin` does not support interpolation of %VAR% in the
index.html.
This is supported with a custom Webpack Plugin for `@nx/webpack` for
generating index.html files.

## Expected Behavior
The `@nx/rspack` plugin should support the same feature set as webpack
for seamless migration.
Add a new `WriteIndexHtmlPlugin` for Rspack to support this.
It should only be used when `useLegacyHtmlPlugin` is set to true
This commit is contained in:
Colum Ferry 2025-04-25 09:58:21 +01:00 committed by GitHub
parent 851196aaa5
commit a65f0f421b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 756 additions and 14 deletions

View File

@ -42,6 +42,7 @@
"less-loader": "11.1.0", "less-loader": "11.1.0",
"license-webpack-plugin": "^4.0.2", "license-webpack-plugin": "^4.0.2",
"loader-utils": "^2.0.3", "loader-utils": "^2.0.3",
"parse5": "4.0.0",
"sass": "^1.85.0", "sass": "^1.85.0",
"sass-embedded": "^1.83.4", "sass-embedded": "^1.83.4",
"sass-loader": "^16.0.4", "sass-loader": "^16.0.4",

View File

@ -34,6 +34,7 @@ describe('Convert webpack', () => {
module.exports = composePlugins( module.exports = composePlugins(
withNx(), withNx(),
withReact({ withReact({
useLegacyHtmlPlugin: true,
// Uncomment this line if you don't want to use SVGR // Uncomment this line if you don't want to use SVGR
// See: https://react-svgr.com/ // See: https://react-svgr.com/
// svgr: false // svgr: false
@ -162,7 +163,7 @@ describe('Convert webpack', () => {
*/ */
export default composePlugins( export default composePlugins(
withNx(), withNx(),
withReact(), withReact({ useLegacyHtmlPlugin: true }),
withModuleFederation(config, { dts: false }) withModuleFederation(config, { dts: false })
); );
" "
@ -262,7 +263,7 @@ describe('Convert webpack', () => {
*/ */
export default composePlugins( export default composePlugins(
withNx(), withNx(),
withReact(), withReact({ useLegacyHtmlPlugin: true }),
withModuleFederation(config, { dts: false }) withModuleFederation(config, { dts: false })
); );
" "
@ -368,7 +369,7 @@ describe('Convert webpack', () => {
*/ */
export default composePlugins( export default composePlugins(
withNx(), withNx(),
withReact(), withReact({ useLegacyHtmlPlugin: true }),
withModuleFederation(config, { dts: false }) withModuleFederation(config, { dts: false })
); );
" "

View File

@ -11,6 +11,111 @@ export function transformCjsConfigFile(tree: Tree, configPath: string) {
transformWithModuleFederation(tree, configPath, scope); transformWithModuleFederation(tree, configPath, scope);
transformWithModuleFederationSSR(tree, configPath, scope); transformWithModuleFederationSSR(tree, configPath, scope);
}); });
// Add useLegacyHtmlPlugin: true to withWeb() calls
transformWithWebCalls(tree, configPath);
}
function transformWithWebCalls(tree: Tree, configPath: string) {
const configContents = tree.read(configPath, 'utf-8');
const ast = tsquery.ast(configContents);
// Find withWeb() calls
const withWebCallNodes = tsquery(
ast,
'CallExpression > Identifier[name=withWeb]'
);
// If there are withWeb calls, update them
if (withWebCallNodes.length > 0) {
let newContents = configContents;
for (const node of withWebCallNodes) {
const callExpr = node.parent;
if (!callExpr) continue;
const startPos = callExpr.getStart();
const endPos = callExpr.getEnd();
const callText = configContents.substring(startPos, endPos);
// Skip if useLegacyHtmlPlugin is already present
if (callText.includes('useLegacyHtmlPlugin')) {
continue;
}
// If it's already withWeb({ ... }), add useLegacyHtmlPlugin: true to the options
if (callText.includes('{') && callText.includes('}')) {
const newCallText = callText.replace(
/\{\s*/,
'{ useLegacyHtmlPlugin: true,\n '
);
newContents =
newContents.substring(0, startPos) +
newCallText +
newContents.substring(endPos);
} else {
// If it's just withWeb(), replace with withWeb({ useLegacyHtmlPlugin: true })
const newCallText = 'withWeb({ useLegacyHtmlPlugin: true })';
newContents =
newContents.substring(0, startPos) +
newCallText +
newContents.substring(endPos);
}
}
if (newContents !== configContents) {
tree.write(configPath, newContents);
}
return;
}
// If no withWeb calls, check for withReact calls
const withReactCallNodes = tsquery(
ast,
'CallExpression > Identifier[name=withReact]'
);
if (withReactCallNodes.length === 0) {
return;
}
let newContents = configContents;
for (const node of withReactCallNodes) {
const callExpr = node.parent;
if (!callExpr) continue;
const startPos = callExpr.getStart();
const endPos = callExpr.getEnd();
const callText = configContents.substring(startPos, endPos);
// Skip if useLegacyHtmlPlugin is already present
if (callText.includes('useLegacyHtmlPlugin')) {
continue;
}
// If it's already withReact({ ... }), add useLegacyHtmlPlugin: true to the options
if (callText.includes('{') && callText.includes('}')) {
const newCallText = callText.replace(
/\{\s*/,
'{ useLegacyHtmlPlugin: true,\n '
);
newContents =
newContents.substring(0, startPos) +
newCallText +
newContents.substring(endPos);
} else {
// If it's just withReact(), replace with withReact({ useLegacyHtmlPlugin: true })
const newCallText = 'withReact({ useLegacyHtmlPlugin: true })';
newContents =
newContents.substring(0, startPos) +
newCallText +
newContents.substring(endPos);
}
}
if (newContents !== configContents) {
tree.write(configPath, newContents);
}
} }
function transformComposePlugins( function transformComposePlugins(

View File

@ -11,6 +11,111 @@ export function transformEsmConfigFile(tree: Tree, configPath: string) {
transformWithModuleFederation(tree, configPath, scope); transformWithModuleFederation(tree, configPath, scope);
transformWithModuleFederationSSR(tree, configPath, scope); transformWithModuleFederationSSR(tree, configPath, scope);
}); });
// Add useLegacyHtmlPlugin: true to withWeb() calls
transformWithWebCalls(tree, configPath);
}
function transformWithWebCalls(tree: Tree, configPath: string) {
const configContents = tree.read(configPath, 'utf-8');
const ast = tsquery.ast(configContents);
// Find withWeb() calls
const withWebCallNodes = tsquery(
ast,
'CallExpression > Identifier[name=withWeb]'
);
// If there are withWeb calls, update them
if (withWebCallNodes.length > 0) {
let newContents = configContents;
for (const node of withWebCallNodes) {
const callExpr = node.parent;
if (!callExpr) continue;
const startPos = callExpr.getStart();
const endPos = callExpr.getEnd();
const callText = configContents.substring(startPos, endPos);
// Skip if useLegacyHtmlPlugin is already present
if (callText.includes('useLegacyHtmlPlugin')) {
continue;
}
// If it's already withWeb({ ... }), add useLegacyHtmlPlugin: true to the options
if (callText.includes('{') && callText.includes('}')) {
const newCallText = callText.replace(
/\{\s*/,
'{ useLegacyHtmlPlugin: true,\n '
);
newContents =
newContents.substring(0, startPos) +
newCallText +
newContents.substring(endPos);
} else {
// If it's just withWeb(), replace with withWeb({ useLegacyHtmlPlugin: true })
const newCallText = 'withWeb({ useLegacyHtmlPlugin: true })';
newContents =
newContents.substring(0, startPos) +
newCallText +
newContents.substring(endPos);
}
}
if (newContents !== configContents) {
tree.write(configPath, newContents);
}
return;
}
// If no withWeb calls, check for withReact calls
const withReactCallNodes = tsquery(
ast,
'CallExpression > Identifier[name=withReact]'
);
if (withReactCallNodes.length === 0) {
return;
}
let newContents = configContents;
for (const node of withReactCallNodes) {
const callExpr = node.parent;
if (!callExpr) continue;
const startPos = callExpr.getStart();
const endPos = callExpr.getEnd();
const callText = configContents.substring(startPos, endPos);
// Skip if useLegacyHtmlPlugin is already present
if (callText.includes('useLegacyHtmlPlugin')) {
continue;
}
// If it's already withReact({ ... }), add useLegacyHtmlPlugin: true to the options
if (callText.includes('{') && callText.includes('}')) {
const newCallText = callText.replace(
/\{\s*/,
'{ useLegacyHtmlPlugin: true,\n '
);
newContents =
newContents.substring(0, startPos) +
newCallText +
newContents.substring(endPos);
} else {
// If it's just withReact(), replace with withReact({ useLegacyHtmlPlugin: true })
const newCallText = 'withReact({ useLegacyHtmlPlugin: true })';
newContents =
newContents.substring(0, startPos) +
newCallText +
newContents.substring(endPos);
}
}
if (newContents !== configContents) {
tree.write(configPath, newContents);
}
} }
function transformComposePlugins( function transformComposePlugins(

View File

@ -4,11 +4,12 @@ import {
type RuleSetRule, type RuleSetRule,
LightningCssMinimizerRspackPlugin, LightningCssMinimizerRspackPlugin,
DefinePlugin, DefinePlugin,
HtmlRspackPlugin,
CssExtractRspackPlugin, CssExtractRspackPlugin,
EnvironmentPlugin, EnvironmentPlugin,
RspackOptionsNormalized, RspackOptionsNormalized,
HtmlRspackPlugin,
} from '@rspack/core'; } from '@rspack/core';
import { WriteIndexHtmlPlugin } from '../write-index-html-plugin';
import { instantiateScriptPlugins } from './instantiate-script-plugins'; import { instantiateScriptPlugins } from './instantiate-script-plugins';
import { join, resolve } from 'path'; import { join, resolve } from 'path';
import { getOutputHashFormat } from './hash-format'; import { getOutputHashFormat } from './hash-format';
@ -66,16 +67,32 @@ export function applyWebConfig(
plugins.push(...instantiateScriptPlugins(options)); plugins.push(...instantiateScriptPlugins(options));
} }
if (options.index && options.generateIndexHtml) { if (options.index && options.generateIndexHtml) {
plugins.push( if (options.useLegacyHtmlPlugin) {
new HtmlRspackPlugin({ plugins.push(
template: options.index, new WriteIndexHtmlPlugin({
sri: options.subresourceIntegrity ? 'sha256' : undefined, indexPath: options.index,
...(options.baseHref ? { base: { href: options.baseHref } } : {}), outputPath: 'index.html',
...(config.output?.scriptType === 'module' baseHref:
? { scriptLoading: 'module' } typeof options.baseHref === 'string' ? options.baseHref : undefined,
: {}), sri: options.subresourceIntegrity,
}) scripts: options.scripts,
); styles: options.styles,
crossOrigin:
config.output?.scriptType === 'module' ? 'anonymous' : undefined,
})
);
} else {
plugins.push(
new HtmlRspackPlugin({
template: options.index,
sri: options.subresourceIntegrity ? 'sha256' : undefined,
...(options.baseHref ? { base: { href: options.baseHref } } : {}),
...(config.output?.scriptType === 'module'
? { scriptLoading: 'module' }
: {}),
})
);
}
} }
const minimizer: RspackPluginInstance[] = []; const minimizer: RspackPluginInstance[] = [];

View File

@ -247,6 +247,10 @@ export interface NxAppRspackPluginOptions {
* Whether to rebase absolute path for assets in postcss cli resources. * Whether to rebase absolute path for assets in postcss cli resources.
*/ */
rebaseRootRelative?: boolean; rebaseRootRelative?: boolean;
/**
* Use the legacy WriteIndexHtmlPlugin instead of the built-in HtmlRspackPlugin.
*/
useLegacyHtmlPlugin?: boolean;
} }
export interface NormalizedNxAppRspackPluginOptions export interface NormalizedNxAppRspackPluginOptions
@ -260,4 +264,5 @@ export interface NormalizedNxAppRspackPluginOptions
projectGraph: ProjectGraph; projectGraph: ProjectGraph;
outputFileName: string; outputFileName: string;
assets: AssetGlobPattern[]; assets: AssetGlobPattern[];
useLegacyHtmlPlugin: boolean;
} }

View File

@ -106,6 +106,8 @@ export function normalizeOptions(
), ),
generateIndexHtml: generateIndexHtml:
combinedPluginAndMaybeExecutorOptions.generateIndexHtml ?? true, combinedPluginAndMaybeExecutorOptions.generateIndexHtml ?? true,
useLegacyHtmlPlugin:
combinedPluginAndMaybeExecutorOptions.useLegacyHtmlPlugin ?? false,
main: combinedPluginAndMaybeExecutorOptions.main, main: combinedPluginAndMaybeExecutorOptions.main,
namedChunks: combinedPluginAndMaybeExecutorOptions.namedChunks ?? !isProd, namedChunks: combinedPluginAndMaybeExecutorOptions.namedChunks ?? !isProd,
optimization: combinedPluginAndMaybeExecutorOptions.optimization ?? isProd, optimization: combinedPluginAndMaybeExecutorOptions.optimization ?? isProd,

View File

@ -0,0 +1,378 @@
import * as rspack from '@rspack/core';
import { Compiler } from '@rspack/core';
import { createHash } from 'crypto';
import { readFileSync } from 'fs';
import { EmittedFile, ExtraEntryPoint } from '../utils/model';
import { interpolateEnvironmentVariablesToIndex } from '../utils/webpack/interpolate-env-variables-to-index';
import { generateEntryPoints } from '../utils/webpack/package-chunk-sort';
import { extname } from 'path';
const parse5 = require('parse5');
export interface WriteIndexHtmlOptions {
indexPath: string;
outputPath: string;
baseHref?: string;
deployUrl?: string;
sri?: boolean;
scripts?: ExtraEntryPoint[];
styles?: ExtraEntryPoint[];
crossOrigin?: 'none' | 'anonymous' | 'use-credentials';
}
export class WriteIndexHtmlPlugin {
constructor(private readonly options: WriteIndexHtmlOptions) {}
apply(compiler: Compiler) {
const {
outputPath,
indexPath,
baseHref,
deployUrl,
sri = false,
scripts = [],
styles = [],
crossOrigin,
} = this.options;
compiler.hooks.thisCompilation.tap(
'WriteIndexHtmlPlugin',
(compilation) => {
compilation.hooks.processAssets.tap(
{
name: 'WriteIndexHtmlPlugin',
// After minification and sourcemaps are done
stage: rspack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE_INLINE,
},
() => {
const moduleFiles = this.getEmittedFiles(compilation);
const files = moduleFiles.filter((x) => x.extension === '.css');
let content = readFileSync(indexPath).toString();
content = this.stripBom(content);
compilation.assets[outputPath] = this.augmentIndexHtml({
input: outputPath,
inputContent: interpolateEnvironmentVariablesToIndex(
content,
deployUrl
),
baseHref,
deployUrl,
crossOrigin,
sri,
entrypoints: generateEntryPoints({ scripts, styles }),
files: this.filterAndMapBuildFiles(files, ['.js', '.css']),
moduleFiles: this.filterAndMapBuildFiles(moduleFiles, ['.js']),
loadOutputFile: (filePath) =>
compilation.assets[filePath].source().toString(),
});
}
);
}
);
}
private getEmittedFiles(compilation: rspack.Compilation): EmittedFile[] {
const files: EmittedFile[] = [];
// adds all chunks to the list of emitted files such as lazy loaded modules
for (const chunk of compilation.chunks) {
for (const file of chunk.files) {
files.push({
// The id is guaranteed to exist at this point in the compilation process
id: chunk.id.toString(),
name: chunk.name,
file,
extension: extname(file),
initial: chunk.isOnlyInitial(),
});
}
}
// other all files
for (const file of Object.keys(compilation.assets)) {
files.push({
file,
extension: extname(file),
initial: false,
asset: true,
});
}
// dedupe
return files.filter(
({ file, name }, index) =>
files.findIndex(
(f) => f.file === file && (!name || name === f.name)
) === index
);
}
private stripBom(data: string) {
return data.replace(/^\uFEFF/, '');
}
private augmentIndexHtml(params: {
/* Input file name (e. g. index.html) */
input: string;
/* Input contents */
inputContent: string;
baseHref?: string;
deployUrl?: string;
sri: boolean;
/** crossorigin attribute setting of elements that provide CORS support */
crossOrigin?: 'none' | 'anonymous' | 'use-credentials';
/*
* Files emitted by the build.
*/
files: {
file: string;
name: string;
extension: string;
}[];
/** Files that should be added using 'module'. */
moduleFiles?: {
file: string;
name: string;
extension: string;
}[];
/*
* Function that loads a file used.
* This allows us to use different routines within the IndexHtmlWebpackPlugin and
* when used without this plugin.
*/
loadOutputFile: (file: string) => string;
/** Used to sort the inseration of files in the HTML file */
entrypoints: string[];
}): rspack.sources.Source {
const { loadOutputFile, files, moduleFiles = [], entrypoints } = params;
let { crossOrigin = 'none' } = params;
if (params.sri && crossOrigin === 'none') {
crossOrigin = 'anonymous';
}
const stylesheets = new Set<string>();
const scripts = new Set<string>();
// Sort files in the order we want to insert them by entrypoint and dedupes duplicates
const mergedFiles = [...moduleFiles, ...files];
for (const entrypoint of entrypoints) {
for (const { extension, file, name } of mergedFiles) {
if (name !== entrypoint) {
continue;
}
switch (extension) {
case '.js':
scripts.add(file);
break;
case '.css':
stylesheets.add(file);
break;
}
}
}
// Find the head and body elements
const treeAdapter = parse5.treeAdapters.default;
const document = parse5.parse(params.inputContent, {
treeAdapter,
locationInfo: true,
});
let headElement;
let bodyElement;
for (const docChild of document.childNodes) {
if (docChild.tagName === 'html') {
for (const htmlChild of docChild.childNodes) {
if (htmlChild.tagName === 'head') {
headElement = htmlChild;
} else if (htmlChild.tagName === 'body') {
bodyElement = htmlChild;
}
}
}
}
if (!headElement || !bodyElement) {
throw new Error('Missing head and/or body elements');
}
// Determine script insertion point
let scriptInsertionPoint;
if (bodyElement.__location && bodyElement.__location.endTag) {
scriptInsertionPoint = bodyElement.__location.endTag.startOffset;
} else {
// Less accurate fallback
// parse5 4.x does not provide locations if malformed html is present
scriptInsertionPoint = params.inputContent.indexOf('</body>');
}
let styleInsertionPoint;
if (headElement.__location && headElement.__location.endTag) {
styleInsertionPoint = headElement.__location.endTag.startOffset;
} else {
// Less accurate fallback
// parse5 4.x does not provide locations if malformed html is present
styleInsertionPoint = params.inputContent.indexOf('</head>');
}
// Inject into the html
const indexSource = new rspack.sources.ReplaceSource(
new rspack.sources.RawSource(params.inputContent),
params.input
);
let scriptElements = '';
for (const script of scripts) {
const attrs: { name: string; value: string | null }[] = [
{ name: 'src', value: (params.deployUrl || '') + script },
];
if (crossOrigin !== 'none') {
attrs.push({ name: 'crossorigin', value: crossOrigin });
}
// We want to include nomodule or module when a file is not common amongs all
// such as runtime.js
const scriptPredictor = ({
file,
}: {
file: string;
name: string;
extension: string;
}): boolean => file === script;
if (!files.some(scriptPredictor)) {
// in some cases for differential loading file with the same name is avialable in both
// nomodule and module such as scripts.js
// we shall not add these attributes if that's the case
const isModuleType = moduleFiles.some(scriptPredictor);
if (isModuleType) {
attrs.push({ name: 'type', value: 'module' });
} else {
attrs.push({ name: 'defer', value: null });
}
} else {
attrs.push({ name: 'type', value: 'module' });
}
if (params.sri) {
const content = loadOutputFile(script);
attrs.push(...this.generateSriAttributes(content));
}
const attributes = attrs
.map((attr) =>
attr.value === null ? attr.name : `${attr.name}="${attr.value}"`
)
.join(' ');
scriptElements += `<script ${attributes}></script>`;
}
indexSource.insert(scriptInsertionPoint, scriptElements);
// Adjust base href if specified
if (typeof params.baseHref == 'string') {
let baseElement;
for (const headChild of headElement.childNodes) {
if (headChild.tagName === 'base') {
baseElement = headChild;
}
}
const baseFragment = treeAdapter.createDocumentFragment();
if (!baseElement) {
baseElement = treeAdapter.createElement('base', undefined, [
{ name: 'href', value: params.baseHref },
]);
treeAdapter.appendChild(baseFragment, baseElement);
indexSource.insert(
headElement.__location.startTag.endOffset,
parse5.serialize(baseFragment, { treeAdapter })
);
} else {
let hrefAttribute;
for (const attribute of baseElement.attrs) {
if (attribute.name === 'href') {
hrefAttribute = attribute;
}
}
if (hrefAttribute) {
hrefAttribute.value = params.baseHref;
} else {
baseElement.attrs.push({ name: 'href', value: params.baseHref });
}
treeAdapter.appendChild(baseFragment, baseElement);
indexSource.replace(
baseElement.__location.startOffset,
baseElement.__location.endOffset,
parse5.serialize(baseFragment, { treeAdapter })
);
}
}
const styleElements = treeAdapter.createDocumentFragment();
for (const stylesheet of stylesheets) {
const attrs = [
{ name: 'rel', value: 'stylesheet' },
{ name: 'href', value: (params.deployUrl || '') + stylesheet },
];
if (crossOrigin !== 'none') {
attrs.push({ name: 'crossorigin', value: crossOrigin });
}
if (params.sri) {
const content = loadOutputFile(stylesheet);
attrs.push(...this.generateSriAttributes(content));
}
const element = treeAdapter.createElement('link', undefined, attrs);
treeAdapter.appendChild(styleElements, element);
}
indexSource.insert(
styleInsertionPoint,
parse5.serialize(styleElements, { treeAdapter })
);
return indexSource;
}
private generateSriAttributes(content: string) {
const algo = 'sha384';
const hash = createHash(algo).update(content, 'utf8').digest('base64');
return [{ name: 'integrity', value: `${algo}-${hash}` }];
}
private filterAndMapBuildFiles(
files: EmittedFile[],
extensionFilter: string[]
): {
file: string;
name: string;
extension: string;
}[] {
const filteredFiles: {
file: string;
name: string;
extension: string;
}[] = [];
// This test excludes files generated by HMR (e.g. main.hot-update.js).
const hotUpdateAsset = /hot-update\.[cm]?js$/;
for (const { file, name, extension, initial } of files) {
if (
name &&
initial &&
extensionFilter.includes(extension) &&
!hotUpdateAsset.test(file)
) {
filteredFiles.push({ file, extension, name });
}
}
return filteredFiles;
}
}

View File

@ -0,0 +1,39 @@
export function interpolateEnvironmentVariablesToIndex(
contents: string,
deployUrl?: string
): string {
const environmentVariables = getClientEnvironment(deployUrl || '');
return interpolateEnvironmentVariables(contents, environmentVariables as any);
}
const NX_PREFIX = /^NX_PUBLIC_/i;
function isNxEnvironmentKey(x: string): boolean {
return NX_PREFIX.test(x);
}
function getClientEnvironment(deployUrl: string) {
return Object.keys(process.env)
.filter(isNxEnvironmentKey)
.reduce(
(env, key) => {
env[key] = process.env[key];
return env;
},
{
NODE_ENV: process.env.NODE_ENV || 'development',
DEPLOY_URL: deployUrl || process.env.DEPLOY_URL || '',
}
);
}
function interpolateEnvironmentVariables(
documentContents: string,
environmentVariables: Record<string, string>
): string {
let temp = documentContents;
for (const [key, value] of Object.entries(environmentVariables)) {
temp = temp.replace(new RegExp(`%${key}%`, 'g'), value);
}
return temp;
}

View File

@ -0,0 +1,30 @@
import { ExtraEntryPoint, NormalizedEntryPoint } from '../model';
export function normalizeExtraEntryPoints(
extraEntryPoints: ExtraEntryPoint[],
defaultBundleName: string
): NormalizedEntryPoint[] {
return extraEntryPoints.map((entry) => {
let normalizedEntry;
if (typeof entry === 'string') {
normalizedEntry = {
input: entry,
inject: true,
bundleName: defaultBundleName,
};
} else {
const { inject = true, ...newEntry } = entry;
let bundleName;
if (entry.bundleName) {
bundleName = entry.bundleName;
} else {
bundleName = defaultBundleName;
}
normalizedEntry = { ...newEntry, bundleName };
}
return normalizedEntry;
});
}

View File

@ -0,0 +1,53 @@
import { ExtraEntryPoint } from '../model';
import { normalizeExtraEntryPoints } from './normalize-entry';
export function generateEntryPoints(appConfig: {
styles: ExtraEntryPoint[];
scripts: ExtraEntryPoint[];
}) {
// Add all styles/scripts, except lazy-loaded ones.
const extraEntryPoints = (
extraEntryPoints: ExtraEntryPoint[],
defaultBundleName: string
): string[] => {
const entryPoints = normalizeExtraEntryPoints(
extraEntryPoints,
defaultBundleName
).map((entry) => entry.bundleName);
// remove duplicates
return [...new Set(entryPoints)];
};
const styleEntryPoints = appConfig.styles.filter(
(style) => !(typeof style !== 'string' && !style.inject)
);
const scriptEntryPoints = appConfig.scripts.filter(
(script) => !(typeof script !== 'string' && !script.inject)
);
const entryPoints = [
'runtime',
'polyfills',
'sw-register',
...extraEntryPoints(styleEntryPoints, 'styles'),
...extraEntryPoints(scriptEntryPoints, 'scripts'),
'vendor',
'main',
];
const duplicates = [
...new Set(
entryPoints.filter(
(x) => entryPoints.indexOf(x) !== entryPoints.lastIndexOf(x)
)
),
];
if (duplicates.length > 0) {
throw new Error(
`Multiple bundles have been named the same: '${duplicates.join(`', '`)}'.`
);
}
return entryPoints;
}

View File

@ -40,6 +40,7 @@ export function withNx(
targetName: context.targetName, targetName: context.targetName,
configurationName: context.configurationName, configurationName: context.configurationName,
projectGraph: context.projectGraph, projectGraph: context.projectGraph,
useLegacyHtmlPlugin: pluginOptions.useLegacyHtmlPlugin ?? false,
}, },
config config
); );

View File

@ -20,6 +20,10 @@ export interface WithWebOptions {
}; };
cssModules?: boolean; cssModules?: boolean;
ssr?: boolean; ssr?: boolean;
/**
* Use the legacy WriteIndexHtmlPlugin instead of the built-in HtmlRspackPlugin.
*/
useLegacyHtmlPlugin?: boolean;
} }
const processed = new Set(); const processed = new Set();
@ -42,6 +46,7 @@ export function withWeb(pluginOptions: WithWebOptions = {}) {
targetName: context.targetName, targetName: context.targetName,
configurationName: context.configurationName, configurationName: context.configurationName,
projectGraph: context.projectGraph, projectGraph: context.projectGraph,
useLegacyHtmlPlugin: pluginOptions.useLegacyHtmlPlugin ?? false,
}, },
config config
); );