amphora-html 6.0.0-dev.0 → 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
177
 
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
-
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
 
@@ -303,7 +289,7 @@ function getScriptFiles(state) {
303
289
  assetPath = site && site.assetPath || '',
304
290
  assetHost = site && site.assetHost || '';
305
291
 
306
- return filterManifestComponents(state)
292
+ return state._components
307
293
  .reduce((prev, name) => {
308
294
  prev.push(
309
295
  path.join(assetDir, 'js', `${name}.js`),
@@ -316,36 +302,6 @@ function getScriptFiles(state) {
316
302
  .map(pathJoin(assetHost, assetPath, assetDir));
317
303
  }
318
304
 
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
305
  /**
350
306
  * Trim the path on the file system bythe link of the
351
307
  * asset directory and then join it with either the assetHost
@@ -378,6 +334,14 @@ function configure(options, cacheBuster = '') {
378
334
  if (options && _.isObject(options)) {
379
335
  module.exports.editStylesTags = options.styles || false;
380
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
+ }
381
345
  } else {
382
346
  module.exports.editStylesTags = options;
383
347
  module.exports.editScriptsTags = options;
@@ -397,6 +361,7 @@ function configure(options, cacheBuster = '') {
397
361
  * @param {Object} state
398
362
  * @return {Function}
399
363
  */
364
+ /* eslint-disable complexity */
400
365
  function injectScriptsAndStyles(state) {
401
366
  const { locals } = state;
402
367
 
@@ -412,26 +377,50 @@ function injectScriptsAndStyles(state) {
412
377
  mediaMap = module.exports.getMediaMap(state);
413
378
 
414
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;
415
383
  if (setup.resolveMedia) mediaMap = setup.resolveMedia(mediaMap, locals) || mediaMap;
416
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
+
417
403
  if (!locals.edit) {
418
404
  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);
405
+ mediaMap.scripts = combineFileContents(mediaMap.scripts, 'public/js', '/js/', SCRIPT_TAG);
422
406
  } 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);
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);
425
409
  }
426
410
 
427
411
  return bluebird.props(mediaMap)
428
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;
429
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")
430
418
  html = combinedFiles.scripts ? appendMediaToBottom(combinedFiles.scripts, html) : html; // If there are scripts, append them
431
419
  return html; // Return the compiled HTML
432
420
  });
433
421
  };
434
422
  }
423
+ /* eslint-enable complexity */
435
424
 
436
425
  module.exports.injectScriptsAndStyles = injectScriptsAndStyles;
437
426
  module.exports.getMediaMap = getMediaMap;
@@ -439,8 +428,11 @@ module.exports.cacheBuster = '';
439
428
  module.exports.configure = configure;
440
429
  module.exports.editStylesTags = false;
441
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;
442
435
 
443
436
  // For testing
444
- module.exports.getManifestAssets = getManifestAssets;
445
437
  module.exports.getScriptFiles = getScriptFiles;
446
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-dev.0",
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