Add support for shadow DOM
danistefanovic opened this issue · comments
Initial checklist
- I read the support docs
- I read the contributing guide
- I agree to follow the code of conduct
- I searched issues and couldn’t find anything (or linked relevant results below)
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;
}
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'}),
])
- I meant instead of
isShadow
(orisShadowRoot
), only supporting thecontent
field. So instead ofisShadow
,isShadowRoot
, andshadowRoot
on elements,content
would be used. - Why should
isShadow
exist onroot
? 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!
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!
good :) closing as this can be achieved!