From a33dec84c5ac19a500f21ce3ba5de2a64d9239d9 Mon Sep 17 00:00:00 2001 From: Artur Arseniev Date: Fri, 22 May 2026 11:44:36 +0400 Subject: [PATCH] Add support for custom HTML code parsers (#6767) * Initial setup for custom code parser * Update ParserHtml * Move methods * Up * Move utils * Fix tests * Up sanitize * Up domToParsedNode * Up fragment node * Remove node cloning * Convert ParserHtml to class * Convert ParserCss to a class * Update Custom CSS/HTML docs * Always use isParsedNode if available * Update API * Update docs for isParserNode --- docs/api/commands.md | 89 +- docs/api/editor.md | 6 +- docs/api/parser.md | 92 +- docs/guides/Custom-CSS-parser.md | 35 +- docs/guides/Custom-HTML-parser.md | 143 +++ docs/modules/Components.md | 39 + packages/core/package.json | 2 +- packages/core/src/dom_components/index.ts | 16 +- .../src/dom_components/model/Component.ts | 2 + .../src/dom_components/model/ComponentMap.ts | 5 +- .../dom_components/model/ComponentVideo.ts | 3 +- .../core/src/dom_components/model/types.ts | 2 + .../domain_abstract/model/StyleableModel.ts | 2 +- packages/core/src/index.ts | 8 + packages/core/src/parser/config/config.ts | 45 +- packages/core/src/parser/index.ts | 67 +- .../src/parser/model/BrowserParserHtml.ts | 83 -- packages/core/src/parser/model/ParserCss.ts | 24 +- packages/core/src/parser/model/ParserHtml.ts | 901 ++++++++++-------- .../core/src/parser/model/SyntheticElement.ts | 105 ++ packages/core/src/parser/model/utils.ts | 200 ++++ packages/core/src/parser/types.ts | 65 +- packages/core/test/specs/parser/index.ts | 75 ++ .../core/test/specs/parser/model/ParserCss.ts | 6 +- .../test/specs/parser/model/ParserHtml.ts | 228 ++++- 25 files changed, 1609 insertions(+), 634 deletions(-) create mode 100644 docs/guides/Custom-HTML-parser.md create mode 100644 packages/core/src/parser/model/SyntheticElement.ts create mode 100644 packages/core/src/parser/model/utils.ts create mode 100644 packages/core/test/specs/parser/index.ts diff --git a/docs/api/commands.md b/docs/api/commands.md index 32ee5cbb3..7afe76cbb 100644 --- a/docs/api/commands.md +++ b/docs/api/commands.md @@ -96,14 +96,15 @@ editor.on('command:call:my-command', ({ result, options, type }) => { ... }); ## Methods * [add][2] -* [get][3] -* [getAll][4] -* [extend][5] -* [has][6] -* [run][7] -* [stop][8] -* [isActive][9] -* [getActive][10] +* [remove][3] +* [get][4] +* [getAll][5] +* [extend][6] +* [has][7] +* [run][8] +* [stop][9] +* [isActive][10] +* [getActive][11] ## add @@ -111,8 +112,8 @@ Add new command to the collection ### Parameters -* `id` **[string][11]** Command's ID -* `command` **([Object][12] | [Function][13])** Object representing your command, +* `id` **[string][12]** Command's ID +* `command` **([Object][13] | [Function][14])** Object representing your command, By passing just a function it's intended as a stateless command (just like passing an object with only `run` method). @@ -132,13 +133,23 @@ commands.add('myCommand2', editor => { ... }); Returns **this** +## remove + +Remove command from the collection + +### Parameters + +* `id` **[string][12]** Command's ID + +Returns **this** + ## get Get command by ID ### Parameters -* `id` **[string][11]** Command's ID +* `id` **[string][12]** Command's ID ### Examples @@ -147,7 +158,7 @@ var myCommand = commands.get('myCommand'); myCommand.run(); ``` -Returns **[Object][12]** Object representing the command +Returns **[Object][13]** Object representing the command ## extend @@ -155,9 +166,9 @@ Extend the command. The command to extend should be defined as an object ### Parameters -* `id` **[string][11]** Command's ID -* `cmd` **CommandObject** (optional, default `{}`) -* `Object` **[Object][12]** with the new command functions +* `id` **[string][12]** Command's ID +* `cmd` **CommandObjectById\** (optional, default `{}as CommandObjectById`) +* `Object` **[Object][13]** with the new command functions ### Examples @@ -177,15 +188,15 @@ Check if command exists ### Parameters -* `id` **[string][11]** Command's ID +* `id` **[string][12]** Command's ID -Returns **[Boolean][14]** +Returns **[Boolean][15]** ## getAll Get an object containing all the commands -Returns **[Object][12]** +Returns **[Object][13]** ## run @@ -193,8 +204,9 @@ Execute the command ### Parameters -* `id` **[String][11]** Command ID -* `options` **[Object][12]** Options (optional, default `{}`) +* `id` **[String][12]** Command ID +* `args` **...CommandRunArgs\** +* `options` **[Object][13]** Options (optional, default `{}`) ### Examples @@ -210,8 +222,9 @@ Stop the command ### Parameters -* `id` **[String][11]** Command ID -* `options` **[Object][12]** Options (optional, default `{}`) +* `id` **[String][12]** Command ID +* `args` **...CommandStopArgs\** +* `options` **[Object][13]** Options (optional, default `{}`) ### Examples @@ -229,7 +242,7 @@ method it can't be registered as active ### Parameters -* `id` **[String][11]** Command id +* `id` **[String][12]** Command id ### Examples @@ -243,7 +256,7 @@ commands.isActive(cId); // -> false ``` -Returns **[Boolean][14]** +Returns **[Boolean][15]** ## getActive @@ -256,32 +269,34 @@ console.log(commands.getActive()); // -> { someCommand: itsLastReturn, anotherOne: ... }; ``` -Returns **[Object][12]** +Returns **[Object][13]** [1]: https://github.com/GrapesJS/grapesjs/blob/master/src/commands/config/config.ts [2]: #add -[3]: #get +[3]: #remove + +[4]: #get -[4]: #getall +[5]: #getall -[5]: #extend +[6]: #extend -[6]: #has +[7]: #has -[7]: #run +[8]: #run -[8]: #stop +[9]: #stop -[9]: #isactive +[10]: #isactive -[10]: #getactive +[11]: #getactive -[11]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String +[12]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String -[12]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object +[13]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object -[13]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function +[14]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function -[14]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean +[15]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean diff --git a/docs/api/editor.md b/docs/api/editor.md index 078d85397..b78b4029d 100644 --- a/docs/api/editor.md +++ b/docs/api/editor.md @@ -444,7 +444,8 @@ Execute command ### Parameters * `id` **[string][18]** Command ID -* `options` **[Object][16]** Custom options (optional, default `{}`) +* `args` **...CommandRunArgs\** +* `options` **[Object][16]** Custom options ### Examples @@ -461,7 +462,8 @@ Stop the command if stop method was provided ### Parameters * `id` **[string][18]** Command ID -* `options` **[Object][16]** Custom options (optional, default `{}`) +* `args` **...CommandStopArgs\** +* `options` **[Object][16]** Custom options ### Examples diff --git a/docs/api/parser.md b/docs/api/parser.md index e3c4efc99..dc692175e 100644 --- a/docs/api/parser.md +++ b/docs/api/parser.md @@ -62,12 +62,15 @@ editor.on('parse', ({ event, ... }) => { ... }); * [getConfig][2] * [parseHtml][3] * [parseCss][4] +* [addParserCode][5] +* [getParserCode][6] +* [removeParserCode][7] ## getConfig Get configuration object -Returns **[Object][5]** +Returns **[Object][8]** ## parseHtml @@ -75,19 +78,20 @@ Parse HTML string and return the object containing the Component Definition ### Parameters -* `input` **[String][6]** HTML string to parse -* `options` **[Object][5]?** Options (optional, default `{}`) - - * `options.htmlType` **[String][6]?** [HTML mime type][7] to parse - * `options.allowScripts` **[Boolean][8]** Allow ` ``` -To work fast and easier GrapesJS needs to compile a simple string (HTML/CSS) into structured nodes (nested JS objects). Fortunately, most of the hard work (parsing) is already done by the browser itself which translates that string into its own objects ([DOM](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model)/[CSSOM](https://developer.mozilla.org/en-US/docs/Web/API/CSS_Object_Model)) and so we just rely on those, by traversing them and creating our nodes (unfortunately browser's objects are not enough). The fact we're able to parse our strings just by using the browser itself it's very cool, we can enable the import feature without requiring any third-party library, so... where is the problem? Well, while the generated DOM is performing quite well, as we're able to extract what we need, unfortunately, it's not the same for the CSSOM, so let's see in the next paragraph what is wrong with it. +To work quickly and efficiently, GrapesJS needs to compile a simple string (HTML/CSS) into structured nodes (nested JS objects). Fortunately, most of the hard parsing work is already done by the browser itself, which translates that string into its own objects ([DOM](https://developer.mozilla.org/en-US/docs/Web/API/Document_Object_Model)/[CSSOM](https://developer.mozilla.org/en-US/docs/Web/API/CSS_Object_Model)), so we can traverse them and build our own nodes from there. Browser objects alone are not enough, but they give us a strong starting point. + +Being able to parse strings just by using the browser is great, because it lets us support import without requiring any third-party library. So where is the problem? While the generated DOM works quite well and lets us extract what we need, the same is not true for the CSSOM. In the next paragraph, we'll see why. ## CSSOM results are inconsistent -Unfortunately, we have discovered that the CSSOM generated by browsers are highly inconsistent from what we ask to parse. To demonstrate it, we gonna create a simple example by using the built-in parser and we'll check its result. -So, for our case we just take in account a simple rule, we'll parse it and print the CSSOM result on screen. +Unfortunately, we discovered that the CSSOM generated by browsers can be highly inconsistent compared to the CSS we ask them to parse. To demonstrate this, we'll create a simple example using the built-in parser and inspect its result. + +For this example, we'll use a simple rule, parse it, and print the CSSOM result on screen. ```html

To parse

@@ -97,24 +100,24 @@ So, for our case we just take in account a simple rule, we'll parse it and print ### Results -Here some results (using latest versions + IE11) +Here are some results (using the latest browser versions plus IE11). -As you see, this is what we get for asking only 7 properties, who adds more or less, someone converts colors to rgba functions and someone else changes the order of our values (eg. `box-shadow`). Webkit-based browsers attach also properties they self don't understand +As you can see, this is what we get when asking for only 7 properties: some browsers add more properties, some convert colors to `rgba(...)`, and others change the order of values (eg. `box-shadow`). WebKit-based browsers also attach properties they don't even understand themselves. -So it's clear that we can't rely on CSSOM objects, that's why we added the possibility to set custom CSS parser via `editor.setCustomParserCss` method or `config.Parser.parserCss` option to use on initialization. Let's see in detail how it's expected to work +So it's clear that we can't rely on CSSOM objects. That's why we added the ability to set a custom CSS parser via the `editor.setCustomParserCss` method or the `config.Parser.parserCss` option during initialization. Let's see in detail how this is expected to work. ## CSSOM results can be nonintuitive -As per current [csswg specification](https://drafts.csswg.org/css-variables-1/#variables-in-shorthands) variables in shorthand properties can serialize to the empty string. -This means that while `background-color: var(--my-var)` will serialize fine `background: var(--my-var)` will not. +According to the current [CSSWG specification](https://drafts.csswg.org/css-variables-1/#variables-in-shorthands), variables in shorthand properties can serialize to an empty string. +This means that while `background-color: var(--my-var)` serializes correctly, `background: var(--my-var)` does not. ## Set CSS parser -The custom parser you have to use it's just a function receiving 2 arguments: `css`, as the CSS string to parse, and `editor`, the instance of the current editor. As the result, you should return an array containing valid rule objects, the syntax of those objects are explained below. This is how you can set the custom parser +The custom parser is just a function that receives 2 arguments: `css`, the CSS string to parse, and `editor`, the current editor instance. It should return an array of valid rule objects. The syntax of those objects is explained below. This is how you can set the custom parser. ```js const parserCss = (css, editor) => { @@ -125,11 +128,11 @@ const parserCss = (css, editor) => { style: { color: 'red' }, }); // ... - return result; // Result should be ALWAYS an array + return result; // The result should always be an array }; // On initialization -// This is the recommended way, as you gonna use the parser from the beginning +// This is the recommended way, as you'll use the parser from the beginning const editor = grapesjs.init({ //... parser: { @@ -143,7 +146,7 @@ editor.setCustomParserCss(parserCss); ## Rule Objects -The syntax of rule objects is pretty straightforward, each object might contain following keys +The syntax of rule objects is pretty straightforward. Each object can contain the following keys. | Key | Description | Example | | ----------- | ------------------------------------------------------------------------------------------------- | ------------------------------- | @@ -152,7 +155,7 @@ The syntax of rule objects is pretty straightforward, each object might contain | `atRule` | At-rule name | `media` | | `params` | Parameters of the at-rule | `screen and (min-width: 480px)` | -To make it more clear let's see a few examples +To make it clearer, let's look at a few examples. ```js // Input @@ -252,6 +255,6 @@ To make it more clear let's see a few examples ## Plugins -Below the list of current available CSS parsers as plugins, if you need to create your own we highly suggest to explore their sources +Below is the list of currently available CSS parser plugins. If you need to create your own, we highly recommend exploring their source code. - [grapesjs-parser-postcss](https://github.com/GrapesJS/parser-postcss) - Using [PostCSS](https://github.com/postcss/postcss) parser diff --git a/docs/guides/Custom-HTML-parser.md b/docs/guides/Custom-HTML-parser.md new file mode 100644 index 000000000..6de1ab1d8 --- /dev/null +++ b/docs/guides/Custom-HTML-parser.md @@ -0,0 +1,143 @@ +--- +title: Use Custom HTML Parser +--- + +# Use Custom HTML Parser + +If your GrapesJS integration needs to parse HTML in environments where DOM APIs are not available, you can register a custom HTML code parser and let GrapesJS compile the returned parsed nodes into components. + +This is useful for: + +- server-side or worker-based HTML imports +- integrations that already have their own HTML parser +- cases where component recognition should not depend on browser DOM nodes + +::: warning +This guide requires GrapesJS v0.23.1 or higher +::: + +[[toc]] + +## Register a parser + +Code parsers are managed by the `Parser` module. + +```js +const { Parser } = editor; + +Parser.addParserCode( + 'my-parser', + (input) => { + return [ + { + nodeType: 1, + tagName: 'section', + attributes: { class: 'hero' }, + childNodes: [{ nodeType: 3, textContent: 'Hello world' }], + }, + ]; + } +); +``` + +The parser function must always return an array of parsed nodes. + +## Select a parser + +You can select the active parser globally: + +```js +Parser.parserCode = 'my-parser'; +const result = Parser.parseHtml('
Hello world
'); +``` + +Or for a single call: + +```js +const result = Parser.parseHtml('
Hello world
', { + parserCode: 'my-parser', +}); +``` + +Passing `parserCode: ''` forces the built-in DOM parser path for that call. + +## Parsed nodes + +Custom parsers return nodes shaped like this: + +```ts +interface ParsedNode { + nodeType?: number; + tagName?: string; + namespaceURI?: string; + attributes?: Record; + childNodes?: ParsedNode[]; + textContent?: string; +} +``` + +Supported node types in the current implementation are: + +- `1` for elements +- `3` for text nodes +- `8` for comments +- `9` for documents +- `11` for document fragments + +When `asDocument: true` is used, GrapesJS normalizes the parser output to a document-like root so `root`, `head`, and `body` can still be compiled. + +## Component recognition + +For headless parsing, component types can implement `isParsedNode`: + +```js +editor.Components.addType('my-component', { + isParsedNode(node, opts) { + if (node.tagName === 'my-component') { + return { type: 'my-component' }; + } + }, +}); +``` + +When `parserCode` is active, `isParsedNode` is preferred over `isComponent`. + +## Legacy `isComponent` fallback + +Existing components that only implement `isComponent` continue to work with `parserCode`. +GrapesJS passes a read-only synthetic element that exposes the most common DOM-like properties: + +- `nodeType` +- `tagName` +- `nodeName` +- `namespaceURI` +- `textContent` +- `nodeValue` +- `parentNode` +- `childNodes` +- `children` +- `getAttribute` +- `hasAttribute` + +If you need more DOM-like helpers, extend the base synthetic element: + +```js +editor.Parser.config.customSyntheticElement = (SyntheticElement) => + class MySyntheticElement extends SyntheticElement { + get foo() { + return this.getAttribute('data-foo') || ''; + } + }; +``` + +## Registry helpers + +You can inspect and manage the registry at runtime: + +```js +const parser = Parser.getParserCode('my-parser'); +const removed = Parser.removeParserCode('my-parser'); +const registry = Parser.parsersCode; +``` + +Removing the selected parser clears `Parser.parserCode`. diff --git a/docs/modules/Components.md b/docs/modules/Components.md index fa2329985..3b420281c 100644 --- a/docs/modules/Components.md +++ b/docs/modules/Components.md @@ -93,6 +93,8 @@ As we mentioned before, when you pass an HTML string as a component to the edito +Component recognition can also rely on `isParsedNode`, which receives the normalized parsed node instead of a DOM element. If a component defines both `isParsedNode` and `isComponent`, `isParsedNode` is preferred. + ::: tip If you're importing big string chunks of HTML code you might want to improve the performances by skipping the parsing and the component recognition steps by passing directly Component Definition objects or using the JSX syntax. Read [here](#setup-jsx-syntax) about how to setup JSX syntax parser @@ -254,6 +256,10 @@ To understand better how Traits work you should read its [dedicated page](Traits ### isComponent +::: warning +From `v0.23.1`, prefer `isParsedNode` over `isComponent` for new component types. It is the recommended API and avoids relying on DOM-specific behavior during component recognition. +::: + Let's see in detail what we have done so far. The first thing to notice is the `isComponent` function, we have already mentioned its usage in [this](#component-recognition-and-component-type-stack) section and we need it to make the editor understand `` during the component recognition step. It receives only the `el` argument, which is the parsed HTMLElement node and expects a truthy value in case the element satisfies your logic condition. So, if we add this HTML string as component @@ -339,6 +345,39 @@ editor.addComponents('...'); If you define the Component Type without using `isComponent`, the only way for the editor to see that component will be with an explicitly declared type (via an object `{ type: '...' }` or using `data-gjs-type`). +### isParsedNode + +If you want to avoid DOM dependencies during component recognition, you can use `isParsedNode`. + +```js +editor.Components.addType('my-input-type', { + isParsedNode: (node) => { + if (node.tagName === 'input') { + return { + type: 'my-input-type', + }; + } + }, + // ... +}); +``` + +The method receives a normalized parsed node and can return the same kind of values accepted by `isComponent`. +If both `isParsedNode` and `isComponent` are provided, `isParsedNode` always has priority. + +Existing `isComponent` definitions continue to work in headless parsing too. When a custom HTML code parser is active, GrapesJS provides a read-only synthetic element with common DOM-like properties such as `tagName`, `childNodes`, `children`, `getAttribute`, and `textContent`. + +If one of your legacy checks needs extra helpers, extend the synthetic element globally: + +```js +editor.Parser.config.customSyntheticElement = (SyntheticElement) => + class MySyntheticElement extends SyntheticElement { + get foo() { + return this.getAttribute('data-foo') || ''; + } + }; +``` + ### Model Now that we got how `isComponent` works we can start to explore the `model` property. diff --git a/packages/core/package.json b/packages/core/package.json index 6ecf19a43..da3835a0a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "grapesjs", "description": "Free and Open Source Web Builder Framework", - "version": "0.22.16", + "version": "0.23.0", "author": "Artur Arseniev", "license": "BSD-3-Clause", "homepage": "http://grapesjs.com", 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/domain_abstract/model/StyleableModel.ts b/packages/core/src/domain_abstract/model/StyleableModel.ts index d63e9ebfc..e5ddae014 100644 --- a/packages/core/src/domain_abstract/model/StyleableModel.ts +++ b/packages/core/src/domain_abstract/model/StyleableModel.ts @@ -25,7 +25,7 @@ export interface UpdateStyleOptions extends SetOptions, DataWatchersOptions { export type StyleableView = ComponentView | CssRuleView; -const parserHtml = ParserHtml(); +const parserHtml = new ParserHtml(); export const getLastStyleValue = (value: string | string[]) => { return isArray(value) ? value[value.length - 1] : value; 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..00c2ba690 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,34 @@ 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; + parserHtml: ParserHtml; + parserCss: ParserCss; + 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); + this.parserCss = new ParserCss(em, config); + this.parserHtml = new ParserHtml(em, config); + const { parserCode } = config; + Object.entries(config.parsersCode || {}).forEach(([id, parser]) => this.addParserCode(id, parser)); + if (parserCode !== undefined) { + this.parserCode = parserCode; + } + } + + get parserCode() { + return this._parserCode; + } + + set parserCode(value: string) { + this._parserCode = value || ''; + this.getConfig().parserCode = this._parserCode; } /** @@ -61,6 +80,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 +114,43 @@ export default class ParserModule extends Module { return res; }; - -/** - * POC, custom html parser specs - * Parse an HTML string to an array of nodes - * example - * parse(`
Hello
World example`) - * // result - * [ - * { - * tagName: 'div', - * attributes: { class: 'mycls', 'data-test': '' }, - * childNodes: ['Hello'], - * },{ - * tagName: 'span', - * childNodes: [ - * 'World ', - * { - * tagName: 'b', - * childNodes: ['example'], - * } - * ], - * } - * ] - * - -export const parseNodes = nodes => { - const result = []; - - for (let i = 0; i < nodes.length; i++) { - result.push(parseNode(nodes[i])); - } - - return result; -}; - -export const parseAttributes = attrs => { - const result = {}; - - for (let j = 0; j < attrs.length; j++) { - const attr = attrs[j]; - const nodeName = attr.nodeName; - const nodeValue = attr.nodeValue; - result[nodeName] = nodeValue; - } - - return result; -}; - -export const parseNode = el => { - // Return the string of the textnode element - if (el.nodeType === 3) { - return el.nodeValue; - } - - const tagName = node.tagName ? node.tagName.toLowerCase() : ''; - const attrs = el.attributes || []; - const nodes = el.childNodes || []; - - return { - ...(tagName && { tagName }), - ...(attrs.length && { - attributes: parseAttributes(attrs) - }), - ...(nodes.length && { - childNodes: parseNodes(nodes) - }) - }; -}; - -export default (str, config = {}) => { - const result = []; - const el = document.createElement('div'); - el.innerHTML = str; - const nodes = el.childNodes; - const len = nodes.length; - - for (let i = 0; i < len; i++) { - result.push(parseNode(nodes[i])); - } - - return result; -}; - */ diff --git a/packages/core/src/parser/model/ParserCss.ts b/packages/core/src/parser/model/ParserCss.ts index c9ad2b513..a0412282d 100644 --- a/packages/core/src/parser/model/ParserCss.ts +++ b/packages/core/src/parser/model/ParserCss.ts @@ -1,11 +1,17 @@ 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'; -const ParserCss = (em?: EditorModel, config: ParserConfig = {}) => ({ +export default class ParserCss { + constructor( + private em?: EditorModel, + private config: ParserConfig = {}, + ) {} + /** * Parse CSS string to a desired model object * @param {String} str CSS string @@ -13,11 +19,11 @@ const ParserCss = (em?: EditorModel, config: ParserConfig = {}) => ({ */ parse(str: string, opts: { throwOnError?: boolean } = {}) { let output: CssRuleJSON[] = []; - const { parserCss } = config; - const editor = em?.Editor; + const { parserCss } = this.config; + const editor = this.em?.Editor; let nodes: CssRuleJSON[] | ParsedCssRule[] = []; let error: unknown; - const Parser = em?.Parser; + const Parser = this.em?.Parser; const inputOptions = { input: str }; Parser?.__emitEvent(ParserEvents.cssBefore, inputOptions); const { input } = inputOptions; @@ -33,7 +39,7 @@ const ParserCss = (em?: EditorModel, config: ParserConfig = {}) => ({ Parser?.__emitEvent(ParserEvents.css, { input, output, nodes, error }); return output; - }, + } /** * Check the returned node from a custom parser and transforms it to @@ -69,7 +75,5 @@ const ParserCss = (em?: EditorModel, config: ParserConfig = {}) => ({ } return result; - }, -}); - -export default ParserCss; + } +} diff --git a/packages/core/src/parser/model/ParserHtml.ts b/packages/core/src/parser/model/ParserHtml.ts index bec85aca2..c9d5852f6 100644 --- a/packages/core/src/parser/model/ParserHtml.ts +++ b/packages/core/src/parser/model/ParserHtml.ts @@ -2,464 +2,521 @@ import { each, isArray, isFunction, isUndefined, result } from 'underscore'; import { ObjectAny, ObjectStrings } from '../../common'; import { ComponentDefinitionDefined, ComponentStackItem } from '../../dom_components/model/types'; import EditorModel from '../../editor/model/Editor'; -import { HTMLParseResult, HTMLParserOptions, ParseNodeOptions, ParserConfig } from '../config/config'; -import BrowserParserHtml from './BrowserParserHtml'; -import { doctypeToString, processDataGjsAttributeHyphen } from '../../utils/dom'; +import { processDataGjsAttributeHyphen } from '../../utils/dom'; import { isDef } from '../../utils/mixins'; -import { ParserEvents } from '../types'; +import { HTMLParserOptions, ParseNodeOptions, ParserConfig } from '../config/config'; +import { + HTMLParseResult, + ParsedElementNode, + ParsedNodeMeta, + ParsedNodeNamespace, + ParsedNodeType, + ParserEvents, + SyntheticElementCtor, +} from '../types'; +import BrowserParserHtml from './BrowserParserHtml'; +import { getSyntheticElementCtor } from './SyntheticElement'; +import { + createElementNode, + createFragmentRoot, + domDocumentToParsedNode, + domRootToFragmentParsedNode, + findChildElement, + getNodeChildNodes, + getNodeTextContent, + getSourceNode, + normalizeDocumentRoot, + removeElementNodes, + sanitizeNode, +} from './utils'; const modelAttrStart = 'data-gjs-'; -const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boolean } = {}) => { - return { - compTypes: [] as ComponentStackItem[], - - modelAttrStart, - - parseAttributeValue(attrValue?: string | boolean) { - if (typeof attrValue !== 'string') { - return attrValue; - } - - const valueLen = attrValue?.length || 0; - const firstChar = attrValue?.substring(0, 1); - const lastChar = attrValue?.substring(valueLen - 1); - let value: any = attrValue === 'true' ? true : attrValue === 'false' ? false : attrValue; - - // Try to parse JSON where it's possible - // I can get false positive here (eg. a selector '[data-attr]') - // so put it under try/catch and let fail silently - try { - value = - (firstChar == '{' && lastChar == '}') || (firstChar == '[' && lastChar == ']') ? JSON.parse(value) : value; - } catch (e) {} - - return value; - }, - - getPropAttribute(attrName: string, attrValue?: string) { - const name = attrName.replace(this.modelAttrStart, ''); - const value = this.parseAttributeValue(attrValue); - - return { - name, - value, - }; - }, - - shouldConvertAttributeValue( - attribute: string, - value: string | boolean, - node: HTMLElement, - convertAttributeValues: HTMLParserOptions['convertAttributeValues'], - ) { - if (!convertAttributeValues) { - return false; - } else if (convertAttributeValues === true) { - return true; - } else if (isArray(convertAttributeValues)) { - return convertAttributeValues.includes(attribute); - } else if (isFunction(convertAttributeValues)) { - return !!convertAttributeValues({ attribute, value, node }); - } - +interface ParserHtmlInternalOptions extends ParseNodeOptions { + __parsedMode?: boolean; + __syntheticElementCtor?: SyntheticElementCtor; +} + +const hasOwn = (obj: object, key: string) => Object.prototype.hasOwnProperty.call(obj, key); + +export default class ParserHtml { + compTypes: ComponentStackItem[] = []; + modelAttrStart = modelAttrStart; + + constructor( + private em?: EditorModel, + private config: ParserConfig & { returnArray?: boolean } = {}, + ) {} + + parseAttributeValue(attrValue?: string | boolean) { + if (typeof attrValue !== 'string') { + return attrValue; + } + + const valueLen = attrValue?.length || 0; + const firstChar = attrValue?.substring(0, 1); + const lastChar = attrValue?.substring(valueLen - 1); + let value: any = attrValue === 'true' ? true : attrValue === 'false' ? false : attrValue; + + // Try to parse JSON where it's possible + // I can get false positive here (eg. a selector '[data-attr]') + // so put it under try/catch and let fail silently + try { + value = + (firstChar == '{' && lastChar == '}') || (firstChar == '[' && lastChar == ']') ? JSON.parse(value) : value; + } catch (e) {} + + return value; + } + + getPropAttribute(attrName: string, attrValue?: string) { + const name = attrName.replace(this.modelAttrStart, ''); + const value = this.parseAttributeValue(attrValue); + + return { + name, + value, + }; + } + + shouldConvertAttributeValue( + attribute: string, + value: string | boolean, + node: HTMLElement | ParsedElementNode, + convertAttributeValues: HTMLParserOptions['convertAttributeValues'], + ) { + if (!convertAttributeValues) { return false; - }, - - /** - * Extract component props from an attribute object - * @param {Object} attr - * @returns {Object} An object containing props and attributes without them - */ - splitPropsFromAttr(attr: ObjectAny = {}) { - const props: ObjectAny = {}; - const attrs: ObjectStrings = {}; - - each(attr, (value, key) => { - if (key.indexOf(this.modelAttrStart) === 0) { - const propsResult = this.getPropAttribute(key, value); - props[propsResult.name] = propsResult.value; - } else { - attrs[key] = value; + } else if (convertAttributeValues === true) { + return true; + } else if (isArray(convertAttributeValues)) { + return convertAttributeValues.includes(attribute); + } else if (isFunction(convertAttributeValues)) { + return !!convertAttributeValues({ attribute, value, node }); + } + + return false; + } + + /** + * Extract component props from an attribute object + * @param {Object} attr + * @returns {Object} An object containing props and attributes without them + */ + splitPropsFromAttr(attr: ObjectAny = {}) { + const props: ObjectAny = {}; + const attrs: ObjectStrings = {}; + + each(attr, (value, key) => { + if (key.indexOf(this.modelAttrStart) === 0) { + const propsResult = this.getPropAttribute(key, value); + props[propsResult.name] = propsResult.value; + } else { + attrs[key] = value; + } + }); + + return { + props, + attrs, + }; + } + + /** + * Parse style string to object + * @param {string} str + * @return {Object} + * @example + * var stl = ParserHtml.parseStyle('color:black; width:100px; test:value;'); + * console.log(stl); + * // {color: 'black', width: '100px', test: 'value'} + */ + parseStyle(str: string) { + const result: Record = {}; + + while (str.indexOf('/*') >= 0) { + const start = str.indexOf('/*'); + const end = str.indexOf('*/'); + const endIndex = end > -1 ? end + 2 : undefined; + str = str.replace(str.slice(start, endIndex), ''); + } + + const decls = str.split(';'); + + for (let i = 0, len = decls.length; i < len; i++) { + const decl = decls[i].trim(); + if (!decl) continue; + const prop = decl.split(':'); + const key = prop[0].trim(); + const value = prop.slice(1).join(':').trim(); + + // Support multiple values for the same key + if (result[key]) { + if (!isArray(result[key])) { + result[key] = [result[key] as string]; } - }); - return { - props, - attrs, - }; - }, - - /** - * Parse style string to object - * @param {string} str - * @return {Object} - * @example - * var stl = ParserHtml.parseStyle('color:black; width:100px; test:value;'); - * console.log(stl); - * // {color: 'black', width: '100px', test: 'value'} - */ - parseStyle(str: string) { - const result: Record = {}; - - while (str.indexOf('/*') >= 0) { - const start = str.indexOf('/*'); - const end = str.indexOf('*/'); - const endIndex = end > -1 ? end + 2 : undefined; - str = str.replace(str.slice(start, endIndex), ''); + (result[key] as string[]).push(value); + } else { + result[key] = value; } + } + + return result; + } + + /** + * Parse class string to array + * @param {string} str + * @return {Array} + * @example + * var res = ParserHtml.parseClass('test1 test2 test3'); + * console.log(res); + * // ['test1', 'test2', 'test3'] + */ + parseClass(str: string) { + const result = []; + const cls = str.split(' '); + + for (let i = 0, len = cls.length; i < len; i++) { + const cl = cls[i].trim(); + if (!cl) continue; + result.push(cl); + } + + return result; + } + + parseNodeAttr( + node: ParsedNodeMeta, + modelResult?: ComponentDefinitionDefined, + opts: HTMLParserOptions = this.config.optionsHtml || {}, + ) { + const model = modelResult || {}; + const attrs = node.attributes || {}; + const convertHyphens = !!opts.convertDataGjsAttributesHyphens; + const { convertAttributeValues } = opts; + const defaults = + (convertHyphens && + !!model.type && + result(this.em?.Components.getType(model.type)?.model.prototype, 'defaults')) || + {}; + const sourceNode = getSourceNode(node) as HTMLElement | ParsedElementNode; + + each(attrs, (attrValue, attrName) => { + let nodeValue: any = attrValue; + + if (attrName == 'style') { + model.style = this.parseStyle(`${nodeValue}`); + } else if (attrName == 'class') { + model.classes = this.parseClass(`${nodeValue}`); + } else if (attrName == 'contenteditable') { + return; + } else if (attrName.indexOf(this.modelAttrStart) === 0) { + const propsResult = this.getPropAttribute(attrName, `${nodeValue}`); + let resolvedName = propsResult.name; + if (convertHyphens && !(resolvedName in defaults)) { + const transformed = processDataGjsAttributeHyphen(resolvedName); + resolvedName = transformed in defaults ? transformed : resolvedName; + } - const decls = str.split(';'); - - for (let i = 0, len = decls.length; i < len; i++) { - const decl = decls[i].trim(); - if (!decl) continue; - const prop = decl.split(':'); - const key = prop[0].trim(); - const value = prop.slice(1).join(':').trim(); + model[resolvedName] = propsResult.value; + } else { + if ( + nodeValue === '' && + ((node.__domNode as any)?.[attrName] === true || node.__boolAttributes?.includes(attrName)) + ) { + nodeValue = true; + } - // Support multiple values for the same key - if (result[key]) { - if (!isArray(result[key])) { - result[key] = [result[key] as string]; - } + if (this.shouldConvertAttributeValue(attrName, nodeValue, sourceNode, convertAttributeValues)) { + nodeValue = this.parseAttributeValue(nodeValue); + } - (result[key] as string[]).push(value); - } else { - result[key] = value; + if (!model.attributes) { + model.attributes = {}; } - } - return result; - }, - - /** - * Parse class string to array - * @param {string} str - * @return {Array} - * @example - * var res = ParserHtml.parseClass('test1 test2 test3'); - * console.log(res); - * // ['test1', 'test2', 'test3'] - */ - parseClass(str: string) { - const result = []; - const cls = str.split(' '); - - for (let i = 0, len = cls.length; i < len; i++) { - const cl = cls[i].trim(); - if (!cl) continue; - result.push(cl); + model.attributes[attrName] = nodeValue; } + }); - return result; - }, - - parseNodeAttr( - node: HTMLElement, - modelResult?: ComponentDefinitionDefined, - opts: HTMLParserOptions = config.optionsHtml || {}, - ) { - const model = modelResult || {}; - const attrs = node.attributes || []; - const attrsLen = attrs.length; - const convertHyphens = !!opts.convertDataGjsAttributesHyphens; - const { convertAttributeValues } = opts; - const defaults = - (convertHyphens && !!model.type && result(em?.Components.getType(model.type)?.model.prototype, 'defaults')) || - {}; - - for (let i = 0; i < attrsLen; i++) { - let nodeName = attrs[i].nodeName; - let nodeValue: any = attrs[i].nodeValue!; - - if (nodeName == 'style') { - model.style = this.parseStyle(nodeValue); - } else if (nodeName == 'class') { - model.classes = this.parseClass(nodeValue); - } else if (nodeName == 'contenteditable') { - continue; - } else if (nodeName.indexOf(this.modelAttrStart) === 0) { - const propsResult = this.getPropAttribute(nodeName, nodeValue); - let resolvedName = propsResult.name; - if (convertHyphens && !(resolvedName in defaults)) { - const transformed = processDataGjsAttributeHyphen(resolvedName); - resolvedName = transformed in defaults ? transformed : resolvedName; - } + return model; + } - model[resolvedName] = propsResult.value; - } else { - // @ts-ignore Check for attributes from props (eg. required, disabled) - if (nodeValue === '' && node[nodeName] === true) { - nodeValue = true; - } + detectNode(node: ParsedNodeMeta, opts: ParserHtmlInternalOptions = {}) { + const { compTypes } = this; + let result: ComponentDefinitionDefined = {}; - if (this.shouldConvertAttributeValue(nodeName, nodeValue, node, convertAttributeValues)) { - nodeValue = this.parseAttributeValue(nodeValue); - } + if (compTypes) { + const type = node.attributes?.[`${this.modelAttrStart}type`]; - if (!model.attributes) { - model.attributes = {}; + // If the type is already defined, use it + if (type) { + result = { type }; + } else { + // Find the component type + for (let i = 0; i < compTypes.length; i++) { + const compType = compTypes[i]; + const { model } = compType; + let obj = model.isParsedNode ? model.isParsedNode(node, opts) : undefined; + + if (!model.isParsedNode) { + obj = opts.__parsedMode + ? model.isComponent(this.__getSyntheticNode(node, opts) as any, opts) + : model.isComponent(getSourceNode(node), opts); } - model.attributes[nodeName] = nodeValue; - } - } - - return model; - }, - - detectNode(node: HTMLElement, opts: ParseNodeOptions = {}) { - const { compTypes } = this; - let result: ComponentDefinitionDefined = {}; - - if (compTypes) { - const type = node.getAttribute?.(`${this.modelAttrStart}type`); - - // If the type is already defined, use it - if (type) { - result = { type }; - } else { - // Find the component type - for (let i = 0; i < compTypes.length; i++) { - const compType = compTypes[i]; - let obj = compType.model.isComponent(node, opts); - - if (obj) { - if (typeof obj !== 'object') { - obj = { type: compType.id }; - } - result = obj; - break; + if (obj) { + if (typeof obj !== 'object') { + obj = { type: compType.id }; } + result = obj; + break; } } } - - return result; - }, - - parseNode(node: HTMLElement, opts: ParseNodeOptions = {}) { - const nodes = (node as HTMLTemplateElement).content?.childNodes || node.childNodes; - const nodesLen = nodes.length; - let model = this.detectNode(node, opts); - - if (!model.tagName && model.tagName !== '') { - const tag = node.tagName || ''; - const ns = node.namespaceURI || ''; - model.tagName = tag && ns === 'http://www.w3.org/1999/xhtml' ? tag.toLowerCase() : tag; - } - - model = this.parseNodeAttr(node, model, opts); - - // Check for custom void elements (valid in XML) - if (!nodesLen && `${node.outerHTML}`.slice(-2) === '/>') { - model.void = true; - } - - // Check for nested elements but avoid it if already provided - if (nodesLen && !model.components && !opts.skipChildren) { - // Avoid infinite nested text nodes - const firstChild = nodes[0]; - - // If there is only one child and it's a TEXTNODE - // just make it content of the current node - if (nodesLen === 1 && firstChild.nodeType === 3) { - !model.type && (model.type = 'text'); - model.components = { - type: 'textnode', - content: firstChild.nodeValue, - }; - } else { - model.components = this.parseNodes(node, { - ...opts, - inSvg: opts.inSvg || model.type === 'svg', - }); - } + } + + return result; + } + + parseNode(node: ParsedNodeMeta, opts: ParserHtmlInternalOptions = {}) { + const nodes = getNodeChildNodes(node); + const nodesLen = nodes.length; + let model = this.detectNode(node, opts); + + if (!model.tagName && model.tagName !== '') { + const tag = node.tagName || ''; + const ns = node.namespaceURI || ''; + model.tagName = tag && ns === ParsedNodeNamespace.html ? tag.toLowerCase() : tag; + } + + model = this.parseNodeAttr(node, model, opts); + + // Check for custom void elements (valid in XML) + if (!nodesLen && node.__selfClosing) { + model.void = true; + } + + // Check for nested elements but avoid it if already provided + if (nodesLen && !model.components && !opts.skipChildren) { + // Avoid infinite nested text nodes + const firstChild = nodes[0]; + + // If there is only one child and it's a TEXTNODE + // just make it content of the current node + if (nodesLen === 1 && firstChild.nodeType === ParsedNodeType.text) { + !model.type && (model.type = 'text'); + model.components = { + type: 'textnode', + content: firstChild.textContent, + }; + } else { + model.components = this.parseNodes(node, { + ...opts, + inSvg: opts.inSvg || model.type === 'svg', + }); } - - // If all children are texts and there is any textnode inside, the parent should - // be text too otherwise it won't be possible to edit texnodes. - const comps = model.components; - if (!model.type && comps?.length) { - const { textTypes = [], textTags = [] } = config; - let allTxt = true; - let foundTextNode = false; - - for (let i = 0; i < comps.length; i++) { - const comp = comps[i]; - const cType = comp.type; - - if (!textTypes.includes(cType) && !textTags.includes(comp.tagName)) { - allTxt = false; - break; - } - - if (cType === 'textnode') { - foundTextNode = true; - } + } + + // If all children are texts and there is any textnode inside, the parent should + // be text too otherwise it won't be possible to edit texnodes. + const comps = model.components; + if (!model.type && comps?.length) { + const { textTypes = [], textTags = [] } = this.config; + let allTxt = true; + let foundTextNode = false; + + for (let i = 0; i < comps.length; i++) { + const comp = comps[i]; + const cType = comp.type; + + if (!textTypes.includes(cType) && !textTags.includes(comp.tagName)) { + allTxt = false; + break; } - if (allTxt && foundTextNode) { - model.type = 'text'; + if (cType === 'textnode') { + foundTextNode = true; } } - return model; - }, - - /** - * Get data from the node element - * @param {HTMLElement} el DOM element to traverse - * @return {Array} - */ - parseNodes(el: HTMLElement, opts: ParseNodeOptions = {}) { - const result: ComponentDefinitionDefined[] = []; - const nodes = (el as HTMLTemplateElement).content?.childNodes || el.childNodes; - const nodesLen = nodes.length; - - for (let i = 0; i < nodesLen; i++) { - const node = nodes[i] as HTMLElement; - const nodePrev = result[result.length - 1]; - const model = this.parseNode(node, opts); - - // Check if it's a text node and if it could be moved to the prevous one - if (model.type === 'textnode') { - if (nodePrev?.type === 'textnode') { - nodePrev.content += model.content; - continue; - } - - // Try to keep meaningful whitespaces when possible (#5984) - // Ref: https://github.com/GrapesJS/grapesjs/pull/5719#discussion_r1518531999 - if (!opts.keepEmptyTextNodes) { - const content = node.nodeValue || ''; - const isFirstOrLast = i === 0 || i === nodesLen - 1; - const hasNewLive = content.includes('\n'); - if (content != ' ' && !content.trim() && (isFirstOrLast || hasNewLive)) { - continue; - } - } - } - - // If the tagName is empty and it's not a textnode, skip it - if (!model.tagName && isUndefined(model.content)) { + if (allTxt && foundTextNode) { + model.type = 'text'; + } + } + + return model; + } + + /** + * Get data from the node element + * @param {HTMLElement} el DOM element to traverse + * @return {Array} + */ + parseNodes(el: ParsedNodeMeta, opts: ParserHtmlInternalOptions = {}) { + const result: ComponentDefinitionDefined[] = []; + const nodes = getNodeChildNodes(el); + const nodesLen = nodes.length; + + for (let i = 0; i < nodesLen; i++) { + const node = nodes[i]; + const nodePrev = result[result.length - 1]; + const model = this.parseNode(node, opts); + + // Check if it's a text node and if it could be moved to the prevous one + if (model.type === 'textnode') { + if (nodePrev?.type === 'textnode') { + nodePrev.content += model.content; continue; } - result.push(model); - } - - return result; - }, - - /** - * Parse HTML string to a desired model object - * @param {string} str HTML string - * @param {ParserCss} parserCss In case there is style tags inside HTML - * @return {Object} - */ - parse(str: string, parserCss?: any, opts: HTMLParserOptions = {}) { - const conf = em?.get('Config') || {}; - const Parser = em?.Parser; - const res: HTMLParseResult = { html: [] }; - const preOptions = { - ...config.optionsHtml, - // @ts-ignore Support previous `configParser.htmlType` option - htmlType: config.optionsHtml?.htmlType || config.htmlType, - ...opts, - }; - const options = { - ...preOptions, - asDocument: this.__checkAsDocument(str, preOptions), - }; - const cf = { ...config, ...options }; - const { preParser, asDocument } = options; - const inputOptions = { input: isFunction(preParser) ? preParser(str, { editor: em?.getEditor()! }) : str }; - Parser?.__emitEvent(ParserEvents.htmlBefore, inputOptions); - const { input } = inputOptions; - const parseRes = isFunction(cf.parserHtml) ? cf.parserHtml(input, options) : BrowserParserHtml(input, options); - let root = parseRes as HTMLElement; - const docEl = parseRes as Document; - - if (asDocument) { - root = docEl.documentElement; - res.doctype = doctypeToString(docEl.doctype); - } - - const scripts = root.querySelectorAll('script'); - let i = scripts.length; - - // Support previous `configMain.allowScripts` option - const allowScripts = !isUndefined(conf.allowScripts) ? conf.allowScripts : options.allowScripts; - - // Remove script tags - if (!allowScripts) { - while (i--) scripts[i].parentNode?.removeChild(scripts[i]); - } - - // Remove unsafe attributes - if (!options.allowUnsafeAttr || !options.allowUnsafeAttrValue) { - this.__sanitizeNode(root, options); - } - - // Detach style tags and parse them - if (parserCss) { - const styles = root.querySelectorAll('style'); - let j = styles.length; - let styleStr = ''; - - while (j--) { - styleStr = styles[j].innerHTML + styleStr; - styles[j].parentNode?.removeChild(styles[j]); + // Try to keep meaningful whitespaces when possible (#5984) + // Ref: https://github.com/GrapesJS/grapesjs/pull/5719#discussion_r1518531999 + if (!opts.keepEmptyTextNodes) { + const content = node.textContent || ''; + const isFirstOrLast = i === 0 || i === nodesLen - 1; + const hasNewLine = content.includes('\n'); + if (content != ' ' && !content.trim() && (isFirstOrLast || hasNewLine)) { + continue; + } } - - if (styleStr) res.css = parserCss.parse(styleStr); } - Parser?.__emitEvent(ParserEvents.htmlRoot, { input, root }); - let resHtml: HTMLParseResult['html'] = []; - - if (asDocument) { - res.head = this.parseNode(docEl.head, cf); - res.root = this.parseNodeAttr(root, undefined, cf); - resHtml = this.parseNode(docEl.body, cf); - } else { - const result = this.parseNodes(root, cf); - // I have to keep it otherwise it breaks the DomComponents.addComponent (returns always array) - resHtml = result.length === 1 && !cf.returnArray ? result[0] : result; + // If the tagName is empty and it's not a textnode, skip it + if (!model.tagName && isUndefined(model.content)) { + continue; } - res.html = resHtml; - Parser?.__emitEvent(ParserEvents.html, { input, output: res, options }); - - return res; - }, - - __sanitizeNode(node: HTMLElement, opts: HTMLParserOptions) { - const attrs = node.attributes || []; - const nodes = node.childNodes || []; - const toRemove: string[] = []; - each(attrs, (attr) => { - const name = attr.nodeName || ''; - const value = attr.nodeValue || ''; - !opts.allowUnsafeAttr && name.startsWith('on') && toRemove.push(name); - !opts.allowUnsafeAttrValue && value.startsWith('javascript:') && toRemove.push(name); - }); - toRemove.map((name) => node.removeAttribute(name)); - each(nodes, (node) => this.__sanitizeNode(node as HTMLElement, opts)); - }, - - __checkAsDocument(str: string, opts: HTMLParserOptions) { - if (isDef(opts.asDocument)) { - return opts.asDocument; - } else if (isFunction(opts.detectDocument)) { - return !!opts.detectDocument(str); - } else if (opts.detectDocument) { - return str.toLowerCase().trim().startsWith(' getNodeTextContent(node)).join(''); + if (styleStr) res.css = parserCss.parse(styleStr); + } + + Parser?.__emitEvent(ParserEvents.htmlRoot, { input, root }); + let resHtml: HTMLParseResult['html'] = []; + + if (asDocument) { + const docNode: ParsedNodeMeta = + root.nodeType === ParsedNodeType.document ? root : normalizeDocumentRoot(getNodeChildNodes(root)); + const htmlNode = findChildElement(docNode, 'html'); + const headNode = htmlNode && findChildElement(htmlNode, 'head'); + const bodyNode = (htmlNode && findChildElement(htmlNode, 'body')) || createElementNode('body'); + + res.doctype = docNode.__doctype; + headNode && (res.head = this.parseNode(headNode, parseOptions)); + htmlNode && (res.root = this.parseNodeAttr(htmlNode, undefined, parseOptions)); + resHtml = this.parseNode(bodyNode, parseOptions); + } else { + const result = this.parseNodes(root, parseOptions); + // Need this otherwise it breaks the DomComponents.addComponent (returns always array) + resHtml = result.length === 1 && !cf.returnArray ? result[0] : result; + } + + res.html = resHtml; + Parser?.__emitEvent(ParserEvents.html, { input, output: res, options }); + + return res; + } + + __getSyntheticNode(node: ParsedNodeMeta, opts: ParserHtmlInternalOptions) { + const parserConfig = this.em?.Parser?.getConfig() || this.config; + const SyntheticElement = + opts.__syntheticElementCtor || getSyntheticElementCtor(parserConfig.customSyntheticElement); + return new SyntheticElement(node); + } + + __parseInput(input: string, options: HTMLParserOptions, cf: ParserConfig, parserCode: string) { + const codeParser = parserCode ? this.em?.Parser?.getParserCode(parserCode) : undefined; + const { asDocument } = options; + + if (parserCode) { + if (!codeParser) throw new Error(`Parser code "${parserCode}" not found`); + + const parsedNode = codeParser.parse(input, { editor: this.em?.getEditor()!, options }); + const parsedNodes = isArray(parsedNode) ? parsedNode : [parsedNode]; -export default ParserHtml; + return { + root: asDocument ? normalizeDocumentRoot(parsedNodes) : createFragmentRoot(parsedNodes), + isParsedMode: true, + }; + } + + const parseRes = isFunction(cf.parserHtml) ? cf.parserHtml(input, options) : BrowserParserHtml(input, options); + + return { + root: asDocument + ? domDocumentToParsedNode(parseRes as Document) + : domRootToFragmentParsedNode(parseRes as HTMLElement), + isParsedMode: false, + }; + } + + __getParserCodeId(options: HTMLParserOptions) { + if (hasOwn(options, 'parserCode')) return options.parserCode || ''; + return this.em?.Parser?.parserCode || this.config.parserCode || ''; + } + + __checkAsDocument(str: string, opts: HTMLParserOptions) { + if (isDef(opts.asDocument)) { + return opts.asDocument; + } else if (isFunction(opts.detectDocument)) { + return !!opts.detectDocument(str); + } else if (opts.detectDocument) { + return str.toLowerCase().trim().startsWith(' { + const tagName = node.tagName || ''; + return tagName && (!node.namespaceURI || node.namespaceURI === ParsedNodeNamespace.html) + ? tagName.toUpperCase() + : tagName; +}; + +const getNodeTextContent = (node: ParsedNode): string => { + if (node.nodeType === ParsedNodeType.text || node.nodeType === ParsedNodeType.comment) { + 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 === ParsedNodeType.text || this.nodeType === ParsedNodeType.comment ? 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/model/utils.ts b/packages/core/src/parser/model/utils.ts new file mode 100644 index 000000000..ae30de965 --- /dev/null +++ b/packages/core/src/parser/model/utils.ts @@ -0,0 +1,200 @@ +import { each } from 'underscore'; +import { doctypeToString } from '../../utils/dom'; +import { HTMLParserOptions } from '../config/config'; +import { ParsedNode, ParsedNodeMeta, ParsedNodeNamespace, ParsedNodeType } from '../types'; + +export const getNodeChildNodes = (node: ParsedNodeMeta) => node.childNodes || []; + +export const getNodeTagName = (node: ParsedNodeMeta) => `${node.tagName || ''}`.toLowerCase(); + +export const getSourceNode = (node: ParsedNodeMeta) => node.__domNode || node; + +export const getDomChildNodes = (node: Node) => { + const template = node as HTMLTemplateElement; + const childNodes = template.content?.childNodes || node.childNodes || []; + return Array.from(childNodes); +}; + +export const createElementNode = (tagName: string, childNodes: ParsedNodeMeta[] = []): ParsedNodeMeta => ({ + nodeType: ParsedNodeType.element, + tagName, + namespaceURI: ParsedNodeNamespace.html, + childNodes, +}); + +export const createFragmentRoot = (childNodes: ParsedNode[]): ParsedNodeMeta => ({ + nodeType: ParsedNodeType.fragment, + childNodes, +}); + +export const appendChildElement = (node: ParsedNodeMeta, tagName: string) => { + const child = createElementNode(tagName); + node.childNodes = [...getNodeChildNodes(node), child]; + return child; +}; + +export const findChildElement = (node: ParsedNodeMeta, tagName: string) => + getNodeChildNodes(node).find((child) => getNodeTagName(child) === tagName); + +export const getNodeTextContent = (node: ParsedNodeMeta): string => { + if (node.nodeType === ParsedNodeType.text || node.nodeType === ParsedNodeType.comment) { + return node.textContent ?? ''; + } + + if (node.textContent && !node.childNodes?.length) { + return node.textContent; + } + + return getNodeChildNodes(node) + .map((child) => getNodeTextContent(child)) + .join(''); +}; + +export const removeElementNodes = (root: ParsedNodeMeta, tagName: string) => { + const removed: ParsedNodeMeta[] = []; + const remove = (node: ParsedNodeMeta) => { + if (!node.childNodes?.length) return; + const nextNodes: ParsedNodeMeta[] = []; + + node.childNodes.forEach((child) => { + if (getNodeTagName(child) === tagName) { + removed.push(child); + return; + } + + remove(child); + nextNodes.push(child); + }); + + node.childNodes = nextNodes; + }; + + remove(root); + return removed; +}; + +export const sanitizeNode = (node: ParsedNodeMeta, opts: HTMLParserOptions) => { + const attrs = node.attributes || {}; + const cleanAttrs: Record = {}; + + each(attrs, (value, name) => { + const attrValue = `${value}`; + const isUnsafeAttr = !opts.allowUnsafeAttr && name.startsWith('on'); + const isUnsafeValue = !opts.allowUnsafeAttrValue && attrValue.startsWith('javascript:'); + + if (!isUnsafeAttr && !isUnsafeValue) { + cleanAttrs[name] = attrValue; + } + }); + + if (Object.keys(cleanAttrs).length) { + node.attributes = cleanAttrs; + } else { + delete node.attributes; + } + + getNodeChildNodes(node).forEach((child) => sanitizeNode(child, opts)); +}; + +export const domDocumentToParsedNode = (doc: Document): ParsedNodeMeta => ({ + nodeType: ParsedNodeType.document, + __domNode: doc, + __doctype: doctypeToString(doc.doctype), + childNodes: doc.documentElement ? [domToParsedNode(doc.documentElement)] : [], +}); + +export const domToParsedNode = (node: Node): ParsedNodeMeta => { + const { nodeType } = node; + if (nodeType === ParsedNodeType.document) return domDocumentToParsedNode(node as Document); + + const el = node as HTMLElement; + const parsedNode: ParsedNodeMeta = { + nodeType, + tagName: el.tagName || '', + namespaceURI: el.namespaceURI || undefined, + __domNode: node, + }; + + if (nodeType === ParsedNodeType.text || nodeType === ParsedNodeType.comment) { + parsedNode.textContent = node.textContent ?? ''; + } + + if (nodeType === ParsedNodeType.element) { + const el = node as HTMLElement; + + const attrs = el.attributes || []; + if (attrs.length) { + parsedNode.attributes = {}; + } + + const boolAttributes: string[] = []; + for (let i = 0; i < attrs.length; i++) { + const attr = attrs[i]; + parsedNode.attributes![attr.nodeName] = attr.nodeValue || ''; + if (attr.nodeValue === '' && (el as any)[attr.nodeName] === true) { + boolAttributes.push(attr.nodeName); + } + } + + boolAttributes.length && (parsedNode.__boolAttributes = boolAttributes); + + const childNodes = getDomChildNodes(el); + childNodes.length && (parsedNode.childNodes = childNodes.map((child) => domToParsedNode(child))); + parsedNode.__selfClosing = `${el.outerHTML || ''}`.slice(-2) === '/>'; + } + + return parsedNode; +}; + +export const domRootToFragmentParsedNode = (root: HTMLElement): ParsedNodeMeta => ({ + nodeType: ParsedNodeType.fragment, + __domNode: root, + childNodes: getDomChildNodes(root).map((node) => domToParsedNode(node)), +}); + +export const normalizeDocumentRoot = (nodes: ParsedNode[]) => { + const flatNodes = nodes.flatMap((node) => + node.nodeType === ParsedNodeType.fragment ? getNodeChildNodes(node) : [node], + ); + const documentNode = flatNodes.find((node) => node.nodeType === ParsedNodeType.document); + if (documentNode) { + return documentNode; + } + + const htmlNode = flatNodes.find((node) => getNodeTagName(node) === 'html'); + const documentRoot: ParsedNodeMeta = { + nodeType: ParsedNodeType.document, + childNodes: [], + }; + + if (htmlNode) { + const extraNodes = flatNodes.filter((node) => node !== htmlNode); + if (extraNodes.length) { + const bodyNode = findChildElement(htmlNode, 'body') || appendChildElement(htmlNode, 'body'); + bodyNode.childNodes = [...getNodeChildNodes(bodyNode), ...extraNodes]; + } + + documentRoot.childNodes = [htmlNode]; + return documentRoot; + } + + const remaining = [...flatNodes]; + const headIndex = remaining.findIndex((node) => getNodeTagName(node) === 'head'); + const bodyIndex = remaining.findIndex((node) => getNodeTagName(node) === 'body'); + const headNode = headIndex >= 0 ? remaining.splice(headIndex, 1)[0] : undefined; + const bodyNode = + bodyIndex >= 0 + ? remaining.splice(bodyIndex > headIndex && headIndex >= 0 ? bodyIndex - 1 : bodyIndex, 1)[0] + : undefined; + const htmlRoot = createElementNode('html'); + const htmlChildren: ParsedNodeMeta[] = []; + + headNode && htmlChildren.push(headNode); + const normalizedBody = bodyNode || createElementNode('body'); + normalizedBody.childNodes = [...getNodeChildNodes(normalizedBody), ...remaining]; + htmlChildren.push(normalizedBody); + + htmlRoot.childNodes = htmlChildren; + documentRoot.childNodes = [htmlRoot]; + return documentRoot; +}; diff --git a/packages/core/src/parser/types.ts b/packages/core/src/parser/types.ts index 4f62fad6f..03bb90e36 100644 --- a/packages/core/src/parser/types.ts +++ b/packages/core/src/parser/types.ts @@ -1,6 +1,67 @@ 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 interface ParsedNodeMeta extends ParsedNode { + __boolAttributes?: string[]; + __doctype?: string; + __domNode?: any; + __selfClosing?: boolean; +} + +export enum ParsedNodeType { + element = 1, + text = 3, + comment = 8, + document = 9, + fragment = 11, +} + +export enum ParsedNodeNamespace { + html = 'http://www.w3.org/1999/xhtml', +} + +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 +115,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/ParserCss.ts b/packages/core/test/specs/parser/model/ParserCss.ts index 7a9f17698..a81e2e47d 100644 --- a/packages/core/test/specs/parser/model/ParserCss.ts +++ b/packages/core/test/specs/parser/model/ParserCss.ts @@ -17,12 +17,12 @@ export const CSS_BG_OBJ = { }; describe('ParserCss', () => { - let obj: ReturnType; + let obj: ParserCss; let em: EditorModel; beforeEach(() => { em = new EditorModel({}); - obj = ParserCss(em, {}); + obj = new ParserCss(em, {}); }); afterEach(() => { @@ -466,7 +466,7 @@ describe('ParserCss', () => { selectors: ['test1'], style: { color: 'blue' }, }; - obj = ParserCss(em, { + obj = new ParserCss(em, { parserCss: () => [result], }); expect(obj.parse(str)).toEqual([result]); diff --git a/packages/core/test/specs/parser/model/ParserHtml.ts b/packages/core/test/specs/parser/model/ParserHtml.ts index a1671eb9d..8d93fb777 100644 --- a/packages/core/test/specs/parser/model/ParserHtml.ts +++ b/packages/core/test/specs/parser/model/ParserHtml.ts @@ -5,13 +5,13 @@ import Editor from '../../../../src/editor/model/Editor'; import { CSS_BG_OBJ, CSS_BG_STR } from './ParserCss'; describe('ParserHtml', () => { - let obj: ReturnType; + let obj: ParserHtml; let em: Editor; beforeEach(() => { em = new Editor({}); const dom = new DomComponents(em); - obj = ParserHtml(em, { + obj = new ParserHtml(em, { textTags: ['br', 'b', 'i', 'u'], textTypes: ['text', 'textnode', 'comment'], returnArray: true, @@ -438,7 +438,7 @@ describe('ParserHtml', () => { style: { color: 'blue' }, }, ]; - const res = obj.parse(str, ParserCss()); + const res = obj.parse(str, new ParserCss()); expect(res.html).toEqual(resHtml); expect(res.css).toEqual(resCss); }); @@ -458,7 +458,7 @@ describe('ParserHtml', () => {
a div
`; - const css = obj.parse(str, ParserCss()).css || []; + const css = obj.parse(str, new ParserCss()).css || []; expect(css).toHaveLength(2); expect(css[0]).toEqual({ selectors: [], @@ -910,7 +910,7 @@ describe('ParserHtml', () => { }, }); - obj = ParserHtml(em, { + obj = new ParserHtml(em, { textTags: ['br', 'b', 'i', 'u'], textTypes: ['text', 'textnode', 'comment'], returnArray: true, @@ -964,7 +964,7 @@ describe('ParserHtml', () => { }, }); - obj = ParserHtml(em, { + obj = new ParserHtml(em, { returnArray: true, optionsHtml: { convertDataGjsAttributesHyphens: true }, }); @@ -1059,7 +1059,7 @@ describe('ParserHtml', () => { describe('with keepEmptyTextNodes ON', () => { beforeEach(() => { - obj = ParserHtml(em, { + obj = new ParserHtml(em, { returnArray: true, optionsHtml: { keepEmptyTextNodes: true }, }); @@ -1095,4 +1095,218 @@ 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('prefers isParsedNode even on DOM parser runs', () => { + em.Components.addType('parsed-dom-cmp', { + isParsedNode: (node) => node.tagName === 'DIV' && { type: 'parsed-dom-cmp', parsed: true }, + isComponent: () => ({ type: 'legacy-dom-cmp' }), + }); + obj.compTypes = em.Components.componentTypes; + + expect(obj.parse('
').html).toEqual([ + { + tagName: 'div', + type: 'parsed-dom-cmp', + parsed: true, + }, + ]); + }); + + 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 = new 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('', new 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'); + }); + }); });