syntax-tree / hast-util-from-dom

utility to transform a DOM tree to hast

Home Page:https://unifiedjs.com

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Add support for shadow DOM

danistefanovic opened this issue · comments

Initial checklist

Problem

Hast and related packages do not support the use of shadom DOM currently. The use case is the serialization and deserialization of web components.

Failing test for `hast-util-from-dom`
  const shadowHost = document.createElement('my-element')
  const shadowRoot = shadowHost.attachShadow({mode: 'open'})
  const innerStyle = document.createElement('style')
  const innerSpan = document.createElement('span')
  innerStyle.textContent = ':host { color: blue; }'
  innerSpan.textContent = 'Hello from a custom element'
  shadowRoot.append(innerStyle)
  shadowRoot.append(innerSpan)

  t.deepEqual(
    fromDom(shadowHost),
    {
      type: 'element',
      tagName: 'my-element',
      properties: {},
      children: [],
      content: {
        type: 'root',
        // ? isShadow: true
        children: [
          {
            type: 'element',
            tagName: 'style',
            properties: {},
            children: [
              {
                type: 'text',
                value: ':host { color: blue; }'
              }
            ]
          },
          {
            type: 'element',
            tagName: 'span',
            properties: {},
            children: [{type: 'text', value: 'Hello from a custom element'}]
          }
        ]
      }
    },
    'should transform shadow DOM'
  )

// Actual output:
// { 
//    type: 'element', 
//    tagName: 'my-element', 
//    properties: {}, 
//    children: [] 
// }
Failing test for `hast-util-to-dom`
  const customElement = toDom({
    type: 'element',
    tagName: 'my-element',
    properties: {},
    children: [],
    content: {
      type: 'root',
      // ? isShadow: true,
      children: [
        {
          type: 'element',
          tagName: 'style',
          properties: {},
          children: [
            {
              type: 'text',
              value: ':host { color: blue; }'
            }
          ]
        },
        {
          type: 'element',
          tagName: 'span',
          properties: {},
          children: [{type: 'text', value: 'Hello from a custom element'}]
        }
      ]
    }
  })

  t.equal(
    serializeNodeToHtmlString(customElement),
    '<my-element></my-element>',
    'creates a shadow DOM host'
  )

  // ↓ Fails

  t.equal(
    // @ts-ignore
    serializeNodeToHtmlString(customElement.shadowRoot),
    '<style>:host { color: blue; }</style><span>Hello from a custom element</span>',
    'creates a tree inside the shadow DOM'
  )

Solution

The serialized DOM (= hast) should include shadow DOM components. The deserialization should be able to recreate shadow DOM from hast.

Approach 1: Root.isShadow and node.content

hast

  interface Root <: Parent {
    type: "root"
++  isShadow: boolean?
  }

hast-util-from-dom

 function element(node, ctx) {
   const space = node.namespaceURI
   const fn = space === webNamespaces.svg ? s : h
   const tagName =
     space === webNamespaces.html ? node.tagName.toLowerCase() : node.tagName
-- /** @type {DocumentFragment|Element} */
-- const content =
--   // @ts-expect-error Types are wrong.
--   space === webNamespaces.html && tagName === 'template' ? node.content : node
   const attributes = node.getAttributeNames()
   /** @type {Object.<string, string>} */
   const props = {}
   let index = -1

   while (++index < attributes.length) {
     props[attributes[index]] = node.getAttribute(attributes[index]) || ''
   }

++ /** @type {DocumentFragment|Element} */
++ let content = node
++ if (space === webNamespaces.html && tagName === 'template') content = node.content
++ if (node.shadowRoot) content = node.shadowRoot

-- return fn(tagName, props, all(content, ctx))
++ const result = fn(tagName, props, all(content, ctx))
++
++ if (node.shadowRoot) {
++  result.content = {type:'root', isShadow: true, children: result.children};
++  result.children = [];
++ }
++
++ return result;
 }

hast-util-to-dom

 function element(node, ctx) {
   const {namespace, doc} = ctx
   let impliedNamespace = ctx.impliedNamespace || namespace
   const {
     tagName = impliedNamespace === webNamespaces.svg ? 'g' : 'div',
     properties = {},
     children = [],
++   content,
   } = node

  // ...

++ if (content && content.isShadow) {
++   const shadow = result.attachShadow({mode: 'open'});
++   appendAll(shadow, content.children, {...ctx, impliedNamespace});
++   return result;
++ }

   return appendAll(result, children, {...ctx, impliedNamespace})
 }
Approach 2: Element.isShadowRoot

hast

  interface Element <: Parent {
    type: "element"
    tagName: string
    properties: Properties?
    content: Root?
    children: [Element | Comment | Text]
++  isShadowRoot: boolean?
  }

hast-util-from-dom

 function element(node, ctx) {
   const space = node.namespaceURI
   const fn = space === webNamespaces.svg ? s : h
   const tagName =
     space === webNamespaces.html ? node.tagName.toLowerCase() : node.tagName
-- /** @type {DocumentFragment|Element} */
-- const content =
--   // @ts-expect-error Types are wrong.
--   space === webNamespaces.html && tagName === 'template' ? node.content : node
   const attributes = node.getAttributeNames()
   /** @type {Object.<string, string>} */
   const props = {}
   let index = -1

   while (++index < attributes.length) {
     props[attributes[index]] = node.getAttribute(attributes[index]) || ''
   }

++ /** @type {DocumentFragment|Element} */
++ let content = node
++ if (space === webNamespaces.html && tagName === 'template') content = node.content
++ if (node.shadowRoot) content = node.shadowRoot

-- return fn(tagName, props, all(content, ctx))
++ const result = fn(tagName, props, all(content, ctx))
++ if (node.shadowRoot) result.isShadowRoot = true
++
++ return result;
 }

hast-util-to-dom

function element(node, ctx) {
  const {namespace, doc} = ctx
  let impliedNamespace = ctx.impliedNamespace || namespace
  const {
++  isShadowRoot,
    tagName = impliedNamespace === webNamespaces.svg ? 'g' : 'div',
    properties = {},
    children = []
  } = node

  // ...

-- return appendAll(result, children, {...ctx, impliedNamespace})
++ const parent = isShadowRoot ? result.attachShadow({mode: 'open'}) : result;
++ appendAll(parent, children, {...ctx, impliedNamespace});
++
++ return result;
}
commented

Could node.content be repurposed, other than <template>s, for shadow roots?

Hey, Titus. The first approach (based on Root.isShadow) reuses node.content. You can see it in the following lines:

// hast-util-from-dom
result.content = {type:'root', isShadow: true, children: result.children};
// hast-util-to-dom
const {
  // ...
  content,
} = node

if (content && content.isShadow) {
// ...

I've updated my initial comment to make it more readable.

Instead of adding the entire shadow DOM handling to hast-util-from-dom, another alternative could be to add that logic to hastscript. Custom components could be prefixed with a modifier in the tag name (e.g. ~):

h('.foo#some-id', [
  h('~my-custom-component', 'some text'),
  h('input', {type: 'text', value: 'foo'}),
])
commented
  • I meant instead of isShadow (or isShadowRoot), only supporting the content field. So instead of isShadow, isShadowRoot , and shadowRoot on elements, content would be used.
  • Why should isShadow exist on root? What is it needed for?

Other than proposing solutions (which might indeed be good solutions for your problem), can you explain your problem at a practical level? What input do you have? What actual output do you get? What output did you expect? What doesn’t work?

I meant instead of isShadow (or isShadowRoot), only supporting the content field.

Ah, got it. Instead of using a "shadow" flag, it's maybe possible to check for the following condition:

const isShadowHost = node.content && node.tagName !== 'template'

However, I'm not sure yet if this conflicts with other scenarios.

The "is shadow" information is required to decide whether a ShadowRoot with its encapsulated children needs to be created when a hast tree gets deserializied with hast-util-to-dom (see Element.attachShadow()).

Other than proposing solutions (which might indeed be good solutions for your problem), can you explain your problem at a practical level?

You're absolutely right. I was already too deep trying to determine if the whole thing was even possible to implement, forgetting to describe the fundamental problem – my bad. I've just updated the initial comment with more information.

Let me know if there's anything that requires more clarification, and I'll be happy to help!

commented

I’m wondering whether this should be added. It’s intentional that DOM parsing / serializing doesn’t “revive” shadow DOMs.

wrapper = document.createElement('div')
shadowHost = document.createElement('my-element')
shadowRoot = shadowHost.attachShadow({mode: 'open'})

innerStyle = document.createElement('style')
innerSpan = document.createElement('span')
innerStyle.textContent = ':host { color: blue; }'
innerSpan.textContent = 'Hello from a custom element'
shadowRoot.append(innerStyle)
shadowRoot.append(innerSpan)

wrapper.appendChild(shadowHost)

console.log(wrapper.outerHTML) //=> <div><my-element></my-element></div>
console.log(shadowHost.outerHTML) //=> <my-element></my-element>
console.log(shadowRoot.outerHTML) //=> undefined

…and the word “reviving” is intentional, in a similar vain to JSON.parse.

And, could your afterTransform hooks do it? Perhaps how to approach this problem can be documented in readme examples?

Note that nodes already allow arbitrary data on them, which could be used by those hooks

I’m wondering whether this should be added. It’s intentional that DOM parsing / serializing doesn’t “revive” shadow DOMs. [...] …and the word “reviving” is intentional, in a similar vain to JSON.parse.

Valid point. From that perspective, it makes sense to not include the shadow DOM handling in those packages.

And, could your afterTransform hooks do it?

The first attempt seems to work fine 👍

const serialize = (dom) => (
  fromDom(dom, {
    afterTransform: (domNode, hastNode) => {
      if (domNode.shadowRoot) {
         hastNode.data = { shadow: serialize(domNode.shadowRoot) };
      }
      // ...
    }
  })
)

const deserialize = (hast) => (
  toDom(hast, {
    afterTransform: (hastNode, domNode) => {
      if (hastNode?.data?.shadow) {
        const shadowRoot = domNode.attachShadow({ mode: 'open' });
        const shadowTree = deserialize(hastNode.data.shadow);
        shadowRoot.append(shadowTree);
      }
      //...
    }
  })
)

From my side, this issue can be closed. Thanks for your time to review this!

commented

good :) closing as this can be achieved!