diff --git a/packages/core/src/parser/model/ParserHtml.ts b/packages/core/src/parser/model/ParserHtml.ts index 19beaee39..07bd304b6 100644 --- a/packages/core/src/parser/model/ParserHtml.ts +++ b/packages/core/src/parser/model/ParserHtml.ts @@ -2,13 +2,12 @@ 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 { doctypeToString, processDataGjsAttributeHyphen } from '../../utils/dom'; +import { processDataGjsAttributeHyphen } from '../../utils/dom'; import { isDef } from '../../utils/mixins'; import { HTMLParserOptions, ParseNodeOptions, ParserConfig } from '../config/config'; import { HTMLParseResult, ParsedElementNode, - ParsedNode, ParsedNodeMeta, ParsedNodeNamespace, ParsedNodeType, @@ -17,6 +16,19 @@ import { } from '../types'; import BrowserParserHtml from './BrowserParserHtml'; import { getSyntheticElementCtor } from './SyntheticElement'; +import { + createElementNode, + createFragmentRoot, + domDocumentToParsedNode, + domRootToFragment, + findChildElement, + getNodeChildNodes, + getNodeTextContent, + getSourceNode, + normalizeDocumentRoot, + removeElementNodes, + sanitizeNode, +} from './utils'; const modelAttrStart = 'data-gjs-'; @@ -27,226 +39,6 @@ interface ParserHtmlInternalOptions extends ParseNodeOptions { 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 getDomChildNodes = (node: Node) => { - const template = node as HTMLTemplateElement; - const childNodes = template.content?.childNodes || node.childNodes || []; - return Array.from(childNodes); -}; - -const cloneParsedNode = (node: ParsedNode): ParsedNodeMeta => { - const cloned: ParsedNodeMeta = { - nodeType: node.nodeType, - }; - - node.tagName && (cloned.tagName = node.tagName); - node.namespaceURI && (cloned.namespaceURI = node.namespaceURI); - node.textContent !== undefined && (cloned.textContent = node.textContent); - node.attributes && (cloned.attributes = { ...node.attributes }); - node.childNodes && (cloned.childNodes = node.childNodes.map((child) => cloneParsedNode(child))); - - const meta = node as ParsedNodeMeta & { doctype?: string; selfClosing?: boolean }; - meta.__doctype && (cloned.__doctype = meta.__doctype); - meta.doctype && (cloned.__doctype = meta.doctype); - meta.__selfClosing && (cloned.__selfClosing = meta.__selfClosing); - meta.selfClosing && (cloned.__selfClosing = meta.selfClosing); - meta.__boolAttributes && (cloned.__boolAttributes = [...meta.__boolAttributes]); - - return cloned; -}; - -const createElementNode = (tagName: string, childNodes: ParsedNodeMeta[] = []): ParsedNodeMeta => ({ - nodeType: ParsedNodeType.element, - tagName, - namespaceURI: ParsedNodeNamespace.html, - childNodes, -}); - -const createFragmentRoot = (nodes: ParsedNode[]): ParsedNodeMeta => ({ - nodeType: ParsedNodeType.fragment, - childNodes: nodes.map((node) => cloneParsedNode(node)), -}); - -const appendChildElement = (node: ParsedNodeMeta, tagName: string) => { - const child = createElementNode(tagName); - node.childNodes = [...getNodeChildNodes(node), child]; - return child; -}; - -const findChildElement = (node: ParsedNodeMeta, tagName: string) => - getNodeChildNodes(node).find((child) => getNodeTagName(child) === tagName); - -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(''); -}; - -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; -}; - -const sanitizeNode = (node: ParsedNodeMeta, opts: HTMLParserOptions) => { - const attrs = node.attributes || {}; - const cleanAttrs: Record = {}; - - each(attrs, (value, name) => { - const attrValue = `${value}`; - const isUnsafeAttr = !opts.allowUnsafeAttr && name.startsWith('on'); - const isUnsafeValue = !opts.allowUnsafeAttrValue && attrValue.startsWith('javascript:'); - - if (!isUnsafeAttr && !isUnsafeValue) { - cleanAttrs[name] = attrValue; - } - }); - - if (Object.keys(cleanAttrs).length) { - node.attributes = cleanAttrs; - } else { - delete node.attributes; - } - - each(getNodeChildNodes(node), (child) => sanitizeNode(child, opts)); -}; - -const domDocumentToParsedNode = (doc: Document): ParsedNodeMeta => ({ - nodeType: ParsedNodeType.document, - __domNode: doc, - __doctype: doctypeToString(doc.doctype), - childNodes: doc.documentElement ? [domToParsedNode(doc.documentElement)] : [], -}); - -const domToParsedNode = (node: Node): ParsedNodeMeta => { - if (node.nodeType === ParsedNodeType.document) { - return domDocumentToParsedNode(node as Document); - } - - const parsedNode: ParsedNodeMeta = { - nodeType: node.nodeType, - __domNode: node, - }; - - if (node.nodeType === ParsedNodeType.text || node.nodeType === ParsedNodeType.comment) { - parsedNode.textContent = node.textContent ?? ''; - } - - if (node.nodeType === ParsedNodeType.element) { - const el = node as HTMLElement; - parsedNode.tagName = el.tagName || ''; - parsedNode.namespaceURI = el.namespaceURI || undefined; - - 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; -}; - -const domRootToFragment = (root: HTMLElement): ParsedNodeMeta => ({ - nodeType: ParsedNodeType.fragment, - __domNode: root, - childNodes: getDomChildNodes(root).map((node) => domToParsedNode(node)), -}); - -const normalizeDocumentRoot = (nodes: ParsedNode[]) => { - const flatNodes = nodes - .map((node) => cloneParsedNode(node)) - .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) => node.nodeType === ParsedNodeType.element && 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) => node.nodeType === ParsedNodeType.element && getNodeTagName(node) === 'head', - ); - const bodyIndex = remaining.findIndex( - (node) => node.nodeType === ParsedNodeType.element && 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; -}; - const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boolean } = {}) => { return { compTypes: [] as ComponentStackItem[], @@ -626,12 +418,11 @@ const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boo const inputOptions = { input: isFunction(preParser) ? preParser(str, { editor: em?.getEditor()! }) : str }; Parser?.__emitEvent(ParserEvents.htmlBefore, inputOptions); const { input } = inputOptions; - const parseRes = this.__parseInput(input, options, cf, parserCode); - const root = parseRes.root; + const { root, isParsedMode } = this.__parseInput(input, options, cf, parserCode); const parserConfig = Parser?.getConfig() || config; const parseOptions: ParserHtmlInternalOptions = { ...cf, - __parsedMode: parseRes.isParsedMode, + __parsedMode: isParsedMode, __syntheticElementCtor: getSyntheticElementCtor(parserConfig.customSyntheticElement), }; diff --git a/packages/core/src/parser/model/utils.ts b/packages/core/src/parser/model/utils.ts new file mode 100644 index 000000000..ac55d6551 --- /dev/null +++ b/packages/core/src/parser/model/utils.ts @@ -0,0 +1,227 @@ +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 cloneParsedNode = (node: ParsedNode): ParsedNodeMeta => { + const cloned: ParsedNodeMeta = { + nodeType: node.nodeType, + }; + + node.tagName && (cloned.tagName = node.tagName); + node.namespaceURI && (cloned.namespaceURI = node.namespaceURI); + node.textContent !== undefined && (cloned.textContent = node.textContent); + node.attributes && (cloned.attributes = { ...node.attributes }); + node.childNodes && (cloned.childNodes = node.childNodes.map((child) => cloneParsedNode(child))); + + const meta = node as ParsedNodeMeta & { doctype?: string; selfClosing?: boolean }; + meta.__doctype && (cloned.__doctype = meta.__doctype); + meta.doctype && (cloned.__doctype = meta.doctype); + meta.__selfClosing && (cloned.__selfClosing = meta.__selfClosing); + meta.selfClosing && (cloned.__selfClosing = meta.selfClosing); + meta.__boolAttributes && (cloned.__boolAttributes = [...meta.__boolAttributes]); + + return cloned; +}; + +export const createElementNode = (tagName: string, childNodes: ParsedNodeMeta[] = []): ParsedNodeMeta => ({ + nodeType: ParsedNodeType.element, + tagName, + namespaceURI: ParsedNodeNamespace.html, + childNodes, +}); + +export const createFragmentRoot = (nodes: ParsedNode[]): ParsedNodeMeta => ({ + nodeType: ParsedNodeType.fragment, + childNodes: nodes.map((node) => cloneParsedNode(node)), +}); + +export const appendChildElement = (node: ParsedNodeMeta, tagName: string) => { + const child = createElementNode(tagName); + node.childNodes = [...getNodeChildNodes(node), child]; + return child; +}; + +export const findChildElement = (node: ParsedNodeMeta, tagName: string) => + getNodeChildNodes(node).find((child) => getNodeTagName(child) === tagName); + +export const getNodeTextContent = (node: ParsedNodeMeta): string => { + if (node.nodeType === ParsedNodeType.text || node.nodeType === ParsedNodeType.comment) { + return node.textContent ?? ''; + } + + if (node.textContent && !node.childNodes?.length) { + return node.textContent; + } + + return getNodeChildNodes(node) + .map((child) => getNodeTextContent(child)) + .join(''); +}; + +export const removeElementNodes = (root: ParsedNodeMeta, tagName: string) => { + const removed: ParsedNodeMeta[] = []; + const remove = (node: ParsedNodeMeta) => { + if (!node.childNodes?.length) return; + const nextNodes: ParsedNodeMeta[] = []; + + node.childNodes.forEach((child) => { + if (getNodeTagName(child) === tagName) { + removed.push(child); + return; + } + + remove(child); + nextNodes.push(child); + }); + + node.childNodes = nextNodes; + }; + + remove(root); + return removed; +}; + +export const sanitizeNode = (node: ParsedNodeMeta, opts: HTMLParserOptions) => { + const attrs = node.attributes || {}; + const cleanAttrs: Record = {}; + + each(attrs, (value, name) => { + const attrValue = `${value}`; + const isUnsafeAttr = !opts.allowUnsafeAttr && name.startsWith('on'); + const isUnsafeValue = !opts.allowUnsafeAttrValue && attrValue.startsWith('javascript:'); + + if (!isUnsafeAttr && !isUnsafeValue) { + cleanAttrs[name] = attrValue; + } + }); + + if (Object.keys(cleanAttrs).length) { + node.attributes = cleanAttrs; + } else { + delete node.attributes; + } + + each(getNodeChildNodes(node), (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 => { + if (node.nodeType === ParsedNodeType.document) { + return domDocumentToParsedNode(node as Document); + } + + const parsedNode: ParsedNodeMeta = { + nodeType: node.nodeType, + __domNode: node, + }; + + if (node.nodeType === ParsedNodeType.text || node.nodeType === ParsedNodeType.comment) { + parsedNode.textContent = node.textContent ?? ''; + } + + if (node.nodeType === ParsedNodeType.element) { + const el = node as HTMLElement; + parsedNode.tagName = el.tagName || ''; + parsedNode.namespaceURI = el.namespaceURI || undefined; + + 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 domRootToFragment = (root: HTMLElement): ParsedNodeMeta => ({ + nodeType: ParsedNodeType.fragment, + __domNode: root, + childNodes: getDomChildNodes(root).map((node) => domToParsedNode(node)), +}); + +export const normalizeDocumentRoot = (nodes: ParsedNode[]) => { + const flatNodes = nodes + .map((node) => cloneParsedNode(node)) + .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) => node.nodeType === ParsedNodeType.element && 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) => node.nodeType === ParsedNodeType.element && getNodeTagName(node) === 'head', + ); + const bodyIndex = remaining.findIndex( + (node) => node.nodeType === ParsedNodeType.element && 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; +};