mirror of https://github.com/artf/grapesjs.git
Browse Source
* 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 isParserNoderelease-v0.23.1-rc.0
committed by
GitHub
25 changed files with 1609 additions and 634 deletions
@ -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`. |
|||
@ -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); |
|||
@ -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; |
|||
}; |
|||
@ -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', |
|||
); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue