UNPKG

@print-one/grapesjs

Version:

Free and Open Source Web Builder Framework

368 lines (311 loc) 11.8 kB
import { each, isArray, isFunction, isUndefined } from 'underscore'; import { ObjectAny } from '../../common'; import { CssRuleJSON } from '../../css_composer/model/CssRule'; import { ComponentDefinitionDefined } from '../../dom_components/model/types'; import EditorModel from '../../editor/model/Editor'; import { HTMLParserOptions, ParserConfig } from '../config/config'; import BrowserParserHtml from './BrowserParserHtml'; type StringObject = Record<string, string>; type HTMLParseResult = { html: ComponentDefinitionDefined | ComponentDefinitionDefined[]; // TODO replace with components css?: CssRuleJSON[]; }; const modelAttrStart = 'data-gjs-'; const event = 'parse:html'; const ParserHtml = (em?: EditorModel, config: ParserConfig & { returnArray?: boolean } = {}) => { return { compTypes: '', modelAttrStart, getPropAttribute(attrName: string, attrValue?: string) { const name = attrName.replace(this.modelAttrStart, ''); 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 { name, value, }; }, /** * 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: StringObject = {}; 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('*/') + 2; str = str.replace(str.slice(start, end), ''); } 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]; } (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; }, /** * Get data from the node element * @param {HTMLElement} el DOM element to traverse * @return {Array<Object>} */ parseNode(el: HTMLElement, opts: ObjectAny = {}) { const result: ComponentDefinitionDefined[] = []; const nodes = el.childNodes; for (var i = 0, len = nodes.length; i < len; i++) { const node = nodes[i] as HTMLElement; const attrs = node.attributes || []; const attrsLen = attrs.length; const nodePrev = result[result.length - 1]; const nodeChild = node.childNodes.length; const ct = this.compTypes; let model: ComponentDefinitionDefined = {}; // TODO use component properties // Start with understanding what kind of component it is if (ct) { let obj: any = ''; let type = node.getAttribute && node.getAttribute(`${this.modelAttrStart}type`); // If the type is already defined, use it if (type) { model = { type }; } else { // Iterate over all available Component Types and // the first with a valid result will be that component for (let it = 0; it < ct.length; it++) { const compType = ct[it]; // @ts-ignore obj = compType.model.isComponent(node, opts); if (obj) { if (typeof obj !== 'object') { // @ts-ignore obj = { type: compType.id }; } break; } } model = obj; } } // Set tag name if not yet done if (!model.tagName) { const tag = node.tagName || ''; const ns = node.namespaceURI || ''; model.tagName = tag && ns === 'http://www.w3.org/1999/xhtml' ? tag.toLowerCase() : tag; } if (attrsLen) { model.attributes = {}; } // Parse attributes for (let j = 0; j < attrsLen; j++) { const nodeName = attrs[j].nodeName; let nodeValue: string | boolean = attrs[j].nodeValue!; // Isolate attributes 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); model[propsResult.name] = propsResult.value; } else { // @ts-ignore Check for attributes from props (eg. required, disabled) if (nodeValue === '' && node[nodeName] === true) { nodeValue = true; } model.attributes[nodeName] = nodeValue; } } // Check for nested elements but avoid it if already provided if (nodeChild && !model.components) { // Avoid infinite nested text nodes const firstChild = node.childNodes[0]; // If there is only one child and it's a TEXTNODE // just make it content of the current node if (nodeChild === 1 && firstChild.nodeType === 3) { !model.type && (model.type = 'text'); model.components = { type: 'textnode', content: firstChild.nodeValue, }; } else { model.components = this.parseNode(node, { ...opts, inSvg: opts.inSvg || model.type === 'svg', }); } } // Check if it's a text node and if could be moved to the prevous model if (model.type == 'textnode') { if (nodePrev && nodePrev.type == 'textnode') { nodePrev.content += model.content; continue; } // Throw away empty nodes (keep spaces) if (!opts.keepEmptyTextNodes) { const content = node.nodeValue; if (content != ' ' && !content!.trim()) { continue; } } } // Check for custom void elements (valid in XML) if (!nodeChild && `${node.outerHTML}`.slice(-2) === '/>') { model.void = true; } // If all children are texts and there is some textnode the parent should // be text too otherwise I'm unable to edit texnodes const comps = model.components; if (!model.type && comps) { let allTxt = 1; let foundTextNode = 0; for (let ci = 0; ci < comps.length; ci++) { const comp = comps[ci]; const cType = comp.type; if (['text', 'textnode'].indexOf(cType) < 0 && config.textTags!.indexOf(comp.tagName) < 0) { allTxt = 0; break; } if (cType == 'textnode') { foundTextNode = 1; } } if (allTxt && foundTextNode) { model.type = 'text'; } } // If tagName is still empty and is not a textnode, do not push it if (!model.tagName && model.type != 'textnode') { 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 res: HTMLParseResult = { html: [] }; const cf: ObjectAny = { ...config, ...opts }; const options = { ...config.optionsHtml, // @ts-ignore Support previous `configParser.htmlType` option htmlType: config.optionsHtml?.htmlType || config.htmlType, ...opts, }; const el = isFunction(cf.parserHtml) ? cf.parserHtml(str, options) : BrowserParserHtml(str, options); const scripts = el.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) { this.__clearUnsafeAttr(el); } // Detach style tags and parse them if (parserCss) { const styles = el.querySelectorAll('style'); let j = styles.length; let styleStr = ''; while (j--) { styleStr = styles[j].innerHTML + styleStr; styles[j].parentNode.removeChild(styles[j]); } if (styleStr) res.css = parserCss.parse(styleStr); } em && em.trigger(`${event}:root`, { input: str, root: el }); const result = this.parseNode(el, cf); // I have to keep it otherwise it breaks the DomComponents.addComponent (returns always array) const resHtml = result.length === 1 && !cf.returnArray ? result[0] : result; res.html = resHtml; em && em.trigger(event, { input: str, output: res }); return res; }, __clearUnsafeAttr(node: HTMLElement) { const attrs = node.attributes || []; const nodes = node.childNodes || []; const toRemove: string[] = []; each(attrs, attr => { const name = attr.nodeName || ''; name.indexOf('on') === 0 && toRemove.push(name); }); toRemove.map(name => node.removeAttribute(name)); each(nodes, node => this.__clearUnsafeAttr(node as HTMLElement)); }, }; }; export default ParserHtml;