UNPKG

@asimojs/lml

Version:

LML - List Markup Language

572 lines (521 loc) 22.1 kB
import { LML, LmlAttributeMap, JsxContent, LmlFormatter, LmlNodeInfo, LmlObject, LmlUpdate, LmlNode, LmlNodeListUpdate, LmlTextNode, LmlNodeType, LmlFragment, LmlSanitizationRules } from "./types"; /** * Return the type of an LML node * @param node * @returns */ export function nodeType(node: LML): LmlNodeType { if (Array.isArray(node)) { if (node.length === 0) return "fragment"; const v0 = node[0]; if (typeof v0 === "string" && v0.length > 1) { const ch0 = v0[0]; if (ch0 === ELT_PREFIX) { return "element"; } else if (ch0 === CPT_PREFIX) { return "component"; } else if (ch0 === DECO_PREFIX || ch0 === RESERVED_PREFIX) { return "invalid"; // not yet supported } } return "fragment"; } else if (typeof node === "string") { return "text"; } return "invalid"; } const ELT_PREFIX = "#"; const CPT_PREFIX = "*"; const DECO_PREFIX = "@"; const RESERVED_PREFIX = "!"; const ATT_KEY_SEPARATOR = "!"; const ATT_CLASS_SEPARATOR = "."; // [nodetype:#|*|!|@][namespace:?][nodename][+typeattribute?][.classattributes*][!keyattribute?] export const RX_NODE_NAME = /^(\#|\*|\!|\@)(\w+\:)?([\w\-]+)(\+[\w\-]+)?(\.[\.\w\-]+)*(\!.+)?$/; /** * Scan LML data and transform them to JSX thanks to the formatter passed as arguement * @param v an LML value * @param f the formatter (that will call the jsx runtime behinde the scenes) * @returns */ export function processJSX(v: LML, f: LmlFormatter): JsxContent { if (v === undefined || v === null) return ""; const ndType = nodeType(v); if (ndType === "text") { // text node return textNode(v as string); } if (ndType === "invalid") { return error(`Invalid LML node: ${JSON.stringify(v)}`); } const isFragment = ndType === "fragment"; const ls = v; const len = ls.length; if (len === 0) return ""; if (!isFragment) { const v0 = ls[0] as string; // check if first value matches a node name const m = v0.match(RX_NODE_NAME); if (m) { const nodeKind = m[1]; const nsGroup = m[2]; const name = m[3]; const typeGroup = m[4]; const classGroup = m[5]; const key = m[6]; const ns = nsGroup ? nsGroup.slice(0, -1) : ""; const ntype = typeGroup ? typeGroup.slice(1) : ""; let clsValues: string[] | undefined; if (classGroup) { const parts = classGroup.split(ATT_CLASS_SEPARATOR) clsValues = []; for (const s of parts) { if (s != "") { clsValues.push(s); } } } // lookup attributes let nextIdx = 1; let atts: LmlObject | undefined = undefined; const next = ls[nextIdx]; if (next && typeof next === "object" && !Array.isArray(next)) { // atts is the attribute map atts = next; nextIdx++; if (ntype || clsValues || key) { if (ntype) { atts["type"] = ntype; } if (clsValues) { if (atts["class"] && typeof atts["class"] === "string") { atts["class"] = clsValues.join(" ") + " " + atts["class"]; } else { atts["class"] = clsValues.join(" "); } } if (key) { atts["keyValue"] = atts["key"] = key.slice(1); } // as we mutated the original object, remove type and class from name (ls[0] as any) = `${nodeKind}${nsGroup || ""}${name}`; } const kv = atts["key"]; if (kv && kv !== atts["keyValue"]) { atts["keyValue"] = atts["key"] } } else { // no atts object if (key) { const kv = key.slice(1);; atts = { key: kv, keyValue: kv }; } if (clsValues || ntype) { atts = atts || {}; if (ntype) { atts["type"] = ntype; } if (clsValues) { atts["class"] = clsValues.join(" "); } } } // lookup children let children: any = undefined; if (len > nextIdx) { // next entries are children children = []; for (let i = nextIdx; i < len; i++) { children.push(processJSX(ls[i] as any, f)); } } if (nodeKind === ELT_PREFIX || nodeKind === CPT_PREFIX || nodeKind === DECO_PREFIX) { let kind = (nodeKind === ELT_PREFIX) ? "element" : "component"; if (nodeKind === DECO_PREFIX) { kind = "decorator"; } const ndInfo: LmlNodeInfo = { type: kind as any, tagName: name, ns } return f.format(ndInfo, atts, children); } else { // other error(`Unsupported node prefix: ${nodeKind}`); } return ""; } } // ls is a fragment const r: (JSX.Element | string)[] = []; for (const nd of ls) { if (typeof nd === "string" || Array.isArray(nd)) { const val = processJSX(nd, f); if (val) { if (Array.isArray(val)) { for (let item of val) { r.push(item); } } else { r.push(val); } } } else { error(`Invalid LML node: ${JSON.stringify(nd)}`); } } return r.length > 0 ? r : ""; function textNode(v: string) { return f.format({ type: "text", value: v }); } function error(msg: string): string { const MAXLEN = 100; if (f.error) { if (msg.length > MAXLEN) { msg = msg.slice(0, MAXLEN) + "..."; } f.error(msg); } else { console.error("[LML Scan Error] " + msg); } return ""; } } /** * Default sanitization rules - rather aggressive to avoid unecessary complexity * (can be overridden and tuned on the application side) */ export const defaultSanitizationRules: LmlSanitizationRules = { /** * Allowed tags - img + tags from https://github.com/apostrophecms/sanitize-html * Note: form and input are not in the list */ allowedElements: new Set([ "address", "article", "aside", "footer", "header", "h1", "h2", "h3", "h4", "h5", "h6", "hgroup", "main", "nav", "section", "blockquote", "dd", "div", "dl", "dt", "figcaption", "figure", "hr", "li", "main", "ol", "p", "pre", "ul", "a", "abbr", "b", "bdi", "bdo", "br", "cite", "code", "data", "dfn", "em", "i", "kbd", "mark", "q", "rb", "rp", "rt", "rtc", "ruby", "s", "samp", "small", "span", "strong", "sub", "sup", "time", "u", "var", "wbr", "caption", "col", "colgroup", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "img" ]), /** Forbid style, srcset and event handler attributes */ forbiddenElementAttributes: new Set(["style", "srcset"]), /** Tell if elemeent event handlers attributes must be discarded */ forbidEventHandlers: true, /** * URL attributes used in allowedElements, will be checked against allowedUrlPrefixes * as per https://stackoverflow.com/questions/2725156/complete-list-of-html-tag-attributes-which-have-a-url-value */ urlAttributes: new Set(["href", "src", "cite", "action", "profile", "longdesc", "usemap", "formaction", "icon", "poster", "background", "codebase", "data", "classid", "manifest"]), /** Allowed URLs - DO NOT PUT "data:text" -> data:text/html can contain malicious scripts */ allowedUrlPrefixes: ["/", "./", "http://", "https://", "mailto://", "tel://", "data:image/"] } /** * Convert an LML structure to a JSX tree through a createElement like function (argument). * The JSX tree is also sanitized * @param v the lml data to convert * @param createElement the React.createElement function (or h function for preact) * @param getComponent a function called when a component is found to retrieve an actual component reference [optional] * @param error error handler that will be called in case of error [optional] * @returns */ export function lml2jsx(v: LML, createElement: (type: any | Function, props: { [key: string]: any }, ...children: any) => JSX.Element, getComponent?: ((name: string, namespace: string) => Function | null) | null, error?: ((msg: string) => void) | null, sanitizationRules?: LmlSanitizationRules) : JsxContent { error = error || ((m: string) => console.error("[lm2JSX Error] " + m)); const rules = sanitizationRules || defaultSanitizationRules; let allowedUrlPrefixes: RegExp | null = null; if (rules.allowedUrlPrefixes.length) { const rx = `^(${rules.allowedUrlPrefixes.join('|')})`; allowedUrlPrefixes = new RegExp(rx, "i"); } return processJSX(v, { format: (ndi: LmlNodeInfo, attributes?: LmlAttributeMap, children?: (JSX.Element | string)[]): JSX.Element | string => { const tp = ndi.type; if (tp === "text") { return ndi.value; } else if (tp === "element" || tp === "component") { if (tp === "element") { if (!rules.allowedElements.has(ndi.tagName)) { error!(`Unauthorized element: ${ndi.tagName}`); return ""; } if (attributes) { for (const name of Object.getOwnPropertyNames(attributes)) { if (rules.forbiddenElementAttributes.has(name)) { error!(`Unauthorized element attribute: ${name}`); delete attributes[name]; } else if (rules.forbidEventHandlers && name.match(/^on/i)) { error!(`Unauthorized event handler: ${name}`); delete attributes[name]; } else if (allowedUrlPrefixes && rules.urlAttributes.has(name)) { const v = attributes[name]; if (typeof (v) !== "string" || !v.match(allowedUrlPrefixes)) { error!(`Unauthorized URL: ${name}="${v}"`); delete attributes[name]; } } } } } // change class into className if (attributes && attributes["class"] && !attributes["className"]) { attributes["className"] = attributes["class"]; // delete attributes["class"]; // seems to be ignored by react or preact when className is set } let nameOrRef = (ndi as any).tagName; if (tp === "component") { const cpt = (getComponent && getComponent(nameOrRef, ndi.ns || "")) || null; if (!cpt) { error!("Invalid component: " + (ndi.ns ? ndi.ns + ":" : "") + nameOrRef); return ""; } nameOrRef = cpt; } if (children) { return createElement(nameOrRef, attributes as any || null, ...children); } return createElement(nameOrRef, attributes as any || null); } error!("Unsupported node type: " + tp); return ""; }, error }); } /** * In-place update of an LML data structure with instructions provided as arguments * Return the new data structure (may be different if the original data is not a fragment) * @param data * @param instructions * @returns */ export function updateLML(data: LML, instructions: LmlUpdate[]): LML { let root: LmlFragment = nodeType(data) === "fragment" ? data as LmlFragment : [data]; // instructions mapped by node key const targetKeys: Set<string> = new Set(); for (const instruction of instructions) { instruction.node && targetKeys.add(instruction.node); } const nodes: Map<string, { node: LmlNode, parent: any, parentRef: string | number }> = new Map(); const max = targetKeys.size; if (max > 0) { let count = 0; scanNode(data, (k, node, parent, parentRef) => { if (targetKeys.has(k)) { count++; nodes.set(k, { node, parent, parentRef }); } return count < max; // stop when all nodes have been found }, root, 0); } // apply the insructions in order for (const ins of instructions) { const action = ins.action; let node: LmlFragment | LmlNode = root, parent: any = null, prop: string | number = "", path = ""; if (ins.node) { let nd = nodes.get(ins.node); if (nd) { parent = nd.parent; prop = nd.parentRef; node = nd.node; path = ins.path || ""; // path is always empty if no node is provided if (path === "") { const ndt = nodeType(node); if (ndt !== "invalid" && ndt !== "text" && (action === "append" || action === "prepend")) { path = "children"; // default value } } } else { continue; } } if (path === "children") { if (node.length > 1) { let child1 = 1; if (typeof node[child1] !== "string" && !Array.isArray(node[child1])) { child1++; } if (action === "delete") { node.splice(child1, node.length - child1); } else { const content: any = ins.content; const contentType = nodeType(content); if (contentType !== "invalid") { const isFragment = contentType === "fragment"; // special case as children are not stored as attributes if (action === "insertBefore" || action === "prepend") { if (isFragment) { node.splice.apply(node, [child1, 0, ...content]); } else { node.splice(child1, 0, content); } } else if (action === "insertAfter" || action === "append") { if (isFragment) { node.push.apply(node, content as LmlFragment); } else { node.push(content); } } else if (action === "replace") { if (isFragment) { node.splice.apply(node, [child1, node.length - child1, ...content]); } else { node.splice(child1, node.length - child1, content); } } } } } continue; } else if (path) { const pathElts = path.split("/"); if (Array.isArray(node) && node.length > 1) { node = node[1] as any; // attribute object } else { // invalid path continue; } for (const pe of pathElts) { parent = node; // argument object prop = pe; node = parent[prop]; } } if (!node) continue; if (action === "delete") { if (Array.isArray(parent)) { if (typeof prop === "number") { parent.splice(prop, 1); } } else if (parent) { // node is root parent[prop] = []; } else { root = []; } } else { const content: any = ins.content!; const contentType = nodeType(content); if (contentType !== "invalid") { const isContentFragment = contentType === "fragment"; if (action === "insertBefore" || action === "insertAfter" || action === "replace") { if (Array.isArray(parent)) { if (typeof prop === "number") { let idx = prop, nbrOfDel = 0; // insertBefore defaults if (action === "insertAfter") { idx++; } else if (action === "replace") { nbrOfDel++; } if (isContentFragment) { parent.splice.apply(parent, [idx, nbrOfDel, ...content]); } else { parent.splice(idx, nbrOfDel, content); } } // else: should be unreachable } else if (parent) { // node is root if (action === "insertBefore") { if (isContentFragment) { parent[prop] = [...content, node]; } else { parent[prop] = [content, node]; } } else if (action === "insertAfter") { if (isContentFragment) { parent[prop] = [node, ...content]; } else { parent[prop] = [node, content]; } } else if (action === "replace") { parent[prop] = content; } } else if (action === "replace") { // root node root = isContentFragment ? content : [content]; } } else if ((action === "prepend" || action === "append") && nodeType(node) === "fragment") { if (action === "append") { if (isContentFragment) { node.push.apply(node, content as LmlFragment); } else { node.push(content); } } else if (action === "prepend") { if (isContentFragment) { node.splice.apply(node, [0, 0, ...content]); } else { node.splice(0, 0, content); } } } } } } if (root.length === 1) return root[0]; return root; } export function scan(data: any, process: (nodeKey: string, node: LmlNode, parent: any, parentRef: string | number) => boolean) { scanNode(data, process, null, 0); } /** * Recursively scan an LML structure and call the process callback when a node with a key is found * If the process callback return false, the scanning process stops * @param data * @param process * @returns */ function scanNode( data: any, process: (nodeKey: string, node: LmlNode, parent: any, parentRef: string | number) => boolean, parent: any, parentRef: string | number): boolean { if (!data) return true; const ndType = nodeType(data); if (Array.isArray(data) && data.length) { // data can be either a fragment or a node const isNode = ndType === "component" || ndType === "element"; if (isNode) { let key = ""; const v0 = data[0]; const idx = v0.indexOf(ATT_KEY_SEPARATOR); if (idx > -1) { key = data[0].slice(idx + 1); } else { const v1 = data[1]; if (v1 && typeof v1 === "object" && !Array.isArray(v1)) { key = v1["key"]; } } if (key) { const r = process(key, data as LmlNode, parent, parentRef); if (r === false) return false; // stop scanning } // scan attributes and children for (let i = 1; data.length > i; i++) { if (!scanNode(data[i], process, data, i)) return false; // stop scanning } } else { // process all fragment nodes one by one for (let i = 0; data.length > i; i++) { if (!scanNode(data[i], process, data, i)) return false; // stop scanning } } } else if (typeof data === "object") { // scan each object property for (const k of Object.getOwnPropertyNames(data)) { if (!scanNode((data as any)[k], process, data, k)) return false; // stop scanning } } return true; }