class Signal { #value; #subscribers; constructor(value) { this.#value = value; this.#subscribers = new Set(); } get value() { return this.#value; } set value(newValue) { this.#value = newValue; for (const callback of this.#subscribers) { callback(newValue); } } subscribe(callback) { if (this.#value) callback(this.#value); if (this.#value) //console.info(this.#value); this.#subscribers.add(callback); return () => this.#subscribers.delete(callback); } } class Record { //TODO: convert to dynamic getters to save CPU cycles #id; // internal id, rely on database content; // payload, strings, objects, signals... isTemplateVariable = true; // all records are template variables unsubscribe = []; // functions to execute on destroy constructor(id, content, intelligence){ this.#id = id; this.content = content; // context analysis/data if(typeof intelligence === 'string') Object.assign(this, decodeAttribute(intelligence)); if(typeof intelligence === 'object') Object.assign(this, intelligence); // dynamic content classification Object.assign(this, classifyContent(content)); if(this.isNode){ // set by decodeAttribute this.isNodeListType = content instanceof NodeList; this.isHTMLCollectionType = content instanceof HTMLCollection; this.isNodeType = content instanceof Node; } } } function html({ raw: strings }, ...values) { const [stream, database] = createStream(strings, values); // console.table(stream); console.table([...database.entries()]); const html = createIntermediateHtml(stream, database); const template = document.createElement("template"); template.innerHTML = html; const node = document.importNode(template.content, true); upgradeIntermediate(node, database); node.unsubscribe = () => [...database.values()] .filter((o) => o.isTemplateVariable) .filter((o) => o.unsubscribe.length > 0) .map((o) => o.unsubscribe) .flat() .map((bye) => bye()); return node; } // const output = myTag`That ${person} is a ${age}.`; const value1 = "world1"; const value2 = "world2"; const purple = new Signal("purple"); const blue = new Signal("blue"); const plue = new Signal({ background: blue, color: purple, }); setInterval(() => { purple.value = purple.value == "purple" ? "white" : "purple"; }, 11100); setInterval(() => { blue.value = blue.value == "blue" ? "white" : "blue"; }, 11130); const someName = new Signal("world"); // const someHtml1 = html`

Wee!

` // const someHtml2 = html`
  • are unaffected by this style
  • will still show a bullet
  • and have appropriate left margin
  • ` const createdElement = document.createElement("div"); // const element = html` // This text has color. // This text has color. // This text has color. // This text has color. // // It’s all red! // XXXXX It’s all blue! // whoa // hover me //

    GG ${value1}

    //

    Hello ${someName}

    //

    Hello ${someHtml1}

    //

    Hello ${someHtml2}

    //

    Hello ${createdElement}

    // // `; const element = html`

    GG1 ${value1} ${value2}

    GG2 ${value1} ${value2}

    `; document.body.appendChild(element); //console.log(document.body.innerHTML); setTimeout(() => { //console.log(element.unsubscribe()); }, 115_000); function createStream(strings, values) { const stream = []; const database = new Map(); for (const [index, string] of strings.entries()) { const content = values[index]; // log the raw string in correct sequence stream.push({ isTemplateChunk: true, content: string }); if(content === undefined) continue; // store a record in the records database, this helps with subscription management, state, and debugging const recordId = "::" + index; database.set(recordId, new Record(recordId, content, string)); // keep the record reference in the stream // This is a consistency kludge: we want to keep records in the database, and references in the stream. There will be more records to add to the database than what we find here, but we must still keep value references in order. stream.push({ isReference: true, id: recordId }); } return [stream, database]; } /** * identifyType - Identifies the JavaScript data type of a value, including object classes. * * @param {*} value - The value to identify. * @returns {string} - The type or class name as a string. */ function classifyContent(value) { if (value === null) return { isNullType: true, type: "null" }; if (value === undefined) return { isUndefinedType: true, type: "undefined" }; const primitiveType = typeof value; if (primitiveType !== "object") { const type = String(primitiveType).charAt(0).toUpperCase() + String(primitiveType).slice(1); return { ["is" + type + "Type"]: true, type }; } // Handle built-in objects and user-defined classes if (Array.isArray(value)) return { isArrayType: true, type: "Array" }; // Try to get the constructor name if (value.constructor && value.constructor.name) { return { ["is" + value.constructor.name + "Type"]: true, type: value.constructor.name, }; } // Fallback for objects without a constructor const fallback = Object.prototype.toString.call(value).slice(8, -1); return { ["is" + fallback + "Type"]: true, type: fallback }; } function decodeAttribute(htmlStr) { const lastToken = htmlStr.trim().split(/\s+/).pop(); const tagState = htmlStr .trim() .split(/[^<>]/) .filter((o) => o) .pop(); if (lastToken.endsWith("=")) { const attributeName = lastToken.substr(0, lastToken.length - 1); const capitalizedName = String(attributeName).charAt(0).toUpperCase() + String(attributeName).slice(1); const isAttributeValue = true; return { attributeName, isAttributeValue, ["is" + capitalizedName + "Attribute"]: true, }; } else if (tagState == "<") { const isAttributeObject = true; return { isAttributeObject }; } else { return { isNode: true }; } } function createIntermediateHtml(stream, database) { const result = []; for (const entry of stream) { // Dereference // NOTE: Raw html strings that are part of HTML are just strings and do not need dereferencing, Local Database Records/variables, there will bemore of them than are mentioned in the code, if HTML references an object, we will add properties of that object to the database const fragment = entry.isReference?database.get(entry.id):entry; // //console.log(fragment) // Primary const { isTemplateChunk, isTemplateVariable } = fragment; // Secondary const { isAttributeValue, isAttributeObject, isNode } = fragment; // Soecial markers to process when HTML becomes a Document if (isTemplateChunk) { // Just output the HTML result.push(fragment.content); } else if (isTemplateVariable && isAttributeValue) { result.push(entry.id); } else if (isTemplateVariable && isAttributeObject) { result.push(entry.id + '=""'); } else if (isTemplateVariable && isNode) { result.push(""); } } ////console.dir(result) return result.join(""); } function upgradeIntermediate(root, database) { let nodeFilter = undefined; // A callback function or an object with an acceptNode() method, which returns NodeFilter.FILTER_ACCEPT, NodeFilter.FILTER_REJECT, or NodeFilter.FILTER_SKIP. const elementWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT, nodeFilter); while (elementWalker.nextNode()) { for (const attribute of elementWalker.currentNode.attributes) { // Gather attributes for injection // NOTE: attributes are injected from two sites const attributeList = []; // ::4="" if (attribute.name.startsWith("::")) { const packet = database.get(attribute.name); for (const [attributeName, content] of Object.entries(packet.content)) { //NOTE: here the name is the object property const capitalizedName = String(attributeName).charAt(0).toUpperCase() + String(attributeName).slice(1); const recordId = attribute.name+'-'+attributeName; const intelligence = { isAttributeValue: true, ["is" + capitalizedName + "Attribute"]: true }; const attributePayload = new Record(recordId, content, intelligence) database.set(recordId, attributePayload); attributeList.push({attributeName, attributePayload}); } // Remove the [now malformed] marker as we will apply separate values elementWalker.currentNode.attributes.removeNamedItem(attribute.name); } console.info(attributeList) // color="::6" // NOTE: Here the attributeName is set by node attribute that startsWith("::") if (attribute.value.startsWith("::")){ const attributeName = attribute.name; const recordId = attribute.value; const attributePayload = database.get(recordId); attributeList.push({attributeName, attributePayload}); elementWalker.currentNode.attributes.removeNamedItem(attribute.name); // remove the signal installation triggering attribute } for (const {attributeName, attributePayload} of attributeList){ // NOTE: Required because elementWalker will keep updating... const currentNode = elementWalker.currentNode; if (attributePayload.isSignalType && attributePayload.isStyleAttribute) { const signal = attributePayload.content; const unsubscribe = signal.subscribe((v) => { for (const [cssProperty, cssValue] of Object.entries(v)) { if (cssValue.subscribe) { const unsubscribe = cssValue.subscribe(v=>currentNode.style[cssProperty] = v); attributePayload.unsubscribe.push(unsubscribe); } else { // not a signal currentNode.style[cssProperty] = cssValue; } } }); attributePayload.unsubscribe.push(unsubscribe); } else if (attributePayload.isSignalType) { // NOTE: Signals are a special case as they require a subscription const signal = attributePayload.content; const unsubscribe = signal.subscribe((v) => currentNode.setAttribute(attributeName, v)); attributePayload.unsubscribe.push(unsubscribe); } else if (attributePayload.isObjectType && attributePayload.isStyleAttribute) { // NOTE: Styles are super special case as they require special handling for (const [cssProperty, cssValue] of Object.entries(attributePayload.content)) { currentNode.style[cssProperty] = cssValue; } } else if (attributePayload.isFunctionType) { // NOTE: Functions are a special case because the node attribute must be removed, since we are adding a method currentNode[attributeName] = attributePayload.content; // TODO: Cover other cases, but without using generic/reusable functions, approach this on case by case basis. } else if (attributePayload.isStringType) { // NOTE: Example of how simple things work * example of how to add support for other objects currentNode.setAttribute(attributeName, attributePayload.content) } else if (attributePayload.isNumberType) { // NOTE: Example of how simple things work currentNode.setAttribute(attributeName, String(attributePayload.content)) } else { // allow plain old value coercion currentNode.setAttribute(attributeName, attributePayload.content) } } } const markerNodesNodes = []; const commentWalker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT, nodeFilter); while (commentWalker.nextNode()) { const currentNode = commentWalker.currentNode; if (/^::/.test(currentNode.data)) { console.log(currentNode.data) const parentNode = currentNode.parentNode; const record = database.get(currentNode.data); const content = record.content; // TODO: Accumulator Pattern if (record.isStringType) { console.log(content) const textNode = document.createTextNode(content + 'after'); // parentNode.insertAfter(textNode, currentNode); currentNode.after(textNode ); markerNodesNodes.push(currentNode); } else if (record.isNodeType) { parentNode.insertAfter(content, currentNode); markerNodesNodes.push(currentNode); } else if (record.isStringType) { const textNode = document.createTextNode(content); // parentNode.insertAfter(textNode, currentNode); currentNode.after(textNode); markerNodesNodes.push(currentNode); } else if (record.isNumberType) { const textNode = document.createTextNode(content); parentNode.insertAfter(textNode, currentNode); markerNodesNodes.push(currentNode); } else if (record.isSignalType) { const signal = record.content; const textNode = document.createTextNode(signal.value); parentNode.insertAfter(textNode, currentNode); const unsubscribe = signal.subscribe((v) => textNode.nodeValue = v); record.unsubscribe.push(unsubscribe); markerNodesNodes.push(currentNode); } else if (record.isNodeListType) { let target = currentNode; content.forEach(node => { target = parentNode.insertAfter(node, target); }); markerNodesNodes.push(currentNode); } else if (record.isHTMLCollectionType) { let target = currentNode; content.forEach(node => { target = parentNode.insertAfter(node, target); }); markerNodesNodes.push(currentNode); } else { const textNode = document.createTextNode(content); parentNode.insertAfter(textNode, currentNode); markerNodesNodes.push(currentNode); } } // walker for (const node of markerNodesNodes) { node.parentNode.removeChild(node); } } } }