Skip to content

add preload link for chunks with webpack preload magic comment in entry point #25

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -99,4 +99,5 @@ src/*.js
__tests__/CHANGELOG-heavy.md
lib
test/dist/
package-lock.json
package-lock.json
test/**/dist
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@
"peacock.color": "#000000",
"peacock.affectActivityBar": false,
"peacock.affectTitleBar": false,
"liveServer.settings.multiRootWorkspaceName": "html-webpack-inject-preload",
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ module.exports = {
* files: An array of files object
* match: A regular expression to target files you want to preload
* attributes: Any attributes you want to use. The plugin will add the attribute `rel="preload"` by default.
* entryPointWebpackPreload: boolean. support `/* webpackPreload: true */ ` magic comment, when preloading files in initial chunk, see this [issue](https://github.com/jantimon/html-webpack-plugin/issues/1317) in detail. `crossorigin` for font will be add automatically.

**Usage**

Expand Down
161 changes: 161 additions & 0 deletions src/entry-point-webpack-preload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import type { Compilation } from 'webpack';
import type {
default as HtmlWebpackPluginInstance,
HtmlTagObject,
} from 'html-webpack-plugin';


type AlterAssetTagGroupsHookParam = Parameters<Parameters<HtmlWebpackPluginInstance.Hooks['alterAssetTagGroups']['tapAsync']>[1]>[0];

type EntryName = string;
type File = string;

export function addLinkForEntryPointWebpackPreload(
compilation: Compilation,
htmlPluginData: AlterAssetTagGroupsHookParam,
) {

// Html can contain multiple entrypoints, entries contains preloaded ChunkGroups, ChunkGroups contains chunks, chunks contains files.
// Files are what we need.

const entryFileMap = prepareEntryFileMap(compilation, htmlPluginData);

// Prepare link tags for HtmlWebpackPlugin
const publicPath = getPublicPath(compilation, htmlPluginData);
const entryHtmlTagObjectMap = generateHtmlTagObject(entryFileMap, publicPath, compilation);

// Related files's link tags should follow parent script tag (the entries scripts' tag)
// according to this [blog](https://web.dev/priority-hints/#using-preload-after-chrome-95).
alterAssetTagGroups(entryHtmlTagObjectMap, compilation, htmlPluginData);
}

function alterAssetTagGroups(entryHtmlTagObjectMap: Map<EntryName, Set<HtmlTagObject>>, compilation: Compilation, htmlPluginData: AlterAssetTagGroupsHookParam) {
for (const [entryName, linkTags] of entryHtmlTagObjectMap) {
//Find first link index to inject before, which is the script elemet for the entrypoint.
let files = compilation.entrypoints.get(entryName)?.getEntrypointChunk().files;
if (!files || files.size === 0) {
continue;
}
const lastFile = [...files][files.size - 1]
const findLastFileScriptTagIndex = tag => tag.tagName === 'script' && (tag.attributes.src as string).indexOf(lastFile) !== -1;
let linkIndex = htmlPluginData.headTags.findIndex(
findLastFileScriptTagIndex
);
if (linkIndex === -1) {
htmlPluginData.bodyTags.findIndex(findLastFileScriptTagIndex);
}
if (linkIndex === -1) {
console.warn(`cannot find entrypoints\'s script tags for entry: ${entryName}`);
continue;
};
htmlPluginData.headTags.splice(linkIndex, 0, ...linkTags);
}
}

/**
* Get entrypoints related preload files' names
*
* Html can contain multiple entrypoints, entries contains preloaded ChunkGroups, ChunkGroups contains chunks, chunks contains files.
* Files are what we need.
* @param compilation
* @param htmlPluginData
*/
function prepareEntryFileMap(
compilation: Compilation,
htmlPluginData: AlterAssetTagGroupsHookParam) {
const entryFileMap = new Map<EntryName, Set<File>>;

const entries = htmlPluginData.plugin.options?.chunks ?? 'all';
let entriesKeys = Array.isArray(entries) ? entries : Array.from(compilation.entrypoints.keys());

for (const key of entriesKeys) {
const files = new Set<string>();
const preloaded = compilation.entrypoints.get(key)?.getChildrenByOrders(compilation.moduleGraph, compilation.chunkGraph).preload;
if (!preloaded) continue;
entryFileMap.set(key, files);
// cannot get font files in `preload`
for (const group of preloaded) { // the order of preloaded is relevant
for (const chunk of group.chunks)
for (const file of chunk.files) files.add(file);
}
}

return entryFileMap;
}

/**
* Generate HtmlTagObjects for HtmlWebpackPlugin
* @param entryFileMap
* @param publicPath
* @returns
*/
function generateHtmlTagObject(entryFileMap: Map<string, Set<string>>, publicPath: string, compilation: Compilation): Map<EntryName, Set<HtmlTagObject>> {
const map = new Map();
for (const [key, filesNames] of entryFileMap) {
map.set(key, [...filesNames].map(fileName => {
const href = `${publicPath}${fileName}`;
const as = getTypeOfResource(fileName);
const crossOrigin = as === 'font' ? 'anonymous' : compilation.outputOptions.crossOriginLoading;
let attributes: HtmlTagObject['attributes'] = {
rel: 'preload',
href,
as
}
if (crossOrigin) {
attributes = { ...attributes, crossorigin: crossOrigin }
}
return {
tagName: 'link',
attributes,
voidTag: true,
meta: {
plugin: 'html-webpack-inject-preload',
},
}
}));

}
return map;
}

function getTypeOfResource(fileName: String) {
if (fileName.match(/.js$/)) {
return 'script'
}
if (fileName.match(/.css$/)) {
return 'style'
}
if (fileName.match(/.(woff2|woff|ttf|otf)$/)) {
return 'font'
}
if (fileName.match(/.(gif|jpeg|png|svg)$/)) {
return 'image'
}
}

function getPublicPath(compilation: Compilation, htmlPluginData: AlterAssetTagGroupsHookParam) {
//Get public path
//html-webpack-plugin v5
let publicPath = htmlPluginData.publicPath;

//html-webpack-plugin v4
if (typeof publicPath === 'undefined') {
if (
htmlPluginData.plugin.options?.publicPath &&
htmlPluginData.plugin.options?.publicPath !== 'auto'
) {
publicPath = htmlPluginData.plugin.options?.publicPath;
} else {
publicPath =
typeof compilation.options.output.publicPath === 'string'
? compilation.options.output.publicPath
: '/';
}

//prevent wrong url
if (publicPath[publicPath.length - 1] !== '/') {
publicPath = publicPath + '/';
}
}
return publicPath;
}
27 changes: 22 additions & 5 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,17 @@ import type {
default as HtmlWebpackPluginInstance,
HtmlTagObject,
} from 'html-webpack-plugin';
import type {Compilation, Compiler, WebpackPluginInstance} from 'webpack';
import type { Compilation, Compiler, WebpackPluginInstance } from 'webpack';
import { addLinkForEntryPointWebpackPreload } from './entry-point-webpack-preload';

declare namespace HtmlWebpackInjectPreload {
interface Options {
files: HtmlWebpackInjectPreload.File[];
files?: HtmlWebpackInjectPreload.File[];
/**
* generate link tag for `import()`s in a entry chunk
* @defaultValue false
*/
entryPointWebpackPreload?: boolean;
}

interface File {
Expand Down Expand Up @@ -46,6 +52,7 @@ interface HtmlWebpackPluginData {
class HtmlWebpackInjectPreload implements WebpackPluginInstance {
private options: HtmlWebpackInjectPreload.Options = {
files: [],
entryPointWebpackPreload: false
};

/**
Expand Down Expand Up @@ -106,6 +113,11 @@ class HtmlWebpackInjectPreload implements WebpackPluginInstance {
}
}

// generate link tag for `import()`s in a entry chunk
if (this.options.entryPointWebpackPreload) {
addLinkForEntryPointWebpackPreload(compilation, htmlPluginData);
}

//Get assets name
const assets = new Set(Object.keys(compilation.assets));
compilation.chunks.forEach(chunk => {
Expand All @@ -116,10 +128,13 @@ class HtmlWebpackInjectPreload implements WebpackPluginInstance {
const linkIndex = htmlPluginData.headTags.findIndex(
tag => tag.tagName === 'link',
);

const files = this.options.files;
if (!files) {
return;
}
assets.forEach(asset => {
for (let index = 0; index < this.options.files.length; index++) {
const file = this.options.files[index];
for (let index = 0; index < files.length; index++) {
const file = files[index];

if (file.match.test(asset)) {
let href =
Expand Down Expand Up @@ -185,3 +200,5 @@ class HtmlWebpackInjectPreload implements WebpackPluginInstance {
}

export = HtmlWebpackInjectPreload;
// exports.default = HtmlWebpackInjectPreload;
// module.exports = HtmlWebpackInjectPreload;
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
body {
background-color: blue;
font-family: Roboto, sans-serif;
}

@font-face {
font-family: 'Roboto';
font-weight: 400;
src: url('../../../../Roboto-Regular.woff2') format('woff2'),
url('../../../..//Roboto-Regular.woff') format('woff');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import './await-imported-by-async-chunk.css';
export async function awaitImportByAsyncChunk() {
console.log('awaitImportByAsyncChunk');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
body {
background-color: green;
font-family: Roboto, sans-serif;
}

@font-face {
font-family: 'Roboto';
font-weight: 400;
src: url('../../../../Roboto-Regular.woff2') format('woff2'),
url('../../../..//Roboto-Regular.woff') format('woff');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import './await-imported-by-intial-chunk.css';
export async function awaitImportByInitialChunk() {
const { awaitImportByAsyncChunk } = await import(/* webpackPreload: true */'./await-imported-by-async-chunk');
awaitImportByAsyncChunk();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
async function main() {
const { awaitImportByInitialChunk } = await import(/* webpackPreload: true */ './await-imported-by-intial-chunk');
awaitImportByInitialChunk();
}

main();
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
foo
</body>
</html>
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { webpack } from "webpack";
import { getWebpackConfig } from "../../webpack-base-config";
import path from 'path';
import fs from 'fs';
import { expectSuccessfulBuild } from "../utils";
describe('HTMLWebpackInjectPreload test entry point webpack preload with cross origin loading', () => {
it('should add crossorigin attributions when wepack output crossOriginLoading is \'anonymous\'', done => {
let config = getWebpackConfig('./fixtures/entry.js');
if (config.output) {
config.output.crossOriginLoading = 'anonymous';
}
const compiler = webpack(config);

compiler.run((err, stats) => {
expectSuccessfulBuild(err, stats);

const html = fs.readFileSync(
path.join(__dirname, 'dist/index.html'),
'utf8',
);
const preloadRegex = /<link[^>]+rel=["']preload["'][^>]+as=["']([^"']+)["'][^>]*>/;
const globalPreloadRegex = new RegExp(preloadRegex, "gm");
const result = html.match(globalPreloadRegex);
expect(result).not.toBeNull();
expect(result?.length).toBe(2);

const corssOriginRegex = /<link[^>]+rel=["']preload["'][^>]+crossorigin=["']([^"']+)["'][^>]*>/;
const cssPreload = result![0].match(corssOriginRegex);
expect(cssPreload![1]).toBe('anonymous');
const jsPreload = result![1].match(corssOriginRegex);
expect(jsPreload![1]).toBe('anonymous');
done();
});
});

it('should add crossorigin attributions when wepack output crossOriginLoading is \'use-credentials\'', done => {
let config = getWebpackConfig('./fixtures/entry.js');
if (config.output) {
config.output.crossOriginLoading = 'use-credentials';
}
const compiler = webpack(config);

compiler.run((err, stats) => {
expectSuccessfulBuild(err, stats);

const html = fs.readFileSync(
path.join(__dirname, 'dist/index.html'),
'utf8',
);
const preloadRegex = /<link[^>]+rel=["']preload["'][^>]+as=["']([^"']+)["'][^>]*>/;
const globalPreloadRegex = new RegExp(preloadRegex, "gm");
const result = html.match(globalPreloadRegex);
expect(result).not.toBeNull();
expect(result?.length).toBe(2);

const corssOriginRegex = /<link[^>]+rel=["']preload["'][^>]+crossorigin=["']([^"']+)["'][^>]*>/;
const cssPreload = result![0].match(corssOriginRegex);
expect(cssPreload![1]).toBe('use-credentials');
const jsPreload = result![1].match(corssOriginRegex);
expect(jsPreload![1]).toBe('use-credentials');
done();
});
});
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
body {
background-color: blue;
font-family: Roboto, sans-serif;
}

@font-face {
font-family: 'Roboto';
font-weight: 400;
src: url('../../../../Roboto-Regular.woff2') format('woff2'),
url('../../../..//Roboto-Regular.woff') format('woff');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import './await-imported-by-async-chunk.css';
export async function awaitImportByAsyncChunk() {
console.log('awaitImportByAsyncChunk');
}
Loading