Repository: ProjectEvergreen/greenwood (js)

Issue:
incorrect `<head>` tag assembly for app and page templates when page content also includes head "like" tags
## Type of Change
- [ ] New Feature Request
- [ ] Documentation / Website
- [ ] Improvement / Suggestion
- [x] Bug
- [ ] Other (please clarify below)

## Summary
Contents of a page's templates `<head>` like `<script>` and `<style>` are incorrectly getting templated together in the wrong place if there happens to be one of these in the `<body>` tag of the app template, and so those tags will appended there, instead of in the `<head>`.

> _Noticed as part of working on https://github.com/ProjectEvergreen/greenwood/pull/712#issuecomment-945019172_

## Details
For example. given this app and page template
```html
<!-- app.html -->
<html>
  <body>
   <link rel="xxx" ....></link>
  </body>
</html>
```

```html
<!-- page.html -->
<html>
  <head>
   <link rel="stylesheet" ....></link>
  </head>
</html>
```

#### Expected
One in the `<head>` (page) and one in the `<body>` (app)
```html
<html>
  <head>
   <link rel="stylesheet" ....></link>
  </head>
  <body>
   <link rel="xxx" ....></link>
  </body>
</html>
```

#### Actual
Two in the `<body>` (app and page)
```html
<!-- page.html -->
<html>
  <body>
   <link rel="xxx" ....></link>
   <link rel="stylesheet" ....></link>
  </body>
</html>
```

Pull request:
correctly merge page template `<head>` tags with app template `<head>` tags
<!--
## Submitting a Pull Request
We love contributions and appreciate any help you can offer!
-->

## Related Issue
resolves #782

## Summary of Changes
1. As outlined in the issue, `<head>` tags from the page were merging anywhere a corresponding tag was found, instead of going into the `<head>` as expected
1. Updated specs accordingly to cover this case

Patch:
diff --git a/packages/cli/src/plugins/resource/plugin-standard-html.js b/packages/cli/src/plugins/resource/plugin-standard-html.js
index 185a01b9..c71a3eb6 100644
--- a/packages/cli/src/plugins/resource/plugin-standard-html.js
+++ b/packages/cli/src/plugins/resource/plugin-standard-html.js
@@ -1,3 +1,4 @@
+/* eslint-disable complexity */
 /*
  * 
  * Manages web standard resource related operations for HTML and markdown.
@@ -61,10 +62,6 @@ const getPageTemplate = (barePath, templatesDir, template, contextPlugins = [],
 };
 
 const getAppTemplate = (contents, templatesDir, customImports = [], contextPlugins, enableHud) => {
-  function sliceTemplate(template, pos, needle, replacer) {
-    return template.slice(0, pos) + template.slice(pos).replace(needle, replacer);
-  }
-  
   const userAppTemplatePath = `${templatesDir}app.html`;
   const customAppTemplates = getCustomPageTemplates(contextPlugins, 'app');
 
@@ -80,6 +77,10 @@ const getAppTemplate = (contents, templatesDir, customImports = [], contextPlugi
     noscript: true,
     pre: true
   });
+  const appRoot = htmlparser.parse(appTemplateContents, {
+    script: true,
+    style: true
+  });
 
   if (!root.valid) {
     console.debug('ERROR: Invalid HTML detected');
@@ -106,6 +107,7 @@ const getAppTemplate = (contents, templatesDir, customImports = [], contextPlugi
     const headMeta = root.querySelectorAll('head meta');
     const headStyles = root.querySelectorAll('head style');
     const headTitle = root.querySelector('head title');
+    const appTemplateHeadContents = appRoot.querySelector('head').innerHTML;
 
     appTemplateContents = appTemplateContents.replace(/<page-outlet><\/page-outlet>/, body);
 
@@ -113,87 +115,101 @@ const getAppTemplate = (contents, templatesDir, customImports = [], contextPlugi
       appTemplateContents = appTemplateContents.replace(/<title>(.*)<\/title>/, `<title>${headTitle.rawText}</title>`);
     }
 
-    headScripts.forEach((script) => {
-      const matchNeedle = '</script>';
-      const matchPos = appTemplateContents.lastIndexOf(matchNeedle);
-
-      if (script.text === '') {
-        if (matchPos > 0) {
-          appTemplateContents = sliceTemplate(appTemplateContents, matchPos, matchNeedle, `</script>\n
-            <script ${script.rawAttrs}></script>\n
-          `);
+    // merge <script> tags
+    if (headScripts.length > 0) {
+      const matchNeedleScript = /<script .*/g;
+      const appHeadScriptMatches = appTemplateHeadContents.match(matchNeedleScript);
+      const lastScript = appHeadScriptMatches && appHeadScriptMatches.length && appHeadScriptMatches.length > 0
+        ? appHeadScriptMatches[appHeadScriptMatches.length - 1]
+        : '</head>';
+      const pageBodyScripts = headScripts.map((script) => {
+        if (script.text === '') {
+          return `<script ${script.rawAttrs}></script>`;
         } else {
-          appTemplateContents = appTemplateContents.replace('</head>', `
-              <script ${script.rawAttrs}></script>\n
-            </head>
-          `);
-        }
-      }
-
-      if (script.text !== '') {
-        const attributes = script.rawAttrs !== ''
-          ? ` ${script.rawAttrs}`
-          : '';
-        const source = script.text
-          .replace(/\$/g, '$$$'); // https://github.com/ProjectEvergreen/greenwood/issues/656
+          const attributes = script.rawAttrs !== ''
+            ? ` ${script.rawAttrs}`
+            : '';
+          const source = script.text.replace(/\$/g, '$$$'); // https://github.com/ProjectEvergreen/greenwood/issues/656
 
-        if (matchPos > 0) {
-          appTemplateContents = sliceTemplate(appTemplateContents, matchPos, matchNeedle, `</script>\n
+          return `
             <script${attributes}>
               ${source}
-            </script>\n
-          `);
-        } else {
-          appTemplateContents = appTemplateContents.replace('</head>', `
-              <script${attributes}>
-                ${source}
-              </script>\n
-            </head>
-          `);
+            </script>
+          `;
         }
-      }
-    });
+      });
 
-    headLinks.forEach((link) => {
-      const matchNeedle = /<link .*/g;
-      const matches = appTemplateContents.match(matchNeedle);
-      const lastLink = matches && matches.length && matches.length > 0
-        ? matches[matches.length - 1]
-        : '<head>';
+      if (lastScript === '</head>') {
+        appTemplateContents = appTemplateContents.replace(lastScript, `
+          ${pageBodyScripts.join('\n')}
+        ${lastScript}\n
+      `);
+      } else {
+        appTemplateContents = appTemplateContents.replace(lastScript, `${lastScript}\n
+          ${pageBodyScripts.join('\n')}
+        `);
+      }
+    }
 
-      appTemplateContents = appTemplateContents.replace(lastLink, `${lastLink}\n
-        <link ${link.rawAttrs}/>
+    // merge <link> tags
+    if (headLinks.length > 0) {
+      const matchNeedleLink = /<link .*/g;
+      const appHeadLinkMatches = appTemplateHeadContents.match(matchNeedleLink);
+      const lastLink = appHeadLinkMatches && appHeadLinkMatches.length && appHeadLinkMatches.length > 0
+        ? appHeadLinkMatches[appHeadLinkMatches.length - 1]
+        : '</head>';
+      const pageHeadLinks = headLinks.map((link) => {
+        return `<link ${link.rawAttrs}/>`;
+      });
+
+      if (lastLink === '</head>') {
+        appTemplateContents = appTemplateContents.replace(lastLink, `
+          ${pageHeadLinks.join('\n')}
+        ${lastLink}\n
       `);
-    });
+      } else {
+        appTemplateContents = appTemplateContents.replace(lastLink, `${lastLink}\n
+          ${pageHeadLinks.join('\n')}
+        `);
+      }
+    }
 
-    headStyles.forEach((style) => {
-      const matchNeedle = '</style>';
-      const matchPos = appTemplateContents.lastIndexOf(matchNeedle);
+    // merge <style> tags
+    if (headStyles.length > 0) {
+      const latestHeadContents = appTemplateContents.match(/<head>.*.<\/head>/s)[0];
+      const pageBodyStyles = headStyles.map((style) => {
+        const attributes = style.rawAttrs === '' ? '' : ` ${style.rawAttrs}`;
+
+        return `
+          <style${attributes}>
+            ${style.text}
+          </style>
+        `;
+      });
+      const lastIndex = latestHeadContents.lastIndexOf('</style>') === -1
+        ? latestHeadContents.lastIndexOf('</head>')
+        : latestHeadContents.lastIndexOf('</style>');
+      const offset = lastIndex === -1 ? '</head>'.length : '</style>'.length;
+      const result = latestHeadContents.substring(0, lastIndex + offset) + pageBodyStyles.join('\n') + latestHeadContents.substring(lastIndex);
+
+      appTemplateContents = appTemplateContents.replace(latestHeadContents, result);
+    }
 
-      if (style.rawAttrs === '') {
-        if (matchPos > 0) {
-          appTemplateContents = sliceTemplate(appTemplateContents, matchPos, matchNeedle, `</style>\n
-            <style>
-              ${style.text}
-            </style>\n
-          `);
-        } else {
-          appTemplateContents = appTemplateContents.replace('<head>', `
-            <head> \n
-              <style>
-                ${style.text}
-              </style>\n
-          `);
-        }
-      }
-    });
+    // merge <meta>
+    if (headMeta.length > 0) {
+      const matchNeedleMeta = /<meta .*/g;
+      const appHeadMetaMatches = appTemplateHeadContents.match(matchNeedleMeta);
+      const lastMeta = appHeadMetaMatches && appHeadMetaMatches.length && appHeadMetaMatches.length > 0
+        ? appHeadMetaMatches[appHeadMetaMatches.length - 1]
+        : '<head>';
+      const pageMeta = headMeta.map((meta) => {
+        return `<meta ${meta.rawAttrs}/>`;
+      });
 
-    headMeta.forEach((meta) => {
-      appTemplateContents = appTemplateContents.replace('<head>', `
-        <head>
-          <meta ${meta.rawAttrs}/>
+      appTemplateContents = appTemplateContents.replace(lastMeta.replace('>', '/>'), `${lastMeta.replace('>', '/>')}\n
+        ${pageMeta.join('\n')}
       `);
-    });
+    }
 
     customImports.forEach((customImport) => {
       const extension = path.extname(customImport);


Test patch:
diff --git a/packages/cli/test/cases/build.default.workspace-template-page-and-app/build.default.workspace-template-page-and-app.spec.js b/packages/cli/test/cases/build.default.workspace-template-page-and-app/build.default.workspace-template-page-and-app.spec.js
index 516606e9..42be1dd5 100644
--- a/packages/cli/test/cases/build.default.workspace-template-page-and-app/build.default.workspace-template-page-and-app.spec.js
+++ b/packages/cli/test/cases/build.default.workspace-template-page-and-app/build.default.workspace-template-page-and-app.spec.js
@@ -53,7 +53,7 @@ describe('Build Greenwood With: ', function() {
       await runner.runCommand(cliPath, 'build');
     });
 
-    runSmokeTest(['public', 'index'], LABEL);
+    runSmokeTest(['public'], LABEL);
 
     describe('Custom App and Page Templates', function() {
       let dom;
@@ -121,6 +121,30 @@ describe('Build Greenwood With: ', function() {
           expect(styleTags[4].textContent).to.contain('page-template-two-style');
         });
       });
+
+      describe('<head> "like" tags that should be in the <body>', function() {
+        let scriptTags;
+        let linkTags;
+        let styleTags;
+
+        before(function() {
+          scriptTags = dom.window.document.querySelectorAll('body > script');
+          linkTags = dom.window.document.querySelectorAll('body > link');
+          styleTags = dom.window.document.querySelectorAll('body > style');
+        });
+
+        it('should have 1 <script> tag in the <body>', function() {
+          expect(scriptTags.length).to.equal(1);
+        });
+
+        it('should have 1 <link> tag in the <body>', function() {
+          expect(linkTags.length).to.equal(1);
+        });
+
+        it('should have 1 <style> tag in the <body>', function() {
+          expect(styleTags.length).to.equal(1);
+        });
+      });
     });
 
   });
diff --git a/packages/cli/test/cases/build.default.workspace-template-page-and-app/src/templates/app.html b/packages/cli/test/cases/build.default.workspace-template-page-and-app/src/templates/app.html
index db0c7839..cd977cd8 100644
--- a/packages/cli/test/cases/build.default.workspace-template-page-and-app/src/templates/app.html
+++ b/packages/cli/test/cases/build.default.workspace-template-page-and-app/src/templates/app.html
@@ -25,6 +25,10 @@
   </head>
 
   <body>
+    <script></script>
+    <link rel="html"/>
+    <style></style>
+
     <page-outlet></page-outlet>
   </body>