Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature/issue 1268 import map and attribute polyfill configuration #1269

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"@rollup/plugin-replace": "^5.0.5",
"@rollup/plugin-terser": "^0.4.4",
"acorn": "^8.0.1",
"acorn-import-attributes": "^1.9.5",
"acorn-walk": "^8.0.0",
"commander": "^2.20.0",
"css-tree": "^2.2.1",
Expand Down
40 changes: 30 additions & 10 deletions packages/cli/src/config/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
const externalizedResources = ['css', 'json'];

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

// filter first for any bare specifiers
Expand Down Expand Up @@ -254,8 +257,7 @@
const normalizedId = id.replace(/\\\\/g, '/').replace(/\\/g, '/'); // windows shenanigans...
let idUrl = new URL(`file://${cleanRollupId(id)}`);
const headers = {
'Accept': 'text/javascript',
'Sec-Fetch-Dest': 'empty'
'Accept': 'text/javascript'
};
const request = new Request(idUrl, {
headers
Expand Down Expand Up @@ -364,7 +366,7 @@
}
}
} else {
// TODO figure out how to handle URL chunk from SSR pages

Check warning on line 369 in packages/cli/src/config/rollup.config.js

View workflow job for this annotation

GitHub Actions / build (18)

Unexpected 'todo' comment: 'TODO figure out how to handle URL chunk...'

Check warning on line 369 in packages/cli/src/config/rollup.config.js

View workflow job for this annotation

GitHub Actions / build (18)

Unexpected ' TODO' comment: 'TODO figure out how to handle URL chunk...'

Check warning on line 369 in packages/cli/src/config/rollup.config.js

View workflow job for this annotation

GitHub Actions / build (18)

Unexpected 'todo' comment: 'TODO figure out how to handle URL chunk...'

Check warning on line 369 in packages/cli/src/config/rollup.config.js

View workflow job for this annotation

GitHub Actions / build (18)

Unexpected ' TODO' comment: 'TODO figure out how to handle URL chunk...'
// https://github.com/ProjectEvergreen/greenwood/issues/1163
}

Expand Down Expand Up @@ -424,7 +426,8 @@
// - sync externalized import attribute paths with bundled CSS paths
function greenwoodSyncImportAttributes(compilation) {
const unbundledAssetsRefMapper = {};
const { basePath } = compilation.config;
const { basePath, polyfills } = compilation.config;
const { importAttributes } = polyfills;

return {
name: 'greenwood-sync-import-attributes',
Expand All @@ -451,7 +454,16 @@
if (externalizedResources.includes(extension)) {
let preBundled = false;
let inlineOptimization = false;
bundles[bundle].code = bundles[bundle].code.replace(/assert{/g, 'with{');

if (importAttributes && importAttributes.includes(extension)) {
importAttributes.forEach((attribute) => {
if (attribute === extension) {
bundles[bundle].code = bundles[bundle].code.replace(new RegExp(`"assert{type:"${attribute}"}`, 'g'), `?polyfill=type-${extension}"`);
}
});
} else {
bundles[bundle].code = bundles[bundle].code.replace(/assert{/g, 'with{');
}

// check for app level assets, like say a shared theme.css
compilation.resources.forEach((resource) => {
Expand Down Expand Up @@ -529,9 +541,17 @@
// have to apply Greenwood's optimizing here instead of in generateBundle
// since we can't do async work inside a sync AST operation
if (!asset.preBundled) {
const assetUrl = unbundledAssetsRefMapper[asset].sourceURL;
const request = new Request(assetUrl, { headers: { 'Accept': 'text/css' } });
let response = new Response(unbundledAssetsRefMapper[asset].source, { headers: { 'Content-Type': 'text/css' } });
const type = ext === 'css'
? 'text/css'
: ext === 'css'
? 'application/json'
: '';
const assetUrl = importAttributes && importAttributes.includes(ext)
? new URL(`${unbundledAssetsRefMapper[asset].sourceURL.href}?polyfill=type-${ext}`)
: unbundledAssetsRefMapper[asset].sourceURL;

const request = new Request(assetUrl, { headers: { 'Accept': type } });
let response = new Response(unbundledAssetsRefMapper[asset].source, { headers: { 'Content-Type': type } });

for (const plugin of resourcePlugins) {
if (plugin.shouldPreIntercept && await plugin.shouldPreIntercept(assetUrl, request, response.clone())) {
Expand Down
31 changes: 29 additions & 2 deletions packages/cli/src/lifecycles/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@ const defaultConfig = {
prerender: false,
isolation: false,
pagesDirectory: 'pages',
layoutsDirectory: 'layouts'
layoutsDirectory: 'layouts',
polyfills: {
importAttributes: null, // or ['css', 'json']
importMaps: false
}
};

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

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

// workspace validation
if (workspace) {
Expand Down Expand Up @@ -239,6 +244,28 @@ const readAndMergeConfig = async() => {
reject(`Error: greenwood.config.js staticRouter must be a boolean; true or false. Passed value was typeof: ${typeof staticRouter}`);
}
}

if (polyfills !== undefined) {
const { importMaps, importAttributes } = polyfills;

customConfig.polyfills = {};

if (importMaps) {
if (typeof importMaps === 'boolean') {
customConfig.polyfills.importMaps = true;
} else {
reject(`Error: greenwood.config.js polyfills.importMaps must be a boolean; true or false. Passed value was typeof: ${typeof importMaps}`);
}
}

if (importAttributes) {
if (Array.isArray(importAttributes)) {
customConfig.polyfills.importAttributes = importAttributes;
} else {
reject(`Error: greenwood.config.js polyfills.importAttributes must be an array of types; ['css', 'json']. Passed value was typeof: ${typeof importAttributes}`);
}
}
}
} else {
// SPA should _not_ prerender unless if user has specified prerender should be true
if (isSPA) {
Expand Down
12 changes: 8 additions & 4 deletions packages/cli/src/lifecycles/prerender.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ async function preRenderCompilationWorker(compilation, workerPrerender) {
async function preRenderCompilationCustom(compilation, customPrerender) {
const { scratchDir } = compilation.context;
const renderer = (await import(customPrerender.customUrl)).default;
const { importMaps } = compilation.config.polyfills;

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

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

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

// clean this up to avoid sending webcomponents-bundle to rollup
body = body.replace(/<script src="(.*webcomponents-bundle.js)"><\/script>/, '');
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/src/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,7 @@ const resourcePlugins = config.plugins

async function getCustomLoaderResponse(initUrl, checkOnly = false) {
const headers = {
'Accept': 'text/javascript',
'Sec-Fetch-Dest': 'empty'
'Accept': 'text/javascript'
};
const initResponse = new Response('');
let request = new Request(initUrl, { headers });
Expand Down
11 changes: 7 additions & 4 deletions packages/cli/src/plugins/resource/plugin-node-modules.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,14 @@ class NodeModulesResource extends ResourceInterface {
}

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

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

body = body.replace(/\<head>(.*)<\/head>/s, contents.replace(/\$/g, '$$$')); // https://github.com/ProjectEvergreen/greenwood/issues/656);
Expand All @@ -97,8 +100,8 @@ class NodeModulesResource extends ResourceInterface {
// apply import map and shim for users
body = body.replace('<head>', `
<head>
<script defer src="/node_modules/es-module-shims/dist/es-module-shims.js"></script>
<script type="importmap-shim">
${importMapShimScript}
<script type="${importMapType}">
{
"imports": ${JSON.stringify(importMap, null, 1)}
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/plugins/resource/plugin-standard-css.js
Original file line number Diff line number Diff line change
Expand Up @@ -312,14 +312,14 @@ class StandardCssResource extends ResourceInterface {

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

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

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

body = `const sheet = new CSSStyleSheet();sheet.replaceSync(\`${contents}\`);export default sheet;`;
Expand Down
38 changes: 38 additions & 0 deletions packages/cli/src/plugins/resource/plugin-standard-javascript.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
import fs from 'fs/promises';
import { ResourceInterface } from '../../lib/resource-interface.js';
import terser from '@rollup/plugin-terser';
import * as acorn from 'acorn';
import * as walk from 'acorn-walk';
import { importAttributes } from 'acorn-import-attributes';

class StandardJavaScriptResource extends ResourceInterface {
constructor(compilation, options) {
Expand All @@ -28,6 +31,41 @@ class StandardJavaScriptResource extends ResourceInterface {
}
});
}

async shouldPreIntercept(url, request, response) {
const { polyfills } = this.compilation.config;

return (polyfills?.importAttributes || []).length > 0 && url.protocol === 'file:' && response.headers.get('Content-Type').indexOf(this.contentType) >= 0;
}

async preIntercept(url, request, response) {
const { polyfills } = this.compilation.config;
const body = await response.clone().text();
let polyfilled = body;

walk.simple(acorn.Parser.extend(importAttributes).parse(body, {
ecmaVersion: 'latest',
sourceType: 'module'
}), {
async ImportDeclaration(node) {
const line = body.slice(node.start, node.end);
const { value } = node.source;

polyfills.importAttributes.forEach((attribute) => {
if (line.replace(/ /g, '').replace(/"/g, '\'').includes(`with{type:'${attribute}'}`)) {
polyfilled = polyfilled.replace(line, `${line.split('with')[0]};\n`);
polyfilled = polyfilled.replace(value, `${value}?polyfill=type-${attribute}`);
}
});
}
});

return new Response(polyfilled, {
headers: {
'Content-Type': this.contentType
}
});
}
}

const greenwoodPluginStandardJavascript = [{
Expand Down
5 changes: 4 additions & 1 deletion packages/cli/src/plugins/resource/plugin-standard-json.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,10 @@ class StandardJsonResource extends ResourceInterface {
const { protocol, pathname, searchParams } = url;
const ext = pathname.split('.').pop();

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

async intercept(url, request, response) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* Use Case
* Run Greenwood build command with a bad value for polyfill.importAttributes in a custom config.
*
* User Result
* Should throw an error.
*
* User Command
* greenwood build
*
* User Config
* {
* polyfills: {
* importAttributes: {}
* }
* }
*
* User Workspace
* Greenwood default
*/
import chai from 'chai';
import path from 'path';
import { Runner } from 'gallinago';
import { fileURLToPath, URL } from 'url';

const expect = chai.expect;

describe('Build Greenwood With: ', function() {
const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js');
const outputPath = fileURLToPath(new URL('.', import.meta.url));
let runner;

before(function() {
this.context = {
publicDir: path.join(outputPath, 'public')
};
runner = new Runner();
});

describe('Custom Configuration with a bad value for Polyfills w/ Import Attributes', function() {
it('should throw an error that polyfills.importAttributes must be an array of types; [\'css\', \'json\']', function() {
try {
runner.setup(outputPath);
runner.runCommand(cliPath, 'build');
} catch (err) {
expect(err).to.contain('Error: greenwood.config.js polyfill.importAttributes must be a an array of types; [\'css\', \'json\']. Passed value was typeof: object');
}
});
});

});
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default {
polyfills: {
importAttributes: null
}
};
Loading
Loading