diff --git a/docs/guides/Custom-HTML-parser.md b/docs/guides/Custom-HTML-parser.md
new file mode 100644
index 000000000..1aa2843dc
--- /dev/null
+++ b/docs/guides/Custom-HTML-parser.md
@@ -0,0 +1,137 @@
+---
+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
+
+## 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('');
+```
+
+Or for a single call:
+
+```js
+const result = Parser.parseHtml('', {
+ 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..4ae1a810d 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
+When you use a custom HTML code parser via `Parser.parserCode`, component recognition can also rely on `isParsedNode`, which receives the normalized parsed node instead of a DOM element.
+
::: 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
@@ -339,6 +341,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 your HTML is parsed through a custom code parser, you can avoid DOM dependencies completely by using `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` has priority while `parserCode` is active.
+
+Existing `isComponent` definitions continue to work in headless parsing too. In that case 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/src/parser/model/ParserHtml.ts b/packages/core/src/parser/model/ParserHtml.ts
index bec85aca2..2671b0918 100644
--- a/packages/core/src/parser/model/ParserHtml.ts
+++ b/packages/core/src/parser/model/ParserHtml.ts
@@ -2,14 +2,35 @@ 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 { isDef } from '../../utils/mixins';
-import { ParserEvents } from '../types';
+import { HTMLParserOptions, ParseNodeOptions, ParserConfig } from '../config/config';
+import {
+ HTMLParseResult,
+ ParsedElementNode,
+ ParsedNode,
+ ParsedNodeMeta,
+ ParsedNodeNamespace,
+ ParsedNodeType,
+ ParserEvents,
+ SyntheticElementCtor,
+} from '../types';
+import BrowserParserHtml from './BrowserParserHtml';
+import { getSyntheticElementCtor } from './SyntheticElement';
const modelAttrStart = 'data-gjs-';
+interface ParserHtmlInternalOptions extends ParseNodeOptions {
+ __parsedMode?: boolean;
+ __syntheticElementCtor?: SyntheticElementCtor;
+}
+
+const hasOwn = (obj: object, key: string) => Object.prototype.hasOwnProperty.call(obj, key);
+
+const getNodeChildNodes = (node: ParsedNodeMeta) => node.childNodes || [];
+const getNodeTagName = (node: ParsedNodeMeta) => `${node.tagName || ''}`.toLowerCase();
+const getSourceNode = (node: ParsedNodeMeta) => node.__domNode || node;
+
const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boolean } = {}) => {
return {
compTypes: [] as ComponentStackItem[],
@@ -50,7 +71,7 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo
shouldConvertAttributeValue(
attribute: string,
value: string | boolean,
- node: HTMLElement,
+ node: HTMLElement | ParsedElementNode,
convertAttributeValues: HTMLParserOptions['convertAttributeValues'],
) {
if (!convertAttributeValues) {
@@ -156,31 +177,30 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo
},
parseNodeAttr(
- node: HTMLElement,
+ node: ParsedNodeMeta,
modelResult?: ComponentDefinitionDefined,
opts: HTMLParserOptions = config.optionsHtml || {},
) {
const model = modelResult || {};
- const attrs = node.attributes || [];
- const attrsLen = attrs.length;
+ const attrs = node.attributes || {};
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);
+ 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);
@@ -189,12 +209,14 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo
model[resolvedName] = propsResult.value;
} else {
- // @ts-ignore Check for attributes from props (eg. required, disabled)
- if (nodeValue === '' && node[nodeName] === true) {
+ if (
+ nodeValue === '' &&
+ ((node.__domNode as any)?.[attrName] === true || node.__boolAttributes?.includes(attrName))
+ ) {
nodeValue = true;
}
- if (this.shouldConvertAttributeValue(nodeName, nodeValue, node, convertAttributeValues)) {
+ if (this.shouldConvertAttributeValue(attrName, nodeValue, sourceNode, convertAttributeValues)) {
nodeValue = this.parseAttributeValue(nodeValue);
}
@@ -202,19 +224,19 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo
model.attributes = {};
}
- model.attributes[nodeName] = nodeValue;
+ model.attributes[attrName] = nodeValue;
}
- }
+ });
return model;
},
- detectNode(node: HTMLElement, opts: ParseNodeOptions = {}) {
+ detectNode(node: ParsedNodeMeta, opts: ParserHtmlInternalOptions = {}) {
const { compTypes } = this;
let result: ComponentDefinitionDefined = {};
if (compTypes) {
- const type = node.getAttribute?.(`${this.modelAttrStart}type`);
+ const type = node.attributes?.[`${this.modelAttrStart}type`];
// If the type is already defined, use it
if (type) {
@@ -223,7 +245,16 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo
// Find the component type
for (let i = 0; i < compTypes.length; i++) {
const compType = compTypes[i];
- let obj = compType.model.isComponent(node, opts);
+ const { model } = compType;
+ let obj;
+
+ if (opts.__parsedMode) {
+ obj = model.isParsedNode
+ ? model.isParsedNode(node, opts)
+ : model.isComponent(this.__getSyntheticNode(node, opts) as any, opts);
+ } else {
+ obj = model.isComponent(getSourceNode(node), opts);
+ }
if (obj) {
if (typeof obj !== 'object') {
@@ -239,21 +270,21 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo
return result;
},
- parseNode(node: HTMLElement, opts: ParseNodeOptions = {}) {
- const nodes = (node as HTMLTemplateElement).content?.childNodes || node.childNodes;
+ 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 === 'http://www.w3.org/1999/xhtml' ? tag.toLowerCase() : tag;
+ 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.outerHTML}`.slice(-2) === '/>') {
+ if (!nodesLen && node.__selfClosing) {
model.void = true;
}
@@ -264,11 +295,11 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo
// 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) {
+ if (nodesLen === 1 && firstChild.nodeType === ParsedNodeType.text) {
!model.type && (model.type = 'text');
model.components = {
type: 'textnode',
- content: firstChild.nodeValue,
+ content: firstChild.textContent,
};
} else {
model.components = this.parseNodes(node, {
@@ -313,13 +344,13 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo
* @param {HTMLElement} el DOM element to traverse
* @return {Array