Skip to content
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

feat: insert <link rel=preload> for static file #98

Open
wants to merge 1 commit 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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,9 @@ Adds a Subresource Integrity (SRI) hash in the integrity attribute when generati
Path to the `node_modules` folder to "serve" packages from. This is used to determinate what version to request for packages from the CDN.

If not provided, the value returned by `process.cwd()` is used.
##### `preload`:`boolean` | `false`

Adds a `<link rel="preload">` tag for each static file. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Preloading_content) for more information.
### Contribution

This is a pretty simple plugin and caters mostly for my needs. However, I have made it as flexible and customizable as possible.
Expand Down
152 changes: 101 additions & 51 deletions module.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ const assetEmptyPrefix = /^\.\//;
const backSlashes = /\\/g;
const nodeModulesRegex = /[\\/]node_modules[\\/].+?[\\/](.*)/;
const DEFAULT_MODULE_KEY = 'defaultCdnModuleKey____';
const preloadDirective = {
'.js': 'script',
'.css': 'style',
'.woff': 'font',
'.woff2': 'font',
'.jpeg': 'image',
'.jpg': 'image',
'.gif': 'image',
'.png': 'image',
'.svg': 'image',
};

class WebpackCdnPlugin {
constructor({
Expand All @@ -18,6 +29,7 @@ class WebpackCdnPlugin {
prodUrl = 'https://unpkg.com/:name@:version/:path',
devUrl = ':name/:path',
publicPath,
preload = false,
optimize = false,
crossOrigin = false,
sri = false,
Expand All @@ -31,6 +43,8 @@ class WebpackCdnPlugin {
this.crossOrigin = crossOrigin;
this.sri = sri;
this.pathToNodeModules = pathToNodeModules;
this.preload = preload !== false;
this.preloads = [];
}

apply(compiler) {
Expand Down Expand Up @@ -69,11 +83,13 @@ class WebpackCdnPlugin {
WebpackCdnPlugin._cleanModules(modules, this.pathToNodeModules);

modules = modules.filter((module) => module.version);

data.assets.js = WebpackCdnPlugin._getJs(modules, ...getArgs).concat(data.assets.js);
data.assets.css = WebpackCdnPlugin._getCss(modules, ...getArgs).concat(
const js = WebpackCdnPlugin._getJs(modules, ...getArgs);
data.assets.js = js.concat(data.assets.js);
const css = WebpackCdnPlugin._getCss(modules, ...getArgs);
data.assets.css = css.concat(
data.assets.css,
);
this.preloads = [...js, ...css];

if (this.prefix === empty) {
WebpackCdnPlugin._assetNormalize(data.assets.js);
Expand All @@ -97,65 +113,99 @@ class WebpackCdnPlugin {
});

compiler.options.externals = externals;

if (this.prod && (this.crossOrigin || this.sri)) {
compiler.hooks.afterPlugins.tap('WebpackCdnPlugin', () => {
compiler.hooks.thisCompilation.tap('WebpackCdnPlugin', () => {
compiler.hooks.compilation.tap('HtmlWebpackPluginHooks', (compilation) => {
WebpackCdnPlugin._getHtmlHook(compilation, 'alterAssetTags', 'htmlWebpackPluginAlterAssetTags').tapPromise(
'WebpackCdnPlugin',
this.alterAssetTags.bind(this),
);
});
compiler.hooks.afterPlugins.tap('WebpackCdnPlugin', () => {
compiler.hooks.thisCompilation.tap('WebpackCdnPlugin', () => {
compiler.hooks.compilation.tap('HtmlWebpackPluginHooks', (compilation) => {
WebpackCdnPlugin._getHtmlHook(compilation, 'alterAssetTags', 'htmlWebpackPluginAlterAssetTags').tapPromise(
'WebpackCdnPlugin',
this.alterAssetTags.bind(this),
);
});
});
}
});
}

async alterAssetTags(pluginArgs) {
const getProdUrlPrefixes = () => {
const urls = this.modules[Reflect.ownKeys(this.modules)[0]]
.filter((m) => m.prodUrl).map((m) => m.prodUrl);
urls.push(this.url);
return [...new Set(urls)].map((url) => url.split('/:')[0]);
};

const prefixes = getProdUrlPrefixes();

const filterTag = (tag) => {
const url = (tag.tagName === 'script' && tag.attributes.src)
|| (tag.tagName === 'link' && tag.attributes.href);
return url && prefixes.filter((prefix) => url.indexOf(prefix) === 0).length !== 0;
};

const processTag = async (tag) => {
if (this.crossOrigin) {
tag.attributes.crossorigin = this.crossOrigin;
if (this.preload && pluginArgs.plugin.options.preload !== false) {
const links = this.preloads.map((href) => this.createResourceHintTag(href, pluginArgs));
/* istanbul ignore else */
if (pluginArgs.assetTags) {
pluginArgs.assetTags.styles = links.concat(pluginArgs.assetTags.styles);
} else {
await Promise.all(pluginArgs.head = links.concat(pluginArgs.head));
}
if (this.sri) {
let url;
if (tag.tagName === 'link') {
url = tag.attributes.href;
}
if (tag.tagName === 'script') {
url = tag.attributes.src;
}
if (this.prod && (this.crossOrigin || this.sri)) {
const getProdUrlPrefixes = () => {
const urls = this.modules[Reflect.ownKeys(this.modules)[0]]
.filter((m) => m.prodUrl).map((m) => m.prodUrl);
urls.push(this.url);
return [...new Set(urls)].map((url) => url.split('/:')[0]);
};

const prefixes = getProdUrlPrefixes();

const filterTag = (tag) => {
const url = (tag.tagName === 'script' && tag.attributes.src)
|| (tag.tagName === 'link' && tag.attributes.href);
return url && prefixes.filter((prefix) => url.indexOf(prefix) === 0).length !== 0;
};

const processTag = async (tag) => {
if (this.crossOrigin) {
tag.attributes.crossorigin = this.crossOrigin;
}
try {
tag.attributes.integrity = await createSri(url);
} catch (e) {
throw new Error(`Failed to generate hash for resource ${url}.\n${e}`);
if (this.sri) {
let url;
if (tag.tagName === 'link') {
url = tag.attributes.href;
}
if (tag.tagName === 'script') {
url = tag.attributes.src;
}
try {
tag.attributes.integrity = await createSri(url);
} catch (e) {
throw new Error(`Failed to generate hash for resource ${url}.\n${e}`);
}
}
};

/* istanbul ignore next */
if (pluginArgs.assetTags) {
await Promise.all(pluginArgs.assetTags.scripts.filter(filterTag).map(processTag));
await Promise.all(pluginArgs.assetTags.styles.filter(filterTag).map(processTag));
} else {
await Promise.all(pluginArgs.head.filter(filterTag).map(processTag));
await Promise.all(pluginArgs.body.filter(filterTag).map(processTag));
}
};
}
}

/* istanbul ignore next */
if (pluginArgs.assetTags) {
await Promise.all(pluginArgs.assetTags.scripts.filter(filterTag).map(processTag));
await Promise.all(pluginArgs.assetTags.styles.filter(filterTag).map(processTag));
} else {
await Promise.all(pluginArgs.head.filter(filterTag).map(processTag));
await Promise.all(pluginArgs.body.filter(filterTag).map(processTag));
/**
* The as attribute's value must be a valid request destination.
* If the provided value is omitted, the value is initialized to the empty string.
*
* @see https://w3c.github.io/preload/#link-element-interface-extensions
*
*/
createResourceHintTag(href, pluginArgs) {
const attributes = {
rel: 'preload',
href,
};
const ext = path.extname(href);
if (preloadDirective[ext]) {
attributes.as = preloadDirective[ext];
}
if (this.crossOrigin) {
attributes.crossorigin = this.crossOrigin;
}
return {
tagName: 'link',
selfClosingTag: !!pluginArgs.plugin.options.xhtml,
attributes,
};
}

/**
Expand Down
40 changes: 37 additions & 3 deletions spec/webpack.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ const WebpackCdnPlugin = require('../module');

const cssMatcher = /<link href="([^"]+?)" rel="stylesheet"( crossorigin="anonymous")?( integrity="sha.[^"]+?")?>/g;
const jsMatcher = /<script(?: type="text\/javascript")? src="([^"]+?)"( crossorigin="anonymous")?( integrity="sha[^"]+?")?>/g;

const cssPreloadMatcher = /<link rel="preload" href="([^"]+?.css)" as="style"( crossorigin="anonymous")?>/g;
const jsPreloadMatcher = /<link rel="preload" href="([^"]+?.js)" as="script"( crossorigin="anonymous")?>/g;
let cssAssets;
let jsAssets;
let cssAssets2;
Expand All @@ -18,6 +19,10 @@ let cssCrossOrigin;
let jsCrossOrigin;
let cssCrossOrigin2;
let jsCrossOrigin2;
let cssPreload;
let jsPreload;
let cssPreload2;
let jsPreload2;

const versions = {
jasmine: WebpackCdnPlugin.getVersionInNodeModules('jasmine'),
Expand All @@ -43,6 +48,10 @@ function runWebpack(callback, config) {
jsCrossOrigin = [];
cssCrossOrigin2 = [];
jsCrossOrigin2 = [];
cssPreload = [];
jsPreload = [];
cssPreload2 = [];
jsPreload2 = [];

const compiler = webpack(config);
compiler.outputFileSystem = fs;
Expand Down Expand Up @@ -83,7 +92,18 @@ function runWebpack(callback, config) {
jsSri2.push(sriMatches[1]);
}
}

while ((matches = cssPreloadMatcher.exec(html))) {
cssPreload.push(/rel="preload"/.test(matches[0]) && /as="style"/.test(matches[0]));
}
while ((matches = cssPreloadMatcher.exec(html2))) {
cssPreload2.push(/rel="preload"/.test(matches[0]) && /as="style"/.test(matches[2]));
}
while ((matches = jsPreloadMatcher.exec(html))) {
jsPreload.push(/rel="preload"/.test(matches[0]) && /as="script"/.test(matches[0]));
}
while ((matches = jsPreloadMatcher.exec(html2))) {
jsPreload2.push(/rel="preload"/.test(matches[0]) && /as="script"/.test(matches[0]));
}
callback();
});
}
Expand All @@ -100,6 +120,7 @@ function getConfig({
optimize,
crossOrigin,
sri,
preload,
}) {
const output = {
path: path.join(__dirname, 'dist/assets'),
Expand Down Expand Up @@ -183,12 +204,12 @@ function getConfig({
optimize,
crossOrigin,
sri,
preload,
};

if (publicPath !== undefined) {
options.publicPath = publicPath;
}

return {
mode: prod ? 'production' : 'development',
entry: path.join(__dirname, '../example/app.js'),
Expand Down Expand Up @@ -628,5 +649,18 @@ describe('Webpack Integration', () => {
expect(jsAssets).toEqual(['/jasmine/lib/jasmine.js', '/app.js']);
});
});
describe('When `preload` is set', () => {
beforeAll((done) => {
runWebpack(done, getConfig({ prod: true, preload: true, crossOrigin: 'anonymous' }));
});

it('should output the right assets preload (css)', () => {
expect(cssPreload).toEqual([true, true, true]);
});

it('should output the right assets preload (js)', () => {
expect(jsPreload).toEqual([true, true, true, true]);
});
});
});
});