Skip to content

Commit 88548ac

Browse files
feature/issue 1268 import map and attribute polyfill configuration (#1269)
* import map polyfill config flag * import attributes polyfill config option * import attributes demo * develop test cases for import maps and attributes for development * import attributes polyfill config serve test cases * polyfills configuration error test cases * bundle polyfilled import attributes for the browser * polyfills configuration docs and import attributes call outs * misc refactoring * add acorn-import-attributes as a CLI dependency * refine pre-intercepting logic to include all JS resource types * remove demo code * more robust bundling and serve test case
1 parent 90a3a21 commit 88548ac

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+1017
-56
lines changed

packages/cli/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
"@rollup/plugin-replace": "^5.0.5",
3636
"@rollup/plugin-terser": "^0.4.4",
3737
"acorn": "^8.0.1",
38+
"acorn-import-attributes": "^1.9.5",
3839
"acorn-walk": "^8.0.0",
3940
"commander": "^2.20.0",
4041
"css-tree": "^2.2.1",

packages/cli/src/config/rollup.config.js

+30-10
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ function cleanRollupId(id) {
1616
const externalizedResources = ['css', 'json'];
1717

1818
function greenwoodResourceLoader (compilation, browser = false) {
19+
const { importAttributes } = compilation.config?.polyfills;
1920
const resourcePlugins = compilation.config.plugins.filter((plugin) => {
2021
return plugin.type === 'resource';
2122
}).map((plugin) => {
@@ -33,7 +34,10 @@ function greenwoodResourceLoader (compilation, browser = false) {
3334
if (normalizedId.startsWith('.')) {
3435
const importerUrl = new URL(normalizedId, `file://${importer}`);
3536
const extension = importerUrl.pathname.split('.').pop();
36-
const external = externalizedResources.includes(extension) && browser && !importerUrl.searchParams.has('type');
37+
// if we are polyfilling import attributes for the browser we will want Rollup to bundles these as JS files
38+
// instead of externalizing as their native content-type
39+
const shouldPolyfill = browser && (importAttributes || []).includes(extension);
40+
const external = !shouldPolyfill && externalizedResources.includes(extension) && browser && !importerUrl.searchParams.has('type');
3741
const isUserWorkspaceUrl = importerUrl.pathname.startsWith(userWorkspace.pathname);
3842
const prefix = normalizedId.startsWith('..') ? './' : '';
3943
// if its not in the users workspace, we clean up the dot-dots and check that against the user's workspace
@@ -54,8 +58,7 @@ function greenwoodResourceLoader (compilation, browser = false) {
5458
const { pathname } = idUrl;
5559
const extension = pathname.split('.').pop();
5660
const headers = {
57-
'Accept': 'text/javascript',
58-
'Sec-Fetch-Dest': 'empty'
61+
'Accept': 'text/javascript'
5962
};
6063

6164
// filter first for any bare specifiers
@@ -254,8 +257,7 @@ function greenwoodImportMetaUrl(compilation) {
254257
const normalizedId = id.replace(/\\\\/g, '/').replace(/\\/g, '/'); // windows shenanigans...
255258
let idUrl = new URL(`file://${cleanRollupId(id)}`);
256259
const headers = {
257-
'Accept': 'text/javascript',
258-
'Sec-Fetch-Dest': 'empty'
260+
'Accept': 'text/javascript'
259261
};
260262
const request = new Request(idUrl, {
261263
headers
@@ -424,7 +426,8 @@ function greenwoodImportMetaUrl(compilation) {
424426
// - sync externalized import attribute paths with bundled CSS paths
425427
function greenwoodSyncImportAttributes(compilation) {
426428
const unbundledAssetsRefMapper = {};
427-
const { basePath } = compilation.config;
429+
const { basePath, polyfills } = compilation.config;
430+
const { importAttributes } = polyfills;
428431

429432
return {
430433
name: 'greenwood-sync-import-attributes',
@@ -451,7 +454,16 @@ function greenwoodSyncImportAttributes(compilation) {
451454
if (externalizedResources.includes(extension)) {
452455
let preBundled = false;
453456
let inlineOptimization = false;
454-
bundles[bundle].code = bundles[bundle].code.replace(/assert{/g, 'with{');
457+
458+
if (importAttributes && importAttributes.includes(extension)) {
459+
importAttributes.forEach((attribute) => {
460+
if (attribute === extension) {
461+
bundles[bundle].code = bundles[bundle].code.replace(new RegExp(`"assert{type:"${attribute}"}`, 'g'), `?polyfill=type-${extension}"`);
462+
}
463+
});
464+
} else {
465+
bundles[bundle].code = bundles[bundle].code.replace(/assert{/g, 'with{');
466+
}
455467

456468
// check for app level assets, like say a shared theme.css
457469
compilation.resources.forEach((resource) => {
@@ -529,9 +541,17 @@ function greenwoodSyncImportAttributes(compilation) {
529541
// have to apply Greenwood's optimizing here instead of in generateBundle
530542
// since we can't do async work inside a sync AST operation
531543
if (!asset.preBundled) {
532-
const assetUrl = unbundledAssetsRefMapper[asset].sourceURL;
533-
const request = new Request(assetUrl, { headers: { 'Accept': 'text/css' } });
534-
let response = new Response(unbundledAssetsRefMapper[asset].source, { headers: { 'Content-Type': 'text/css' } });
544+
const type = ext === 'css'
545+
? 'text/css'
546+
: ext === 'css'
547+
? 'application/json'
548+
: '';
549+
const assetUrl = importAttributes && importAttributes.includes(ext)
550+
? new URL(`${unbundledAssetsRefMapper[asset].sourceURL.href}?polyfill=type-${ext}`)
551+
: unbundledAssetsRefMapper[asset].sourceURL;
552+
553+
const request = new Request(assetUrl, { headers: { 'Accept': type } });
554+
let response = new Response(unbundledAssetsRefMapper[asset].source, { headers: { 'Content-Type': type } });
535555

536556
for (const plugin of resourcePlugins) {
537557
if (plugin.shouldPreIntercept && await plugin.shouldPreIntercept(assetUrl, request, response.clone())) {

packages/cli/src/lifecycles/config.js

+29-2
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,11 @@ const defaultConfig = {
5252
prerender: false,
5353
isolation: false,
5454
pagesDirectory: 'pages',
55-
layoutsDirectory: 'layouts'
55+
layoutsDirectory: 'layouts',
56+
polyfills: {
57+
importAttributes: null, // or ['css', 'json']
58+
importMaps: false
59+
}
5660
};
5761

5862
const readAndMergeConfig = async() => {
@@ -77,7 +81,8 @@ const readAndMergeConfig = async() => {
7781

7882
if (hasConfigFile) {
7983
const userCfgFile = (await import(configUrl)).default;
80-
const { workspace, devServer, markdown, optimization, plugins, port, prerender, basePath, staticRouter, pagesDirectory, layoutsDirectory, interpolateFrontmatter, isolation } = userCfgFile;
84+
// eslint-disable-next-line max-len
85+
const { workspace, devServer, markdown, optimization, plugins, port, prerender, basePath, staticRouter, pagesDirectory, layoutsDirectory, interpolateFrontmatter, isolation, polyfills } = userCfgFile;
8186

8287
// workspace validation
8388
if (workspace) {
@@ -239,6 +244,28 @@ const readAndMergeConfig = async() => {
239244
reject(`Error: greenwood.config.js staticRouter must be a boolean; true or false. Passed value was typeof: ${typeof staticRouter}`);
240245
}
241246
}
247+
248+
if (polyfills !== undefined) {
249+
const { importMaps, importAttributes } = polyfills;
250+
251+
customConfig.polyfills = {};
252+
253+
if (importMaps) {
254+
if (typeof importMaps === 'boolean') {
255+
customConfig.polyfills.importMaps = true;
256+
} else {
257+
reject(`Error: greenwood.config.js polyfills.importMaps must be a boolean; true or false. Passed value was typeof: ${typeof importMaps}`);
258+
}
259+
}
260+
261+
if (importAttributes) {
262+
if (Array.isArray(importAttributes)) {
263+
customConfig.polyfills.importAttributes = importAttributes;
264+
} else {
265+
reject(`Error: greenwood.config.js polyfills.importAttributes must be an array of types; ['css', 'json']. Passed value was typeof: ${typeof importAttributes}`);
266+
}
267+
}
268+
}
242269
} else {
243270
// SPA should _not_ prerender unless if user has specified prerender should be true
244271
if (isSPA) {

packages/cli/src/lifecycles/prerender.js

+8-4
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ async function preRenderCompilationWorker(compilation, workerPrerender) {
121121
async function preRenderCompilationCustom(compilation, customPrerender) {
122122
const { scratchDir } = compilation.context;
123123
const renderer = (await import(customPrerender.customUrl)).default;
124+
const { importMaps } = compilation.config.polyfills;
124125

125126
console.info('pages to generate', `\n ${compilation.graph.map(page => page.route).join('\n ')}`);
126127

@@ -129,10 +130,13 @@ async function preRenderCompilationCustom(compilation, customPrerender) {
129130
const outputPathUrl = new URL(`.${outputPath}`, scratchDir);
130131

131132
// clean up special Greenwood dev only assets that would come through if prerendering with a headless browser
132-
body = body.replace(/<script src="(.*lit\/polyfill-support.js)"><\/script>/, '');
133-
body = body.replace(/<script type="importmap-shim">.*?<\/script>/s, '');
134-
body = body.replace(/<script defer="" src="(.*es-module-shims.js)"><\/script>/, '');
135-
body = body.replace(/type="module-shim"/g, 'type="module"');
133+
if (importMaps) {
134+
body = body.replace(/<script type="importmap-shim">.*?<\/script>/s, '');
135+
body = body.replace(/<script defer="" src="(.*es-module-shims.js)"><\/script>/, '');
136+
body = body.replace(/type="module-shim"/g, 'type="module"');
137+
} else {
138+
body = body.replace(/<script type="importmap">.*?<\/script>/s, '');
139+
}
136140

137141
// clean this up to avoid sending webcomponents-bundle to rollup
138142
body = body.replace(/<script src="(.*webcomponents-bundle.js)"><\/script>/, '');

packages/cli/src/loader.js

+1-2
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,7 @@ const resourcePlugins = config.plugins
1919

2020
async function getCustomLoaderResponse(initUrl, checkOnly = false) {
2121
const headers = {
22-
'Accept': 'text/javascript',
23-
'Sec-Fetch-Dest': 'empty'
22+
'Accept': 'text/javascript'
2423
};
2524
const initResponse = new Response('');
2625
let request = new Request(initUrl, { headers });

packages/cli/src/plugins/resource/plugin-node-modules.js

+7-4
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,14 @@ class NodeModulesResource extends ResourceInterface {
7373
}
7474

7575
async intercept(url, request, response) {
76-
const { context } = this.compilation;
76+
const { context, config } = this.compilation;
77+
const { importMaps } = config.polyfills;
78+
const importMapType = importMaps ? 'importmap-shim' : 'importmap';
79+
const importMapShimScript = importMaps ? '<script defer src="/node_modules/es-module-shims/dist/es-module-shims.js"></script>' : '';
7780
let body = await response.text();
7881
const hasHead = body.match(/\<head>(.*)<\/head>/s);
7982

80-
if (hasHead && hasHead.length > 0) {
83+
if (importMaps && hasHead && hasHead.length > 0) {
8184
const contents = hasHead[0].replace(/type="module"/g, 'type="module-shim"');
8285

8386
body = body.replace(/\<head>(.*)<\/head>/s, contents.replace(/\$/g, '$$$')); // https://github.com/ProjectEvergreen/greenwood/issues/656);
@@ -97,8 +100,8 @@ class NodeModulesResource extends ResourceInterface {
97100
// apply import map and shim for users
98101
body = body.replace('<head>', `
99102
<head>
100-
<script defer src="/node_modules/es-module-shims/dist/es-module-shims.js"></script>
101-
<script type="importmap-shim">
103+
${importMapShimScript}
104+
<script type="${importMapType}">
102105
{
103106
"imports": ${JSON.stringify(importMap, null, 1)}
104107
}

packages/cli/src/plugins/resource/plugin-standard-css.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -312,14 +312,14 @@ class StandardCssResource extends ResourceInterface {
312312

313313
return url.protocol === 'file:'
314314
&& ext === this.extensions[0]
315-
&& (response.headers.get('Content-Type')?.indexOf('text/css') >= 0 || request.headers.get('Accept')?.indexOf('text/javascript') >= 0);
315+
&& (response.headers.get('Content-Type')?.indexOf('text/css') >= 0 || request.headers.get('Accept')?.indexOf('text/javascript') >= 0) || url.searchParams?.get('polyfill') === 'type-css';
316316
}
317317

318318
async intercept(url, request, response) {
319319
let body = bundleCss(await response.text(), url, this.compilation);
320320
let headers = {};
321321

322-
if (request.headers.get('Accept')?.indexOf('text/javascript') >= 0 && !url.searchParams.has('type')) {
322+
if ((request.headers.get('Accept')?.indexOf('text/javascript') >= 0 || url.searchParams?.get('polyfill') === 'type-css') && !url.searchParams.has('type')) {
323323
const contents = body.replace(/\r?\n|\r/g, ' ').replace(/\\/g, '\\\\');
324324

325325
body = `const sheet = new CSSStyleSheet();sheet.replaceSync(\`${contents}\`);export default sheet;`;

packages/cli/src/plugins/resource/plugin-standard-javascript.js

+38
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
import fs from 'fs/promises';
88
import { ResourceInterface } from '../../lib/resource-interface.js';
99
import terser from '@rollup/plugin-terser';
10+
import * as acorn from 'acorn';
11+
import * as walk from 'acorn-walk';
12+
import { importAttributes } from 'acorn-import-attributes';
1013

1114
class StandardJavaScriptResource extends ResourceInterface {
1215
constructor(compilation, options) {
@@ -28,6 +31,41 @@ class StandardJavaScriptResource extends ResourceInterface {
2831
}
2932
});
3033
}
34+
35+
async shouldPreIntercept(url, request, response) {
36+
const { polyfills } = this.compilation.config;
37+
38+
return (polyfills?.importAttributes || []).length > 0 && url.protocol === 'file:' && response.headers.get('Content-Type').indexOf(this.contentType) >= 0;
39+
}
40+
41+
async preIntercept(url, request, response) {
42+
const { polyfills } = this.compilation.config;
43+
const body = await response.clone().text();
44+
let polyfilled = body;
45+
46+
walk.simple(acorn.Parser.extend(importAttributes).parse(body, {
47+
ecmaVersion: 'latest',
48+
sourceType: 'module'
49+
}), {
50+
async ImportDeclaration(node) {
51+
const line = body.slice(node.start, node.end);
52+
const { value } = node.source;
53+
54+
polyfills.importAttributes.forEach((attribute) => {
55+
if (line.replace(/ /g, '').replace(/"/g, '\'').includes(`with{type:'${attribute}'}`)) {
56+
polyfilled = polyfilled.replace(line, `${line.split('with')[0]};\n`);
57+
polyfilled = polyfilled.replace(value, `${value}?polyfill=type-${attribute}`);
58+
}
59+
});
60+
}
61+
});
62+
63+
return new Response(polyfilled, {
64+
headers: {
65+
'Content-Type': this.contentType
66+
}
67+
});
68+
}
3169
}
3270

3371
const greenwoodPluginStandardJavascript = [{

packages/cli/src/plugins/resource/plugin-standard-json.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@ class StandardJsonResource extends ResourceInterface {
4545
const { protocol, pathname, searchParams } = url;
4646
const ext = pathname.split('.').pop();
4747

48-
return protocol === 'file:' && request.headers.get('Accept')?.indexOf('text/javascript') >= 0 && ext === this.extensions[0] && !searchParams.has('type');
48+
return protocol === 'file:'
49+
&& ext === this.extensions[0]
50+
&& !searchParams.has('type')
51+
&& (request.headers.get('Accept')?.indexOf('text/javascript') >= 0 || url.searchParams?.get('polyfill') === 'type-json');
4952
}
5053

5154
async intercept(url, request, response) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Use Case
3+
* Run Greenwood build command with a bad value for polyfill.importAttributes in a custom config.
4+
*
5+
* User Result
6+
* Should throw an error.
7+
*
8+
* User Command
9+
* greenwood build
10+
*
11+
* User Config
12+
* {
13+
* polyfills: {
14+
* importAttributes: {}
15+
* }
16+
* }
17+
*
18+
* User Workspace
19+
* Greenwood default
20+
*/
21+
import chai from 'chai';
22+
import path from 'path';
23+
import { Runner } from 'gallinago';
24+
import { fileURLToPath, URL } from 'url';
25+
26+
const expect = chai.expect;
27+
28+
describe('Build Greenwood With: ', function() {
29+
const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js');
30+
const outputPath = fileURLToPath(new URL('.', import.meta.url));
31+
let runner;
32+
33+
before(function() {
34+
this.context = {
35+
publicDir: path.join(outputPath, 'public')
36+
};
37+
runner = new Runner();
38+
});
39+
40+
describe('Custom Configuration with a bad value for Polyfills w/ Import Attributes', function() {
41+
it('should throw an error that polyfills.importAttributes must be an array of types; [\'css\', \'json\']', function() {
42+
try {
43+
runner.setup(outputPath);
44+
runner.runCommand(cliPath, 'build');
45+
} catch (err) {
46+
expect(err).to.contain('Error: greenwood.config.js polyfill.importAttributes must be a an array of types; [\'css\', \'json\']. Passed value was typeof: object');
47+
}
48+
});
49+
});
50+
51+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export default {
2+
polyfills: {
3+
importAttributes: null
4+
}
5+
};

0 commit comments

Comments
 (0)