import { attrsSetter, attrsUpdater, createAttrUpdater, isProp, makePropSetter, setClass, updateClass, } from "./attributes"; import { config } from "./config"; import { createEventHandler } from "./events"; import type { VNode } from "./index"; import { VMulti } from "./multi"; import { toText } from "./text"; const getDescriptor = (o: any, p: any) => Object.getOwnPropertyDescriptor(o, p)!; const nodeProto = Node.prototype; const elementProto = Element.prototype; const characterDataProto = CharacterData.prototype; const characterDataSetData = getDescriptor(characterDataProto, "data").set!; const nodeGetFirstChild = getDescriptor(nodeProto, "firstChild").get!; const nodeGetNextSibling = getDescriptor(nodeProto, "nextSibling").get!; const NO_OP = () => {}; // ----------------------------------------------------------------------------- // Main compiler code // ----------------------------------------------------------------------------- type BlockType = (data?: any[], children?: VNode[]) => VNode; const cache: { [key: string]: BlockType } = {}; /** * Compiling blocks is a multi-step process: * * 1. build an IntermediateTree from the HTML element. This intermediate tree * is a binary tree structure that encode dynamic info sub nodes, and the * path required to reach them * 2. process the tree to build a block context, which is an object that aggregate * all dynamic info in a list, and also, all ref indexes. * 3. process the context to build appropriate builder/setter functions * 4. make a dynamic block class, which will efficiently collect references and * create/update dynamic locations/children * * @param str * @returns a new block type, that can build concrete blocks */ export function createBlock(str: string): BlockType { if (str in cache) { return cache[str]; } // step 0: prepare html base element const doc = new DOMParser().parseFromString(`${str}`, "text/xml"); const node = doc.firstChild!.firstChild!; if (config.shouldNormalizeDom) { normalizeNode(node as any); } // step 1: prepare intermediate tree const tree = buildTree(node); // step 2: prepare block context const context = buildContext(tree); // step 3: build the final block class const template = tree.el as HTMLElement; const Block = buildBlock(template, context); cache[str] = Block; return Block; } // ----------------------------------------------------------------------------- // Helper // ----------------------------------------------------------------------------- function normalizeNode(node: HTMLElement | Text) { if (node.nodeType === Node.TEXT_NODE) { if (!/\S/.test((node as Text).textContent!)) { (node as Text).remove(); return; } } if (node.nodeType === Node.ELEMENT_NODE) { if ((node as HTMLElement).tagName === "pre") { return; } } for (let i = node.childNodes.length - 1; i >= 0; --i) { normalizeNode(node.childNodes.item(i) as any); } } // ----------------------------------------------------------------------------- // building a intermediate tree // ----------------------------------------------------------------------------- interface DynamicInfo { idx: number; refIdx?: number; type: "text" | "child" | "handler" | "attribute" | "attributes" | "ref"; isOnlyChild?: boolean; name?: string; tag?: string; event?: string; } interface IntermediateTree { parent: IntermediateTree | null; firstChild: IntermediateTree | null; nextSibling: IntermediateTree | null; el: Node; info: DynamicInfo[]; forceRef?: boolean; refIdx?: number; refN: number; } function buildTree( node: Node, parent: IntermediateTree | null = null, domParentTree: IntermediateTree | null = null ): IntermediateTree { switch (node.nodeType) { case 1: { // HTMLElement let isActive = false; const tagName = (node as Element).tagName; let el: Node | undefined = undefined; const info: DynamicInfo[] = []; if (tagName.startsWith("block-text-")) { const index = parseInt(tagName.slice(11), 10); info.push({ type: "text", idx: index }); el = document.createTextNode(""); isActive = true; } if (tagName.startsWith("block-child-")) { domParentTree!.forceRef = true; const index = parseInt(tagName.slice(12), 10); info.push({ type: "child", idx: index }); el = document.createTextNode(""); isActive = true; } if (!el) { el = document.createElement(tagName); } if (el instanceof HTMLElement) { const attrs = (node as Element).attributes; for (let i = 0; i < attrs.length; i++) { const attrName = attrs[i].name; const attrValue = attrs[i].value; if (attrName.startsWith("block-handler-")) { isActive = true; const idx = parseInt(attrName.slice(14), 10); info.push({ type: "handler", idx, event: attrValue, }); } else if (attrName.startsWith("block-attribute-")) { isActive = true; const idx = parseInt(attrName.slice(16), 10); info.push({ type: "attribute", idx, name: attrValue, tag: tagName, }); } else if (attrName === "block-attributes") { isActive = true; info.push({ type: "attributes", idx: parseInt(attrValue, 10), }); } else if (attrName === "block-ref") { isActive = true; info.push({ type: "ref", idx: parseInt(attrValue, 10), }); } else { el.setAttribute(attrs[i].name, attrValue); } } } const tree: IntermediateTree = { parent, firstChild: null, nextSibling: null, el, info, refN: isActive ? 1 : 0, }; if (node.firstChild) { const childNode = node.childNodes[0]; if ( node.childNodes.length === 1 && childNode.nodeType === 1 && (childNode as Element).tagName.startsWith("block-child-") ) { const tagName = (childNode as Element).tagName; const index = parseInt(tagName.slice(12), 10); info.push({ idx: index, type: "child", isOnlyChild: true }); isActive = true; tree.refN = 1; } else { tree.firstChild = buildTree(node.firstChild, tree, tree); el.appendChild(tree.firstChild.el); let curNode: Node | null = node.firstChild; let curTree: IntermediateTree | null = tree.firstChild; while ((curNode = curNode.nextSibling)) { curTree.nextSibling = buildTree(curNode, curTree, tree); el.appendChild(curTree.nextSibling.el); curTree = curTree.nextSibling; } } } if (isActive) { let cur: IntermediateTree | null = tree; while ((cur = cur.parent)) { cur.refN++; } } return tree; } case 3: case 8: { // text node or comment node const el = node.nodeType === 3 ? document.createTextNode(node.textContent!) : document.createComment(node.textContent!); return { parent: parent, firstChild: null, nextSibling: null, el, info: [], refN: 0, }; } } throw new Error("boom"); } function parentTree(tree: IntermediateTree): IntermediateTree | null { let parent = tree.parent; while (parent && parent.nextSibling === tree) { tree = parent; parent = parent.parent; } return parent; } // ----------------------------------------------------------------------------- // Building a block context // ----------------------------------------------------------------------------- interface RefCollector { idx: number; prevIdx: number; getVal: Function; } export type Setter = (this: T, value: any) => void; export type Updater = (this: T, value: any, oldVal: any) => void; interface Location { refIdx: number; setData: Setter; updateData: Updater; } interface IndexedLocation extends Location { idx: number; } interface Child { parentRefIdx: number; afterRefIdx?: number; isOnlyChild?: boolean; } interface BlockCtx { refN: number; collectors: RefCollector[]; locations: IndexedLocation[]; children: Child[]; cbRefs: number[]; } function buildContext( tree: IntermediateTree, ctx?: BlockCtx, fromIdx?: number, toIdx?: number ): BlockCtx { if (!ctx) { const children = new Array(tree.info.filter((v) => v.type === "child").length); ctx = { collectors: [], locations: [], children, cbRefs: [], refN: tree.refN }; fromIdx = 0; toIdx = tree.refN - 1; } if (tree.refN) { const initialIdx = fromIdx!; const isRef = tree.forceRef || tree.info.length > 0; const firstChild = tree.firstChild ? tree.firstChild.refN : 0; const nextSibling = tree.nextSibling ? tree.nextSibling.refN : 0; //node if (isRef) { for (let info of tree.info) { info.refIdx = initialIdx!; } tree.refIdx = initialIdx!; updateCtx(ctx, tree); fromIdx!++; } // right if (nextSibling) { const idx = fromIdx! + firstChild; ctx.collectors.push({ idx, prevIdx: initialIdx, getVal: nodeGetNextSibling }); buildContext(tree.nextSibling!, ctx, idx, toIdx); } // left if (firstChild) { ctx.collectors.push({ idx: fromIdx!, prevIdx: initialIdx, getVal: nodeGetFirstChild }); buildContext(tree.firstChild!, ctx, fromIdx!, toIdx! - nextSibling); } } return ctx; } function updateCtx(ctx: BlockCtx, tree: IntermediateTree) { for (let info of tree.info) { switch (info.type) { case "text": ctx.locations.push({ idx: info.idx, refIdx: info.refIdx!, setData: setText, updateData: setText, }); break; case "child": if (info.isOnlyChild) { // tree is the parentnode here ctx.children[info.idx] = { parentRefIdx: info.refIdx!, isOnlyChild: true, }; } else { // tree is the anchor text node ctx.children[info.idx] = { parentRefIdx: parentTree(tree)!.refIdx!, afterRefIdx: info.refIdx!, }; } break; case "attribute": { const refIdx = info.refIdx!; let updater: any; let setter: any; if (isProp(info.tag!, info.name!)) { const setProp = makePropSetter(info.name!); setter = setProp; updater = setProp; } else if (info.name === "class") { setter = setClass; updater = updateClass; } else { setter = createAttrUpdater(info.name!); updater = setter; } ctx.locations.push({ idx: info.idx, refIdx, setData: setter, updateData: updater, }); break; } case "attributes": ctx.locations.push({ idx: info.idx, refIdx: info.refIdx!, setData: attrsSetter, updateData: attrsUpdater, }); break; case "handler": { const setupHandler = createEventHandler(info.event!); ctx.locations.push({ idx: info.idx, refIdx: info.refIdx!, setData: setupHandler, updateData: setupHandler, }); break; } case "ref": ctx.cbRefs.push(info.idx); ctx.locations.push({ idx: info.idx, refIdx: info.refIdx!, setData: setRef, updateData: NO_OP, }); } } } // ----------------------------------------------------------------------------- // building the concrete block class // ----------------------------------------------------------------------------- function buildBlock(template: HTMLElement, ctx: BlockCtx): BlockType { let B = createBlockClass(template, ctx); if (ctx.cbRefs.length) { const refs = ctx.cbRefs; B = class extends B { remove() { super.remove(); for (let ref of refs) { let fn = (this as any).data[ref]; fn(null); } } }; } if (ctx.children.length) { B = class extends B { children: (VNode | undefined)[] | undefined; constructor(data?: any[], children?: VNode[]) { super(data); this.children = children; } }; B.prototype.beforeRemove = VMulti.prototype.beforeRemove; return (data?: any[], children: (VNode | undefined)[] = []) => new B(data, children); } return (data?: any[]) => new B(data); } type Constructor = new (...args: any[]) => T; type BlockClass = Constructor>; function createBlockClass(template: HTMLElement, ctx: BlockCtx): BlockClass { const { refN, collectors, children } = ctx; const colN = collectors.length; ctx.locations.sort((a, b) => a.idx - b.idx); const locations: Location[] = ctx.locations.map((loc) => ({ refIdx: loc.refIdx, setData: loc.setData, updateData: loc.updateData, })); const locN = locations.length; const childN = children.length; const childrenLocs = children; const isDynamic = refN > 0; // these values are defined here to make them faster to lookup in the class // block scope const nodeCloneNode = nodeProto.cloneNode; const nodeInsertBefore = nodeProto.insertBefore; const elementRemove = elementProto.remove; return class Block { el: HTMLElement | undefined; refs: Node[] | undefined; data: any[] | undefined; parentEl?: HTMLElement | undefined; children?: (VNode | undefined)[]; constructor(data?: any[]) { this.data = data; } beforeRemove() {} remove() { elementRemove.call(this.el); } firstNode(): Node { return this.el!; } moveBefore(other: Block | null, afterNode: Node | null) { const target = other ? other.el! : afterNode; nodeInsertBefore.call(this.parentEl, this.el!, target); } mount(parent: HTMLElement, afterNode: Node | null) { const el = nodeCloneNode.call(template, true); nodeInsertBefore.call(parent, el, afterNode); if (isDynamic) { // collecting references const refs: Node[] = new Array(refN); this.refs = refs; refs[0] = el; for (let i = 0; i < colN; i++) { const w = collectors[i]; refs[w.idx] = w.getVal.call(refs[w.prevIdx]); } // applying data to all update points if (locN) { const data = this.data!; for (let i = 0; i < locN; i++) { const loc = locations[i]; loc.setData.call(refs[loc.refIdx], data[i]); } } // preparing all children if (childN) { const children = this.children; for (let i = 0; i < childN; i++) { const child = children![i]; if (child) { const loc = childrenLocs[i]; const afterNode = loc.afterRefIdx ? refs[loc.afterRefIdx] : null; child.isOnlyChild = loc.isOnlyChild; child.mount(refs[loc.parentRefIdx] as any, afterNode); } } } } this.el = el as HTMLElement; this.parentEl = parent; } patch(other: Block, withBeforeRemove: boolean) { if (this === other) { return; } const refs = this.refs!; // update texts/attributes/ if (locN) { const data1 = this.data!; const data2 = other.data!; for (let i = 0; i < locN; i++) { const val1 = data1[i]; const val2 = data2[i]; if (val1 !== val2) { const loc = locations[i]; loc.updateData.call(refs[loc.refIdx], val2, val1); } } this.data = data2; } // update children if (childN) { let children1 = this.children; const children2 = other.children; for (let i = 0; i < childN; i++) { const child1 = children1![i]; const child2 = children2![i]; if (child1) { if (child2) { child1.patch(child2, withBeforeRemove); } else { if (withBeforeRemove) { child1.beforeRemove(); } child1.remove(); children1![i] = undefined; } } else if (child2) { const loc = childrenLocs[i]; const afterNode = loc.afterRefIdx ? refs[loc.afterRefIdx] : null; child2.mount(refs[loc.parentRefIdx] as any, afterNode); children1![i] = child2; } } } } toString() { const div = document.createElement("div"); this.mount(div, null); return div.innerHTML; } }; } function setText(this: Text, value: any) { characterDataSetData.call(this, toText(value)); } function setRef(this: HTMLElement, fn: any) { fn(this); }