Free and Open source Web Builder Framework. Next generation tool for building templates without coding
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

368 lines
12 KiB

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) {
const { textTypes = [], textTags = [] } = config;
let allTxt = 1;
let foundTextNode = 0;
for (let ci = 0; ci < comps.length; ci++) {
const comp = comps[ci];
const cType = comp.type;
if (!textTypes.includes(cType) && !textTags.includes(comp.tagName)) {
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 && isUndefined(model.content)) {
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;