Browse Source

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
release-v0.23.1-rc.0
Artur Arseniev 4 months ago
committed by GitHub
parent
commit
a33dec84c5
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 89
      docs/api/commands.md
  2. 6
      docs/api/editor.md
  3. 92
      docs/api/parser.md
  4. 35
      docs/guides/Custom-CSS-parser.md
  5. 143
      docs/guides/Custom-HTML-parser.md
  6. 39
      docs/modules/Components.md
  7. 2
      packages/core/package.json
  8. 16
      packages/core/src/dom_components/index.ts
  9. 2
      packages/core/src/dom_components/model/Component.ts
  10. 5
      packages/core/src/dom_components/model/ComponentMap.ts
  11. 3
      packages/core/src/dom_components/model/ComponentVideo.ts
  12. 2
      packages/core/src/dom_components/model/types.ts
  13. 2
      packages/core/src/domain_abstract/model/StyleableModel.ts
  14. 8
      packages/core/src/index.ts
  15. 45
      packages/core/src/parser/config/config.ts
  16. 67
      packages/core/src/parser/index.ts
  17. 83
      packages/core/src/parser/model/BrowserParserHtml.ts
  18. 24
      packages/core/src/parser/model/ParserCss.ts
  19. 901
      packages/core/src/parser/model/ParserHtml.ts
  20. 105
      packages/core/src/parser/model/SyntheticElement.ts
  21. 200
      packages/core/src/parser/model/utils.ts
  22. 65
      packages/core/src/parser/types.ts
  23. 75
      packages/core/test/specs/parser/index.ts
  24. 6
      packages/core/test/specs/parser/model/ParserCss.ts
  25. 228
      packages/core/test/specs/parser/model/ParserHtml.ts

89
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\<TId, ObjectAny>** (optional, default `{}as CommandObjectById<TId,ObjectAny>`)
* `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]**&#x20;
Returns **[Boolean][15]**&#x20;
## getAll
Get an object containing all the commands
Returns **[Object][12]**&#x20;
Returns **[Object][13]**&#x20;
## 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\<TId>**&#x20;
* `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\<TId>**&#x20;
* `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]**&#x20;
Returns **[Boolean][15]**&#x20;
## getActive
@ -256,32 +269,34 @@ console.log(commands.getActive());
// -> { someCommand: itsLastReturn, anotherOne: ... };
```
Returns **[Object][12]**&#x20;
Returns **[Object][13]**&#x20;
[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

6
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\<TId>**&#x20;
* `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\<TId>**&#x20;
* `options` **[Object][16]** Custom options
### Examples

92
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]**&#x20;
Returns **[Object][8]**&#x20;
## 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 `<script>` tags (optional, default `false`)
* `options.allowUnsafeAttr` **[Boolean][8]** Allow unsafe HTML attributes (eg. `on*` inline event handlers) (optional, default `false`)
* `options.allowUnsafeAttrValue` **[Boolean][8]** Allow unsafe HTML attribute values (eg. `src="javascript:..."`) (optional, default `false`)
* `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.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`)
* `input` **[String][9]** HTML string to parse
* `options` **[Object][8]?** Options (optional, default `{}`)
* `options.htmlType` **[String][9]?** [HTML mime type][10] to parse
* `options.allowScripts` **[Boolean][11]** Allow `<script>` tags (optional, default `false`)
* `options.allowUnsafeAttr` **[Boolean][11]** Allow unsafe HTML attributes (eg. `on*` inline event handlers) (optional, default `false`)
* `options.allowUnsafeAttrValue` **[Boolean][11]** Allow unsafe HTML attribute values (eg. `src="javascript:..."`) (optional, default `false`)
* `options.keepEmptyTextNodes` **[Boolean][11]** Keep whitespaces regardless of whether they are meaningful (optional, default `false`)
* `options.asDocument` **[Boolean][11]?** Treat the HTML string as document
* `options.detectDocument` **([Boolean][11] | [Function][12])?** Indicate if or how to detect if the HTML string should be treated as document
* `options.parserCode` **[String][9]?** Use a specific parser from the code parser registry. Pass an empty string to force the built-in/legacy parser path.
* `options.preParser` **[Function][12]?** How to pre-process the HTML string before parsing
* `options.convertDataGjsAttributesHyphens` **[Boolean][11]** Convert `data-gjs-*` attributes from hyphenated to camelCase (eg. `data-gjs-my-component` to `data-gjs-myComponent`) (optional, default `false`)
* `options.convertAttributeValues` **([Boolean][11] | [Array][13]<[String][9]> | [Function][12])** Convert regular HTML attribute values using the same parser used by `data-gjs-*` attributes (optional, default `false`)
### Examples
@ -103,7 +107,7 @@ const resXml = Parser.parseHtml(`<table><div>Hi</div></table>`, {
// This will preserve the original format as, from the XML point of view, is a valid format
```
Returns **[Object][5]** Object containing the result `{ html: ..., css: ... }`
Returns **[Object][8]** Object containing the result `{ html: ..., css: ... }`
## parseCss
@ -111,7 +115,7 @@ Parse CSS string and return an array of valid definition objects for CSSRules
### Parameters
* `input` **[String][6]** CSS string to parse
* `input` **[String][9]** CSS string to parse
### Examples
@ -120,7 +124,41 @@ const res = Parser.parseCss('.cls { color: red }');
// [{ ... }]
```
Returns **[Array][10]<[Object][5]>** Array containing the result
Returns **[Array][13]<[Object][8]>** Array containing the result
## addParserCode
Add a new HTML code parser to the registry.
### Parameters
* `id` **[string][9]** Parser ID
* `parse` **[Function][12]** Parser function
* `options` **[Object][8]** Parser options (optional, default `{}`)
* `options.skipSelect` **[Boolean][11]** Avoid selecting the added parser as default (optional, default `false`)
Returns **[Object][8]** Added parser definition
## getParserCode
Get an HTML code parser by id.
### Parameters
* `id` **[string][9]** Parser ID
Returns **([Object][8] | [undefined][14])** Parser definition
## removeParserCode
Remove an HTML code parser from the registry.
### Parameters
* `id` **[string][9]** Parser ID
Returns **([Object][8] | [undefined][14])** Removed parser definition
[1]: https://github.com/GrapesJS/grapesjs/blob/master/src/parser/config/config.ts
@ -130,14 +168,22 @@ Returns **[Array][10]<[Object][5]>** Array containing the result
[4]: #parsecss
[5]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object
[5]: #addparsercode
[6]: #getparsercode
[7]: #removeparsercode
[8]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object
[9]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String
[6]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String
[10]: https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString#Argument02
[7]: https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString#Argument02
[11]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean
[8]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean
[12]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function
[9]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/function
[13]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array
[10]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array
[14]: https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/undefined

35
docs/guides/Custom-CSS-parser.md

@ -4,7 +4,7 @@ title: Use Custom CSS Parser
# Use Custom CSS Parser
If you just use GrapesJS for building templates from scratch, so you start from an empty canvas and for editing you strictly rely on the generated JSON (final HTML/CSS only for end-users) then, probably, you might skip this guide. On the other hand, if you import templates from already defined HTML/CSS or let the user embed custom codes (eg. using the [grapesjs-custom-code](https://github.com/GrapesJS/components-custom-code) plugin), then you have to know that you might face strange behaviors.
If you use GrapesJS only to build templates from scratch, starting from an empty canvas and relying strictly on the generated JSON for editing (with final HTML/CSS only for end users), you can probably skip this guide. On the other hand, if you import templates from existing HTML/CSS or let users embed custom code (eg. using the [grapesjs-custom-code](https://github.com/GrapesJS/components-custom-code) plugin), you should know that you might face some unexpected behaviors.
::: warning
This guide requires GrapesJS v0.14.33 or higher
@ -14,7 +14,7 @@ This guide requires GrapesJS v0.14.33 or higher
## Import HTML/CSS
Importing already defined HTML/CSS is a really good feature as it lets you start editing immediately any kind of template and obviously, GrapesJS itself promotes this kind of approach
Importing existing HTML/CSS is a great feature because it lets you start editing any kind of template immediately, and GrapesJS itself promotes this kind of workflow.
```html
<div id="gjs">
@ -34,12 +34,15 @@ Importing already defined HTML/CSS is a really good feature as it lets you start
</script>
```
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
<h1>To parse</h1>
@ -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).
<img :src="$withBase('/cssom-result.jpg')">
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.
<img :src="$withBase('/cssom-devtools.png')">
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

143
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('<section>Hello world</section>');
```
Or for a single call:
```js
const result = Parser.parseHtml('<section>Hello world</section>', {
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<string, string>;
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`.

39
docs/modules/Components.md

@ -93,6 +93,8 @@ As we mentioned before, when you pass an HTML string as a component to the edito
<img :src="$withBase('/component-type-stack.svg')" class="img-ctr">
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 `<input>` 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('<some-element data-gjs-type="some-component">...');
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.

2
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",

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>> {

2
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;

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,

67
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<ParserConfig & { name?: string }> {
parserHtml: ReturnType<typeof ParserHtml>;
parserCss: ReturnType<typeof ParserCss>;
parserHtml: ParserHtml;
parserCss: 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);
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<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 +114,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);

83
packages/core/src/parser/model/BrowserParserHtml.ts

@ -31,86 +31,3 @@ export default (str: string, config: HTMLParserOptions = {}) => {
return res;
};
/**
* POC, custom html parser specs
* Parse an HTML string to an array of nodes
* example
* parse(`<div class="mycls" data-test>Hello</div><span>World <b>example</b></span>`)
* // 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;
};
*/

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

901
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<string, string | string[]> = {};
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<string, string | string[]> = {};
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<string>}
* @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<string>}
* @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<Object>}
*/
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<Object>}
*/
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('<!doctype');
}
},
};
};
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 { config, em } = this;
const conf = em?.get('Config') || {};
const Parser = em?.Parser;
const res: HTMLParseResult = { html: [] };
const parserCode = this.__getParserCodeId(opts);
const preOptions = {
...config.optionsHtml,
htmlType: config.optionsHtml?.htmlType || (config as any).htmlType,
...opts,
};
const options = {
...preOptions,
parserCode,
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 { root, isParsedMode } = this.__parseInput(input, options, cf, parserCode);
const parserConfig = Parser?.getConfig() || config;
const parseOptions: ParserHtmlInternalOptions = {
...cf,
__parsedMode: isParsedMode,
__syntheticElementCtor: getSyntheticElementCtor(parserConfig.customSyntheticElement),
};
const allowScripts = !isUndefined(conf.allowScripts) ? conf.allowScripts : options.allowScripts;
if (!allowScripts) {
removeElementNodes(root, 'script');
}
if (!options.allowUnsafeAttr || !options.allowUnsafeAttrValue) {
sanitizeNode(root, options);
}
if (parserCss) {
const styleNodes = removeElementNodes(root, 'style');
const styleStr = styleNodes.map((node) => 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('<!doctype');
}
}
}

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

@ -0,0 +1,105 @@
import { ParsedNode, ParsedNodeNamespace, ParsedNodeType, SyntheticElementCtor } from '../types';
const getTagName = (node: ParsedNode) => {
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);

200
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<string, string> = {};
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;
};

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

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',
);
});
});

6
packages/core/test/specs/parser/model/ParserCss.ts

@ -17,12 +17,12 @@ export const CSS_BG_OBJ = {
};
describe('ParserCss', () => {
let obj: ReturnType<typeof ParserCss>;
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]);

228
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<typeof ParserHtml>;
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', () => {
<div>a div</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('<div></div>', 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('<div></div>').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');
});
});
});

Loading…
Cancel
Save