diff --git a/src/dom-shim.js b/src/dom-shim.js index cbf5398a..b25255df 100644 --- a/src/dom-shim.js +++ b/src/dom-shim.js @@ -1,3 +1,57 @@ +/* eslint-disable no-warning-comments */ + +import { parse, parseFragment, serialize } from 'parse5'; + +export function getParse(html) { + return html.indexOf('') >= 0 || html.indexOf('
') >= 0 || html.indexOf('') >= 0 + ? parse + : parseFragment; +} + +function isShadowRoot(element) { + return Object.getPrototypeOf(element).constructor.name === 'ShadowRoot'; +} + +function deepClone(obj, map = new WeakMap()) { + if (obj === null || typeof obj !== 'object') { + return obj; // Return primitives or functions as-is + } + + if (typeof obj === 'function') { + const clonedFn = obj.bind({}); + Object.assign(clonedFn, obj); + return clonedFn; + } + + if (map.has(obj)) { + return map.get(obj); + } + + const result = Array.isArray(obj) ? [] : {}; + map.set(obj, result); + + for (const key of Object.keys(obj)) { + result[key] = deepClone(obj[key], map); + } + + return result; +} + +// Creates an empty parse5 element without the parse5 overhead resulting in better performance +function getParse5ElementDefaults(element, tagName) { + return { + addEventListener: noop, + attrs: [], + parentNode: element.parentNode, + childNodes: [], + nodeName: tagName, + tagName: tagName, + namespaceURI: 'http://www.w3.org/1999/xhtml', + // eslint-disable-next-line no-extra-parens + ...(tagName === 'template' ? { content: { nodeName: '#document-fragment', childNodes: [] } } : {}) + }; +} + function noop() { } // https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/CSSStyleSheet @@ -19,13 +73,84 @@ class EventTarget { // EventTarget <- Node // TODO should be an interface? class Node extends EventTarget { - // eslint-disable-next-line + constructor() { + super(); + // Parse5 properties + this.attrs = []; + this.parentNode = null; + this.childNodes = []; + this.nodeName = ''; + } + cloneNode(deep) { - return this; + return deep ? deepClone(this) : Object.assign({}, this); } appendChild(node) { - this.innerHTML = this.innerHTML ? this.innerHTML += node.innerHTML : node.innerHTML; + const childNodes = (this.nodeName === 'template' ? this.content : this).childNodes; + + if (node.parentNode) { + node.parentNode?.removeChild?.(node); // Remove from current parent + } + + if (node.nodeName === 'template') { + if (isShadowRoot(this) && this.mode) { + node.attrs = [{ name: 'shadowrootmode', value: this.mode }]; + childNodes.push(node); + node.parentNode = this; + } else { + this.childNodes = [...this.childNodes, ...node.content.childNodes]; + } + } else if (node instanceof DocumentFragment) { + this.childNodes = [...this.childNodes, ...node.childNodes]; + } else { + childNodes.push(node); + node.parentNode = this; + } + + return node; + } + + removeChild(node) { + const childNodes = (this.nodeName === 'template' ? this.content : this).childNodes; + if (!childNodes || !childNodes.length) { + return null; + } + + const index = childNodes.indexOf(node); + if (index === -1) { + return null; + } + + childNodes.splice(index, 1); + node.parentNode = null; + + return node; + } + + get textContent() { + if (this.nodeName === '#text') { + return this.value || ''; // Text nodes should return their value + } + + // Compute textContent for elements by concatenating text of all descendants + return this.childNodes + .map((child) => child.nodeName === '#text' ? child.value : child.textContent) + .join(''); + } + + set textContent(value) { + // Remove all current child nodes + this.childNodes = []; + + if (value) { + // Create a single text node with the given value + const textNode = new Node(); + textNode.nodeName = '#text'; + textNode.value = value; // Text node content + textNode.parentNode = this; + this.childNodes.push(textNode); + } } } @@ -34,32 +159,48 @@ class Node extends EventTarget { class Element extends Node { constructor() { super(); - this.shadowRoot = null; - this.innerHTML = ''; - this.attributes = {}; } attachShadow(options) { this.shadowRoot = new ShadowRoot(options); - + this.shadowRoot.parentNode = this; return this.shadowRoot; } getHTML({ serializableShadowRoots = false }) { - return this.shadowRoot && serializableShadowRoots ? - `${this.shadowRoot.innerHTML}` : this.innerHTML; + return this.shadowRoot && serializableShadowRoots && this.shadowRoot.serializable ? this.shadowRoot.innerHTML : ''; } - setAttribute(name, value) { - this.attributes[name] = value; + // Serialize the content of the DocumentFragment when getting innerHTML + get innerHTML() { + const childNodes = (this.nodeName === 'template' ? this.content : this).childNodes; + return childNodes ? serialize({ childNodes }) : ''; } - getAttribute(name) { - return this.attributes[name]; + set innerHTML(html) { + (this.nodeName === 'template' ? this.content : this).childNodes = getParse(html)(html).childNodes; // Replace content's child nodes } hasAttribute(name) { - return !!this.attributes[name]; + // Modified attribute handling to work with parse5 + return this.attrs.some((attr) => attr.name === name); + } + + getAttribute(name) { + // Modified attribute handling to work with parse5 + const attr = this.attrs.find((attr) => attr.name === name); + return attr ? attr.value : null; + } + + setAttribute(name, value) { + // Modified attribute handling to work with parse5 + const attr = this.attrs?.find((attr) => attr.name === name); + + if (attr) { + attr.value = value; + } else { + this.attrs?.push({ name, value }); + } } } @@ -74,7 +215,7 @@ class Document extends Node { return new HTMLTemplateElement(); default: - return new HTMLElement(); + return new HTMLElement(tagName); } } @@ -87,6 +228,10 @@ class Document extends Node { // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement // EventTarget <- Node <- Element <- HTMLElement class HTMLElement extends Element { + constructor(tagName) { + super(); + Object.assign(this, getParse5ElementDefaults(this, tagName)); + } connectedCallback() { } } @@ -99,9 +244,18 @@ class DocumentFragment extends Node { } class ShadowRoot extends DocumentFragment { constructor(options) { super(); - this.mode = options.mode || 'closed'; + this.mode = options.mode ?? 'closed'; + this.serializable = options.serializable ?? false; this.adoptedStyleSheets = []; } + + get innerHTML() { + return this.childNodes?.[0]?.content?.childNodes ? serialize({ childNodes: this.childNodes[0].content.childNodes }) : ''; + } + + set innerHTML(html) { + this.childNodes = getParse(html)(`${html}`).childNodes; + } } // https://developer.mozilla.org/en-US/docs/Web/API/HTMLTemplateElement @@ -109,18 +263,11 @@ class ShadowRoot extends DocumentFragment { class HTMLTemplateElement extends HTMLElement { constructor() { super(); - this.content = new DocumentFragment(); - } - - // TODO open vs closed shadow root - set innerHTML(html) { - if (this.content) { - this.content.innerHTML = html; - } - } - - get innerHTML() { - return this.content && this.content.innerHTML ? this.content.innerHTML : undefined; + // Gets element defaults for template element instead of parsing a + // with parse5. Results in better performance + // when creating templates + Object.assign(this, getParse5ElementDefaults(this, 'template')); + this.content.cloneNode = this.cloneNode.bind(this); } } @@ -151,4 +298,5 @@ globalThis.addEventListener = globalThis.addEventListener ?? noop; globalThis.document = globalThis.document ?? new Document(); globalThis.customElements = globalThis.customElements ?? new CustomElementsRegistry(); globalThis.HTMLElement = globalThis.HTMLElement ?? HTMLElement; +globalThis.DocumentFragment = globalThis.DocumentFragment ?? DocumentFragment; globalThis.CSSStyleSheet = globalThis.CSSStyleSheet ?? CSSStyleSheet; \ No newline at end of file diff --git a/src/wcc.js b/src/wcc.js index c6872393..b328cf3d 100644 --- a/src/wcc.js +++ b/src/wcc.js @@ -1,39 +1,15 @@ /* eslint-disable max-depth */ // this must come first -import './dom-shim.js'; +import { getParse } from './dom-shim.js'; import * as acorn from 'acorn'; import * as walk from 'acorn-walk'; import { generate } from 'astring'; import { getParser, parseJsx } from './jsx-loader.js'; -import { parse, parseFragment, serialize } from 'parse5'; +import { serialize } from 'parse5'; 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 - : parseFragment; -} - function isCustomElementDefinitionNode(node) { const { expression } = node; @@ -45,7 +21,7 @@ function isCustomElementDefinitionNode(node) { async function renderComponentRoots(tree, definitions) { for (const node of tree.childNodes) { if (node.tagName && node.tagName.indexOf('-') > 0) { - const { tagName } = node; + const { attrs, tagName } = node; if (definitions[tagName]) { const { moduleURL } = definitions[tagName]; @@ -53,31 +29,35 @@ async function renderComponentRoots(tree, definitions) { if (elementInstance) { const hasShadow = elementInstance.shadowRoot; - const elementHtml = hasShadow - ? elementInstance.getHTML({ serializableShadowRoots: 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; + + node.childNodes = hasShadow + ? [...elementInstance.shadowRoot.childNodes, ...node.childNodes] + : elementInstance.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.`); } + + attrs.forEach((attr) => { + if (attr.name === 'hydrate') { + definitions[tagName].hydrate = attr.value; + } + }); + } if (node.childNodes && node.childNodes.length > 0) { await renderComponentRoots(node, definitions); } + if (node.shadowRoot && node.shadowRoot.childNodes?.length > 0) { + await renderComponentRoots(node.shadowRoot, definitions); + } + // does this only apply to `` tags? - if (node.content && node.content.childNodes && node.content.childNodes.length > 0) { + if (node.content && node.content.childNodes?.length > 0) { await renderComponentRoots(node.content, definitions); } } @@ -163,38 +143,6 @@ async function getTagName(moduleURL) { return tagName; } -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; @@ -208,24 +156,15 @@ async function initializeCustomElement(elementURL, tagName, node = {}, definitio const { href } = elementURL; const element = customElements.get(tagName) ?? (await import(href)).default; const dataLoader = (await import(href)).getData; - const data = props - ? props - : dataLoader - ? await dataLoader(props) - : {}; + const data = props ? props : dataLoader ? await dataLoader(props) : {}; if (element) { const elementInstance = new element(data); // eslint-disable-line new-cap - // support for HTML (Light DOM) Web Components - elementInstance.innerHTML = renderLightDomChildren(childNodes); + elementInstance.childNodes = childNodes; attrs.forEach((attr) => { elementInstance.setAttribute(attr.name, attr.value); - - if (attr.name === 'hydrate') { - definitions[tagName].hydrate = attr.value; - } }); await elementInstance.connectedCallback(); @@ -239,22 +178,31 @@ async function renderToString(elementURL, wrappingEntryTag = true, props = {}) { const elementTagName = wrappingEntryTag && await getTagName(elementURL); const isEntry = !!elementTagName; const elementInstance = await initializeCustomElement(elementURL, undefined, undefined, definitions, isEntry, props); + let html; // in case the entry point isn't valid if (elementInstance) { - const elementHtml = elementInstance.shadowRoot - ? elementInstance.getHTML({ serializableShadowRoots: true }) - : elementInstance.innerHTML; - const elementTree = getParse(elementHtml)(elementHtml); - const finalTree = await renderComponentRoots(elementTree, definitions); + elementInstance.nodeName = elementTagName ?? ''; + elementInstance.tagName = elementTagName ?? ''; + + await renderComponentRoots( + elementInstance.shadowRoot + ? + { + nodeName: '#document-fragment', + childNodes: [elementInstance] + } + : elementInstance, + definitions + ); html = wrappingEntryTag && elementTagName ? ` <${elementTagName}> - ${serialize(finalTree)} + ${serialize(elementInstance)} ${elementTagName}> ` - : serialize(finalTree); + : serialize(elementInstance); } else { console.warn('WARNING: No custom element class found for this entry point.'); } diff --git a/test/cases/create-document-fragment/create-document-fragment.spec.js b/test/cases/create-document-fragment/create-document-fragment.spec.js new file mode 100644 index 00000000..2a83c8b8 --- /dev/null +++ b/test/cases/create-document-fragment/create-document-fragment.spec.js @@ -0,0 +1,39 @@ +/* + * Use Case + * Run wcc against a component which creates two document fragments and appends them with appendChild. + * + * User Result + * Should return the expected HTML output based on the content of the appended fragments. + * + * User Workspace + * src/ + * index.js + */ + +import chai from 'chai'; +import { JSDOM } from 'jsdom'; +import { renderToString } from '../../../src/wcc.js'; + +const expect = chai.expect; + +describe('Run WCC For ', function () { + const LABEL = 'Custom Element w/ Document Fragments'; + let dom; + + before(async function () { + const { html } = await renderToString(new URL('./src/index.js', import.meta.url)); + dom = new JSDOM(html); + }); + + describe(LABEL, function () { + it('should have a heading tag with text content equal to "document.createDocumentFragment()"', function () { + expect(dom.window.document.querySelectorAll('h2')[0].textContent).to.equal('document.createDocumentFragment()'); + }); + }); + + describe(LABEL, function () { + it('should have a heading tag with text content equal to new "DocumentFragment()"', function () { + expect(dom.window.document.querySelectorAll('h2')[1].textContent).to.equal('new DocumentFragment()'); + }); + }); +}); diff --git a/test/cases/create-document-fragment/src/index.js b/test/cases/create-document-fragment/src/index.js new file mode 100644 index 00000000..38f2d6dc --- /dev/null +++ b/test/cases/create-document-fragment/src/index.js @@ -0,0 +1,20 @@ +export default class DocumentFragmentComponent extends HTMLElement { + + connectedCallback() { + const fragment1 = document.createDocumentFragment(); + const fragment2 = new DocumentFragment(); + + const h1 = document.createElement('h2'); + h1.textContent = 'document.createDocumentFragment()'; + fragment1.appendChild(h1); + + const h2 = document.createElement('h2'); + h2.textContent = 'new DocumentFragment()'; + fragment2.appendChild(h2); + + this.appendChild(fragment1); + this.appendChild(fragment2); + } +} + +customElements.define('document-fragment-component', DocumentFragmentComponent); \ No newline at end of file diff --git a/test/cases/full-document-component/full-document-component.spec.js b/test/cases/full-document-component/full-document-component.spec.js new file mode 100644 index 00000000..62b5c2fb --- /dev/null +++ b/test/cases/full-document-component/full-document-component.spec.js @@ -0,0 +1,37 @@ +/* + * Use Case + * Run wcc against a component which sets innerHTML to a full HTML document. + * + * User Result + * Should return the expected HTML output with a component containing a full HTML document. + * + * User Workspace + * src/ + * index.js + */ + +import chai from 'chai'; +import { renderToString } from '../../../src/wcc.js'; + +const expect = chai.expect; + +describe('Run WCC For ', function () { + const LABEL = 'Custom Element w/ Document Fragments'; + let renderedContent; + + before(async function () { + const { html } = await renderToString(new URL('./src/index.js', import.meta.url)); + renderedContent = html.replace(/\s+/g, ''); + }); + + describe(LABEL, function () { + it('should have a heading tag with text renderedContent equal to "document.createDocumentFragment()"', function () { + expect(renderedContent).to.equal( + '