amphora-html 6.0.0-8 → 6.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.
@@ -0,0 +1,40 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: ['**']
6
+ tags: ['**']
7
+ pull_request:
8
+ workflow_dispatch:
9
+
10
+ concurrency:
11
+ group: ${{ github.workflow }}-${{ github.ref }}
12
+ cancel-in-progress: true
13
+
14
+ permissions:
15
+ contents: read
16
+
17
+ jobs:
18
+ test:
19
+ name: Test (Node ${{ matrix.node-version }})
20
+ runs-on: ubuntu-latest
21
+ strategy:
22
+ fail-fast: false
23
+ matrix:
24
+ node-version: [20]
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+
28
+ - name: Use Node.js ${{ matrix.node-version }}
29
+ uses: actions/setup-node@v4
30
+ with:
31
+ node-version: ${{ matrix.node-version }}
32
+ cache: npm
33
+ cache-dependency-path: package-lock.json
34
+
35
+ - name: Install dependencies
36
+ run: npm ci
37
+
38
+ - name: Run tests
39
+ run: npm test
40
+
package/lib/media.js CHANGED
@@ -6,12 +6,10 @@ const _ = require('lodash'),
6
6
  bluebird = require('bluebird'),
7
7
  setup = require('./setup'),
8
8
  styleguide = require('./styleguide'),
9
- url = require('url'),
10
9
  STYLE_TAG = 'style',
11
10
  SCRIPT_TAG = 'scripts',
12
- ASYNC_SCRIPT_TAG = 'async',
13
- DEFER_SCRIPT_TAG = 'defer',
14
- ASYNC_DEFER_SCRIPT_TAG = 'async-defer',
11
+ MODULE_SCRIPT_TAG = 'module',
12
+ MODULEPRELOAD_TAG = 'modulepreload',
15
13
  MEDIA_DIRECTORY = path.join(process.cwd(), 'public');
16
14
 
17
15
  /**
@@ -150,47 +148,36 @@ function appendMediaToBottom(scripts, html) {
150
148
  return splice(html, index, scripts);
151
149
  }
152
150
 
153
- function injectTags(fileArray, site, tag) {
151
+ /**
152
+ * Build inline <link>/<script> tags for an array of asset paths. Used by edit
153
+ * mode (where assets are loaded individually rather than bundled) and by the
154
+ * Vite ESM pipeline (module / modulepreload).
155
+ *
156
+ * @param {string[]} fileArray asset paths
157
+ * @param {string} tag one of STYLE_TAG, SCRIPT_TAG, MODULE_SCRIPT_TAG, MODULEPRELOAD_TAG
158
+ * @returns {Promise<string>} newline-joined tag string
159
+ */
160
+ function injectTags(fileArray, tag) {
154
161
  var buster = module.exports.cacheBuster ? `?version=${module.exports.cacheBuster}` : '';
162
+ // When omitCacheBusterOnModules is enabled, content-hashed ESM files omit the
163
+ // ?version= query string — the hash in the filename already provides cache busting.
164
+ // Defaults to false so existing deployments are unaffected.
165
+ var moduleBuster = module.exports.omitCacheBusterOnModules ? '' : buster;
155
166
 
156
167
  return bluebird.resolve(_.map(fileArray, function (file) {
157
168
  if (tag === STYLE_TAG) {
158
169
  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>`;
170
+ } else if (tag === MODULE_SCRIPT_TAG) {
171
+ return `<script type="module" src="${file}${moduleBuster}"></script>`;
172
+ } else if (tag === MODULEPRELOAD_TAG) {
173
+ // <link rel="modulepreload"> tells the browser to fetch ESM scripts early,
174
+ // during HTML parsing, before reaching the <script> tags at </body>.
175
+ return `<link rel="modulepreload" href="${file}${moduleBuster}">`;
167
176
  }
168
- }).join('\n'));
169
- }
170
-
171
- /**
172
- * Filter the components rendered in this request, removing Webpack-managed
173
- * assets so that we can fall back on Browserify.
174
- *
175
- * @param {Object} state - Amphora render state.
176
- * @returns {string[]} - A list of components not available through Webpack.
177
- */
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
-
191
177
 
192
- return state._components
193
- .filter(componentName => !manifest.hasOwnProperty(`${ componentName }.js`));
178
+ // Default: classic <script src=...> for SCRIPT_TAG and any other value.
179
+ return `<script type="text/javascript" src="${file}${buster}"></script>`;
180
+ }).join('\n'));
194
181
  }
195
182
 
196
183
  /**
@@ -202,7 +189,6 @@ function getMediaMap(state) {
202
189
  return {
203
190
  styles: module.exports.getStyleFiles(state),
204
191
  scripts: module.exports.getScriptFiles(state),
205
- manifestAssets: module.exports.getManifestAssets(state)
206
192
  };
207
193
  }
208
194
 
@@ -274,19 +260,13 @@ function getStyleFiles(state) {
274
260
  }
275
261
  }
276
262
 
277
- // check for clay linked font files
263
+ // check for clay compile linked? clay compile font files?
278
264
  const defaultFontPath = path.join(assetDir, 'css', '_linked-fonts._default.css');
279
265
  const siteFontPath = path.join(assetDir, 'css', `_linked-fonts.${siteStyleguide}.css`);
280
266
 
281
- // check for inlined font files
282
- const defaultInlinedFontPath = path.join(assetDir, 'css', '_inlined-fonts._default.css');
283
- const siteInlinedFontPath = path.join(assetDir, 'css', `_inlined-fonts.${siteStyleguide}.css`);
284
-
285
267
  // add any default and site font css
286
268
  cssFilePaths.push(defaultFontPath);
287
269
  cssFilePaths.push(siteFontPath);
288
- cssFilePaths.push(defaultInlinedFontPath);
289
- cssFilePaths.push(siteInlinedFontPath);
290
270
 
291
271
  return cssFilePaths
292
272
  .filter(files.fileExists)
@@ -309,7 +289,7 @@ function getScriptFiles(state) {
309
289
  assetPath = site && site.assetPath || '',
310
290
  assetHost = site && site.assetHost || '';
311
291
 
312
- return filterManifestComponents(state)
292
+ return state._components
313
293
  .reduce((prev, name) => {
314
294
  prev.push(
315
295
  path.join(assetDir, 'js', `${name}.js`),
@@ -322,36 +302,6 @@ function getScriptFiles(state) {
322
302
  .map(pathJoin(assetHost, assetPath, assetDir));
323
303
  }
324
304
 
325
- /**
326
- * Read the asset manifest generated by the `clay pack` bundling command and
327
- * check for scripts which match the rendered component names. The manifest for
328
- * each asset lists its complete set of dependencies, so we dedupe them.
329
- * Webpack's runtime is added to the beginning of the list, downstream legacy
330
- * globals and Clay CLI's init script to the end.
331
- *
332
- * @param {Object} state - Amphora's render state.
333
- * @return {string[]} A list of component assets.
334
- */
335
- function getManifestAssets(state) {
336
- const site = state.locals.site,
337
- assetDir = site && site.assetDir || MEDIA_DIRECTORY,
338
- assetHost = site && site.assetHost || '/',
339
- manifestName = site && site.manifestName || 'assets-manifest.json',
340
- manifestFile = path.resolve(path.join(assetDir, manifestName));
341
- let manifest;
342
-
343
- try {
344
- manifest = files.tryRequire(manifestFile);
345
- } catch (err) {
346
- return [];
347
- }
348
-
349
- const entrypoints = _.get(manifest, 'entrypoints', []);
350
- const scriptFiles = _.flatMap(entrypoints, entrypoint => _.get(entrypoint, 'assets.js', []));
351
-
352
- return scriptFiles.map(script => url.resolve(assetHost, script));
353
- }
354
-
355
305
  /**
356
306
  * Trim the path on the file system bythe link of the
357
307
  * asset directory and then join it with either the assetHost
@@ -384,6 +334,14 @@ function configure(options, cacheBuster = '') {
384
334
  if (options && _.isObject(options)) {
385
335
  module.exports.editStylesTags = options.styles || false;
386
336
  module.exports.editScriptsTags = options.scripts || false;
337
+ // modulepreload: when true, <link rel="modulepreload"> hints are injected
338
+ // into <head> for ESM scripts, and ?version= is omitted from module URLs
339
+ // since content-hashed filenames already provide cache busting.
340
+ // Opt-in only — defaults to false for backwards compatibility.
341
+ if (options.modulepreload !== undefined) {
342
+ module.exports.modulepreload = !!options.modulepreload;
343
+ module.exports.omitCacheBusterOnModules = !!options.modulepreload;
344
+ }
387
345
  } else {
388
346
  module.exports.editStylesTags = options;
389
347
  module.exports.editScriptsTags = options;
@@ -403,6 +361,7 @@ function configure(options, cacheBuster = '') {
403
361
  * @param {Object} state
404
362
  * @return {Function}
405
363
  */
364
+ /* eslint-disable complexity */
406
365
  function injectScriptsAndStyles(state) {
407
366
  const { locals } = state;
408
367
 
@@ -418,26 +377,50 @@ function injectScriptsAndStyles(state) {
418
377
  mediaMap = module.exports.getMediaMap(state);
419
378
 
420
379
  // allow site to change the media map before applying it
380
+ // Expose rendered component names so resolveMedia can do per-component script resolution
381
+ // (e.g. pack-next manifest lookup) without needing a reference to the full state object.
382
+ locals._components = state._components;
421
383
  if (setup.resolveMedia) mediaMap = setup.resolveMedia(mediaMap, locals) || mediaMap;
422
384
 
385
+ // moduleScripts: ESM scripts (e.g. from esbuild pack-next) that need type="module"
386
+ const moduleScriptFiles = mediaMap.moduleScripts || [];
387
+
388
+ mediaMap.moduleScripts = moduleScriptFiles.length
389
+ ? injectTags(moduleScriptFiles, MODULE_SCRIPT_TAG)
390
+ : bluebird.resolve(false);
391
+
392
+ // modulePreloads: <link rel="modulepreload"> hints for <head>.
393
+ // Only active when configure({ modulepreload: true }) has been called — opt-in so
394
+ // sites not using the clay build pipeline are completely unaffected.
395
+ const modulePreloadFiles = module.exports.modulepreload
396
+ ? mediaMap.modulePreloads || []
397
+ : [];
398
+
399
+ mediaMap.modulePreloads = modulePreloadFiles.length
400
+ ? injectTags(modulePreloadFiles, MODULEPRELOAD_TAG)
401
+ : bluebird.resolve(false);
402
+
423
403
  if (!locals.edit) {
424
404
  mediaMap.styles = combineFileContents(mediaMap.styles, 'public/css', '/css/', STYLE_TAG);
425
- mediaMap.scripts = !!mediaMap.manifestAssets && mediaMap.manifestAssets.length > 0
426
- ? injectTags(mediaMap.manifestAssets, locals.site, SCRIPT_TAG)
427
- : combineFileContents(mediaMap.scripts, 'public/js', '/js/', SCRIPT_TAG);
405
+ mediaMap.scripts = combineFileContents(mediaMap.scripts, 'public/js', '/js/', SCRIPT_TAG);
428
406
  } else {
429
- mediaMap.styles = module.exports.editStylesTags ? injectTags(mediaMap.styles, locals.site, STYLE_TAG) : combineFileContents(mediaMap.styles, 'public/css', '/css/', STYLE_TAG);
430
- mediaMap.scripts = module.exports.editScriptsTags ? injectTags(mediaMap.scripts, locals.site, SCRIPT_TAG) : combineFileContents(mediaMap.scripts, 'public/js', '/js/', SCRIPT_TAG);
407
+ mediaMap.styles = module.exports.editStylesTags ? injectTags(mediaMap.styles, STYLE_TAG) : combineFileContents(mediaMap.styles, 'public/css', '/css/', STYLE_TAG);
408
+ mediaMap.scripts = module.exports.editScriptsTags ? injectTags(mediaMap.scripts, SCRIPT_TAG) : combineFileContents(mediaMap.scripts, 'public/js', '/js/', SCRIPT_TAG);
431
409
  }
432
410
 
433
411
  return bluebird.props(mediaMap)
434
412
  .then(combinedFiles => {
413
+ // modulepreload hints go first in <head>, before CSS, so the browser can
414
+ // start fetching ESM scripts at the earliest possible moment.
415
+ html = combinedFiles.modulePreloads ? appendMediaToTop(combinedFiles.modulePreloads, html) : html;
435
416
  html = combinedFiles.styles ? appendMediaToTop(combinedFiles.styles, html) : html; // If there are styles, append them
417
+ html = combinedFiles.moduleScripts ? appendMediaToBottom(combinedFiles.moduleScripts, html) : html; // ESM module scripts (type="module")
436
418
  html = combinedFiles.scripts ? appendMediaToBottom(combinedFiles.scripts, html) : html; // If there are scripts, append them
437
419
  return html; // Return the compiled HTML
438
420
  });
439
421
  };
440
422
  }
423
+ /* eslint-enable complexity */
441
424
 
442
425
  module.exports.injectScriptsAndStyles = injectScriptsAndStyles;
443
426
  module.exports.getMediaMap = getMediaMap;
@@ -445,8 +428,11 @@ module.exports.cacheBuster = '';
445
428
  module.exports.configure = configure;
446
429
  module.exports.editStylesTags = false;
447
430
  module.exports.editScriptsTags = false;
431
+ // Opt-in flags for the clay build (esbuild) pipeline.
432
+ // Enable via configure({ modulepreload: true }).
433
+ module.exports.modulepreload = false;
434
+ module.exports.omitCacheBusterOnModules = false;
448
435
 
449
436
  // For testing
450
- module.exports.getManifestAssets = getManifestAssets;
451
437
  module.exports.getScriptFiles = getScriptFiles;
452
438
  module.exports.getStyleFiles = getStyleFiles;
package/lib/media.test.js CHANGED
@@ -101,7 +101,7 @@ describe(_.startCase(filename), () => {
101
101
  const fn = lib['getMediaMap'];
102
102
 
103
103
  test('accepts state with empty components', () => {
104
- expect(fn(state)).toEqual({manifestAssets: [], scripts: [], styles: []});
104
+ expect(fn(state)).toEqual({scripts: [], styles: []});
105
105
  });
106
106
 
107
107
  test('accepts list, empty slug (non-existent components)', () => {
@@ -114,7 +114,7 @@ describe(_.startCase(filename), () => {
114
114
  _.omit(state, ['locals.site.slug'])
115
115
  )
116
116
  )
117
- ).toEqual({manifestAssets: [], scripts: [], styles: []});
117
+ ).toEqual({scripts: [], styles: []});
118
118
  });
119
119
 
120
120
  test('accepts list and slug (non-existent components)', () => {
@@ -127,7 +127,7 @@ describe(_.startCase(filename), () => {
127
127
  state
128
128
  )
129
129
  )
130
- ).toEqual({manifestAssets: [], scripts: [], styles: []});
130
+ ).toEqual({scripts: [], styles: []});
131
131
  });
132
132
 
133
133
  test(
@@ -151,7 +151,7 @@ describe(_.startCase(filename), () => {
151
151
  }
152
152
  )
153
153
  )
154
- ).toEqual({manifestAssets: [], scripts: expectedScriptFiles, styles: []});
154
+ ).toEqual({scripts: expectedScriptFiles, styles: []});
155
155
  }
156
156
  );
157
157
  });
@@ -625,117 +625,6 @@ describe(_.startCase(filename), () => {
625
625
  );
626
626
  });
627
627
 
628
- describe('getManifestAssets', () => {
629
- const fn = lib['getManifestAssets'];
630
-
631
- describe('constructs the correct asset URL', () => {
632
- test('when assetHost is configured', () => {
633
- const assetHost = 'https://assets.nymag.com';
634
- const localState = {
635
- locals: {
636
- site: {
637
- assetHost
638
- }
639
- },
640
- _components: [
641
- 'a',
642
- 'b'
643
- ]
644
- };
645
- const manifest = {
646
- entrypoints: {
647
- a: {
648
- assets: {
649
- js: ['/a.js']
650
- }
651
- },
652
- b: {
653
- assets: {
654
- js: ['b.js']
655
- }
656
- }
657
- }
658
- };
659
-
660
- jest.spyOn(files, 'tryRequire').mockImplementation(() => manifest);
661
-
662
- expect(fn(localState)).toEqual([
663
- `${ assetHost }/a.js`,
664
- `${ assetHost }/b.js`
665
- ]);
666
- });
667
-
668
- test('when assetHost is not configured', () => {
669
- const localState = {
670
- locals: {
671
- site: {
672
- }
673
- },
674
- _components: [
675
- 'a',
676
- 'b'
677
- ]
678
- };
679
- const manifest = {
680
- entrypoints: {
681
- a: {
682
- assets: {
683
- js: ['/a.js']
684
- }
685
- },
686
- b: {
687
- assets: {
688
- js: ['b.js']
689
- }
690
- }
691
- }
692
- };
693
-
694
- jest.spyOn(files, 'tryRequire').mockImplementation(() => manifest);
695
-
696
- expect(fn(localState)).toEqual([
697
- '/a.js',
698
- '/b.js'
699
- ]);
700
- });
701
-
702
- test('without prepending assetPath', () => {
703
- const localState = {
704
- locals: {
705
- site: {
706
- assetPath: '/strategist'
707
- }
708
- },
709
- _components: [
710
- 'a',
711
- 'b'
712
- ]
713
- };
714
- const manifest = {
715
- entrypoints: {
716
- a: {
717
- assets: {
718
- js: ['/a.js']
719
- }
720
- },
721
- b: {
722
- assets: {
723
- js: ['b.js']
724
- }
725
- }
726
- }
727
- };
728
-
729
- jest.spyOn(files, 'tryRequire').mockImplementation(() => manifest);
730
-
731
- expect(fn(localState)).toEqual([
732
- '/a.js',
733
- '/b.js'
734
- ]);
735
- });
736
- });
737
- });
738
-
739
628
  describe('injectScriptsAndStyles', () => {
740
629
  const fn = lib['injectScriptsAndStyles'];
741
630
 
package/lib/render.js CHANGED
@@ -38,9 +38,12 @@ function applyRenderHooks(ref, data, locals) {
38
38
  *
39
39
  * @param {String} ref
40
40
  * @param {Object} locals
41
+ * @param {Object} res
42
+ * @param {String} self Clay ref of the component being rendered, e.g.
43
+ * "site.com/_components/article/instances/foo"
41
44
  * @returns {Function}
42
45
  */
43
- function applyPostRenderHooks(ref, locals) {
46
+ function applyPostRenderHooks(ref, locals, res, self) {
44
47
  return (html) => {
45
48
 
46
49
  // skip postRender hooks in edit mode
@@ -50,7 +53,7 @@ function applyPostRenderHooks(ref, locals) {
50
53
 
51
54
  return setup.plugins.filter((plugin) => plugin.postRender)
52
55
  .reduce((val, plugin) => {
53
- return plugin.postRender(ref, html, locals);
56
+ return plugin.postRender(ref, html, locals, res, self);
54
57
  }, html);
55
58
  };
56
59
  }
@@ -148,7 +151,7 @@ function render(data, meta, res) {
148
151
  return applyRenderHooks(state._layoutRef || state.self, data, state.locals)
149
152
  .then(makeHtml(state))
150
153
  .then(mediaService.injectScriptsAndStyles(state))
151
- .then(applyPostRenderHooks(state._layoutRef || state.self, state.locals))
154
+ .then(applyPostRenderHooks(state._layoutRef || state.self, state.locals, res, meta._ref))
152
155
  .then(result => {
153
156
  res.type('text/html');
154
157
  res.send(result);
@@ -182,8 +185,12 @@ function logTime(hrStart, msg, route) {
182
185
  };
183
186
  }
184
187
 
185
- function configure({ editAssetTags, cacheBuster }) {
186
- mediaService.configure(editAssetTags, cacheBuster);
188
+ function configure({ editAssetTags, cacheBuster, modulepreload }) {
189
+ const mediaOptions = editAssetTags && typeof editAssetTags === 'object'
190
+ ? Object.assign({}, editAssetTags, modulepreload !== undefined ? { modulepreload } : {})
191
+ : editAssetTags;
192
+
193
+ mediaService.configure(mediaOptions, cacheBuster);
187
194
  }
188
195
 
189
196
  /**
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "amphora-html",
3
- "version": "6.0.0-8",
3
+ "version": "6.0.0",
4
4
  "description": "An HTML renderer for component data",
5
5
  "main": "index.js",
6
+ "engines": {
7
+ "node": ">=20.14.0"
8
+ },
6
9
  "scripts": {
7
10
  "test": "npm run eslint && npm run jest -- --coverage --no-cache",
8
11
  "eslint": "eslint --max-warnings 0 lib test",
@@ -1,44 +0,0 @@
1
- version: 2
2
-
3
- # Set defaults to reuse
4
- defaults: &defaults
5
- working_directory: ~/repo
6
- docker:
7
- - image: circleci/node:8.10.0
8
-
9
- jobs:
10
- test:
11
- <<: *defaults
12
- steps:
13
- - checkout
14
-
15
- - restore_cache:
16
- keys:
17
- - v2-dependencies-{{ checksum "package.json" }}
18
- # fallback to using the latest cache if no exact match is found
19
- - v2-dependencies-
20
-
21
- - run: npm install
22
- - run:
23
- name: Run tests
24
- command: npm test
25
- - run:
26
- name: Send to Coveralls
27
- command: cat coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js
28
- - save_cache:
29
- paths:
30
- - node_modules
31
- key: v2-dependencies-{{ checksum "package.json" }}
32
-
33
- - persist_to_workspace:
34
- root: ~/repo
35
- paths: .
36
-
37
- workflows:
38
- version: 2
39
- test-deploy:
40
- jobs:
41
- - test:
42
- filters:
43
- tags:
44
- only: /^v[0-9]+\.[0-9]+\.[0-9]+/
@@ -1,26 +0,0 @@
1
- on: push
2
- name: Deploy to GitHub Pages
3
- jobs:
4
- filterBranch:
5
- name: Filter branch
6
- runs-on: ubuntu-latest
7
- steps:
8
- - uses: actions/checkout@master
9
- - name: Filter branch
10
- uses: actions/bin/filter@master
11
- with:
12
- args: branch master
13
- - name: Install
14
- uses: actions/npm@master
15
- with:
16
- args: install --prefix ./website
17
- - name: Update version
18
- uses: clay/docusaurus-github-action@master
19
- with:
20
- args: version
21
- - name: Build and push docs
22
- uses: clay/docusaurus-github-action@master
23
- env:
24
- DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
25
- with:
26
- args: deploy