From 2a7630f0570013aa65fd3d5dfe9a407b0c014af6 Mon Sep 17 00:00:00 2001 From: James Browning Date: Wed, 26 Nov 2025 14:40:50 +1300 Subject: [PATCH 1/5] Add declarative shadow dom support --- .../lib/index.ts | 8 ++ packages/parse5/lib/common/html.ts | 5 ++ packages/parse5/lib/common/token.ts | 4 + packages/parse5/lib/parser/index.ts | 48 +++++++++- packages/parse5/lib/serializer/index.ts | 87 ++++++++++++++++--- packages/parse5/lib/tree-adapters/default.ts | 57 +++++++++++- .../parse5/lib/tree-adapters/interface.ts | 43 ++++++++- test/data/serialization/tests.json | 4 +- 8 files changed, 238 insertions(+), 18 deletions(-) diff --git a/packages/parse5-htmlparser2-tree-adapter/lib/index.ts b/packages/parse5-htmlparser2-tree-adapter/lib/index.ts index f99782d16..552096b32 100644 --- a/packages/parse5-htmlparser2-tree-adapter/lib/index.ts +++ b/packages/parse5-htmlparser2-tree-adapter/lib/index.ts @@ -289,4 +289,12 @@ export const adapter: TreeAdapter = { ...endLocation, }; }, + + // Shadow roots + attachDeclarativeShadowRoot(): never { + throw new Error('not supported'); + }, + getShadowRoot(): never { + throw new Error('not supported'); + }, }; diff --git a/packages/parse5/lib/common/html.ts b/packages/parse5/lib/common/html.ts index d9d341243..f4a10905f 100644 --- a/packages/parse5/lib/common/html.ts +++ b/packages/parse5/lib/common/html.ts @@ -17,6 +17,11 @@ export enum ATTRS { COLOR = 'color', FACE = 'face', SIZE = 'size', + SHADOWROOTMODE = 'shadowrootmode', + SHADOWROOTCLONABLE = 'shadowrootclonable', + SHADOWROOTSERIALIZABLE = 'shadowrootserializable', + SHADOWROOTDELEGATESFOCUS = 'shadowrootdelegatesfocus', + SHADOWROOTCUSTOMELEMENTREGISTRY = 'shadowrootcustomelementregistry', } /** diff --git a/packages/parse5/lib/common/token.ts b/packages/parse5/lib/common/token.ts index 4b1a1283d..637d2ad8c 100644 --- a/packages/parse5/lib/common/token.ts +++ b/packages/parse5/lib/common/token.ts @@ -87,6 +87,10 @@ export function getTokenAttr(token: TagToken, attrName: string): string | null { return null; } +export function hasTokenAttr(token: TagToken, attrName: string): boolean { + return getTokenAttr(token, attrName) !== null; +} + export interface CommentToken extends TokenBase { readonly type: TokenType.COMMENT; data: string; diff --git a/packages/parse5/lib/parser/index.ts b/packages/parse5/lib/parser/index.ts index 9779f68f3..a3fe22cbc 100644 --- a/packages/parse5/lib/parser/index.ts +++ b/packages/parse5/lib/parser/index.ts @@ -28,6 +28,7 @@ import { type EOFToken, type LocationWithAttributes, type ElementLocation, + hasTokenAttr, } from '../common/token.js'; //Misc constants @@ -96,6 +97,13 @@ export interface ParserOptions { */ sourceCodeLocationInfo?: boolean; + /** + * Whether declarative shadow roots should be parsed or treated as ordinary templates. + * + * @default `false` + */ + allowDeclarativeShadowRoots?: boolean; + /** * Specifies the resulting tree format. * @@ -114,6 +122,7 @@ export interface ParserOptions { const defaultParserOptions: Required> = { scriptingEnabled: true, sourceCodeLocationInfo: false, + allowDeclarativeShadowRoots: false, treeAdapter: defaultTreeAdapter, onParseError: null, }; @@ -453,14 +462,17 @@ export class Parser implements TokenHandler, Stack } /** @protected */ - _insertTemplate(token: TagToken): void { + _insertTemplate(token: TagToken, onlyAddElementToStack: boolean = false): T['element'] { const tmpl = this.treeAdapter.createElement(token.tagName, NS.HTML, token.attrs); const content = this.treeAdapter.createDocumentFragment(); this.treeAdapter.setTemplateContent(tmpl, content); - this._attachElementToTree(tmpl, token.location); + if (!onlyAddElementToStack) { + this._attachElementToTree(tmpl, token.location); + } this.openElements.push(tmpl, token.tagID); if (this.options.sourceCodeLocationInfo) this.treeAdapter.setNodeSourceCodeLocation(content, null); + return tmpl; } /** @protected */ @@ -1723,11 +1735,41 @@ function startTagInHead(p: Parser, token: TagTo break; } case $.TEMPLATE: { - p._insertTemplate(token); p.activeFormattingElements.insertMarker(); p.framesetOk = false; p.insertionMode = InsertionMode.IN_TEMPLATE; p.tmplInsertionModeStack.unshift(InsertionMode.IN_TEMPLATE); + + const mode = getTokenAttr(token, ATTRS.SHADOWROOTMODE); + if ( + !p.options.allowDeclarativeShadowRoots || + (mode !== 'open' && mode !== 'closed') || + p._getAdjustedCurrentElement() !== p.openElements.current + ) { + p._insertTemplate(token); + } else { + const declarativeShadowHostElement = p._getAdjustedCurrentElement(); + const shadowRoot = p.treeAdapter.getShadowRoot(declarativeShadowHostElement); + if (shadowRoot) { + p._insertTemplate(token); + } else { + const template = p._insertTemplate(token, true); + const clonable = hasTokenAttr(token, ATTRS.SHADOWROOTCLONABLE); + const serializable = hasTokenAttr(token, ATTRS.SHADOWROOTSERIALIZABLE); + const delegatesFocus = hasTokenAttr(token, ATTRS.SHADOWROOTDELEGATESFOCUS); + const customElementRegistry = getTokenAttr(token, ATTRS.SHADOWROOTCUSTOMELEMENTREGISTRY); + const shadowRoot = p.treeAdapter.attachDeclarativeShadowRoot(declarativeShadowHostElement, { + mode, + clonable, + serializable, + delegatesFocus, + customElementRegistry, + declarativeTemplateAttributes: token.attrs, + }); + p.treeAdapter.setTemplateContentForDeclarativeShadowRootParsing(template, shadowRoot); + } + } + break; } case $.HEAD: { diff --git a/packages/parse5/lib/serializer/index.ts b/packages/parse5/lib/serializer/index.ts index ef7326abb..fa350c172 100644 --- a/packages/parse5/lib/serializer/index.ts +++ b/packages/parse5/lib/serializer/index.ts @@ -1,7 +1,8 @@ -import { TAG_NAMES as $, NS, hasUnescapedText } from '../common/html.js'; +import { TAG_NAMES as $, ATTRS, NS, hasUnescapedText } from '../common/html.js'; import { escapeText, escapeAttribute } from 'entities/escape'; import type { TreeAdapter, TreeAdapterTypeMap } from '../tree-adapters/interface.js'; import { defaultTreeAdapter, type DefaultTreeAdapterMap } from '../tree-adapters/default.js'; +import type { Attribute } from '../common/token.js'; // Sets const VOID_ELEMENTS = new Set([ @@ -33,6 +34,21 @@ function isVoidElement(node: T['node'], options: I ); } +const KNOWN_DECLARATIVE_SHADOW_ROOT_ATTRIBUTES = new Set([ + ATTRS.SHADOWROOTCLONABLE, + ATTRS.SHADOWROOTCUSTOMELEMENTREGISTRY, + ATTRS.SHADOWROOTDELEGATESFOCUS, + ATTRS.SHADOWROOTMODE, + ATTRS.SHADOWROOTSERIALIZABLE, +]); + +function isKnownDeclarativeShadowRootAttribute(attr: Attribute): boolean { + if (attr.namespace !== undefined) { + return false; + } + return KNOWN_DECLARATIVE_SHADOW_ROOT_ATTRIBUTES.has(attr.name); +} + export interface SerializerOptions { /** * Specifies input tree format. @@ -47,11 +63,29 @@ export interface SerializerOptions { * @default `true` */ scriptingEnabled?: boolean; + /** + * Whether to serialize shadow roots. + * + * @default `false` + */ + serializableShadowRoots?: boolean; + + /** + * Shadow roots to always serialize. + * + * @default `[]` + */ + shadowRoots?: Array; } type InternalOptions = Required>; -const defaultOpts: InternalOptions = { treeAdapter: defaultTreeAdapter, scriptingEnabled: true }; +const defaultOpts: InternalOptions = { + treeAdapter: defaultTreeAdapter, + scriptingEnabled: true, + serializableShadowRoots: false, + shadowRoots: [], +}; /** * Serializes an AST node to an HTML string. @@ -158,17 +192,50 @@ function serializeNode(node: T['node'], options: I function serializeElement(node: T['element'], options: InternalOptions): string { const tn = options.treeAdapter.getTagName(node); - return `<${tn}${serializeAttributes(node, options)}>${ - isVoidElement(node, options) ? '' : `${serializeChildNodes(node, options)}` - }`; + let html = `<${tn}${serializeAttributes(options.treeAdapter.getAttrList(node))}>`; + if (isVoidElement(node, options)) { + return html; + } + const shadowRoot = options.treeAdapter.getShadowRoot(node); + if (shadowRoot !== null) { + const shadowRootInit = options.treeAdapter.getShadowRootInit(shadowRoot); + + if ( + (options.serializableShadowRoots && shadowRootInit.serializable) || + options.shadowRoots.includes(shadowRoot) + ) { + const declarativeTemplateAttrs: Attribute[] = []; + declarativeTemplateAttrs.push({ name: ATTRS.SHADOWROOTMODE, value: shadowRootInit.mode }); + if (shadowRootInit.delegatesFocus) { + declarativeTemplateAttrs.push({ name: ATTRS.SHADOWROOTMODE, value: '' }); + } + if (shadowRootInit.serializable) { + declarativeTemplateAttrs.push({ name: ATTRS.SHADOWROOTSERIALIZABLE, value: '' }); + } + if (shadowRootInit.clonable) { + declarativeTemplateAttrs.push({ name: ATTRS.SHADOWROOTCLONABLE, value: '' }); + } + if (shadowRootInit.customElementRegistry !== null) { + declarativeTemplateAttrs.push({ name: ATTRS.SHADOWROOTCUSTOMELEMENTREGISTRY, value: '' }); + } + + declarativeTemplateAttrs.push( + ...shadowRootInit.declarativeTemplateAttributes.filter( + (attr) => !isKnownDeclarativeShadowRootAttribute(attr), + ), + ); + + html += `${serializeChildNodes(options.treeAdapter.getChildNodes(shadowRoot), options)}`; + } + } + + html += ``; + return html; } -function serializeAttributes( - node: T['element'], - { treeAdapter }: InternalOptions, -): string { +function serializeAttributes(attrs: Attribute[]): string { let html = ''; - for (const attr of treeAdapter.getAttrList(node)) { + for (const attr of attrs) { html += ' '; if (attr.namespace) { diff --git a/packages/parse5/lib/tree-adapters/default.ts b/packages/parse5/lib/tree-adapters/default.ts index 415a9d79c..4beb76a88 100644 --- a/packages/parse5/lib/tree-adapters/default.ts +++ b/packages/parse5/lib/tree-adapters/default.ts @@ -1,6 +1,6 @@ import { DOCUMENT_MODE, type NS } from '../common/html.js'; import type { Attribute, Location, ElementLocation } from '../common/token.js'; -import type { TreeAdapter, TreeAdapterTypeMap } from './interface.js'; +import type { ShadowRootInit, TreeAdapter, TreeAdapterTypeMap } from './interface.js'; export interface Document { /** The name of the node. */ @@ -18,13 +18,30 @@ export interface Document { export interface DocumentFragment { /** The name of the node. */ - nodeName: '#document-fragment'; + nodeName: '#document-fragment' | '#shadow-root'; /** The node's children. */ childNodes: ChildNode[]; /** Comment source code location info. Available if location info is enabled. */ sourceCodeLocation?: Location | null; } +export interface ShadowRoot extends DocumentFragment { + /** The name of the node. */ + nodeName: '#shadow-root'; + /** The shadow root mode. */ + mode: 'open' | 'closed'; + /** Whether the shadow root delegates focus. */ + delegatesFocus: boolean; + /** Whether the shadow root is clonable or not. */ + clonable: boolean; + /** Whether the shadow root is clonable or not. */ + serializable: boolean; + /** The custom element registry this node belongs to. */ + customElementRegistry: string | null; + /** Attributes that appear on the