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