Repository: ProjectEvergreen/greenwood (js)

Issue:
`<link>` tags that reference remote URLs break the build (v0.10.0 alpha)
## Type of Change
- [ ] New Feature Request
- [ ] Documentation / Website
- [ ] Improvement / Suggestion
- [x] Bug
- [ ] Other (please clarify below)

## Summary
If you try and so something like this in your template
```html
<head>
  <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Avenir+Next&display=swap">
```

Thanks [go bonkos](https://app.netlify.com/sites/silly-snyder-ece0b2/deploys/60562f74a244f000074a6b35), and in fact, we also have an unhandled `Promise rejection` error.
<img width="1278" alt="Screen Shot 2021-03-20 at 1 27 25 PM" src="https://user-images.githubusercontent.com/895923/111881822-b2028800-8988-11eb-9130-d12678badf74.png">
```sh
1:24:08 PM: (node:1423) UnhandledPromiseRejectionWarning: Error: ENOENT: no such file or directory, open '/opt/build/repo/src/fonts.googleapis.com/css?family'
1:24:08 PM:     at Object.openSync (fs.js:476:3)
1:24:08 PM:     at Object.readFileSync (fs.js:377:35)
1:24:08 PM:     at /opt/build/repo/node_modules/@greenwood/cli/src/config/rollup.config.js:226:31
1:24:08 PM:     at Array.forEach (<anonymous>)
1:24:08 PM:     at Object.buildStart (/opt/build/repo/node_modules/@greenwood/cli/src/config/rollup.config.js:210:19)
1:24:08 PM:     at /opt/build/repo/node_modules/rollup/dist/shared/rollup.js:18765:25
1:24:08 PM:     at async Promise.all (index 1)
1:24:08 PM:     at async rollupInternal (/opt/build/repo/node_modules/rollup/dist/shared/rollup.js:19946:9)
1:24:08 PM:     at async /opt/build/repo/node_modules/@greenwood/cli/src/lifecycles/bundle.js:12:24
1:24:08 PM: (Use `node --trace-warnings ...` to show where the warning was created)
1:24:08 PM: (node:1423) UnhandledPromiseRejectionWarning: Unhandled promise rejection. This error originated either by throwing inside of an async function without a catch block, or by rejecting a promise which was not handled with .catch(). To terminate the node process on unhandled promise rejection, use the CLI flag `--unhandled-rejections=strict` (see https://nodejs.org/api/cli.html#cli_unhandled_rejections_mode). (rejection id: 1)
1:24:08 PM: (node:1423) [DEP0018] DeprecationWarning: Unhandled promise rejections are deprecated. In the future, promise rejections that are not handled will terminate the Node.js process with a non-zero exit code.
1:49:36 PM: Canceling deploy
```

## Details
Fix it.  

(should definitely make sure to write tests for `<link> and `<script>`)

Pull request description:
handle no bundling of remote URLs and add error handling for sync rollup hooks
<!--
## Submitting a Pull Request
We love contributions and appreciate any help you can offer!
-->

## Related Issue
resolves #491 

## Summary of Changes
1. Add error handing (`try / catch`) for synchronous rollup build hooks to address unhandled Promise reject error
1. For rollup, avoid bundling remote URLs for `<script>` and `<link>` tags
1. Added new test case

Patch:
diff --git a/packages/cli/src/config/rollup.config.js b/packages/cli/src/config/rollup.config.js
index 43e2d7df..b9ce136d 100644
--- a/packages/cli/src/config/rollup.config.js
+++ b/packages/cli/src/config/rollup.config.js
@@ -98,6 +98,7 @@ function greenwoodWorkspaceResolver (compilation) {
 // https://github.com/rollup/rollup/issues/2873
 function greenwoodHtmlPlugin(compilation) {
   const { projectDirectory, userWorkspace, outputDir, scratchDir } = compilation.context;
+  const isRemoteUrl = (url = '') => url.indexOf('http') === 0 || url.indexOf('//') === 0;
   const customResources = compilation.config.plugins.filter((plugin) => {
     return plugin.type === 'resource';
   }).map((plugin) => {
@@ -142,114 +143,118 @@ function greenwoodHtmlPlugin(compilation) {
       const mappedScripts = new Map();
 
       for (const input in options.input) {
-        const inputHtml = options.input[input];
-        const html = fs.readFileSync(inputHtml, 'utf-8');
-        const root = htmlparser.parse(html, {
-          script: true,
-          style: true
-        });
-        const headScripts = root.querySelectorAll('script');
-        const headLinks = root.querySelectorAll('link');
-    
-        // TODO handle deeper paths. e.g. ../../../
-        headScripts.forEach((scriptTag) => {
-          const parsedAttributes = parseTagForAttributes(scriptTag);
-
-          // handle <script type="module" src="some/path.js"></script>
-          if (parsedAttributes.type === 'module' && parsedAttributes.src && !mappedScripts.get(parsedAttributes.src)) {
-            const { src } = parsedAttributes;
-
-            // TODO avoid using href and set it to the value of rollup fileName instead
-            // since user paths can still be the same file, 
-            // e.g.  ../theme.css and ./theme.css are still the same file
-            mappedScripts.set(src, true);
-
-            const srcPath = src.replace('../', './');
-            const basePath = srcPath.indexOf(tokenNodeModules) >= 0
-              ? projectDirectory
-              : userWorkspace;
-            const source = fs.readFileSync(path.join(basePath, srcPath), 'utf-8');
-
-            this.emitFile({
-              type: 'chunk',
-              id: srcPath.replace('/node_modules', path.join(projectDirectory, tokenNodeModules)),
-              name: srcPath.split('/')[srcPath.split('/').length - 1].replace('.js', ''),
-              source
-            });
-          }
-
-          // handle <script type="module">/* some inline JavaScript code */</script>
-          if (parsedAttributes.type === 'module' && scriptTag.rawText !== '') {
-            const id = Buffer.from(scriptTag.rawText).toString('base64').slice(0, 8).toLowerCase();
-
-            if (!mappedScripts.get(id)) {
-              const filename = `${id}-${tokenSuffix}.js`;
-              const source = `
-                // ${filename}
-                ${scriptTag.rawText}
-              `.trim();
-
-              // have to write a file for rollup?
-              fs.writeFileSync(path.join(scratchDir, filename), source);
+        try {
+          const inputHtml = options.input[input];
+          const html = fs.readFileSync(inputHtml, 'utf-8');
+          const root = htmlparser.parse(html, {
+            script: true,
+            style: true
+          });
+          const headScripts = root.querySelectorAll('script');
+          const headLinks = root.querySelectorAll('link');
+      
+          // TODO handle deeper paths. e.g. ../../../
+          headScripts.forEach((scriptTag) => {
+            const parsedAttributes = parseTagForAttributes(scriptTag);
+     
+            // handle <script type="module" src="some/path.js"></script>
+            if (!isRemoteUrl(parsedAttributes.src) && parsedAttributes.type === 'module' && parsedAttributes.src && !mappedScripts.get(parsedAttributes.src)) {
+              const { src } = parsedAttributes;
 
               // TODO avoid using href and set it to the value of rollup fileName instead
               // since user paths can still be the same file, 
               // e.g.  ../theme.css and ./theme.css are still the same file
-              mappedScripts.set(id, true);
+              mappedScripts.set(src, true);
+
+              const srcPath = src.replace('../', './');
+              const basePath = srcPath.indexOf(tokenNodeModules) >= 0
+                ? projectDirectory
+                : userWorkspace;
+              const source = fs.readFileSync(path.join(basePath, srcPath), 'utf-8');
 
               this.emitFile({
                 type: 'chunk',
-                id: filename,
-                name: filename.replace('.js', ''),
+                id: srcPath.replace('/node_modules', path.join(projectDirectory, tokenNodeModules)),
+                name: srcPath.split('/')[srcPath.split('/').length - 1].replace('.js', ''),
                 source
               });
             }
-          }
-        });
-    
-        headLinks.forEach((linkTag) => {
-          const parsedAttributes = parseTagForAttributes(linkTag);
-
-          // handle <link rel="stylesheet" src="./some/path.css"></link>
-          if (parsedAttributes.rel === 'stylesheet' && !mappedStyles[parsedAttributes.href]) {
-            let { href } = parsedAttributes;
 
-            if (href.charAt(0) === '/') {
-              href = href.slice(1);
+            // handle <script type="module">/* some inline JavaScript code */</script>
+            if (parsedAttributes.type === 'module' && scriptTag.rawText !== '') {
+              const id = Buffer.from(scriptTag.rawText).toString('base64').slice(0, 8).toLowerCase();
+
+              if (!mappedScripts.get(id)) {
+                const filename = `${id}-${tokenSuffix}.js`;
+                const source = `
+                  // ${filename}
+                  ${scriptTag.rawText}
+                `.trim();
+
+                // have to write a file for rollup?
+                fs.writeFileSync(path.join(scratchDir, filename), source);
+
+                // TODO avoid using href and set it to the value of rollup fileName instead
+                // since user paths can still be the same file, 
+                // e.g.  ../theme.css and ./theme.css are still the same file
+                mappedScripts.set(id, true);
+
+                this.emitFile({
+                  type: 'chunk',
+                  id: filename,
+                  name: filename.replace('.js', ''),
+                  source
+                });
+              }
             }
+          });
+      
+          headLinks.forEach((linkTag) => {
+            const parsedAttributes = parseTagForAttributes(linkTag);
 
-            // TODO handle auto expanding deeper paths
-            const basePath = href.indexOf(tokenNodeModules) >= 0
-              ? projectDirectory
-              : userWorkspace;
-            const filePath = path.join(basePath, href.replace('../', './'));
-            const source = fs.readFileSync(filePath, 'utf-8');
-            const to = `${outputDir}/${href}`;
-            const hash = Buffer.from(source).toString('base64').toLowerCase();
-            const fileName = href
-              .replace('.css', `.${hash.slice(0, 8)}.css`)
-              .replace('../', '')
-              .replace('./', '');
-
-            if (!fs.existsSync(path.dirname(to)) && href.indexOf(tokenNodeModules) < 0) {
-              fs.mkdirSync(path.dirname(to), {
-                recursive: true
-              });
-            }
+            // handle <link rel="stylesheet" src="./some/path.css"></link>
+            if (!isRemoteUrl(parsedAttributes.href) && parsedAttributes.rel === 'stylesheet' && !mappedStyles[parsedAttributes.href]) {
+              let { href } = parsedAttributes;
 
-            // TODO avoid using href and set it to the value of rollup fileName instead
-            // since user paths can still be the same file, 
-            // e.g.  ../theme.css and ./theme.css are still the same file
-            mappedStyles[parsedAttributes.href] = {
-              type: 'asset',
-              fileName: fileName.indexOf(tokenNodeModules) >= 0
-                ? path.basename(fileName)
-                : fileName,
-              name: href,
-              source
-            };
-          }
-        });
+              if (href.charAt(0) === '/') {
+                href = href.slice(1);
+              }
+
+              // TODO handle auto expanding deeper paths
+              const basePath = href.indexOf(tokenNodeModules) >= 0
+                ? projectDirectory
+                : userWorkspace;
+              const filePath = path.join(basePath, href.replace('../', './'));
+              const source = fs.readFileSync(filePath, 'utf-8');
+              const to = `${outputDir}/${href}`;
+              const hash = Buffer.from(source).toString('base64').toLowerCase();
+              const fileName = href
+                .replace('.css', `.${hash.slice(0, 8)}.css`)
+                .replace('../', '')
+                .replace('./', '');
+
+              if (!fs.existsSync(path.dirname(to)) && href.indexOf(tokenNodeModules) < 0) {
+                fs.mkdirSync(path.dirname(to), {
+                  recursive: true
+                });
+              }
+
+              // TODO avoid using href and set it to the value of rollup fileName instead
+              // since user paths can still be the same file, 
+              // e.g.  ../theme.css and ./theme.css are still the same file
+              mappedStyles[parsedAttributes.href] = {
+                type: 'asset',
+                fileName: fileName.indexOf(tokenNodeModules) >= 0
+                  ? path.basename(fileName)
+                  : fileName,
+                name: href,
+                source
+              };
+            }
+          });
+        } catch (e) {
+          console.error(e);
+        }
       }
 
       // this is a giant work around because PostCSS and some plugins can only be run async
@@ -285,56 +290,60 @@ function greenwoodHtmlPlugin(compilation) {
     // back to original <script> / <link> tags and update to their bundled filename in the HTML
     generateBundle(outputOptions, bundles) {      
       for (const bundleId of Object.keys(bundles)) {
-        const bundle = bundles[bundleId];
-
-        // TODO handle (!) Generated empty chunks .greenwood/about, .greenwood/index
-        if (bundle.isEntry && path.extname(bundle.facadeModuleId) === '.html') {
-          const html = fs.readFileSync(bundle.facadeModuleId, 'utf-8');
-          const root = htmlparser.parse(html, {
-            script: true,
-            style: true
-          });
-          const headScripts = root.querySelectorAll('script');
-          const headLinks = root.querySelectorAll('link');
-          let newHtml = html;
-
-          headScripts.forEach((scriptTag) => {
-            const parsedAttributes = parseTagForAttributes(scriptTag);
-  
-            // handle <script type="module" src="some/path.js"></script>
-            if (parsedAttributes.type === 'module' && parsedAttributes.src) {
-              for (const innerBundleId of Object.keys(bundles)) {
-                const { src } = parsedAttributes;
-                const facadeModuleId = bundles[innerBundleId].facadeModuleId;
-                const pathToMatch = src.replace('../', '').replace('./', '');
+        try {
+          const bundle = bundles[bundleId];
+
+          // TODO handle (!) Generated empty chunks .greenwood/about, .greenwood/index
+          if (bundle.isEntry && path.extname(bundle.facadeModuleId) === '.html') {
+            const html = fs.readFileSync(bundle.facadeModuleId, 'utf-8');
+            const root = htmlparser.parse(html, {
+              script: true,
+              style: true
+            });
+            const headScripts = root.querySelectorAll('script');
+            const headLinks = root.querySelectorAll('link');
+            let newHtml = html;
 
-                if (facadeModuleId && facadeModuleId.indexOf(pathToMatch) > 0) {
-                  newHtml = newHtml.replace(src, `/${innerBundleId}`);
+            headScripts.forEach((scriptTag) => {
+              const parsedAttributes = parseTagForAttributes(scriptTag);
+    
+              // handle <script type="module" src="some/path.js"></script>
+              if (!isRemoteUrl(parsedAttributes.src) && parsedAttributes.type === 'module' && parsedAttributes.src) {
+                for (const innerBundleId of Object.keys(bundles)) {
+                  const { src } = parsedAttributes;
+                  const facadeModuleId = bundles[innerBundleId].facadeModuleId;
+                  const pathToMatch = src.replace('../', '').replace('./', '');
+
+                  if (facadeModuleId && facadeModuleId.indexOf(pathToMatch) > 0) {
+                    newHtml = newHtml.replace(src, `/${innerBundleId}`);
+                  }
                 }
               }
-            }
-          });
-      
-          headLinks.forEach((linkTag) => {
-            const parsedAttributes = parseTagForAttributes(linkTag);
-            const { href } = parsedAttributes;
-  
-            // handle <link rel="stylesheet" src="/some/path.css"></link>
-            if (parsedAttributes.rel === 'stylesheet') {
-              for (const bundleId2 of Object.keys(bundles)) {
-                if (bundleId2.indexOf('.css') > 0) {
-                  const bundle2 = bundles[bundleId2];
-                  if (href.indexOf(bundle2.name) >= 0) {
-                    newHtml = newHtml.replace(href, `/${bundle2.fileName}`);
+            });
+        
+            headLinks.forEach((linkTag) => {
+              const parsedAttributes = parseTagForAttributes(linkTag);
+              const { href } = parsedAttributes;
+    
+              // handle <link rel="stylesheet" src="/some/path.css"></link>
+              if (parsedAttributes.rel === 'stylesheet') {
+                for (const bundleId2 of Object.keys(bundles)) {
+                  if (bundleId2.indexOf('.css') > 0) {
+                    const bundle2 = bundles[bundleId2];
+                    if (href.indexOf(bundle2.name) >= 0) {
+                      newHtml = newHtml.replace(href, `/${bundle2.fileName}`);
+                    }
                   }
                 }
               }
-            }
-          });
+            });
 
-          // TODO this seems hacky; hardcoded dirs :D
-          bundle.fileName = bundle.facadeModuleId.replace('.greenwood', 'public');
-          bundle.code = newHtml;
+            // TODO this seems hacky; hardcoded dirs :D
+            bundle.fileName = bundle.facadeModuleId.replace('.greenwood', 'public');
+            bundle.code = newHtml;
+          }
+        } catch (e) {
+          console.error('ERROR', e);
         }
       }
     },


Test patch:
diff --git a/packages/cli/test/cases/build.default.workspace-javascript-css-remote/build.default.workspace-javascript-css-remote.spec.js b/packages/cli/test/cases/build.default.workspace-javascript-css-remote/build.default.workspace-javascript-css-remote.spec.js
new file mode 100644
index 00000000..b25d67d2
--- /dev/null
+++ b/packages/cli/test/cases/build.default.workspace-javascript-css-remote/build.default.workspace-javascript-css-remote.spec.js
@@ -0,0 +1,107 @@
+/*
+ * Use Case
+ * Run Greenwood with various usages of JavaScript (<script>) and CSS (<style> / <link>) tags with remove links.
+ * 
+ * Uaer Result
+ * Should generate a bare bones Greenwood build without erroring.
+ *
+ * User Command
+ * greenwood build
+ *
+ * User Config
+ * None
+ *
+ * User Workspace
+ * src/
+ *   pages/
+ *     index.html
+ *     
+ */
+const expect = require('chai').expect;
+const glob = require('glob-promise');
+const { JSDOM } = require('jsdom');
+const path = require('path');
+const TestBed = require('../../../../../test/test-bed');
+
+describe('Build Greenwood With: ', function() {
+  const LABEL = 'Loading remote JavaScript and CSS using <script> and <link> tags';
+
+  let setup;
+
+  before(async function() {
+    setup = new TestBed();
+
+    this.context = await setup.setupTestBed(__dirname);
+  });
+
+  describe(LABEL, function() {
+    let dom;
+
+    before(async function() {
+      await setup.runGreenwoodCommand('build');
+
+      dom = await JSDOM.fromFile(path.resolve(this.context.publicDir, 'index.html'));
+    });
+
+    describe('it should have the expected files in the output directory', function() {
+      it('should output no javascript files', async function() {
+        expect(await glob.promise(path.join(this.context.publicDir, '*.js'))).to.have.lengthOf(0);
+      });
+
+      it('should output no css files', async function() {
+        expect(await glob.promise(path.join(this.context.publicDir, '*.css'))).to.have.lengthOf(0);
+      });
+    });
+
+    describe('two <script></script> tags with remote URLs in the <head>', function() {
+      it('should have two <script src="..."> tags in the <head>', function() {
+        const scriptTags = dom.window.document.querySelectorAll('head > script');
+        const mainScriptTags = Array.prototype.slice.call(scriptTags).filter(script => {
+          return (/http/).test(script.src);
+        });
+        
+        expect(mainScriptTags.length).to.be.equal(2);
+      });
+
+      it('should have one CDN <script src="..."> tag for jquery in the <head> using http', function() {
+        const scriptTags = dom.window.document.querySelectorAll('head > script');
+        const jqueryScriptTag = Array.prototype.slice.call(scriptTags).filter(script => {
+          return (/http:\/\/code.jquery.com\//).test(script.src);
+        });
+        
+        expect(jqueryScriptTag.length).to.be.equal(1);
+      });
+
+      it('should have one UNPKG <script src="..." type="module"> tag for LitElements in the <head> using https', function() {
+        const scriptTags = dom.window.document.querySelectorAll('head > script');
+        const unpkgScriptTag = Array.prototype.slice.call(scriptTags).filter(script => {
+          return (/https:\/\/unpkg.com\//).test(script.src);
+        });
+        
+        expect(unpkgScriptTag.length).to.be.equal(1);
+      });
+    });
+
+    describe('<link rel="stylesheet" href="..."/> tag in the <head>', function() {
+      it('should have one <link> tag in the <head> with no protocol specified', function() {
+        const linkTags = dom.window.document.querySelectorAll('head > link');
+        
+        expect(linkTags.length).to.be.equal(1);
+      });
+
+      it('should have one <link> tag for google fonts in the <head> with no protocol specified', async function() {
+        const linkTags = dom.window.document.querySelectorAll('head > link');
+        const fontsLinkTag = Array.prototype.slice.call(linkTags).filter(link => {
+          return (/\/\/fonts.googleapis.com\//).test(link.href);
+        });
+        
+        expect(fontsLinkTag.length).to.be.equal(1);
+      });
+    });
+  });
+
+  after(function() {
+    setup.teardownTestBed();
+  });
+
+});
\ No newline at end of file
diff --git a/packages/cli/test/cases/build.default.workspace-javascript-css-remote/src/pages/index.html b/packages/cli/test/cases/build.default.workspace-javascript-css-remote/src/pages/index.html
new file mode 100644
index 00000000..5e33361e
--- /dev/null
+++ b/packages/cli/test/cases/build.default.workspace-javascript-css-remote/src/pages/index.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<html lang="en" prefix="og:http://ogp.me/ns#">
+
+  <head>
+    <link rel="stylesheet" href="//fonts.googleapis.com/css?family=Roboto&display=swap"></link>
+    <script src="http://code.jquery.com/jquery-3.6.0.min.js"></script>
+    <script type="module" src="https://unpkg.com/lit-element@2.4.0/lit-element.js"></script>
+  </head>
+
+  <body>
+    <h1>Hello!</h1>
+  </body>
+
+</html>
\ No newline at end of file