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 + +``` + +Pass HTML as children: + +```html + +

My Image

+ My Image + +``` + +With a custom element definition like so: + +```js +export default class PictureFrame extends HTMLElement { + connectedCallback() { + this.innerHTML = ` +
+ ${this.innerHTML} +
+ `; + } +} + +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 {

${title}

- ${title} + ${title}
`; 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}

{title}

- {title} + {title} ); 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 = ` +
+
${title}
+ ${this.innerHTML} +
+ `; + } +} + +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 @@

Declarative Shadow DOM (has JS)

       <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"
       ></sb-card>
     
+

HTML Web Component (Light DOM + has JS)

+ + + + Greenwood logo + + +
+      <sb-picture-frame title="Greenwood Logo">
+        <img
+          src="https://www.greenwoodjs.io/assets/greenwood-logo-og.png"
+          alt="Greenwood logo"
+        />
+      </sb-picture-frame>
+    
+

JSX + Light DOM (no JS)

@@ -102,7 +139,8 @@

JSX + Declarative Shadow DOM (has JS)

       <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"
       ></sb-card-jsx>
     
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('') >= 0 || html.indexOf('') >= 0 || html.indexOf('') >= 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) + ? '' + : ``; + } 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); 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 @@ + +
+ Greenwood logo +
+ Author: WCC + +
+
Greenwood
+ © 2024 +
+
+
+
\ 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