From 62465c7f541e5b10fef9bf20ee82edfa33781cbf Mon Sep 17 00:00:00 2001 From: shangzhiyu Date: Wed, 14 Sep 2022 18:54:48 +0800 Subject: [PATCH] feat: insert for static file --- README.md | 2 + module.js | 152 ++++++++++++++++++++++++++++--------------- spec/webpack.spec.js | 40 +++++++++++- 3 files changed, 140 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 257ff1a..18170e2 100644 --- a/README.md +++ b/README.md @@ -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 `` 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. diff --git a/module.js b/module.js index 1f1bd54..a98e422 100644 --- a/module.js +++ b/module.js @@ -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({ @@ -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, @@ -31,6 +43,8 @@ class WebpackCdnPlugin { this.crossOrigin = crossOrigin; this.sri = sri; this.pathToNodeModules = pathToNodeModules; + this.preload = preload !== false; + this.preloads = []; } apply(compiler) { @@ -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); @@ -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, + }; } /** diff --git a/spec/webpack.spec.js b/spec/webpack.spec.js index 0561e1f..fcfa134 100644 --- a/spec/webpack.spec.js +++ b/spec/webpack.spec.js @@ -5,7 +5,8 @@ const WebpackCdnPlugin = require('../module'); const cssMatcher = //g; const jsMatcher = //g; - +const cssPreloadMatcher = //g; +const jsPreloadMatcher = //g; let cssAssets; let jsAssets; let cssAssets2; @@ -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'), @@ -43,6 +48,10 @@ function runWebpack(callback, config) { jsCrossOrigin = []; cssCrossOrigin2 = []; jsCrossOrigin2 = []; + cssPreload = []; + jsPreload = []; + cssPreload2 = []; + jsPreload2 = []; const compiler = webpack(config); compiler.outputFileSystem = fs; @@ -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(); }); } @@ -100,6 +120,7 @@ function getConfig({ optimize, crossOrigin, sri, + preload, }) { const output = { path: path.join(__dirname, 'dist/assets'), @@ -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'), @@ -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]); + }); + }); }); });