Repository: ProjectEvergreen/wcc (js)

Issue:
Light DOM children / slots (aka HTML Web Components)
## Summary

Although primarily a client side / runtime dependent technique, the idea of [HTML Web Components](https://blog.jim-nielsen.com/2023/html-web-components-an-example/), seems like a really nice technique to be able to do in situations where it would be easer to "slot" children in via HTML instead of attributes, in particular for very content heavy scenarios and that could still be done at SSR time.

For comparison
```html
<!-- Attributes -->
<app-card
  heading="My Heading"
  image="/path/to/some/image.webp"
/></app-card>

<!-- Light DOM Children -->
<app-card>
  <h1>My Heading</h1>
  <img src="/path/to/some/image.webp""/>
</app-card>
```

## Details

Now this basically relies on doing an `innerHTML` in the `connectedCallback` function, e.g.
```js
export class Card extends HTMLElement {
  connectedCallback() {
    this.innerHTML = `
      <div class="card">
        ${this.innerHTML}
      </div>
    `
  }
}
```

but if done on the server, could basically allow for any sort of option for handling light DOM children that is still standard compliant, especially in conjunction with something like https://github.com/ProjectEvergreen/greenwood/issues/1233

----

I've tested this in the browser and so as long as this doesn't break any test cases, this _seems_ like a legit feature to support?

Pull request:
feature/issue 154 light DOM children aka HTML web components
<!--
## Submitting a Pull Request
We love contributions and appreciate any help you can offer!
-->

## Related Issue
resolves #154 

## Summary of Changes
1. Support preservation of nested Light DOM content (HTML Web Components) and add test cases
1. Document HTML Web Components example

## TODO
1. [x] create a sandbox version / validate for standards compliance (this is allowed, right?  😅 )
1. [x] basic test case
1. [x] downstream testing
    - Greenwood test suite - https://github.com/ProjectEvergreen/greenwood/pull/1284
    - new greenwood website - https://github.com/ProjectEvergreen/www.greenwoodjs.dev/pull/94
1. [x] Clean up console log / debugging

---

Additional use cases to validate?
1. [x] Needs to be recursive
1. [ ] Needs to handle duplicate content - why?
1. [x] Needs to handle self closing tags
1. [ ] handle all node types - not sure what else to support...

Patch:
diff --git a/docs/pages/examples.md b/docs/pages/examples.md
index 7dcf17f..d366dc5 100644
--- a/docs/pages/examples.md
+++ b/docs/pages/examples.md
@@ -116,6 +116,41 @@ class Layout extends HTMLElement {
 export default Layout;
 ```
 
+## HTML (Light DOM) Web Components
+
+As detailed in this excellent blog post, HTML Web Components are a strategy for transcluding content into the Light DOM of a custom element instead of (or in addition to) setting attributes.  This can be useful for providing a set of styles to a block of content.
+
+So instead of setting attributes:
+
+```html
+<picture-frame img="/path/to/image.png" title="My Image"></picture-frame>
+```
+
+Pass HTML as children:
+
+```html
+<picture-frame>
+  <h3>My Image<h3>
+  <img src="/path/to/image.png" alt="My Image">
+</picture-frame>
+```
+
+With a custom element definition like so:
+
+```js
+export default class PictureFrame extends HTMLElement {
+  connectedCallback() {
+    this.innerHTML = `
+      <div class="picture-frame">
+        ${this.innerHTML}
+      </div>
+    `;
+  }
+}
+
+customElements.define('picture-frame', PictureFrame);
+```
+
 ## Progressive Hydration
 
 Using the `metadata` information from a custom element with the `hydrate=true` attribute, you can use use the metadata with an [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) to progressively load a custom element.  In this case, _handler.js_ builds `SliderComponent` from HTML and not only uses the `hydrate` attribute and metadata for lazy hydration, but also passes in the animated color via a CSS custom property set at build time!  🤯
diff --git a/sandbox/components/card.js b/sandbox/components/card.js
index d2c05ae..a3dfe39 100644
--- a/sandbox/components/card.js
+++ b/sandbox/components/card.js
@@ -15,6 +15,7 @@ export default class Card extends HTMLElement {
           :host .card {
             width: 30%;
             margin: 0 auto;
+            text-align: center;
           }
 
           :host h3 {
@@ -28,7 +29,7 @@ export default class Card extends HTMLElement {
         </style>
         <div class="card">
           <h3>${title}</h3>
-          <img src="${thumbnail}" alt="${title}" loading="lazy" width="100%">
+          <img src="${thumbnail}" alt="${title}" loading="lazy" width="200" height="200">
           <button onclick="this.getRootNode().host.selectItem()">View Item Details</button>
         </div>
       `;
diff --git a/sandbox/components/card.jsx b/sandbox/components/card.jsx
index 5aed1aa..52242a5 100644
--- a/sandbox/components/card.jsx
+++ b/sandbox/components/card.jsx
@@ -4,9 +4,6 @@ const styles = `
   :host .card {
     width: 30%;
     margin: 0 auto;
-  }
-
-  :host h3 {
     text-align: center;
   }
 
@@ -44,7 +41,7 @@ export default class CardJsx extends HTMLElement {
           {styles}
         </style>
         <h3>{title}</h3>
-        <img src={thumbnail} alt={title} loading="lazy" width="100%"/>
+        <img src={thumbnail} alt={title} loading="lazy" width="200" height="200"/>
         <button onclick={this.selectItem}>View Item Details</button>
       </div>
     );
diff --git a/sandbox/components/picture-frame.js b/sandbox/components/picture-frame.js
new file mode 100644
index 0000000..17cc02f
--- /dev/null
+++ b/sandbox/components/picture-frame.js
@@ -0,0 +1,14 @@
+export default class PictureFrame extends HTMLElement {
+  connectedCallback() {
+    const title = this.getAttribute('title');
+
+    this.innerHTML = `
+      <div class="picture-frame">
+        <h6 class="heading">${title}</h6>
+        ${this.innerHTML}
+      </div>
+    `;
+  }
+}
+
+customElements.define('sb-picture-frame', PictureFrame);
\ No newline at end of file
diff --git a/sandbox/index.html b/sandbox/index.html
index a27f6a0..b262ab4 100644
--- a/sandbox/index.html
+++ b/sandbox/index.html
@@ -15,9 +15,12 @@
       }
 
       pre, details {
-        width: 50%;
-        margin: 0 auto;
+        width: 30%;
+        margin: 10px auto;
+        padding: 20px;
         text-align: center;
+        white-space: collapse;
+        border: 2px dotted #222;
       }
 
       button.reset {
@@ -77,10 +80,44 @@
 
     <pre>
       &lt;sb-card
-        title="iPhone 9" thumbnail="https://d2e6ccujb3mkqf.cloudfront.net/f2c7f593-2e6b-4e39-9d9e-9d92c95d84a3-1_0f59d2ff-bd21-417f-9002-0aa1f1e8236e.jpg"
+        title="iPhone 9"
+        thumbnail="https://www.greenwoodjs.io/assets/greenwood-logo-og.png"
       &gt;&lt;/sb-card&gt;
     </pre>
 
+    <h2>HTML Web Component (Light DOM + has JS)</h2>
+
+    <style>
+      .picture-frame {
+        width: 200px;
+        margin: 0 auto;
+        border: 1px solid #222;
+        padding: 0 0 10px 0;
+        border-radius: 10px;
+
+        & .heading {
+          text-align: center;
+          text-decoration: underline;
+          font-size: 16px;
+        }
+      }
+    </style>
+    <sb-picture-frame title="Greenwood Logo">
+      <img
+        src="https://www.greenwoodjs.io/assets/greenwood-logo-og.png"
+        alt="Greenwood logo"
+      />
+    </sb-picture-frame>
+
+    <pre>
+      &lt;sb-picture-frame title="Greenwood Logo"&gt;
+        &lt;img
+          src="https://www.greenwoodjs.io/assets/greenwood-logo-og.png"
+          alt="Greenwood logo"
+        /&gt;
+      &lt;/sb-picture-frame&gt;
+    </pre>
+
     <hr/>
 
     <h2>JSX + Light DOM (no JS)</h2>
@@ -102,7 +139,8 @@
 
     <pre>
       &lt;sb-card-jsx
-        title="iPhone X" thumbnail="https://d2e6ccujb3mkqf.cloudfront.net/7e8317d3-cc5d-42a8-8350-ba6a02560477-1_d64a25e3-e1f3-4172-b7c9-0c96e82c4d3f.jpg"
+        title="iPhone X"
+        thumbnail="https://d2e6ccujb3mkqf.cloudfront.net/7e8317d3-cc5d-42a8-8350-ba6a02560477-1_d64a25e3-e1f3-4172-b7c9-0c96e82c4d3f.jpg"
       &gt;&lt;/sb-card-jsx&gt;
     </pre>
 
diff --git a/src/wcc.js b/src/wcc.js
index f4943b4..275836c 100644
--- a/src/wcc.js
+++ b/src/wcc.js
@@ -12,6 +12,24 @@ import { importAttributes } from 'acorn-import-attributes';
 import { transform } from 'sucrase';
 import fs from 'fs';
 
+// https://developer.mozilla.org/en-US/docs/Glossary/Void_element
+const VOID_ELEMENTS = [
+  'area',
+  'base',
+  'br',
+  'col',
+  'embed',
+  'hr',
+  'img',
+  'input',
+  'link',
+  'meta',
+  'param', // deprecated
+  'source',
+  'track',
+  'wbr'
+];
+
 function getParse(html) {
   return html.indexOf('<html>') >= 0 || html.indexOf('<body>') >= 0 || html.indexOf('<head>') >= 0
     ? parse
@@ -33,17 +51,26 @@ async function renderComponentRoots(tree, definitions) {
 
       if (definitions[tagName]) {
         const { moduleURL } = definitions[tagName];
-        const elementInstance = await initializeCustomElement(moduleURL, tagName, node.attrs, definitions);
-        const elementHtml = elementInstance.shadowRoot
-          ? elementInstance.getInnerHTML({ includeShadowRoots: true })
-          : elementInstance.innerHTML;
-        const elementTree = parseFragment(elementHtml);
-
-        node.childNodes = node.childNodes.length === 0
-          ? elementTree.childNodes
-          : [...elementTree.childNodes, ...node.childNodes];
+        const elementInstance = await initializeCustomElement(moduleURL, tagName, node, definitions);
+
+        if (elementInstance) {
+          const hasShadow = elementInstance.shadowRoot;
+          const elementHtml = hasShadow
+            ? elementInstance.getInnerHTML({ includeShadowRoots: true })
+            : elementInstance.innerHTML;
+          const elementTree = parseFragment(elementHtml);
+          const hasLight = elementTree.childNodes > 0;
+
+          node.childNodes = node.childNodes.length === 0 && hasLight && !hasShadow
+            ? elementTree.childNodes
+            : hasShadow
+              ? [...elementTree.childNodes, ...node.childNodes]
+              : elementTree.childNodes;
+        } else {
+          console.warn(`WARNING: customElement <${tagName}> detected but not serialized.  You may not have exported it.`);
+        }
       } else {
-        console.warn(`WARNING: customElement <${tagName}> is not defined.  You may not have imported it yet.`);
+        console.warn(`WARNING: customElement <${tagName}> is not defined.  You may not have imported it.`);
       }
     }
 
@@ -82,7 +109,7 @@ function registerDependencies(moduleURL, definitions, depth = 0) {
       const isBareSpecifier = specifier.indexOf('.') !== 0 && specifier.indexOf('/') !== 0;
       const extension = specifier.split('.').pop();
 
-      // TODO would like to decouple .jsx from the core, ideally
+      // would like to decouple .jsx from the core, ideally
       // https://github.com/ProjectEvergreen/wcc/issues/122
       if (!isBareSpecifier && ['js', 'jsx', 'ts'].includes(extension)) {
         const dependencyModuleURL = new URL(node.source.value, moduleURL);
@@ -138,7 +165,41 @@ async function getTagName(moduleURL) {
   return tagName;
 }
 
-async function initializeCustomElement(elementURL, tagName, attrs = [], definitions = [], isEntry, props = {}) {
+function renderLightDomChildren(childNodes, iHTML = '') {
+  let innerHTML = iHTML;
+
+  childNodes.forEach((child) => {
+    const { nodeName, attrs = [], value } = child;
+
+    if (nodeName !== '#text') {
+      innerHTML += `<${nodeName}`;
+
+      if (attrs.length > 0) {
+        attrs.forEach(attr => {
+          innerHTML += ` ${attr.name}="${attr.value}"`;
+        });
+      }
+
+      innerHTML += '>';
+
+      if (child.childNodes.length > 0) {
+        innerHTML = renderLightDomChildren(child.childNodes, innerHTML);
+      }
+
+      innerHTML += VOID_ELEMENTS.includes(nodeName)
+        ? ''
+        : `</${nodeName}>`;
+    } else if (nodeName === '#text') {
+      innerHTML += value;
+    }
+  });
+
+  return innerHTML;
+}
+
+async function initializeCustomElement(elementURL, tagName, node = {}, definitions = [], isEntry, props = {}) {
+  const { attrs = [], childNodes = [] } = node;
+
   if (!tagName) {
     const depth = isEntry ? 1 : 0;
     registerDependencies(elementURL, definitions, depth);
@@ -158,6 +219,9 @@ async function initializeCustomElement(elementURL, tagName, attrs = [], definiti
   if (element) {
     const elementInstance = new element(data); // eslint-disable-line new-cap
 
+    // support for HTML (Light DOM) Web Components
+    elementInstance.innerHTML = renderLightDomChildren(childNodes);
+
     attrs.forEach((attr) => {
       elementInstance.setAttribute(attr.name, attr.value);
   


Test patch:
diff --git a/test/cases/html-web-components/expected.html b/test/cases/html-web-components/expected.html
new file mode 100644
index 0000000..a7466b7
--- /dev/null
+++ b/test/cases/html-web-components/expected.html
@@ -0,0 +1,13 @@
+<wcc-picture-frame title="Greenwood">
+  <div class="picture-frame">
+    <img src="https://www.greenwoodjs.io/assets/greenwood-logo-og.png" alt="Greenwood logo">
+    <br>
+    <span>Author: <span>WCC</span></span>
+    <wcc-caption>
+      <div class="caption">
+        <h6 class="heading">Greenwood</h6>
+        <span>© 2024</span>
+      </div>
+    </wcc-caption>
+  </div>
+</wcc-picture-frame>
\ No newline at end of file
diff --git a/test/cases/html-web-components/html-web-components.spec.js b/test/cases/html-web-components/html-web-components.spec.js
new file mode 100644
index 0000000..89d5028
--- /dev/null
+++ b/test/cases/html-web-components/html-web-components.spec.js
@@ -0,0 +1,85 @@
+/*
+ * Use Case
+ * Run wcc against an "HTML" Web Component.
+ * https://blog.jim-nielsen.com/2023/html-web-components/
+ *
+ * User Result
+ * Should return the expected HTML with no template tags or Shadow Roots.
+ *
+ * User Workspace
+ * src/
+ *   components/
+ *     caption.js
+ *     picture-frame.js
+ *   pages/
+ *     index.js
+ */
+import chai from 'chai';
+import { JSDOM } from 'jsdom';
+import fs from 'fs/promises';
+import { renderToString } from '../../../src/wcc.js';
+
+const expect = chai.expect;
+
+describe('Run WCC For ', function() {
+  const LABEL = 'HTML (Light DOM) Web Components';
+  let dom;
+  let pictureFrame;
+  let expectedHtml;
+  let actualHtml;
+
+  before(async function() {
+    const { html } = await renderToString(new URL('./src/pages/index.js', import.meta.url));
+
+    actualHtml = html;
+    dom = new JSDOM(actualHtml);
+    pictureFrame = dom.window.document.querySelectorAll('wcc-picture-frame');
+    expectedHtml = await fs.readFile(new URL('./expected.html', import.meta.url), 'utf-8');
+  });
+
+  describe(LABEL, function() {
+    it('should not have any <template> tags within the document', function() {
+      expect(dom.window.document.querySelectorAll('template').length).to.equal(0);
+    });
+
+    it('should only have one <wcc-picture-frame> tag', function() {
+      expect(pictureFrame.length).to.equal(1);
+    });
+
+    it('should have the expected image from userland in the HTML', () => {
+      const img = pictureFrame[0].querySelectorAll('.picture-frame img');
+
+      expect(img.length).to.equal(1);
+      expect(img[0].getAttribute('alt')).to.equal('Greenwood logo');
+      expect(img[0].getAttribute('src')).to.equal('https://www.greenwoodjs.io/assets/greenwood-logo-og.png');
+    });
+
+    it('should have the expected Author name <span> from userland in the HTML', () => {
+      const img = pictureFrame[0].querySelectorAll('.picture-frame img + br + span');
+
+      expect(img.length).to.equal(1);
+      expect(img[0].textContent).to.equal('Author: WCC');
+    });
+
+    it('should have the expected title attribute content in the nested <wcc-caption> tag', () => {
+      const caption = pictureFrame[0].querySelectorAll('.picture-frame wcc-caption .caption');
+      const heading = caption[0].querySelectorAll('.heading');
+
+      expect(caption.length).to.equal(1);
+      expect(heading.length).to.equal(1);
+      expect(heading[0].textContent).to.equal('Greenwood');
+    });
+
+    it('should have the expected copyright content in the nested <wcc-caption> tag', () => {
+      const caption = pictureFrame[0].querySelectorAll('.picture-frame wcc-caption .caption');
+      const span = caption[0].querySelectorAll('span');
+
+      expect(span.length).to.equal(1);
+      expect(span[0].textContent).to.equal('© 2024');
+    });
+
+    it('should have the expected recursively generated HTML', () => {
+      expect(expectedHtml.replace(/ /g, '').replace(/\n/g, '')).to.equal(actualHtml.replace(/ /g, '').replace(/\n/g, ''));
+    });
+  });
+});
\ No newline at end of file
diff --git a/test/cases/html-web-components/src/components/caption.js b/test/cases/html-web-components/src/components/caption.js
new file mode 100644
index 0000000..5c6cb94
--- /dev/null
+++ b/test/cases/html-web-components/src/components/caption.js
@@ -0,0 +1,11 @@
+export default class Caption extends HTMLElement {
+  connectedCallback() {
+    this.innerHTML = `
+      <div class="caption">
+        ${this.innerHTML}
+      </div>
+    `;
+  }
+}
+
+customElements.define('wcc-caption', Caption);
\ No newline at end of file
diff --git a/test/cases/html-web-components/src/components/picture-frame.js b/test/cases/html-web-components/src/components/picture-frame.js
new file mode 100644
index 0000000..f152138
--- /dev/null
+++ b/test/cases/html-web-components/src/components/picture-frame.js
@@ -0,0 +1,19 @@
+import './caption.js';
+
+export default class PictureFrame extends HTMLElement {
+  connectedCallback() {
+    const title = this.getAttribute('title');
+
+    this.innerHTML = `
+      <div class="picture-frame">
+        ${this.innerHTML}
+        <wcc-caption>
+          <h6 class="heading">${title}</h6>
+          <span>&copy; 2024</span>
+        </wcc-caption>
+      </div>
+    `;
+  }
+}
+
+customElements.define('wcc-picture-frame', PictureFrame);
\ No newline at end of file
diff --git a/test/cases/html-web-components/src/pages/index.js b/test/cases/html-web-components/src/pages/index.js
new file mode 100644
index 0000000..3ef004f
--- /dev/null
+++ b/test/cases/html-web-components/src/pages/index.js
@@ -0,0 +1,25 @@
+import '../components/picture-frame.js';
+
+export default class HomePage extends HTMLElement {
+
+  connectedCallback() {
+    this.innerHTML = `
+      <wcc-picture-frame title="Greenwood">
+        <img
+          src="https://www.greenwoodjs.io/assets/greenwood-logo-og.png"
+          alt="Greenwood logo"
+        />
+        <br/>
+        <span>Author: <span>WCC</span></span>
+      </wcc-picture-frame>
+    `;
+  }
+
+  getTemplate() {
+    return `
+      <wcc-header></wcc-header>
+
+      <h1>Home Page</h1>
+    `;
+  }
+}
\ No newline at end of file
diff --git a/test/cases/no-define/no-define.spec.js b/test/cases/no-define/no-define.spec.js
new file mode 100644
index 0000000..599b00b
--- /dev/null
+++ b/test/cases/no-define/no-define.spec.js
@@ -0,0 +1,80 @@
+/*
+ * Use Case
+ * Run wcc against a single custom element using with no customElements.define
+ *
+ * User Result
+ * Should run without any errors from the DOM shim.
+ *
+ * User Workspace
+ * src/
+ *   no-define.js
+ *   footer.js
+ *   header.js
+ */
+
+import chai from 'chai';
+import { renderToString, renderFromHTML } from '../../../src/wcc.js';
+
+const expect = chai.expect;
+
+describe('Run WCC For ', function() {
+  const LABEL = 'Single Custom Element with no customElements.define';
+
+  describe(LABEL, function() {
+    describe('renderToString', () => {
+      let rawHtml;
+      let meta;
+
+      before(async function() {
+        const { html, metadata } = await renderToString(new URL('./src/no-define.js', import.meta.url));
+    
+        rawHtml = html;
+        meta = metadata;
+      });
+
+      it('should not throw an error', function() {
+        expect(rawHtml).to.equal(undefined);
+      });
+  
+      it('should not have any definition', function() {
+        expect(meta.length).to.equal(0);
+      });
+    });
+
+    describe('renderFromHTML', () => {
+      let rawHtml;
+      let meta;
+      const contents = `
+        <html>
+          <head>
+            <title>No Export Test</title>
+          </head>
+          <body>
+            <app-no-define-header></app-no-define-header>
+            <h1>Hello World</h1>
+            <app-no-define-footer></app-no-define-footer>
+          </body>
+        </html>
+      `;
+
+      before(async function() {
+        const { html, metadata } = await renderFromHTML(contents, [
+          new URL('./src/footer.js', import.meta.url),
+          new URL('./src/header.js', import.meta.url)
+        ]);
+
+        rawHtml = html;
+        meta = metadata;
+      });
+
+      it('should not throw an error and return the expected contents', function() {
+        expect(rawHtml.replace(/ /g, '').replace(/\n/g, '')).to.equal(contents.replace(/ /g, '').replace(/\n/g, ''));
+      });
+
+      // I think this is broken, should actually be equal to 2?
+      it('should not have any definition', function() {
+        expect(meta.length).to.equal(0);
+      });
+    });
+  });
+});
\ No newline at end of file
diff --git a/test/cases/no-define/src/footer.js b/test/cases/no-define/src/footer.js
new file mode 100644
index 0000000..9763459
--- /dev/null
+++ b/test/cases/no-define/src/footer.js
@@ -0,0 +1,16 @@
+const template = document.createElement('template');
+
+template.innerHTML = '<footer>This is the footer component.</footer>';
+
+class FooterComponent extends HTMLElement {
+  constructor() {
+    super();
+    this.attachShadow({ mode: 'open' });
+  }
+
+  connectedCallback() {
+    this.shadowRoot.appendChild(template.content.cloneNode(true));
+  }
+}
+
+customElements.define('app-no-define-footer', FooterComponent);
\ No newline at end of file
diff --git a/test/cases/no-define/src/header.js b/test/cases/no-define/src/header.js
new file mode 100644
index 0000000..cc0b9ee
--- /dev/null
+++ b/test/cases/no-define/src/header.js
@@ -0,0 +1,18 @@
+const template = document.createElement('template');
+
+template.innerHTML = `
+  <header>This is the header component.</header>
+`;
+
+class HeaderComponent extends HTMLElement {
+  constructor() {
+    super();
+    this.attachShadow({ mode: 'open' });
+  }
+
+  connectedCallback() {
+    this.shadowRoot.appendChild(template.content.cloneNode(true));
+  }
+}
+
+customElements.define('app-no-define-header', HeaderComponent);
\ No newline at end of file
diff --git a/test/cases/no-define/src/no-define.js b/test/cases/no-define/src/no-define.js
new file mode 100644
index 0000000..a00f0f5
--- /dev/null
+++ b/test/cases/no-define/src/no-define.js
@@ -0,0 +1,15 @@
+const template = document.createElement('template');
+
+template.innerHTML = '<p>This is the NoDefineComponent component.</p>';
+
+// eslint-disable-next-line no-unused-vars
+class NoDefineComponent extends HTMLElement {
+  constructor() {
+    super();
+    this.attachShadow({ mode: 'open' });
+  }
+
+  connectedCallback() {
+    this.shadowRoot.appendChild(template.content.cloneNode(true));
+  }
+}
\ No newline at end of file
diff --git a/test/cases/no-export/no-export.spec.js b/test/cases/no-export/no-export.spec.js
index d3257ca..1629f9d 100644
--- a/test/cases/no-export/no-export.spec.js
+++ b/test/cases/no-export/no-export.spec.js
@@ -1,40 +1,80 @@
 /*
  * Use Case
- * Run wcc against a single custom element using with no internal definitions
+ * Run wcc against a single custom element using with no default export
  *
  * User Result
  * Should run without any errors from the DOM shim.
  *
  * User Workspace
  * src/
- *   empty.js
+ *   no-export.js
+ *   header.js
+ *   footer.js
  */
 
 import chai from 'chai';
-import { renderToString } from '../../../src/wcc.js';
+import { renderToString, renderFromHTML } from '../../../src/wcc.js';
 
 const expect = chai.expect;
 
 describe('Run WCC For ', function() {
   const LABEL = 'Single Custom Element with no default export';
-  let rawHtml;
-  let meta;
-
-  before(async function() {
-    const { html, metadata } = await renderToString(new URL('./src/no-export.js', import.meta.url));
-
-    rawHtml = html;
-    meta = metadata;
-  });
 
   describe(LABEL, function() {
-    it('should not throw an error', function() {
-      expect(rawHtml).to.equal(undefined);
+    describe('renderToString', () => {
+      let rawHtml;
+      let meta;
+    
+      before(async function() {
+        const { html, metadata } = await renderToString(new URL('./src/no-export.js', import.meta.url));
+    
+        rawHtml = html;
+        meta = metadata;
+      });
+
+      it('should not throw an error', function() {
+        expect(rawHtml).to.equal(undefined);
+      });
+  
+      it('should not have any definition', function() {
+        expect(meta.length).to.equal(0);
+      });
     });
+    
+    describe('renderFromHTML', () => {
+      let rawHtml;
+      let meta;
+      const contents = `
+        <html>
+          <head>
+            <title>No Export Test</title>
+          </head>
+          <body>
+            <app-no-export-header></app-no-export-header>
+            <h1>Hello World</h1>
+            <app-no-export-footer></app-no-export-footer>
+          </body>
+        </html>
+      `;
 
-    it('should not have any definition', function() {
-      expect(meta.length).to.equal(0);
+      before(async function() {
+        const { html, metadata } = await renderFromHTML(contents, [
+          new URL('./src/footer.js', import.meta.url),
+          new URL('./src/header.js', import.meta.url)
+        ]);
+
+        rawHtml = html;
+        meta = metadata;
+      });
+
+      it('should not throw an error and return the expected contents', function() {
+        expect(rawHtml.replace(/ /g, '').replace(/\n/g, '')).to.equal(contents.replace(/ /g, '').replace(/\n/g, ''));
+      });
+
+      // I think this is broken, should actually be equal to 2?
+      it('should not have any definition', function() {
+        expect(meta.length).to.equal(0);
+      });
     });
   });
-
 });
\ No newline at end of file
diff --git a/test/cases/no-export/src/footer.js b/test/cases/no-export/src/footer.js
new file mode 100644
index 0000000..5d86479
--- /dev/null
+++ b/test/cases/no-export/src/footer.js
@@ -0,0 +1,16 @@
+const template = document.createElement('template');
+
+template.innerHTML = '<footer>This is the footer component.</footer>';
+
+class FooterComponent extends HTMLElement {
+  constructor() {
+    super();
+    this.attachShadow({ mode: 'open' });
+  }
+
+  connectedCallback() {
+    this.shadowRoot.appendChild(template.content.cloneNode(true));
+  }
+}
+
+customElements.define('app-no-export-footer', FooterComponent);
\ No newline at end of file
diff --git a/test/cases/no-export/src/header.js b/test/cases/no-export/src/header.js
new file mode 100644
index 0000000..3ee4ab5
--- /dev/null
+++ b/test/cases/no-export/src/header.js
@@ -0,0 +1,18 @@
+const template = document.createElement('template');
+
+template.innerHTML = `
+  <header>This is the header component.</header>
+`;
+
+class HeaderComponent extends HTMLElement {
+  constructor() {
+    super();
+    this.attachShadow({ mode: 'open' });
+  }
+
+  connectedCallback() {
+    this.shadowRoot.appendChild(template.content.cloneNode(true));
+  }
+}
+
+customElements.define('app-no-export-header', HeaderComponent);
\ No newline at end of file
diff --git a/test/cases/no-export/src/no-export.js b/test/cases/no-export/src/no-export.js
index 6a6222f..2caeda7 100644
--- a/test/cases/no-export/src/no-export.js
+++ b/test/cases/no-export/src/no-export.js
@@ -1,2 +1,17 @@
+const template = document.createElement('template');
+
+template.innerHTML = '<p>This is the NoExportComponent component.</p>';
+
 // eslint-disable-next-line no-unused-vars
-class EmptyComponent extends HTMLElement { }
\ No newline at end of file
+class NoExportComponent extends HTMLElement {
+  constructor() {
+    super();
+    this.attachShadow({ mode: 'open' });
+  }
+
+  connectedCallback() {
+    this.shadowRoot.appendChild(template.content.cloneNode(true));
+  }
+}
+
+customElements.define('app-no-export-example', NoExportComponent);
\ No newline at end of file