Browse Source

Initial setup for custom code parser

custom-code-parser
Artur Arseniev 5 months ago
parent
commit
d1d7881126
  1. 53
      docs/api/parser.md
  2. 16
      packages/core/src/dom_components/index.ts
  3. 2
      packages/core/src/dom_components/model/Component.ts
  4. 5
      packages/core/src/dom_components/model/ComponentMap.ts
  5. 3
      packages/core/src/dom_components/model/ComponentVideo.ts
  6. 2
      packages/core/src/dom_components/model/types.ts
  7. 8
      packages/core/src/index.ts
  8. 45
      packages/core/src/parser/config/config.ts
  9. 57
      packages/core/src/parser/index.ts
  10. 3
      packages/core/src/parser/model/ParserCss.ts
  11. 105
      packages/core/src/parser/model/SyntheticElement.ts
  12. 46
      packages/core/src/parser/types.ts
  13. 75
      packages/core/test/specs/parser/index.ts
  14. 198
      packages/core/test/specs/parser/model/ParserHtml.ts

53
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

16
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<ComponentModelDefinition> & ThisType<ComponentModelDefinition & Component>;
view?: Partial<ComponentViewDefinition> & ThisType<ComponentViewDefinition & ComponentView>;
block?: boolean | Partial<BlockProperties>;
@ -543,7 +546,17 @@ export default class ComponentManager extends ItemManagerModule<DomComponentsCon
*/
addType(type: string, methods: AddComponentTypeOptions) {
const { em } = this;
const { model = {}, view = {}, isComponent, extend, extendView, extendFn = [], extendFnView = [], block } = methods;
const {
model = {},
view = {},
isComponent,
isParsedNode,
extend,
extendView,
extendFn = [],
extendFnView = [],
block,
} = methods;
const compType = this.getType(type);
const extendType = this.getType(extend!);
const extendViewType = this.getType(extendView!);
@ -580,6 +593,7 @@ export default class ComponentManager extends ItemManagerModule<DomComponentsCon
{
typeExtends,
isComponent: compType && !extendType && !isComponent ? modelToExt.isComponent : isComponent || (() => 0),
isParsedNode: compType && !extendType && !isParsedNode ? modelToExt.isParsedNode : isParsedNode || undefined,
},
);
// Reassign the defaults getter to the model

2
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<ComponentProperties> {
}
static typeExtends = new Set<string>();
static isParsedNode?: (node: ParsedNode, opts?: any) => ComponentDefinitionDefined | boolean | undefined;
static getDefaults() {
return result(this.prototype, 'defaults');

5
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 };
}
}
}

3
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);

2
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<C extends Component> {
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<C extends Component = Component, CV extends ComponentView<C> = ComponentView<C>> {

8
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';

45
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<string, string>;
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<string, CustomParserCodeFunction>;
/**
* 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('<div...', options)`).
*/
@ -142,7 +147,11 @@ const config: () => ParserConfig = () => ({
textTypes: ['text', 'textnode', 'comment'],
parserCss: undefined,
parserHtml: undefined,
parsersCode: {},
parserCode: undefined,
customSyntheticElement: undefined,
optionsHtml: {
parserCode: undefined,
htmlType: 'text/html',
allowScripts: false,
allowUnsafeAttr: false,

57
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<ParserConfig & { name?: string }> {
parserHtml: ReturnType<typeof ParserHtml>;
parserCss: ReturnType<typeof ParserCss>;
parsersCode = new Map<string, CustomParserCode>();
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<ParserConfig & { name?: string
* @param {Boolean} [options.keepEmptyTextNodes=false] Keep whitespaces regardless of whether they are meaningful
* @param {Boolean} [options.asDocument] Treat the HTML string as document
* @param {Boolean|Function} [options.detectDocument] Indicate if or how to detect if the HTML string should be treated as document
* @param {String} [options.parserCode] Use a specific parser from the code parser registry. Pass an empty string to force the built-in/legacy parser path.
* @param {Function} [options.preParser] How to pre-process the HTML string before parsing
* @param {Boolean} [options.convertDataGjsAttributesHyphens=false] Convert `data-gjs-*` attributes from hyphenated to camelCase (eg. `data-gjs-my-component` to `data-gjs-myComponent`)
* @param {Boolean|Array<String>|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<ParserConfig & { name?: string
return this.parserCss.parse(input);
}
/**
* Add a new HTML code parser to the registry.
* @param {string} id Parser ID
* @param {Function} parse Parser function
* @param {Object} [options={}] Parser options
* @param {Boolean} [options.skipSelect=false] Avoid selecting the added parser as default
* @returns {Object} Added parser definition
*/
addParserCode(id: string, parse: CustomParserCodeFunction, options: { skipSelect?: boolean } = {}) {
const parser = { id, parse };
this.parsersCode.set(id, parser);
!options.skipSelect && (this.parserCode = id);
return parser;
}
/**
* Get an HTML code parser by id.
* @param {string} id Parser ID
* @returns {Object|undefined} Parser definition
*/
getParserCode(id: string) {
return this.parsersCode.get(id);
}
/**
* Remove an HTML code parser from the registry.
* @param {string} id Parser ID
* @returns {Object|undefined} Removed parser definition
*/
removeParserCode(id: string) {
const parser = this.parsersCode.get(id);
if (!parser) return;
this.parsersCode.delete(id);
this.parserCode === id && (this.parserCode = '');
return parser;
}
__emitEvent(event: string, data: ObjectAny) {
const { em, events } = this;
em.trigger(event, data);

3
packages/core/src/parser/model/ParserCss.ts

@ -1,7 +1,8 @@
import { isString } from 'underscore';
import { CssRuleJSON } from '../../css_composer/model/CssRule';
import EditorModel from '../../editor/model/Editor';
import { ParsedCssRule, ParserConfig } from '../config/config';
import { ParserConfig } from '../config/config';
import { ParsedCssRule } from '../types';
import BrowserCssParser, { parseSelector, createNode } from './BrowserParserCss';
import { ParserEvents } from '../types';

105
packages/core/src/parser/model/SyntheticElement.ts

@ -0,0 +1,105 @@
import { ParsedNode, SyntheticElementCtor } from '../types';
const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
const getTagName = (node: ParsedNode) => {
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);

46
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<string, string>;
atRule?: string;
params?: string;
}
export interface ParsedNode {
nodeType: number;
tagName?: string;
namespaceURI?: string;
attributes?: Record<string, string>;
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]: [

75
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('<div></div>').html).toEqual({ tagName: 'section' });
expect(em.Parser.parseHtml('<div></div>', { parserCode: 'article-parser' }).html).toEqual({ tagName: 'article' });
expect(em.Parser.parseHtml('<div></div>', { parserCode: '' }).html).toEqual({ tagName: 'div' });
expect(() => em.Parser.parseHtml('<div></div>', { parserCode: 'missing-parser' })).toThrow(
'Parser code "missing-parser" not found',
);
});
});

198
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('<div></div>', 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');
});
});
});

Loading…
Cancel
Save