From 88548ac339b41b1e37b89a70d2add2e64c5c908d Mon Sep 17 00:00:00 2001 From: Owen Buckley Date: Thu, 5 Sep 2024 13:37:12 -0400 Subject: [PATCH] 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 --- packages/cli/package.json | 1 + packages/cli/src/config/rollup.config.js | 40 +++- packages/cli/src/lifecycles/config.js | 31 ++- packages/cli/src/lifecycles/prerender.js | 12 +- packages/cli/src/loader.js | 3 +- .../plugins/resource/plugin-node-modules.js | 11 +- .../plugins/resource/plugin-standard-css.js | 4 +- .../resource/plugin-standard-javascript.js | 38 ++++ .../plugins/resource/plugin-standard-json.js | 5 +- ...g.error-polyfill-import-attributes.spec.js | 51 +++++ .../greenwood.config.js | 5 + ....config.error-polyfill-import-maps.spec.js | 51 +++++ .../greenwood.config.js | 5 + .../develop.config.base-path.spec.js | 2 +- ...config.polyfills-import-attributes.spec.js | 185 ++++++++++++++++++ .../greenwood.config.js | 5 + .../package.json | 4 + .../src/components/hero.css | 3 + .../src/components/hero.js | 54 +++++ .../src/components/hero.json | 3 + .../src/index.html | 11 ++ .../src/theme.css | 3 + ...velop.config.polyfills-import-maps.spec.js | 126 ++++++++++++ .../greenwood.config.js | 5 + .../package.json | 4 + .../src/components/hero.js | 64 ++++++ .../src/index.html | 11 ++ .../develop.default/develop.default.spec.js | 11 +- .../greenwood.config.js | 6 + ...config.polyfills-import-attributes.spec.js | 178 +++++++++++++++++ .../src/components/hero.css | 3 + .../src/components/hero.js | 33 ++++ .../src/components/hero.json | 3 + .../src/index.html | 11 ++ .../src/theme.css | 3 + .../develop.default/develop.default.spec.js | 2 +- packages/plugin-import-raw/src/index.js | 5 +- .../develop.default/develop.default.spec.js | 6 +- packages/plugin-renderer-lit/src/index.js | 10 +- www/pages/docs/configuration.md | 57 +++++- www/pages/docs/css-and-images.md | 2 + www/pages/docs/scripts.md | 6 +- 42 files changed, 1017 insertions(+), 56 deletions(-) create mode 100644 packages/cli/test/cases/build.config.error-polyfill-import-attributes/build.config.error-polyfill-import-attributes.spec.js create mode 100644 packages/cli/test/cases/build.config.error-polyfill-import-attributes/greenwood.config.js create mode 100644 packages/cli/test/cases/build.config.error-polyfill-import-maps/build.config.error-polyfill-import-maps.spec.js create mode 100644 packages/cli/test/cases/build.config.error-polyfill-import-maps/greenwood.config.js create mode 100644 packages/cli/test/cases/develop.config.polyfills-import-attributes/develop.config.polyfills-import-attributes.spec.js create mode 100644 packages/cli/test/cases/develop.config.polyfills-import-attributes/greenwood.config.js create mode 100644 packages/cli/test/cases/develop.config.polyfills-import-attributes/package.json create mode 100644 packages/cli/test/cases/develop.config.polyfills-import-attributes/src/components/hero.css create mode 100644 packages/cli/test/cases/develop.config.polyfills-import-attributes/src/components/hero.js create mode 100644 packages/cli/test/cases/develop.config.polyfills-import-attributes/src/components/hero.json create mode 100644 packages/cli/test/cases/develop.config.polyfills-import-attributes/src/index.html create mode 100644 packages/cli/test/cases/develop.config.polyfills-import-attributes/src/theme.css create mode 100644 packages/cli/test/cases/develop.config.polyfills-import-maps/develop.config.polyfills-import-maps.spec.js create mode 100644 packages/cli/test/cases/develop.config.polyfills-import-maps/greenwood.config.js create mode 100644 packages/cli/test/cases/develop.config.polyfills-import-maps/package.json create mode 100644 packages/cli/test/cases/develop.config.polyfills-import-maps/src/components/hero.js create mode 100644 packages/cli/test/cases/develop.config.polyfills-import-maps/src/index.html create mode 100644 packages/cli/test/cases/loaders-serve.config.polyfill-import-attributes/greenwood.config.js create mode 100644 packages/cli/test/cases/loaders-serve.config.polyfill-import-attributes/loaders-serve.config.polyfills-import-attributes.spec.js create mode 100644 packages/cli/test/cases/loaders-serve.config.polyfill-import-attributes/src/components/hero.css create mode 100644 packages/cli/test/cases/loaders-serve.config.polyfill-import-attributes/src/components/hero.js create mode 100644 packages/cli/test/cases/loaders-serve.config.polyfill-import-attributes/src/components/hero.json create mode 100644 packages/cli/test/cases/loaders-serve.config.polyfill-import-attributes/src/index.html create mode 100644 packages/cli/test/cases/loaders-serve.config.polyfill-import-attributes/src/theme.css diff --git a/packages/cli/package.json b/packages/cli/package.json index 4f1751425..3cc14d85b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/cli/src/config/rollup.config.js b/packages/cli/src/config/rollup.config.js index e50e2aa61..53734d8e7 100644 --- a/packages/cli/src/config/rollup.config.js +++ b/packages/cli/src/config/rollup.config.js @@ -16,6 +16,7 @@ function cleanRollupId(id) { 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) => { @@ -33,7 +34,10 @@ function greenwoodResourceLoader (compilation, browser = false) { 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 @@ -54,8 +58,7 @@ function greenwoodResourceLoader (compilation, browser = false) { 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 @@ -254,8 +257,7 @@ function greenwoodImportMetaUrl(compilation) { 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 @@ -424,7 +426,8 @@ function greenwoodImportMetaUrl(compilation) { // - 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', @@ -451,7 +454,16 @@ function greenwoodSyncImportAttributes(compilation) { 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) => { @@ -529,9 +541,17 @@ function greenwoodSyncImportAttributes(compilation) { // 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())) { diff --git a/packages/cli/src/lifecycles/config.js b/packages/cli/src/lifecycles/config.js index 7ee7fc2f7..9c31a230a 100644 --- a/packages/cli/src/lifecycles/config.js +++ b/packages/cli/src/lifecycles/config.js @@ -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() => { @@ -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) { @@ -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) { diff --git a/packages/cli/src/lifecycles/prerender.js b/packages/cli/src/lifecycles/prerender.js index 04007e00e..67994a345 100644 --- a/packages/cli/src/lifecycles/prerender.js +++ b/packages/cli/src/lifecycles/prerender.js @@ -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 ')}`); @@ -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(/' : ''; let body = await response.text(); const hasHead = body.match(/\(.*)<\/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>/s, contents.replace(/\$/g, '$$$')); // https://github.com/ProjectEvergreen/greenwood/issues/656); @@ -97,8 +100,8 @@ class NodeModulesResource extends ResourceInterface { // apply import map and shim for users body = body.replace('', ` - - + + + + + + + \ No newline at end of file diff --git a/packages/cli/test/cases/develop.config.polyfills-import-attributes/src/theme.css b/packages/cli/test/cases/develop.config.polyfills-import-attributes/src/theme.css new file mode 100644 index 000000000..0f7c63922 --- /dev/null +++ b/packages/cli/test/cases/develop.config.polyfills-import-attributes/src/theme.css @@ -0,0 +1,3 @@ +a { + color: blue; +} \ No newline at end of file diff --git a/packages/cli/test/cases/develop.config.polyfills-import-maps/develop.config.polyfills-import-maps.spec.js b/packages/cli/test/cases/develop.config.polyfills-import-maps/develop.config.polyfills-import-maps.spec.js new file mode 100644 index 000000000..4a7bda62b --- /dev/null +++ b/packages/cli/test/cases/develop.config.polyfills-import-maps/develop.config.polyfills-import-maps.spec.js @@ -0,0 +1,126 @@ +/* + * Use Case + * Run Greenwood develop command with import maps polyfill flag enabled. + * + * User Result + * Should start the development server and have all the expected import map shim behaviors. + * + * User Command + * greenwood develop + * + * User Config + * devServer: { + * polyfill: { + * importMaps: true + * } + * } + * + * User Workspace + * src/ + * components/ + * hero.js + * index.html + * greenwood.config.js + * package.json + */ +import chai from 'chai'; +import { JSDOM } from 'jsdom'; +import path from 'path'; +import { getSetupFiles } from '../../../../../test/utils.js'; +import { Runner } from 'gallinago'; +import { fileURLToPath, URL } from 'url'; + +const expect = chai.expect; + +describe('Develop Greenwood With: ', function() { + const LABEL = 'Import Maps Polyfill Configuration'; + const cliPath = path.join(process.cwd(), 'packages/cli/src/index.js'); + const outputPath = fileURLToPath(new URL('.', import.meta.url)); + const hostname = 'http://localhost'; + const port = 1984; + let runner; + + before(function() { + this.context = { + hostname: `${hostname}:${port}` + }; + runner = new Runner(); + }); + + describe(LABEL, function() { + + before(async function() { + + runner.setup(outputPath, [ + ...getSetupFiles(outputPath) + ]); + + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 5000); + + runner.runCommand(cliPath, 'develop', { async: true }); + }); + }); + + describe('Import Map Shim Behaviors', function() { + let response = {}; + let dom; + + before(async function() { + response = await fetch(`${hostname}:${port}/`, { + headers: { + accept: 'text/html' + } + }); + + dom = new JSDOM(await response.clone().text()); + }); + + it('should return the correct content type', function(done) { + expect(response.headers.get('content-type')).to.contain('text/html'); + done(); + }); + + it('should return a 200', function(done) { + expect(response.status).to.equal(200); + + done(); + }); + + // + it('should have a shim-ed importmaps + it('should have a + it('should have a shim-ed + + + + + + + \ No newline at end of file diff --git a/packages/cli/test/cases/develop.default/develop.default.spec.js b/packages/cli/test/cases/develop.default/develop.default.spec.js index 610243c55..3d236e7ea 100644 --- a/packages/cli/test/cases/develop.default/develop.default.spec.js +++ b/packages/cli/test/cases/develop.default/develop.default.spec.js @@ -488,7 +488,7 @@ describe('Develop Greenwood With: ', function() { }); it('should return an import map shim + + + + + + + \ No newline at end of file diff --git a/packages/cli/test/cases/loaders-serve.config.polyfill-import-attributes/src/theme.css b/packages/cli/test/cases/loaders-serve.config.polyfill-import-attributes/src/theme.css new file mode 100644 index 000000000..0f7c63922 --- /dev/null +++ b/packages/cli/test/cases/loaders-serve.config.polyfill-import-attributes/src/theme.css @@ -0,0 +1,3 @@ +a { + color: blue; +} \ No newline at end of file diff --git a/packages/plugin-graphql/test/cases/develop.default/develop.default.spec.js b/packages/plugin-graphql/test/cases/develop.default/develop.default.spec.js index ef0daa1c3..07a6d4f2b 100644 --- a/packages/plugin-graphql/test/cases/develop.default/develop.default.spec.js +++ b/packages/plugin-graphql/test/cases/develop.default/develop.default.spec.js @@ -77,7 +77,7 @@ describe('Develop Greenwood With: ', function() { }); it('should return an import map shim + `); return new Response(body); diff --git a/www/pages/docs/configuration.md b/www/pages/docs/configuration.md index 37cc894a6..6779cc57d 100644 --- a/www/pages/docs/configuration.md +++ b/www/pages/docs/configuration.md @@ -32,7 +32,11 @@ export default { workspace: new URL('./src/', import.meta.url), pagesDirectory: 'pages', // e.g. src/pages layoutsDirectory: 'layouts', // e.g. src/layouts - isolation: false + isolation: false, + polyfills: { + importAttributes: null, // e.g. ['css', 'json'] + importMaps: false + } }; ``` @@ -220,6 +224,57 @@ export default { }; ``` +### Polyfills + +Greenwood provides polyfills for a few Web APIs out of the box. + +#### Import Maps + +> _Only applies to development mode._ + +If you are developing with Greenwood in a browser that doesn't support [import maps](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap#browser_compatibility), with this flag enabled, Greenwood will add the [**ES Module Shims**](https://github.com/guybedford/es-module-shims) polyfill to provide support for import maps. + +```js +export default { + polyfills: { + importMaps: true + } +}; +``` + +#### Import Attributes + +[Import Attributes](https://github.com/tc39/proposal-import-attributes), which are the underlying mechanism for supporting [CSS](https://web.dev/articles/css-module-scripts) and [JSON](https://github.com/tc39/proposal-json-modules) module scripts, are not widely supported in [all browsers yet](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules#browser_compatibility). Greenwood can enable this in a browser compatible why by specifying which attributes you want handled. In both cases, Greenwood bundles these as ES Modules and will strip the attributes syntax. + +```js +export default { + polyfills: { + importAttributes: ['css', 'json'] + } +}; +``` + + +In the case of CSS, Greenwood will inline and export your CSS as a [Constructable Stylesheet](https://web.dev/articles/constructable-stylesheets) + +```js +// this +import sheet from './styles.css' with { type: 'css'}; + +// will fallback to this +const sheet = new CSSStyleSheet();sheet.replaceSync(' /* ... */ ');export default sheet; +``` + +For JSON, Greenwood will simply export an object + +```js +// this +import data from './data.css' with { type: 'json'}; + +// will fallback to this +export default { /* ... */ } +``` + ### Port Unlike the port option for `devServer` configuration, this option allows you to configure the port that your production server will run on when running `greenwood serve`. diff --git a/www/pages/docs/css-and-images.md b/www/pages/docs/css-and-images.md index c15502540..2b450e4fb 100644 --- a/www/pages/docs/css-and-images.md +++ b/www/pages/docs/css-and-images.md @@ -75,6 +75,8 @@ export default class Card extends HTMLElement { customElements.define('x-card', Card); ``` +> ⚠️ _Although Import Attributes are not baseline yet, Greenwood supports polyfilling them with a [configuration flag](/docs/configuration/#polyfills)._ + ### Assets diff --git a/www/pages/docs/scripts.md b/www/pages/docs/scripts.md index 4bee33f9d..3c94461e5 100644 --- a/www/pages/docs/scripts.md +++ b/www/pages/docs/scripts.md @@ -55,16 +55,14 @@ Greenwood also supports (and recommends) usage of ECMAScript Modules (ESM), like ### Import Attributes -[Import Attributes](https://github.com/tc39/proposal-import-attributes) are also supported on the client and on [the server](docs/server-rendering/#custom-imports). By default automatically handles CSS and JSON modules and for CSS, emits a [`CSSStylesheet`](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/CSSStyleSheet). +Greenwood supports [Import Attributes](https://github.com/tc39/proposal-import-attributes) for the client and the [the server](docs/server-rendering/#custom-imports) seamlessly, supporting both CSS and JSON module scripts seamlessly. ```js import sheet from './styles.css' with { type: 'css' }; import data from './data.json' with { type: 'json' }; - -console.log({ sheet, data }); ``` -Combined with Greenwood's [custom import resource plugins](https://www.greenwoodjs.io/plugins/custom-plugins/) (or your own!), Greenwood can handle loading custom file extensions for the client or the server using ESM for just about anything you could need! +> ⚠️ _Although Import Attributes are not baseline yet, Greenwood supports polyfilling them with a [configuration flag](/docs/configuration/#polyfills)._ ### Extensions and Bare Imports