amphora-html 6.0.1-dev.0 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,6 +5,8 @@ The HTML renderer for Clay components that use [Handlebars](http://handlebarsjs.
5
5
  ## Install
6
6
  `$ npm install --save amphora-html`
7
7
 
8
+ Requires Node 26.12.1 or later. Templates are rendered with [clayhandlebars](https://github.com/clay/handlebars) 6, which comes with amphora-html: you don't need to install it or Handlebars yourself.
9
+
8
10
  ## The Why
9
11
 
10
12
  HTML rendering was controlled entirely by [Amphora](https://github.com/nymag/amphora) in v2.x, which meant that Amphora was responsible for a lot of heavy lifting. Furthermore, rendering was handled by a module called [Multiplex Templates](https://www.npmjs.com/package/multiplex-templates) which did some :crystal_ball: dark magic :sparkles: to synchronously render component templates. This required some affordances from Amphora which added to the weight of the middleware. By separating renderers out into separate modules which can be plugged into Amphora >v2.x we can create renderers for specific requirements (XML, Amp, etc.) _and_ the rendering process can be moved to separate machines to create more maintainable systems.
@@ -45,6 +47,21 @@ const helpers = {
45
47
  amphoraHtml.addHelpers(helpers);
46
48
  ```
47
49
 
50
+ ### The Handlebars environment
51
+
52
+ `amphoraHtml.handlebars` is the Handlebars environment amphora-html renders with: clayhandlebars' helpers and partials, the helpers given to `addHelpers`, and a partial for every component and layout (registered when amphora-html is first required). `amphoraHtml.wrapPartial(name, template)` is what each component and layout template is wrapped in before it is registered.
53
+
54
+ Use them when you compile templates outside of a page render, in a test for instance, so that they run with exactly the helpers the pages get, instead of requiring your own copy of clayhandlebars:
55
+
56
+ ```javascript
57
+ const { handlebars, wrapPartial } = require('amphora-html');
58
+
59
+ handlebars.registerPartial('my-partial', handlebars.compile(wrapPartial('my-partial', source), { preventIndent: true }));
60
+ const html = handlebars.compile(template, { preventIndent: true })(data);
61
+ ```
62
+
63
+ It is the `handlebars` module that clayhandlebars requires, so whatever you register on it is seen by the pages too.
64
+
48
65
  ### Amphora HTML Plugins
49
66
 
50
67
 
package/index.js CHANGED
@@ -9,3 +9,5 @@ module.exports.addResolveMedia = setup.addResolveMedia;
9
9
  module.exports.addHelpers = setup.addHelpers;
10
10
  module.exports.addPlugins = setup.addPlugins;
11
11
  module.exports.configureRender = setup.configureRender;
12
+ module.exports.handlebars = setup.handlebars;
13
+ module.exports.wrapPartial = setup.wrapPartial;
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+
3
+ const { types } = require('util'),
4
+ { isObject, assignValue } = require('./utils'),
5
+ toString = Object.prototype.toString,
6
+ // what gets copied; anything else (functions, errors, promises, weak maps...)
7
+ // stays the same object, or becomes {} when it is the value given
8
+ CLONEABLE = new Set([
9
+ '[object Arguments]', '[object Array]', '[object ArrayBuffer]', '[object Boolean]',
10
+ '[object DataView]', '[object Date]', '[object Float32Array]', '[object Float64Array]',
11
+ '[object Int8Array]', '[object Int16Array]', '[object Int32Array]', '[object Map]',
12
+ '[object Number]', '[object Object]', '[object RegExp]', '[object Set]', '[object String]',
13
+ '[object Symbol]', '[object Uint8Array]', '[object Uint8ClampedArray]', '[object Uint16Array]',
14
+ '[object Uint32Array]'
15
+ ]);
16
+
17
+ /**
18
+ * @param {ArrayBuffer} buffer
19
+ * @returns {ArrayBuffer}
20
+ */
21
+ function cloneArrayBuffer(buffer) {
22
+ const result = new buffer.constructor(buffer.byteLength);
23
+
24
+ new Uint8Array(result).set(new Uint8Array(buffer));
25
+ return result;
26
+ }
27
+
28
+ /**
29
+ * An empty object of the same kind, for the kinds that are not plain objects or arrays
30
+ *
31
+ * @param {Object} value
32
+ * @param {string} tag
33
+ * @returns {Object}
34
+ */
35
+ function initCloneByTag(value, tag) { // eslint-disable-line complexity
36
+ const Ctor = value.constructor;
37
+
38
+ switch (tag) {
39
+ case '[object ArrayBuffer]':
40
+ return cloneArrayBuffer(value);
41
+ case '[object Boolean]':
42
+ case '[object Date]':
43
+ return new Ctor(+value);
44
+ case '[object DataView]':
45
+ return new Ctor(cloneArrayBuffer(value.buffer), value.byteOffset, value.byteLength);
46
+ case '[object Number]':
47
+ case '[object String]':
48
+ return new Ctor(value);
49
+ case '[object RegExp]': {
50
+ const result = new Ctor(value.source, /\w*$/.exec(value));
51
+
52
+ result.lastIndex = value.lastIndex;
53
+ return result;
54
+ }
55
+ case '[object Map]':
56
+ case '[object Set]':
57
+ return new Ctor();
58
+ case '[object Symbol]':
59
+ return Object(Symbol.prototype.valueOf.call(value));
60
+ default:
61
+ // typed arrays
62
+ return new Ctor(cloneArrayBuffer(value.buffer), value.byteOffset, value.length);
63
+ }
64
+ }
65
+
66
+ /**
67
+ * An empty object with the prototype of `value`, unless `value` is a prototype
68
+ * itself or its constructor is not a function
69
+ *
70
+ * @param {Object} value
71
+ * @returns {Object}
72
+ */
73
+ function initCloneObject(value) {
74
+ const Ctor = value.constructor,
75
+ proto = Object.getPrototypeOf(value),
76
+ isPrototype = value === (typeof Ctor === 'function' && Ctor.prototype || Object.prototype);
77
+
78
+ return typeof Ctor === 'function' && !isPrototype && isObject(proto) ? Object.create(proto) : {};
79
+ }
80
+
81
+ /**
82
+ * An array of the same kind and length, with what RegExp#exec adds to its results
83
+ *
84
+ * @param {Array} array
85
+ * @returns {Array}
86
+ */
87
+ function initCloneArray(array) {
88
+ const result = new array.constructor(array.length);
89
+
90
+ if (array.length && typeof array[0] === 'string' && Object.hasOwn(array, 'index')) {
91
+ result.index = array.index;
92
+ result.input = array.input;
93
+ }
94
+
95
+ return result;
96
+ }
97
+
98
+ /**
99
+ * Own enumerable keys, symbols included
100
+ *
101
+ * @param {Object} value
102
+ * @returns {Array}
103
+ */
104
+ function ownKeys(value) {
105
+ return Object.keys(value).concat(Object.getOwnPropertySymbols(value).filter((symbol) => Object.prototype.propertyIsEnumerable.call(value, symbol)));
106
+ }
107
+
108
+ /**
109
+ * @param {*} value
110
+ * @param {Object} [parent] the object `value` was read from
111
+ * @param {Map} copies the copy of every object copied so far
112
+ * @returns {*}
113
+ */
114
+ function baseClone(value, parent, copies) { // eslint-disable-line complexity
115
+ if (!isObject(value)) {
116
+ return value;
117
+ }
118
+
119
+ const isArray = Array.isArray(value);
120
+ let result;
121
+
122
+ if (isArray) {
123
+ result = initCloneArray(value);
124
+ } else {
125
+ const tag = toString.call(value),
126
+ isFunction = tag === '[object Function]' || tag === '[object GeneratorFunction]';
127
+
128
+ if (Buffer.isBuffer(value)) {
129
+ // a view of the same memory, as lodash's cloneDeep gave
130
+ return value.subarray();
131
+ }
132
+
133
+ if (tag === '[object Object]' || tag === '[object Arguments]' || isFunction && !parent) {
134
+ result = isFunction ? {} : initCloneObject(value);
135
+ } else if (CLONEABLE.has(tag)) {
136
+ result = initCloneByTag(value, tag);
137
+ } else {
138
+ return parent ? value : {};
139
+ }
140
+ }
141
+
142
+ // an object met before (shared or circular) gets the same copy
143
+ if (copies.has(value)) {
144
+ return copies.get(value);
145
+ }
146
+ copies.set(value, result);
147
+
148
+ if (types.isSet(value)) {
149
+ value.forEach((item) => result.add(baseClone(item, value, copies)));
150
+ } else if (types.isMap(value)) {
151
+ value.forEach((item, key) => result.set(key, baseClone(item, value, copies)));
152
+ }
153
+
154
+ if (isArray) {
155
+ // every index, holes included
156
+ for (let index = 0; index < value.length; index++) {
157
+ assignValue(result, index, baseClone(value[index], value, copies));
158
+ }
159
+ } else {
160
+ for (const key of ownKeys(value)) {
161
+ assignValue(result, key, baseClone(value[key], value, copies));
162
+ }
163
+ }
164
+
165
+ return result;
166
+ }
167
+
168
+ /**
169
+ * A deep copy, made the way lodash's cloneDeep made it: objects keep their
170
+ * prototype, a shared or circular object is copied once, Dates, RegExps, Maps,
171
+ * Sets, typed arrays and boxed values are copied, and functions, errors and
172
+ * other objects that can't be copied are kept as they are.
173
+ *
174
+ * @param {*} value
175
+ * @returns {*}
176
+ */
177
+ function cloneDeep(value) {
178
+ return baseClone(value, undefined, new Map());
179
+ }
180
+
181
+ module.exports = cloneDeep;
package/lib/files.js ADDED
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+
3
+ // What the renderer asks the file system. Every answer is remembered for the
4
+ // life of the process, as with nymag-fs: a file added later is not seen, and
5
+ // each file is read once.
6
+
7
+ const fs = require('fs'),
8
+ path = require('path'),
9
+ { memoize } = require('./utils');
10
+
11
+ /**
12
+ * @param {string} filename
13
+ * @returns {boolean}
14
+ */
15
+ function fileExists(filename) {
16
+ try {
17
+ return !!fs.statSync(filename);
18
+ } catch (e) {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ /**
24
+ * @param {string} file
25
+ * @returns {boolean}
26
+ */
27
+ function isDirectory(file) {
28
+ try {
29
+ return fs.statSync(file).isDirectory();
30
+ } catch (e) {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ /**
36
+ * The files of a folder, without folders, tests and documentation
37
+ *
38
+ * @param {string} dir
39
+ * @returns {string[]}
40
+ */
41
+ function getFiles(dir) {
42
+ try {
43
+ return fs.readdirSync(dir)
44
+ .filter((file) => !isDirectory(path.join(dir, file)) &&
45
+ file.indexOf('.test.') === -1 && // ignore test files
46
+ file.indexOf('.md') === -1); // ignore documentation files
47
+ } catch (e) {
48
+ return [];
49
+ }
50
+ }
51
+
52
+ /**
53
+ * The contents of a file
54
+ *
55
+ * @param {string} file
56
+ * @returns {Promise<string>}
57
+ */
58
+ function readFilePromise(file) {
59
+ return new Promise((resolve, reject) => {
60
+ fs.readFile(file, 'utf8', (err, data) => err ? reject(err) : resolve(data));
61
+ });
62
+ }
63
+
64
+ module.exports.fileExists = memoize(fileExists);
65
+ module.exports.getFiles = memoize(getFiles);
66
+ module.exports.readFilePromise = memoize(readFilePromise);
package/lib/media.js CHANGED
@@ -1,17 +1,14 @@
1
1
  'use strict';
2
2
 
3
- const _ = require('lodash'),
4
- path = require('path'),
5
- files = require('nymag-fs'),
6
- bluebird = require('bluebird'),
3
+ const path = require('path'),
4
+ files = require('./files'),
7
5
  setup = require('./setup'),
8
6
  styleguide = require('./styleguide'),
9
- url = require('url'),
7
+ { isObject, toList } = require('./utils'),
10
8
  STYLE_TAG = 'style',
11
9
  SCRIPT_TAG = 'scripts',
12
- ASYNC_SCRIPT_TAG = 'async',
13
- DEFER_SCRIPT_TAG = 'defer',
14
- ASYNC_DEFER_SCRIPT_TAG = 'async-defer',
10
+ MODULE_SCRIPT_TAG = 'module',
11
+ MODULEPRELOAD_TAG = 'modulepreload',
15
12
  MEDIA_DIRECTORY = path.join(process.cwd(), 'public');
16
13
 
17
14
  /**
@@ -150,47 +147,49 @@ function appendMediaToBottom(scripts, html) {
150
147
  return splice(html, index, scripts);
151
148
  }
152
149
 
153
- function injectTags(fileArray, site, tag) {
150
+ /**
151
+ * Build inline <link>/<script> tags for an array of asset paths. Used by edit
152
+ * mode (where assets are loaded individually rather than bundled) and by the
153
+ * Vite ESM pipeline (module / modulepreload).
154
+ *
155
+ * @param {string[]} fileArray asset paths
156
+ * @param {string} tag one of STYLE_TAG, SCRIPT_TAG, MODULE_SCRIPT_TAG, MODULEPRELOAD_TAG
157
+ * @returns {Promise<string>} newline-joined tag string
158
+ */
159
+ function injectTags(fileArray, tag) {
154
160
  var buster = module.exports.cacheBuster ? `?version=${module.exports.cacheBuster}` : '';
161
+ // When omitCacheBusterOnModules is enabled, content-hashed ESM files omit the
162
+ // ?version= query string — the hash in the filename already provides cache busting.
163
+ // Defaults to false so existing deployments are unaffected.
164
+ var moduleBuster = module.exports.omitCacheBusterOnModules ? '' : buster;
155
165
 
156
- return bluebird.resolve(_.map(fileArray, function (file) {
166
+ return Promise.resolve(toList(fileArray).map(function (file) {
157
167
  if (tag === STYLE_TAG) {
158
168
  return `<link rel="stylesheet" type="text/css" href="${file}${buster}">`;
159
- } else if (tag === ASYNC_SCRIPT_TAG) {
160
- return `<script async src="${file}" type="text/javascript"></script>`;
161
- } else if (tag === DEFER_SCRIPT_TAG) {
162
- return `<script defer src="${file}" type="text/javascript"></script>`;
163
- } else if (tag === ASYNC_DEFER_SCRIPT_TAG) {
164
- return `<script async defer src="${file}" type="text/javascript"></script>`;
165
- } else {
166
- return `<script type="text/javascript" src="${file}${buster}"></script>`;
169
+ } else if (tag === MODULE_SCRIPT_TAG) {
170
+ return `<script type="module" src="${file}${moduleBuster}"></script>`;
171
+ } else if (tag === MODULEPRELOAD_TAG) {
172
+ // <link rel="modulepreload"> tells the browser to fetch ESM scripts early,
173
+ // during HTML parsing, before reaching the <script> tags at </body>.
174
+ return `<link rel="modulepreload" href="${file}${moduleBuster}">`;
167
175
  }
176
+
177
+ // Default: classic <script src=...> for SCRIPT_TAG and any other value.
178
+ return `<script type="text/javascript" src="${file}${buster}"></script>`;
168
179
  }).join('\n'));
169
180
  }
170
181
 
171
182
  /**
172
- * Filter the components rendered in this request, removing Webpack-managed
173
- * assets so that we can fall back on Browserify.
183
+ * Wait for the values of an object, the way Bluebird's props did
174
184
  *
175
- * @param {Object} state - Amphora render state.
176
- * @returns {string[]} - A list of components not available through Webpack.
185
+ * @param {Object} obj values or promises for them
186
+ * @returns {Promise<Object>} the same keys with the values
177
187
  */
178
- function filterManifestComponents(state) {
179
- const site = state.locals.site,
180
- assetDir = site && site.assetDir || MEDIA_DIRECTORY,
181
- manifestName = site && site.manifestName || 'assets-manifest.json',
182
- manifestFile = path.resolve(path.join(assetDir, manifestName));
183
- let manifest;
184
-
185
- try {
186
- manifest = files.tryRequire(manifestFile);
187
- } catch (err) {
188
- return state._components;
189
- }
190
-
188
+ function props(obj) {
189
+ const keys = Object.keys(obj);
191
190
 
192
- return state._components
193
- .filter(componentName => !manifest.hasOwnProperty(`${ componentName }.js`));
191
+ return Promise.all(keys.map((key) => obj[key]))
192
+ .then((values) => Object.fromEntries(keys.map((key, i) => [key, values[i]])));
194
193
  }
195
194
 
196
195
  /**
@@ -202,7 +201,6 @@ function getMediaMap(state) {
202
201
  return {
203
202
  styles: module.exports.getStyleFiles(state),
204
203
  scripts: module.exports.getScriptFiles(state),
205
- manifestAssets: module.exports.getManifestAssets(state)
206
204
  };
207
205
  }
208
206
 
@@ -220,7 +218,7 @@ function getStyleFiles(state) {
220
218
  assetPath = site && site.assetPath || '',
221
219
  assetHost = site && site.assetHost || '',
222
220
  siteStyleguide = site && site.styleguide,
223
- layoutName = _.get(state, '_layoutData.name');
221
+ layoutName = state._layoutData?.name;
224
222
  let cssFilePaths = [], layoutBasePath;
225
223
 
226
224
  // legacy behavior: load <component>.css and <component>.<site>.css
@@ -303,7 +301,7 @@ function getScriptFiles(state) {
303
301
  assetPath = site && site.assetPath || '',
304
302
  assetHost = site && site.assetHost || '';
305
303
 
306
- return filterManifestComponents(state)
304
+ return state._components
307
305
  .reduce((prev, name) => {
308
306
  prev.push(
309
307
  path.join(assetDir, 'js', `${name}.js`),
@@ -316,36 +314,6 @@ function getScriptFiles(state) {
316
314
  .map(pathJoin(assetHost, assetPath, assetDir));
317
315
  }
318
316
 
319
- /**
320
- * Read the asset manifest generated by the `clay pack` bundling command and
321
- * check for scripts which match the rendered component names. The manifest for
322
- * each asset lists its complete set of dependencies, so we dedupe them.
323
- * Webpack's runtime is added to the beginning of the list, downstream legacy
324
- * globals and Clay CLI's init script to the end.
325
- *
326
- * @param {Object} state - Amphora's render state.
327
- * @return {string[]} A list of component assets.
328
- */
329
- function getManifestAssets(state) {
330
- const site = state.locals.site,
331
- assetDir = site && site.assetDir || MEDIA_DIRECTORY,
332
- assetHost = site && site.assetHost || '/',
333
- manifestName = site && site.manifestName || 'assets-manifest.json',
334
- manifestFile = path.resolve(path.join(assetDir, manifestName));
335
- let manifest;
336
-
337
- try {
338
- manifest = files.tryRequire(manifestFile);
339
- } catch (err) {
340
- return [];
341
- }
342
-
343
- const entrypoints = _.get(manifest, 'entrypoints', []);
344
- const scriptFiles = _.flatMap(entrypoints, entrypoint => _.get(entrypoint, 'assets.js', []));
345
-
346
- return scriptFiles.map(script => url.resolve(assetHost, script));
347
- }
348
-
349
317
  /**
350
318
  * Trim the path on the file system bythe link of the
351
319
  * asset directory and then join it with either the assetHost
@@ -375,9 +343,17 @@ function pathJoin(assetHost, assetPath, assetDir) {
375
343
  * @param {string} [cacheBuster]
376
344
  */
377
345
  function configure(options, cacheBuster = '') {
378
- if (options && _.isObject(options)) {
346
+ if (options && isObject(options)) {
379
347
  module.exports.editStylesTags = options.styles || false;
380
348
  module.exports.editScriptsTags = options.scripts || false;
349
+ // modulepreload: when true, <link rel="modulepreload"> hints are injected
350
+ // into <head> for ESM scripts, and ?version= is omitted from module URLs
351
+ // since content-hashed filenames already provide cache busting.
352
+ // Opt-in only — defaults to false for backwards compatibility.
353
+ if (options.modulepreload !== undefined) {
354
+ module.exports.modulepreload = !!options.modulepreload;
355
+ module.exports.omitCacheBusterOnModules = !!options.modulepreload;
356
+ }
381
357
  } else {
382
358
  module.exports.editStylesTags = options;
383
359
  module.exports.editScriptsTags = options;
@@ -397,11 +373,12 @@ function configure(options, cacheBuster = '') {
397
373
  * @param {Object} state
398
374
  * @return {Function}
399
375
  */
376
+ /* eslint-disable complexity */
400
377
  function injectScriptsAndStyles(state) {
401
378
  const { locals } = state;
402
379
 
403
380
  return function (html) {
404
- var mediaMap = {};
381
+ var mediaMap;
405
382
 
406
383
  // Check that HTML is string
407
384
  if (typeof html !== 'string') {
@@ -412,26 +389,50 @@ function injectScriptsAndStyles(state) {
412
389
  mediaMap = module.exports.getMediaMap(state);
413
390
 
414
391
  // allow site to change the media map before applying it
392
+ // Expose rendered component names so resolveMedia can do per-component script resolution
393
+ // (e.g. pack-next manifest lookup) without needing a reference to the full state object.
394
+ locals._components = state._components;
415
395
  if (setup.resolveMedia) mediaMap = setup.resolveMedia(mediaMap, locals) || mediaMap;
416
396
 
397
+ // moduleScripts: ESM scripts (e.g. from esbuild pack-next) that need type="module"
398
+ const moduleScriptFiles = mediaMap.moduleScripts || [];
399
+
400
+ mediaMap.moduleScripts = moduleScriptFiles.length
401
+ ? injectTags(moduleScriptFiles, MODULE_SCRIPT_TAG)
402
+ : Promise.resolve(false);
403
+
404
+ // modulePreloads: <link rel="modulepreload"> hints for <head>.
405
+ // Only active when configure({ modulepreload: true }) has been called — opt-in so
406
+ // sites not using the clay build pipeline are completely unaffected.
407
+ const modulePreloadFiles = module.exports.modulepreload
408
+ ? mediaMap.modulePreloads || []
409
+ : [];
410
+
411
+ mediaMap.modulePreloads = modulePreloadFiles.length
412
+ ? injectTags(modulePreloadFiles, MODULEPRELOAD_TAG)
413
+ : Promise.resolve(false);
414
+
417
415
  if (!locals.edit) {
418
416
  mediaMap.styles = combineFileContents(mediaMap.styles, 'public/css', '/css/', STYLE_TAG);
419
- mediaMap.scripts = !!mediaMap.manifestAssets && mediaMap.manifestAssets.length > 0
420
- ? injectTags(mediaMap.manifestAssets, locals.site, SCRIPT_TAG)
421
- : combineFileContents(mediaMap.scripts, 'public/js', '/js/', SCRIPT_TAG);
417
+ mediaMap.scripts = combineFileContents(mediaMap.scripts, 'public/js', '/js/', SCRIPT_TAG);
422
418
  } else {
423
- mediaMap.styles = module.exports.editStylesTags ? injectTags(mediaMap.styles, locals.site, STYLE_TAG) : combineFileContents(mediaMap.styles, 'public/css', '/css/', STYLE_TAG);
424
- mediaMap.scripts = module.exports.editScriptsTags ? injectTags(mediaMap.scripts, locals.site, SCRIPT_TAG) : combineFileContents(mediaMap.scripts, 'public/js', '/js/', SCRIPT_TAG);
419
+ mediaMap.styles = module.exports.editStylesTags ? injectTags(mediaMap.styles, STYLE_TAG) : combineFileContents(mediaMap.styles, 'public/css', '/css/', STYLE_TAG);
420
+ mediaMap.scripts = module.exports.editScriptsTags ? injectTags(mediaMap.scripts, SCRIPT_TAG) : combineFileContents(mediaMap.scripts, 'public/js', '/js/', SCRIPT_TAG);
425
421
  }
426
422
 
427
- return bluebird.props(mediaMap)
423
+ return props(mediaMap)
428
424
  .then(combinedFiles => {
425
+ // modulepreload hints go first in <head>, before CSS, so the browser can
426
+ // start fetching ESM scripts at the earliest possible moment.
427
+ html = combinedFiles.modulePreloads ? appendMediaToTop(combinedFiles.modulePreloads, html) : html;
429
428
  html = combinedFiles.styles ? appendMediaToTop(combinedFiles.styles, html) : html; // If there are styles, append them
429
+ html = combinedFiles.moduleScripts ? appendMediaToBottom(combinedFiles.moduleScripts, html) : html; // ESM module scripts (type="module")
430
430
  html = combinedFiles.scripts ? appendMediaToBottom(combinedFiles.scripts, html) : html; // If there are scripts, append them
431
431
  return html; // Return the compiled HTML
432
432
  });
433
433
  };
434
434
  }
435
+ /* eslint-enable complexity */
435
436
 
436
437
  module.exports.injectScriptsAndStyles = injectScriptsAndStyles;
437
438
  module.exports.getMediaMap = getMediaMap;
@@ -439,8 +440,11 @@ module.exports.cacheBuster = '';
439
440
  module.exports.configure = configure;
440
441
  module.exports.editStylesTags = false;
441
442
  module.exports.editScriptsTags = false;
443
+ // Opt-in flags for the clay build (esbuild) pipeline.
444
+ // Enable via configure({ modulepreload: true }).
445
+ module.exports.modulepreload = false;
446
+ module.exports.omitCacheBusterOnModules = false;
442
447
 
443
448
  // For testing
444
- module.exports.getManifestAssets = getManifestAssets;
445
449
  module.exports.getScriptFiles = getScriptFiles;
446
450
  module.exports.getStyleFiles = getStyleFiles;
package/lib/render.js CHANGED
@@ -1,11 +1,11 @@
1
1
  'use strict';
2
2
 
3
- const _ = require('lodash'),
4
- mediaService = require('./media'),
3
+ const mediaService = require('./media'),
5
4
  clayUtils = require('clayutils'),
6
- bluebird = require('bluebird'),
7
5
  styleguide = require('./styleguide'),
8
- setup = require('./setup');
6
+ setup = require('./setup'),
7
+ cloneDeep = require('./clone-deep'),
8
+ { assign, pick, toList } = require('./utils');
9
9
 
10
10
  let { getIndices, getSchemaPath, getComponentPath, getLayoutPath } = require('amphora-fs'),
11
11
  log = require('./log').setup({ file: __filename }),
@@ -18,18 +18,24 @@ let { getIndices, getSchemaPath, getComponentPath, getLayoutPath } = require('am
18
18
  * @param {String} ref
19
19
  * @param {Object} data
20
20
  * @param {Object} locals
21
- * @returns {Object} data
21
+ * @returns {Promise<Object>} data
22
22
  */
23
- function applyRenderHooks(ref, data, locals) {
23
+ async function applyRenderHooks(ref, data, locals) {
24
24
  // skip render hooks in edit mode
25
25
  if (locals.edit) {
26
- return bluebird.resolve(data);
26
+ return data;
27
27
  }
28
28
 
29
- return bluebird.filter(setup.plugins, (plugin) => plugin.render)
30
- .reduce((val, plugin) => {
31
- return plugin.render(ref, val, locals);
32
- }, data);
29
+ const plugins = setup.plugins.filter((plugin) => plugin.render);
30
+ // with no data, the first plugin stands in for it and its hook is skipped,
31
+ // as Bluebird's reduce did (a model.js `render` that returns nothing gets here)
32
+ let val = data === undefined ? await plugins.shift() : await data;
33
+
34
+ for (const plugin of plugins) {
35
+ val = await plugin.render(ref, val, locals);
36
+ }
37
+
38
+ return val;
33
39
  }
34
40
 
35
41
  /**
@@ -38,6 +44,9 @@ function applyRenderHooks(ref, data, locals) {
38
44
  *
39
45
  * @param {String} ref
40
46
  * @param {Object} locals
47
+ * @param {Object} res
48
+ * @param {String} self Clay ref of the component being rendered, e.g.
49
+ * "site.com/_components/article/instances/foo"
41
50
  * @returns {Function}
42
51
  */
43
52
  function applyPostRenderHooks(ref, locals, res, self) {
@@ -76,17 +85,29 @@ function makeHtml(state) {
76
85
 
77
86
  // At the final stage of rendering we need to smoosh
78
87
  // all the data together with the state.
79
- _.assign(state, _data);
88
+ assign(state, _data);
80
89
 
81
90
  // send clone of state/data to be used by kiln so kiln doesn't load component data that was mutated by hbs templates
82
91
  if (state.locals.edit) {
83
- _.assign(state, { _componentData: _.cloneDeep(_data) });
92
+ state._componentData = cloneDeep(_data);
84
93
  }
85
94
 
86
95
  return rootPartial(state);
87
96
  };
88
97
  }
89
98
 
99
+ /**
100
+ * The styleguide of the site, `_default` when it has none
101
+ *
102
+ * @param {Object} meta
103
+ * @return {String}
104
+ */
105
+ function getStyleguideName(meta) {
106
+ const name = meta.locals?.site?.styleguide;
107
+
108
+ return name === undefined ? '_default' : name;
109
+ }
110
+
90
111
  /**
91
112
  * Construct the object that is needed by Kiln for rendering in
92
113
  * edit mode. Requires reading from the file system.
@@ -117,16 +138,16 @@ function makeState(data, meta) {
117
138
 
118
139
  // grab the component variations and make available via the state
119
140
  // note: if styleguide isn't set, this gets variations from the '_default' styleguide
120
- initialState._componentVariations = styleguide.getVariations(_.get(meta, 'locals.site.styleguide', '_default'));
141
+ initialState._componentVariations = styleguide.getVariations(getStyleguideName(meta));
121
142
 
122
143
  if (meta.locals.edit) {
123
144
  initialState._envVars = ENV_VARS;
124
- initialState._componentSchemas = _.map(componentList, (name) => {
145
+ initialState._componentSchemas = toList(componentList).map((name) => {
125
146
  return { name, schema: getSchemaPath(getComponentPath(name)) };
126
147
  });
127
148
  }
128
149
 
129
- return _.assign(initialState, _.pick(meta, ['locals', '_layoutRef']));
150
+ return Object.assign(initialState, pick(meta, ['locals', '_layoutRef']));
130
151
  }
131
152
 
132
153
  /**
@@ -182,8 +203,12 @@ function logTime(hrStart, msg, route) {
182
203
  };
183
204
  }
184
205
 
185
- function configure({ editAssetTags, cacheBuster }) {
186
- mediaService.configure(editAssetTags, cacheBuster);
206
+ function configure({ editAssetTags, cacheBuster, modulepreload }) {
207
+ const mediaOptions = editAssetTags && typeof editAssetTags === 'object'
208
+ ? Object.assign({}, editAssetTags, modulepreload !== undefined ? { modulepreload } : {})
209
+ : editAssetTags;
210
+
211
+ mediaService.configure(mediaOptions, cacheBuster);
187
212
  }
188
213
 
189
214
  /**
@@ -193,7 +218,7 @@ function configure({ editAssetTags, cacheBuster }) {
193
218
  * @param {Array} vars
194
219
  */
195
220
  function addEnvVars(vars) {
196
- ENV_VARS = _.pick(process.env, vars);
221
+ ENV_VARS = pick(process.env, vars);
197
222
  log('info', 'env vars registered with amphora html');
198
223
  }
199
224