diff --git a/docs/api/parser.md b/docs/api/parser.md index e3c4efc99..774812fe2 100644 --- a/docs/api/parser.md +++ b/docs/api/parser.md @@ -62,6 +62,9 @@ editor.on('parse', ({ event, ... }) => { ... }); * [getConfig][2] * [parseHtml][3] * [parseCss][4] +* [addParserCode][12] +* [getParserCode][13] +* [removeParserCode][14] ## getConfig @@ -85,6 +88,7 @@ Parse HTML string and return the object containing the Component Definition * `options.keepEmptyTextNodes` **[Boolean][8]** Keep whitespaces regardless of whether they are meaningful (optional, default `false`) * `options.asDocument` **[Boolean][8]?** Treat the HTML string as document * `options.detectDocument` **([Boolean][8] | [Function][9])?** Indicate if or how to detect if the HTML string should be treated as document + * `options.parserCode` **[String][6]?** Use a specific parser from the code parser registry. Pass an empty string to force the built-in/legacy parser path. * `options.preParser` **[Function][9]?** How to pre-process the HTML string before parsing * `options.convertDataGjsAttributesHyphens` **[Boolean][8]** Convert `data-gjs-*` attributes from hyphenated to camelCase (eg. `data-gjs-my-component` to `data-gjs-myComponent`) (optional, default `false`) * `options.convertAttributeValues` **([Boolean][8] | [Array][10]<[String][6]> | [Function][9])** Convert regular HTML attribute values using the same parser used by `data-gjs-*` attributes (optional, default `false`) @@ -122,6 +126,47 @@ const res = Parser.parseCss('.cls { color: red }'); Returns **[Array][10]<[Object][5]>** Array containing the result +## addParserCode + +Add a new HTML code parser to the registry. + +```javascript +const parser = Parser.addParserCode( + 'my-parser', + (input, { editor, options }) => [{ nodeType: 1, tagName: 'section' }], + { skipSelect: true }, +); +``` + +Returns **[Object][5]** Added parser definition + +## getParserCode + +Get a registered HTML code parser by id. + +Returns **([Object][5] | [undefined][11])** + +## removeParserCode + +Remove a registered HTML code parser by id. + +Returns **([Object][5] | [undefined][11])** + +## Properties + +### parserCode + +Get or update the selected HTML code parser id. + +```javascript +Parser.parserCode = 'my-parser'; +Parser.parserCode = ''; +``` + +### parsersCode + +Object containing the registered HTML code parsers keyed by id. + [1]: https://github.com/GrapesJS/grapesjs/blob/master/src/parser/config/config.ts [2]: #getconfig @@ -130,6 +175,12 @@ Returns **[Array][10]<[Object][5]>** Array containing the result [4]: #parsecss +[12]: #addparsercode + +[13]: #getparsercode + +[14]: #removeparsercode + [5]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object [6]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String @@ -141,3 +192,5 @@ Returns **[Array][10]<[Object][5]>** Array containing the result [9]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function [10]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array + +[11]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/undefined diff --git a/packages/core/src/dom_components/index.ts b/packages/core/src/dom_components/index.ts index 3f6d3bd02..66127ce73 100644 --- a/packages/core/src/dom_components/index.ts +++ b/packages/core/src/dom_components/index.ts @@ -130,6 +130,8 @@ import ComponentVideoView from './view/ComponentVideoView'; import ComponentView, { IComponentView } from './view/ComponentView'; import ComponentWrapperView from './view/ComponentWrapperView'; import ComponentsView from './view/ComponentsView'; +import { ParseNodeOptions } from '../parser/config/config'; +import { ParsedNode } from '../parser/types'; export type { ComponentEvent } from './types'; @@ -144,6 +146,7 @@ export interface ComponentViewDefinition extends IComponentView { export interface AddComponentTypeOptions { isComponent?: (el: HTMLElement) => boolean | ComponentDefinitionDefined | undefined; + isParsedNode?: (node: ParsedNode, opts?: ParseNodeOptions) => boolean | ComponentDefinitionDefined | undefined; model?: Partial & ThisType; view?: Partial & ThisType; block?: boolean | Partial; @@ -543,7 +546,17 @@ export default class ComponentManager extends ItemManagerModule 0), + isParsedNode: compType && !extendType && !isParsedNode ? modelToExt.isParsedNode : isParsedNode || undefined, }, ); // Reassign the defaults getter to the model diff --git a/packages/core/src/dom_components/model/Component.ts b/packages/core/src/dom_components/model/Component.ts index fe82d30ea..6463a8020 100644 --- a/packages/core/src/dom_components/model/Component.ts +++ b/packages/core/src/dom_components/model/Component.ts @@ -25,6 +25,7 @@ import StyleableModel, { } from '../../domain_abstract/model/StyleableModel'; import EditorModel from '../../editor/model/Editor'; import ItemView from '../../navigator/view/ItemView'; +import { ParsedNode } from '../../parser/types'; import Selector from '../../selector_manager/model/Selector'; import Selectors from '../../selector_manager/model/Selectors'; import Trait from '../../trait_manager/model/Trait'; @@ -2057,6 +2058,7 @@ export default class Component extends StyleableModel { } static typeExtends = new Set(); + static isParsedNode?: (node: ParsedNode, opts?: any) => ComponentDefinitionDefined | boolean | undefined; static getDefaults() { return result(this.prototype, 'defaults'); diff --git a/packages/core/src/dom_components/model/ComponentMap.ts b/packages/core/src/dom_components/model/ComponentMap.ts index 2689b04cc..3d544466d 100644 --- a/packages/core/src/dom_components/model/ComponentMap.ts +++ b/packages/core/src/dom_components/model/ComponentMap.ts @@ -92,8 +92,9 @@ export default class ComponentMap extends ComponentImage { } static isComponent(el: HTMLIFrameElement) { - if (toLowerCase(el.tagName) == 'iframe' && /maps\.google\.com/.test(el.src)) { - return { type: 'map', src: el.src }; + const src = el.getAttribute?.('src') || ''; + if (toLowerCase(el.tagName) == 'iframe' && /maps\.google\.com/.test(src)) { + return { type: 'map', src }; } } } diff --git a/packages/core/src/dom_components/model/ComponentVideo.ts b/packages/core/src/dom_components/model/ComponentVideo.ts index be5fb6a49..60a383570 100644 --- a/packages/core/src/dom_components/model/ComponentVideo.ts +++ b/packages/core/src/dom_components/model/ComponentVideo.ts @@ -396,7 +396,8 @@ export default class ComponentVideo extends ComponentImage { } static isComponent(el: HTMLVideoElement) { - const { tagName, src } = el; + const { tagName } = el; + const src = el.getAttribute?.('src') || ''; const isYtProv = /youtube\.com\/embed/.test(src); const isYtncProv = /youtube-nocookie\.com\/embed/.test(src); const isViProv = /player\.vimeo\.com\/video/.test(src); diff --git a/packages/core/src/dom_components/model/types.ts b/packages/core/src/dom_components/model/types.ts index ad3b01a15..1ca71d09c 100644 --- a/packages/core/src/dom_components/model/types.ts +++ b/packages/core/src/dom_components/model/types.ts @@ -12,6 +12,7 @@ import Component from './Component'; import Components from './Components'; import { ToolbarButtonProps } from './ToolbarButton'; import { ParseNodeOptions } from '../../parser/config/config'; +import { ParsedNode } from '../../parser/types'; export type DragMode = 'translate' | 'absolute' | ''; @@ -32,6 +33,7 @@ export interface ResetComponentsOptions extends UpdateComponentsOptions { interface ComponentWithCheck { new (props: any, opt: ComponentOptions): C; isComponent(node: HTMLElement, opts?: ParseNodeOptions): ComponentDefinitionDefined | undefined | boolean; + isParsedNode?(node: ParsedNode, opts?: ParseNodeOptions): ComponentDefinitionDefined | undefined | boolean; } export interface ComponentStackItem = ComponentView> { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 581d2af83..4bb07a9cf 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -128,6 +128,14 @@ export type { default as Button } from './panels/model/Button'; export type { default as Buttons } from './panels/model/Buttons'; export type { default as Panel } from './panels/model/Panel'; export type { default as Panels } from './panels/model/Panels'; +export type { + CustomParserCode, + CustomParserCodeContext, + CustomParserCodeFunction, + HTMLParseResult, + ParsedNode, + ParsedCssRule, +} from './parser/types'; export type { default as Selector } from './selector_manager/model/Selector'; export type { default as Selectors } from './selector_manager/model/Selectors'; export type { default as State } from './selector_manager/model/State'; diff --git a/packages/core/src/parser/config/config.ts b/packages/core/src/parser/config/config.ts index 9a1977720..2f828594e 100644 --- a/packages/core/src/parser/config/config.ts +++ b/packages/core/src/parser/config/config.ts @@ -1,14 +1,6 @@ import { OptionAsDocument } from '../../common'; -import { CssRuleJSON } from '../../css_composer/model/CssRule'; -import { ComponentDefinitionDefined } from '../../dom_components/model/types'; import Editor from '../../editor'; - -export interface ParsedCssRule { - selectors: string | string[]; - style: Record; - atRule?: string; - params?: string; -} +import type { CustomParserCodeFunction, ParsedCssRule, ParsedElementNode, SyntheticElementCtor } from '../types'; export type CustomParserCss = (input: string, editor: Editor) => ParsedCssRule[]; @@ -17,15 +9,7 @@ export type CustomParserHtml = (input: string, options: HTMLParserOptions) => HT export type ConvertAttributeValuesOption = | boolean | readonly string[] - | ((props: { attribute: string; value: string | boolean; node: HTMLElement }) => boolean); - -export interface HTMLParseResult { - html: ComponentDefinitionDefined | ComponentDefinitionDefined[]; - css?: CssRuleJSON[]; - doctype?: string; - root?: ComponentDefinitionDefined; - head?: ComponentDefinitionDefined; -} + | ((props: { attribute: string; value: string | boolean; node: HTMLElement | ParsedElementNode }) => boolean); export interface ParseNodeOptions extends HTMLParserOptions { inSvg?: boolean; @@ -33,6 +17,11 @@ export interface ParseNodeOptions extends HTMLParserOptions { } export interface HTMLParserOptions extends OptionAsDocument { + /** + * Default custom parser from the code parser registry. + */ + parserCode?: string; + /** * DOMParser mime type. * If you use the `text/html` parser, it will fix the invalid syntax automatically. @@ -131,6 +120,22 @@ export interface ParserConfig { */ parserHtml?: CustomParserHtml; + /** + * Custom HTML code parsers registry. + */ + parsersCode?: Record; + + /** + * Selected HTML code parser from the registry. + */ + parserCode?: string; + + /** + * Extend the default synthetic element used to bridge legacy `isComponent` checks + * when parsing with `parserCode`. + */ + customSyntheticElement?: (SyntheticElement: SyntheticElementCtor) => SyntheticElementCtor; + /** * Default HTML parser options (used in `parserModule.parseHtml(' ParserConfig = () => ({ textTypes: ['text', 'textnode', 'comment'], parserCss: undefined, parserHtml: undefined, + parsersCode: {}, + parserCode: undefined, + customSyntheticElement: undefined, optionsHtml: { + parserCode: undefined, htmlType: 'text/html', allowScripts: false, allowUnsafeAttr: false, diff --git a/packages/core/src/parser/index.ts b/packages/core/src/parser/index.ts index aff795fda..d88e6ab90 100644 --- a/packages/core/src/parser/index.ts +++ b/packages/core/src/parser/index.ts @@ -20,6 +20,9 @@ * * [getConfig](#getconfig) * * [parseHtml](#parsehtml) * * [parseCss](#parsecss) + * * [addParserCode](#addparsercode) + * * [getParserCode](#getparsercode) + * * [removeParserCode](#removeparsercode) * * @module Parser */ @@ -29,18 +32,32 @@ import EditorModel from '../editor/model/Editor'; import defConfig, { HTMLParserOptions, ParserConfig } from './config/config'; import ParserCss from './model/ParserCss'; import ParserHtml from './model/ParserHtml'; -import { ParserEvents } from './types'; +import { CustomParserCode, CustomParserCodeFunction, ParserEvents } from './types'; export default class ParserModule extends Module { parserHtml: ReturnType; parserCss: ReturnType; + parsersCode = new Map(); events = ParserEvents; + private _parserCode = ''; constructor(em: EditorModel) { super(em, 'Parser', defConfig()); const { config } = this; this.parserCss = ParserCss(em, config); this.parserHtml = ParserHtml(em, config); + const parserCode = config.parserCode || ''; + Object.entries(config.parsersCode || {}).forEach(([id, parser]) => this.addParserCode(id, parser)); + this.parserCode = parserCode; + } + + get parserCode() { + return this._parserCode; + } + + set parserCode(value: string) { + this._parserCode = value || ''; + this.getConfig().parserCode = this._parserCode; } /** @@ -61,6 +78,7 @@ export default class ParserModule extends Module|Function} [options.convertAttributeValues=false] Convert regular HTML attribute values using the same parser used by `data-gjs-*` attributes @@ -94,6 +112,43 @@ export default class ParserModule extends Module { + const tagName = node.tagName || ''; + return tagName && (!node.namespaceURI || node.namespaceURI === HTML_NAMESPACE) ? tagName.toUpperCase() : tagName; +}; + +const getNodeTextContent = (node: ParsedNode): string => { + if (node.nodeType === 3 || node.nodeType === 8) { + return node.textContent ?? ''; + } + + if (node.textContent && !node.childNodes?.length) { + return node.textContent; + } + + return (node.childNodes || []).map(getNodeTextContent).join(''); +}; + +const getClassList = (node: ParsedNode) => { + const value = node.attributes?.class || ''; + const items = value.split(/\s+/).filter(Boolean); + + return { + value, + length: items.length, + contains(name: string) { + return items.includes(name); + }, + item(index: number) { + return items[index] || null; + }, + toString() { + return value; + }, + }; +}; + +export class SyntheticElement { + node: ParsedNode; + parent?: SyntheticElement; + + constructor(node: ParsedNode, parent?: SyntheticElement) { + this.node = node; + this.parent = parent; + } + + get nodeType() { + return this.node.nodeType; + } + + get tagName() { + return getTagName(this.node); + } + + get nodeName() { + return this.tagName; + } + + get namespaceURI() { + return this.node.namespaceURI; + } + + get parentNode() { + return this.parent; + } + + get childNodes() { + return (this.node.childNodes || []).map((node) => new (this.constructor as SyntheticElementCtor)(node, this)); + } + + get children() { + return this.childNodes.filter((node) => node.nodeType === 1); + } + + get textContent() { + return getNodeTextContent(this.node); + } + + get nodeValue() { + return this.nodeType === 3 || this.nodeType === 8 ? this.textContent : null; + } + + get className() { + return this.getAttribute('class') || ''; + } + + get classList() { + return getClassList(this.node); + } + + getAttribute(key: string) { + return this.node.attributes?.[key]; + } + + hasAttribute(key: string) { + return Object.prototype.hasOwnProperty.call(this.node.attributes || {}, key); + } +} + +export const getSyntheticElementCtor = ( + customSyntheticElement?: ((SyntheticElement: SyntheticElementCtor) => SyntheticElementCtor) | null, +) => (customSyntheticElement ? customSyntheticElement(SyntheticElement as SyntheticElementCtor) : SyntheticElement); diff --git a/packages/core/src/parser/types.ts b/packages/core/src/parser/types.ts index 4f62fad6f..ac35fb57c 100644 --- a/packages/core/src/parser/types.ts +++ b/packages/core/src/parser/types.ts @@ -1,6 +1,48 @@ import { ObjectAny } from '../common'; import { CssRuleJSON } from '../css_composer/model/CssRule'; -import { HTMLParseResult, HTMLParserOptions, ParsedCssRule } from './config/config'; +import { ComponentDefinitionDefined } from '../dom_components/model/types'; +import Editor from '../editor'; +import type { HTMLParserOptions } from './config/config'; + +export interface ParsedCssRule { + selectors: string | string[]; + style: Record; + atRule?: string; + params?: string; +} + +export interface ParsedNode { + nodeType: number; + tagName?: string; + namespaceURI?: string; + attributes?: Record; + childNodes?: ParsedNode[]; + textContent?: string; +} + +export type ParsedElementNode = ParsedNode & { tagName: string }; + +export interface CustomParserCodeContext { + editor: Editor; + options: HTMLParserOptions; +} + +export type CustomParserCodeFunction = (input: string, props: CustomParserCodeContext) => ParsedNode[]; + +export interface CustomParserCode { + id: string; + parse: CustomParserCodeFunction; +} + +export type SyntheticElementCtor = new (node: ParsedNode, parent?: any) => any; + +export interface HTMLParseResult { + html: ComponentDefinitionDefined | ComponentDefinitionDefined[]; + css?: CssRuleJSON[]; + doctype?: string; + root?: ComponentDefinitionDefined; + head?: ComponentDefinitionDefined; +} /**{START_EVENTS}*/ export enum ParserEvents { @@ -54,7 +96,7 @@ export type ParserEvent = `${ParserEvents}`; export interface ParserEventCallback { [ParserEvents.htmlBefore]: [{ input: string }]; - [ParserEvents.htmlRoot]: [{ input: string; root: HTMLElement }]; + [ParserEvents.htmlRoot]: [{ input: string; root: ParsedNode }]; [ParserEvents.html]: [{ input: string; output: HTMLParseResult; options: HTMLParserOptions }]; [ParserEvents.cssBefore]: [{ input: string }]; [ParserEvents.css]: [ diff --git a/packages/core/test/specs/parser/index.ts b/packages/core/test/specs/parser/index.ts new file mode 100644 index 000000000..ab8104970 --- /dev/null +++ b/packages/core/test/specs/parser/index.ts @@ -0,0 +1,75 @@ +import Editor from '../../../src/editor/model/Editor'; + +describe('Parser module', () => { + let em: Editor; + + beforeEach(() => { + em = new Editor({}); + }); + + afterEach(() => { + em.destroy(); + }); + + test('adds, gets, removes code parsers and selects them by default', () => { + const parse = jest.fn(() => []); + const parser = em.Parser.addParserCode('test-parser', parse); + + expect(parser).toEqual({ + id: 'test-parser', + parse, + }); + expect(em.Parser.getParserCode('test-parser')).toBe(parser); + expect(em.Parser.parserCode).toBe('test-parser'); + expect(em.Parser.removeParserCode('test-parser')).toBe(parser); + expect(em.Parser.getParserCode('test-parser')).toBeUndefined(); + expect(em.Parser.parserCode).toBe(''); + }); + + test('loads code parsers from init config and selects the last one by default', () => { + const first = jest.fn(() => []); + const second = jest.fn(() => []); + const editor = new Editor({ + parser: { + parsersCode: { + first, + second, + }, + }, + }); + + expect(editor.Parser.getParserCode('first')?.parse).toBe(first); + expect(editor.Parser.getParserCode('second')?.parse).toBe(second); + expect(editor.Parser.parserCode).toBe('second'); + + editor.destroy(); + }); + + test('allows init config to skip selecting a default parser', () => { + const editor = new Editor({ + parser: { + parserCode: '', + parsersCode: { + first: () => [], + second: () => [], + }, + }, + }); + + expect(editor.Parser.parserCode).toBe(''); + + editor.destroy(); + }); + + test('supports global parserCode, per-call overrides, and legacy fallback', () => { + em.Parser.addParserCode('section-parser', () => [{ nodeType: 1, tagName: 'section' }]); + em.Parser.addParserCode('article-parser', () => [{ nodeType: 1, tagName: 'article' }], { skipSelect: true }); + + expect(em.Parser.parseHtml('
').html).toEqual({ tagName: 'section' }); + expect(em.Parser.parseHtml('
', { parserCode: 'article-parser' }).html).toEqual({ tagName: 'article' }); + expect(em.Parser.parseHtml('
', { parserCode: '' }).html).toEqual({ tagName: 'div' }); + expect(() => em.Parser.parseHtml('
', { parserCode: 'missing-parser' })).toThrow( + 'Parser code "missing-parser" not found', + ); + }); +}); diff --git a/packages/core/test/specs/parser/model/ParserHtml.ts b/packages/core/test/specs/parser/model/ParserHtml.ts index a1671eb9d..cb7f6d1ec 100644 --- a/packages/core/test/specs/parser/model/ParserHtml.ts +++ b/packages/core/test/specs/parser/model/ParserHtml.ts @@ -1095,4 +1095,202 @@ describe('ParserHtml', () => { expect(obj.parse(str).html).toEqual(result); }); }); + + describe('with custom code parser', () => { + test('parses nodes from parserCode', () => { + em.Parser.addParserCode('custom-html', () => [{ nodeType: 1, tagName: 'section' }]); + + expect(obj.parse('
', null, { parserCode: 'custom-html' }).html).toEqual([{ tagName: 'section' }]); + }); + + test('uses isParsedNode when available', () => { + em.Components.addType('parsed-cmp', { + isParsedNode: (node) => node.tagName === 'parsed-node' && { type: 'parsed-cmp', parsed: true }, + isComponent: () => false, + }); + obj.compTypes = em.Components.componentTypes; + em.Parser.addParserCode('custom-html', () => [{ nodeType: 1, tagName: 'parsed-node' }]); + + expect(obj.parse('', null, { parserCode: 'custom-html' }).html).toEqual([ + { + tagName: 'parsed-node', + type: 'parsed-cmp', + parsed: true, + }, + ]); + }); + + test('falls back to synthetic element for legacy isComponent', () => { + em.Components.addType('legacy-cmp', { + isComponent: (el: any) => + el.tagName === 'A' + ? { + type: 'legacy-cmp', + hrefProp: el.getAttribute('href'), + hasTextChild: !!el.childNodes.length, + } + : false, + }); + obj.compTypes = em.Components.componentTypes; + em.Parser.addParserCode('custom-html', () => [ + { + nodeType: 1, + tagName: 'a', + attributes: { href: 'https://grapesjs.com' }, + childNodes: [{ nodeType: 3, textContent: 'Read more' }], + }, + ]); + + expect(obj.parse('', null, { parserCode: 'custom-html' }).html).toEqual([ + { + tagName: 'a', + type: 'legacy-cmp', + hrefProp: 'https://grapesjs.com', + hasTextChild: true, + attributes: { href: 'https://grapesjs.com' }, + components: { + type: 'textnode', + content: 'Read more', + }, + }, + ]); + }); + + test('supports custom synthetic element extensions', () => { + em.destroy(); + em = new Editor({ + parser: { + customSyntheticElement: (SyntheticElement) => + class CustomSyntheticElement extends SyntheticElement { + get foo() { + return this.getAttribute('data-foo') || ''; + } + }, + }, + }); + em.Components.addType('custom-synthetic', { + isComponent: (el: any) => el.foo === 'bar' && { type: 'custom-synthetic' }, + }); + obj = ParserHtml(em, { + returnArray: true, + }); + obj.compTypes = em.Components.componentTypes; + em.Parser.addParserCode('custom-html', () => [ + { + nodeType: 1, + tagName: 'div', + attributes: { 'data-foo': 'bar' }, + }, + ]); + + expect(obj.parse('', null, { parserCode: 'custom-html' }).html).toEqual([ + { + tagName: 'div', + type: 'custom-synthetic', + attributes: { 'data-foo': 'bar' }, + }, + ]); + }); + + test('normalizes documents from parserCode', () => { + em.Parser.addParserCode('custom-html', () => [ + { + nodeType: 1, + tagName: 'html', + attributes: { lang: 'en', class: 'cls-html' }, + childNodes: [ + { + nodeType: 1, + tagName: 'head', + childNodes: [{ nodeType: 1, tagName: 'title', childNodes: [{ nodeType: 3, textContent: 'Test' }] }], + }, + { + nodeType: 1, + tagName: 'body', + attributes: { class: 'cls-body' }, + childNodes: [{ nodeType: 1, tagName: 'h1', childNodes: [{ nodeType: 3, textContent: 'H1' }] }], + }, + ], + }, + ]); + + expect(obj.parse('', null, { parserCode: 'custom-html', asDocument: true })).toEqual({ + root: { + classes: ['cls-html'], + attributes: { lang: 'en' }, + }, + head: { + type: 'head', + tagName: 'head', + components: [ + { + tagName: 'title', + type: 'text', + components: { type: 'textnode', content: 'Test' }, + }, + ], + }, + html: { + tagName: 'body', + classes: ['cls-body'], + components: [ + { + tagName: 'h1', + type: 'text', + components: { type: 'textnode', content: 'H1' }, + }, + ], + }, + }); + }); + + test('extracts styles, strips scripts, sanitizes attrs, and emits normalized root', () => { + let rootNode: any; + em.on(em.Parser.events.htmlRoot, ({ root }) => { + rootNode = root; + }); + em.Parser.addParserCode('custom-html', () => [ + { + nodeType: 1, + tagName: 'style', + childNodes: [{ nodeType: 3, textContent: '.cls { color: red }' }], + }, + { + nodeType: 1, + tagName: 'a', + attributes: { + href: 'javascript:alert(1)', + onload: 'alert(1)', + 'data-safe': 'yes', + }, + }, + { + nodeType: 1, + tagName: 'script', + childNodes: [{ nodeType: 3, textContent: 'alert(1)' }], + }, + ]); + + expect(obj.parse('', ParserCss(), { parserCode: 'custom-html' })).toEqual({ + html: [ + { + tagName: 'a', + type: 'link', + attributes: { + 'data-safe': 'yes', + }, + }, + ], + css: [ + { + selectors: ['cls'], + style: { color: 'red' }, + }, + ], + }); + expect(rootNode.nodeType).toBe(11); + expect(rootNode.childNodes).toHaveLength(1); + expect(rootNode.childNodes?.[0].tagName).toBe('a'); + }); + }); });