Skip to content
Merged
71 changes: 66 additions & 5 deletions lib/media.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

/**
Expand Down Expand Up @@ -146,15 +148,35 @@ function appendMediaToBottom(scripts, html) {
return splice(html, index, scripts);
}

function injectTags(fileArray, site, tag) {
/**
* Build inline <link>/<script> tags for an array of asset paths. Used by edit
* mode (where assets are loaded individually rather than bundled) and by the
* Vite ESM pipeline (module / modulepreload).
*
* @param {string[]} fileArray asset paths
* @param {string} tag one of STYLE_TAG, SCRIPT_TAG, MODULE_SCRIPT_TAG, MODULEPRELOAD_TAG
* @returns {Promise<string>} newline-joined tag string
*/
function injectTags(fileArray, tag) {
var buster = module.exports.cacheBuster ? `?version=${module.exports.cacheBuster}` : '';
// When omitCacheBusterOnModules is enabled, content-hashed ESM files omit the
// ?version= query string — the hash in the filename already provides cache busting.
// Defaults to false so existing deployments are unaffected.
var moduleBuster = module.exports.omitCacheBusterOnModules ? '' : buster;

return bluebird.resolve(_.map(fileArray, function (file) {
if (tag === STYLE_TAG) {
return `<link rel="stylesheet" type="text/css" href="${file}${buster}">`;
} else {
return `<script type="text/javascript" src="${file}${buster}"></script>`;
} else if (tag === MODULE_SCRIPT_TAG) {
return `<script type="module" src="${file}${moduleBuster}"></script>`;
} else if (tag === MODULEPRELOAD_TAG) {
// <link rel="modulepreload"> tells the browser to fetch ESM scripts early,
// during HTML parsing, before reaching the <script> tags at </body>.
return `<link rel="modulepreload" href="${file}${moduleBuster}">`;
}

// Default: classic <script src=...> for SCRIPT_TAG and any other value.
return `<script type="text/javascript" src="${file}${buster}"></script>`;
}).join('\n'));
}

Expand Down Expand Up @@ -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, <link rel="modulepreload"> hints are injected
// into <head> 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;
Expand All @@ -331,6 +361,7 @@ function configure(options, cacheBuster = '') {
* @param {Object} state
* @return {Function}
*/
/* eslint-disable complexity */
function injectScriptsAndStyles(state) {
const { locals } = state;

Expand All @@ -346,31 +377,61 @@ 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: <link rel="modulepreload"> hints for <head>.
// 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 <head>, 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;
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;
Expand Down
11 changes: 8 additions & 3 deletions lib/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}

/**
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
Loading