Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,14 @@ file that was distributed with this source code.`,
// produces false positives on type-only syntax.
'no-unused-vars': 'off',
// In TypeScript files, types live in the signature, not in JSDoc.
// JSDoc is kept only for prose descriptions, so neither the tags nor
// their types are required, and parameter names (e.g. destructured
// option bags) are validated by tsc rather than the JSDoc plugin.
'jsdoc/require-param': 'off',
'jsdoc/require-param-type': 'off',
'jsdoc/require-returns': 'off',
'jsdoc/require-returns-type': 'off',
'jsdoc/check-param-names': 'off',
// tsc owns module resolution for `.ts` files (strict, build fails on
// a missing import). eslint-plugin-n is not TS-aware: from a `.ts`
// file it rewrites `.js` -> `.ts` and cannot resolve imports of
Expand Down
9 changes: 8 additions & 1 deletion lib/config-generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -253,9 +253,16 @@ class ConfigGenerator {
: '[path][name].[ext]';
}

const copyFilesLoaderPath = fileURLToPath(
// The loader path is handed to webpack, which require()s it
// directly. The published package ships the compiled `.js`
// (in dist/); when running from the `lib/` sources only the
// `.ts` exists, so fall back to it (Node strips the types).
let copyFilesLoaderPath = fileURLToPath(
import.meta.resolve('./webpack/copy-files-loader.js')
);
if (!fs.existsSync(copyFilesLoaderPath)) {
copyFilesLoaderPath = copyFilesLoaderPath.replace(/\.js$/, '.ts');
}
const copyFilesLoaderConfig = `${copyFilesLoaderPath}?${JSON.stringify({
context: entry.context
? path.resolve(this.webpackConfig.getContext(), entry.context)
Expand Down
2 changes: 1 addition & 1 deletion lib/plugins/delete-unused-entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import type { WebpackPluginInstance } from 'webpack';

import copyEntryTmpName from '../utils/copyEntryTmpName.ts';
import DeleteUnusedEntriesJSPlugin from '../webpack/delete-unused-entries-js-plugin.js';
import DeleteUnusedEntriesJSPlugin from '../webpack/delete-unused-entries-js-plugin.ts';
import type WebpackConfig from '../WebpackConfig.js';
import PluginPriorities from './plugin-priorities.ts';

Expand Down
2 changes: 1 addition & 1 deletion lib/plugins/entry-files-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

import type { WebpackPluginInstance } from 'webpack';

import { EntryPointsPlugin } from '../webpack/entry-points-plugin.js';
import { EntryPointsPlugin } from '../webpack/entry-points-plugin.ts';
import type WebpackConfig from '../WebpackConfig.js';
import PluginPriorities from './plugin-priorities.ts';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@

import path from 'path';

import type { AssetInfo, LoaderContext } from 'webpack';

interface CopyFilesLoaderOptions {
context: string;
filename: string;
regExp?: RegExp;
patternSource: string;
patternFlags: string;
}

export const raw = true; // Needed to avoid corrupted binary files

/**
Expand All @@ -20,14 +30,21 @@ export const raw = true; // Needed to avoid corrupted binary files
* - [path]: relative directory path from context, with trailing slash
* - [hash:N] or [contenthash:N]: content hash, optionally truncated to N characters
*
* @param {string} template - The filename template (e.g., '[path][name].[hash:8].[ext]')
* @param {object} data
* @param {string} data.resourcePath - Absolute path to the resource
* @param {string} data.context - Context directory for computing relative paths
* @param {string} data.contentHash - Pre-computed content hash
* @returns {string} The interpolated filename
* @param template The filename template (e.g., '[path][name].[hash:8].[ext]')
* @param data
* @param data.resourcePath Absolute path to the resource
* @param data.context Context directory for computing relative paths
* @param data.contentHash Pre-computed content hash
* @returns The interpolated filename
*/
function interpolateName(template, { resourcePath, context, contentHash }) {
function interpolateName(
template: string,
{
resourcePath,
context,
contentHash,
}: { resourcePath: string; context: string; contentHash: string }
): string {
const parsed = path.parse(resourcePath);
const ext = parsed.ext.slice(1); // Remove leading dot for file-loader compatibility
const name = parsed.name;
Expand Down Expand Up @@ -57,10 +74,13 @@ function interpolateName(template, { resourcePath, context, contentHash }) {
* native this.emitFile() API. It supports filtering files by pattern
* and customizing output paths using template placeholders.
*
* @param {Buffer} source - The raw file content
* @returns {string} An ESM module that exports the public URL of the copied file
* @param source The raw file content
* @returns An ESM module that exports the public URL of the copied file
*/
export default function loader(source) {
export default function loader(
this: LoaderContext<CopyFilesLoaderOptions>,
source: Buffer
): string {
const options = this.getOptions();

// Retrieve the real path of the resource, relative
Expand Down Expand Up @@ -99,7 +119,7 @@ export default function loader(source) {
});

// Build asset info for webpack
const assetInfo = {
const assetInfo: AssetInfo = {
sourceFilename: path.relative(this.rootContext, resourcePath).replace(/\\/g, '/'),
};

Expand All @@ -109,7 +129,7 @@ export default function loader(source) {
}

// Emit the file to the output directory
this.emitFile(outputPath, source, null, assetInfo);
this.emitFile(outputPath, source, undefined, assetInfo);

// Return a module that exports the public URL
return `export default __webpack_public_path__ + ${JSON.stringify(outputPath)};`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,25 @@
* file that was distributed with this source code.
*/

function DeleteUnusedEntriesJSPlugin(entriesToDelete = []) {
this.entriesToDelete = entriesToDelete;
}
DeleteUnusedEntriesJSPlugin.prototype.apply = function (compiler) {
const deleteEntries = (compilation) => {
// loop over output chunks
compilation.chunks.forEach((chunk) => {
// see of this chunk is one that needs its .js deleted
if (this.entriesToDelete.includes(chunk.name)) {
const removedFiles = [];
import type { Compilation, Compiler } from 'webpack';

export default class DeleteUnusedEntriesJSPlugin {
entriesToDelete: string[];

constructor(entriesToDelete: string[] = []) {
this.entriesToDelete = entriesToDelete;
}

apply(compiler: Compiler): void {
const deleteEntries = (compilation: Compilation): void => {
// loop over output chunks
compilation.chunks.forEach((chunk) => {
// see of this chunk is one that needs its .js deleted
if (typeof chunk.name !== 'string' || !this.entriesToDelete.includes(chunk.name)) {
return;
}

const removedFiles: string[] = [];

// look for main files to delete first
for (const filename of Array.from(chunk.files)) {
Expand Down Expand Up @@ -48,15 +57,13 @@ DeleteUnusedEntriesJSPlugin.prototype.apply = function (compiler) {
`Problem deleting JS entry for ${chunk.name}: ${removedFiles.length} files were deleted (${removedFiles.join(', ')})`
);
}
}
});
};
});
};

compiler.hooks.compilation.tap('DeleteUnusedEntriesJSPlugin', function (compilation) {
compilation.hooks.additionalAssets.tap('DeleteUnusedEntriesJsPlugin', function () {
deleteEntries(compilation);
compiler.hooks.compilation.tap('DeleteUnusedEntriesJSPlugin', function (compilation) {
compilation.hooks.additionalAssets.tap('DeleteUnusedEntriesJsPlugin', function () {
deleteEntries(compilation);
});
});
});
};

export default DeleteUnusedEntriesJSPlugin;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,39 +11,52 @@ import crypto from 'crypto';
import fs from 'fs';
import path from 'path';

import type webpack from 'webpack';

import copyEntryTmpName from '../utils/copyEntryTmpName.ts';

interface EntryPointsManifest {
entrypoints: Record<string, Record<string, string[]>>;
integrity?: Record<string, string>;
}

/**
* Return the file extension from a filename, without the leading dot and without the query string (if any).
*
* @param {string} filename
* @returns {string}
*/
function getFileExtension(filename) {
return path.extname(filename).slice(1).split('?')[0];
function getFileExtension(filename: string): string {
return path.extname(filename).slice(1).split('?')[0]!;
}

export class EntryPointsPlugin {
publicPath: string;
outputPath: string;
integrityAlgorithms: string[];

/**
* @param {object} options
* @param {string} options.publicPath The public path of the assets, from where they are served
* @param {string} options.outputPath The output path of the assets, from where they are saved
* @param {Array<string>} options.integrityAlgorithms The algorithms to use for the integrity hash
* @param options
* @param options.publicPath The public path of the assets, from where they are served
* @param options.outputPath The output path of the assets, from where they are saved
* @param options.integrityAlgorithms The algorithms to use for the integrity hash
*/
constructor({ publicPath, outputPath, integrityAlgorithms }) {
constructor({
publicPath,
outputPath,
integrityAlgorithms,
}: {
publicPath: string;
outputPath: string;
integrityAlgorithms: string[];
}) {
this.publicPath = publicPath;
this.outputPath = outputPath;
this.integrityAlgorithms = integrityAlgorithms;
}

/**
* @param {import('webpack').Compiler} compiler
*/
apply(compiler) {
apply(compiler: webpack.Compiler): void {
compiler.hooks.afterEmit.tapAsync(
{ name: 'EntryPointsPlugin' },
(compilation, callback) => {
const manifest = {
const manifest: EntryPointsManifest = {
entrypoints: {},
};

Expand All @@ -59,15 +72,16 @@ export class EntryPointsPlugin {
errorDetails: false,
});

for (const [entryName, entry] of Object.entries(stats.entrypoints)) {
for (const [entryName, entry] of Object.entries(stats.entrypoints ?? {})) {
// We don't want to include the temporary entry in the manifest
if (entryName === copyEntryTmpName) {
continue;
}

manifest.entrypoints[entryName] = {};
const entrypoint: Record<string, string[]> = {};
manifest.entrypoints[entryName] = entrypoint;

for (const asset of entry.assets) {
for (const asset of entry.assets ?? []) {
// We don't want to include hot-update files in the manifest
if (asset.name.includes('.hot-update.')) {
continue;
Expand All @@ -79,29 +93,26 @@ export class EntryPointsPlugin {
? `${this.publicPath}${asset.name}`
: `${this.publicPath}/${asset.name}`;

if (!(fileExtension in manifest.entrypoints[entryName])) {
manifest.entrypoints[entryName][fileExtension] = [];
}

manifest.entrypoints[entryName][fileExtension].push(assetPath);
(entrypoint[fileExtension] ??= []).push(assetPath);
}
}

if (this.integrityAlgorithms.length > 0) {
manifest.integrity = {};
const integrity: Record<string, string> = {};
manifest.integrity = integrity;

for (const entryName in manifest.entrypoints) {
for (const fileType in manifest.entrypoints[entryName]) {
for (const asset of manifest.entrypoints[entryName][fileType]) {
if (asset in manifest.integrity) {
for (const fileTypes of Object.values(manifest.entrypoints)) {
for (const assets of Object.values(fileTypes)) {
for (const asset of assets) {
if (asset in integrity) {
continue;
}

// Drop query string if any
const assetNormalized = asset.includes('?')
? asset.split('?')[0]
? asset.split('?')[0]!
: asset;
if (assetNormalized in manifest.integrity) {
if (assetNormalized in integrity) {
continue;
}

Expand All @@ -111,7 +122,7 @@ export class EntryPointsPlugin {
);

if (fs.existsSync(filePath)) {
const fileHashes = [];
const fileHashes: string[] = [];

for (const algorithm of this.integrityAlgorithms) {
const hash = crypto.createHash(algorithm);
Expand All @@ -121,7 +132,7 @@ export class EntryPointsPlugin {
fileHashes.push(`${algorithm}-${hash.digest('base64')}`);
}

manifest.integrity[asset] = fileHashes.join(' ');
integrity[asset] = fileHashes.join(' ');
}
}
}
Expand Down