diff --git a/lib/media.js b/lib/media.js index a6b2723..7be79c6 100644 --- a/lib/media.js +++ b/lib/media.js @@ -8,6 +8,8 @@ const _ = require('lodash'), styleguide = require('./styleguide'), STYLE_TAG = 'style', SCRIPT_TAG = 'scripts', + MODULE_SCRIPT_TAG = 'module', + MODULEPRELOAD_TAG = 'modulepreload', MEDIA_DIRECTORY = path.join(process.cwd(), 'public'); /** @@ -146,15 +148,35 @@ function appendMediaToBottom(scripts, html) { return splice(html, index, scripts); } -function injectTags(fileArray, site, tag) { +/** + * Build inline /`; + } else if (tag === MODULE_SCRIPT_TAG) { + return ``; + } else if (tag === MODULEPRELOAD_TAG) { + // tells the browser to fetch ESM scripts early, + // during HTML parsing, before reaching the `; }).join('\n')); } @@ -312,6 +334,14 @@ function configure(options, cacheBuster = '') { if (options && _.isObject(options)) { module.exports.editStylesTags = options.styles || false; module.exports.editScriptsTags = options.scripts || false; + // modulepreload: when true, hints are injected + // into for ESM scripts, and ?version= is omitted from module URLs + // since content-hashed filenames already provide cache busting. + // Opt-in only — defaults to false for backwards compatibility. + if (options.modulepreload !== undefined) { + module.exports.modulepreload = !!options.modulepreload; + module.exports.omitCacheBusterOnModules = !!options.modulepreload; + } } else { module.exports.editStylesTags = options; module.exports.editScriptsTags = options; @@ -331,6 +361,7 @@ function configure(options, cacheBuster = '') { * @param {Object} state * @return {Function} */ +/* eslint-disable complexity */ function injectScriptsAndStyles(state) { const { locals } = state; @@ -346,24 +377,50 @@ function injectScriptsAndStyles(state) { mediaMap = module.exports.getMediaMap(state); // allow site to change the media map before applying it + // Expose rendered component names so resolveMedia can do per-component script resolution + // (e.g. pack-next manifest lookup) without needing a reference to the full state object. + locals._components = state._components; if (setup.resolveMedia) mediaMap = setup.resolveMedia(mediaMap, locals) || mediaMap; + // moduleScripts: ESM scripts (e.g. from esbuild pack-next) that need type="module" + const moduleScriptFiles = mediaMap.moduleScripts || []; + + mediaMap.moduleScripts = moduleScriptFiles.length + ? injectTags(moduleScriptFiles, MODULE_SCRIPT_TAG) + : bluebird.resolve(false); + + // modulePreloads: hints for . + // Only active when configure({ modulepreload: true }) has been called — opt-in so + // sites not using the clay build pipeline are completely unaffected. + const modulePreloadFiles = module.exports.modulepreload + ? mediaMap.modulePreloads || [] + : []; + + mediaMap.modulePreloads = modulePreloadFiles.length + ? injectTags(modulePreloadFiles, MODULEPRELOAD_TAG) + : bluebird.resolve(false); + if (!locals.edit) { mediaMap.styles = combineFileContents(mediaMap.styles, 'public/css', '/css/', STYLE_TAG); mediaMap.scripts = combineFileContents(mediaMap.scripts, 'public/js', '/js/', SCRIPT_TAG); } else { - mediaMap.styles = module.exports.editStylesTags ? injectTags(mediaMap.styles, locals.site, STYLE_TAG) : combineFileContents(mediaMap.styles, 'public/css', '/css/', STYLE_TAG); - mediaMap.scripts = module.exports.editScriptsTags ? injectTags(mediaMap.scripts, locals.site, SCRIPT_TAG) : combineFileContents(mediaMap.scripts, 'public/js', '/js/', SCRIPT_TAG); + mediaMap.styles = module.exports.editStylesTags ? injectTags(mediaMap.styles, STYLE_TAG) : combineFileContents(mediaMap.styles, 'public/css', '/css/', STYLE_TAG); + mediaMap.scripts = module.exports.editScriptsTags ? injectTags(mediaMap.scripts, SCRIPT_TAG) : combineFileContents(mediaMap.scripts, 'public/js', '/js/', SCRIPT_TAG); } return bluebird.props(mediaMap) .then(combinedFiles => { + // modulepreload hints go first in , before CSS, so the browser can + // start fetching ESM scripts at the earliest possible moment. + html = combinedFiles.modulePreloads ? appendMediaToTop(combinedFiles.modulePreloads, html) : html; html = combinedFiles.styles ? appendMediaToTop(combinedFiles.styles, html) : html; // If there are styles, append them + html = combinedFiles.moduleScripts ? appendMediaToBottom(combinedFiles.moduleScripts, html) : html; // ESM module scripts (type="module") html = combinedFiles.scripts ? appendMediaToBottom(combinedFiles.scripts, html) : html; // If there are scripts, append them return html; // Return the compiled HTML }); }; } +/* eslint-enable complexity */ module.exports.injectScriptsAndStyles = injectScriptsAndStyles; module.exports.getMediaMap = getMediaMap; @@ -371,6 +428,10 @@ module.exports.cacheBuster = ''; module.exports.configure = configure; module.exports.editStylesTags = false; module.exports.editScriptsTags = false; +// Opt-in flags for the clay build (esbuild) pipeline. +// Enable via configure({ modulepreload: true }). +module.exports.modulepreload = false; +module.exports.omitCacheBusterOnModules = false; // For testing module.exports.getScriptFiles = getScriptFiles; diff --git a/lib/render.js b/lib/render.js index 08d3604..cee44df 100644 --- a/lib/render.js +++ b/lib/render.js @@ -39,7 +39,8 @@ function applyRenderHooks(ref, data, locals) { * @param {String} ref * @param {Object} locals * @param {Object} res - * @param {Object} self + * @param {String} self Clay ref of the component being rendered, e.g. + * "site.com/_components/article/instances/foo" * @returns {Function} */ function applyPostRenderHooks(ref, locals, res, self) { @@ -184,8 +185,12 @@ function logTime(hrStart, msg, route) { }; } -function configure({ editAssetTags, cacheBuster }) { - mediaService.configure(editAssetTags, cacheBuster); +function configure({ editAssetTags, cacheBuster, modulepreload }) { + const mediaOptions = editAssetTags && typeof editAssetTags === 'object' + ? Object.assign({}, editAssetTags, modulepreload !== undefined ? { modulepreload } : {}) + : editAssetTags; + + mediaService.configure(mediaOptions, cacheBuster); } /** diff --git a/package-lock.json b/package-lock.json index b2945d7..3a9f351 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "amphora-html", - "version": "5.1.0", + "version": "6.0.1-dev.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "amphora-html", - "version": "5.1.0", + "version": "6.0.1-dev.0", "license": "MIT", "dependencies": { "amphora-fs": "^2.0.0", diff --git a/package.json b/package.json index 6979db3..9185a35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amphora-html", - "version": "5.1.0", + "version": "6.0.1-dev.0", "description": "An HTML renderer for component data", "main": "index.js", "scripts": {