import { ExternalLinkWarningModal } from "/js/modals/externalLinkWarning.modal.js"; import "/js/components/toggle-switch.js"; import "/js/components/plugin-profiles-list.js"; import "/js/components/plugin-posts-feed.js"; import "/js/components/plugin-icon.js"; import "/js/components/plugin-blob-image.js"; function isExternalHref(href) { try { return new URL(href).origin !== window.location.origin; } catch { return false; } } const ALLOWED_TAGS = [ "div", "span", "p", "h1", "h2", "h3", "h4", "ul", "ol", "li", "strong", "em", "b", "i", "code", "pre", "br", "hr", "button", "input", "select", "option", "label", "textarea", "a", "plugin-profiles-list", "plugin-posts-feed", "plugin-icon", "plugin-blob-image", "toggle-switch", ]; const ALLOWED_EVENTS = ["click", "change", "input"]; function isAllowedTag(tag) { return ALLOWED_TAGS.includes(tag); } const ALLOWED_ATTRS = [ "class", "title", "role", "lang", "dir", "type", "value", "placeholder", "checked", "selected", "disabled", "name", "for", "id", "href", ]; function isSafeHref(value) { if (typeof value !== "string") return false; try { const url = new URL(value); return url.protocol === "https:"; } catch { return false; } } function isAllowedAttr(name, tag) { if (ALLOWED_ATTRS.includes(name)) return true; if (name.startsWith("data-") || name.startsWith("aria-")) return true; if (tag && tag.includes("-")) { // Allow custom elements observed attributes const ctor = customElements.get(tag); if (ctor?.observedAttributes?.includes(name)) return true; } return false; } function createVirtualEvent(e) { const target = e.target ?? {}; const virtualTarget = {}; if (typeof target.value === "string") virtualTarget.value = target.value; if (typeof target.checked === "boolean") { virtualTarget.checked = target.checked; } return { type: e.type, target: virtualTarget, }; } const HANDLER_MAP = Symbol("pluginHandlerMap"); function resolveTag(node, pluginId) { let tag = typeof node.tag === "string" ? node.tag.toLowerCase() : "div"; if (!isAllowedTag(tag)) { if (pluginId !== undefined) { console.warn( `[plugins] "${pluginId}" tried to render disallowed tag <${tag}>`, ); } tag = "span"; } return tag; } // Render a serialized VirtualEl node ({ tag, attrs, text, children }) into a DOM element. export class PluginRenderer { constructor(pluginBridge, pluginId, renderContext) { this.pluginBridge = pluginBridge; this.pluginId = pluginId; this.renderContext = renderContext; } createRoot() { const renderer = this; const pluginId = this.pluginId; return { tree: null, el: null, render(node) { if (this.el && renderer._sameType(this.tree, node)) { renderer._patch(this.el, this.tree, node, pluginId); } else { this.el = renderer._create(node, pluginId); } this.tree = node; return this.el; }, reset() { this.el = null; this.tree = null; }, }; } _sameType(oldNode, newNode) { if (!oldNode || !newNode) return false; return resolveTag(oldNode) === resolveTag(newNode); } _create(node, pluginId) { const tag = resolveTag(node, pluginId); const element = document.createElement(tag); if (tag === "a") { element.setAttribute("target", "_blank"); element.setAttribute("rel", "noopener noreferrer"); element.addEventListener("click", (event) => { const href = element.getAttribute("href"); if (!href || !isExternalHref(href)) return; event.preventDefault(); ExternalLinkWarningModal.open({ href }); }); } if (tag === "plugin-profiles-list") { const { dataLayer } = this.renderContext; element.dataLayer = dataLayer; } if (tag === "plugin-posts-feed") { const { dataLayer, isAuthenticated, pluginService, interactionHandlers: { postInteractionHandler }, } = this.renderContext; element.dataLayer = dataLayer; element.isAuthenticated = isAuthenticated; element.pluginService = pluginService; element.postInteractionHandler = postInteractionHandler; } if (tag === "toggle-switch") { // toggle-switch is controlled — flip its state here since the plugin // worker can't observe events synchronously to re-render. element.addEventListener("change", (event) => { element.checked = event.detail?.checked ?? !element.checked; }); } if (node.attrs) { for (const [name, value] of Object.entries(node.attrs)) { if (!isAllowedAttr(name, tag)) { console.warn( `[plugins] "${pluginId}" tried to set disallowed attribute "${name}" on <${tag}>`, ); continue; } if (name === "href" && !isSafeHref(value)) { console.warn( `[plugins] "${pluginId}" tried to set unsafe href "${value}"`, ); continue; } element.setAttribute(name, String(value)); } } this._patchEvents(element, null, node.events, pluginId); const children = Array.isArray(node.children) ? node.children : []; const hasText = node.text != null && node.text !== ""; if (hasText && children.length === 0) { element.textContent = node.text; } else { if (hasText) { element.appendChild(document.createTextNode(node.text)); } for (const child of children) { element.appendChild(this._create(child, pluginId)); } } return element; } _patch(element, oldNode, newNode, pluginId) { const oldAttrs = oldNode.attrs ?? {}; const newAttrs = newNode.attrs ?? {}; const isFocused = document.activeElement === element; const tag = element.localName; for (const name of Object.keys(oldAttrs)) { if (!(name in newAttrs) && isAllowedAttr(name, tag)) { element.removeAttribute(name); } } for (const [name, value] of Object.entries(newAttrs)) { if (!isAllowedAttr(name, tag)) { console.warn( `[plugins] "${pluginId}" tried to set disallowed attribute "${name}"`, ); continue; } // Don't clobber what the user is currently editing. if (isFocused && (name === "value" || name === "checked")) continue; if (name === "href" && !isSafeHref(value)) { console.warn( `[plugins] "${pluginId}" tried to set unsafe href "${value}"`, ); element.removeAttribute("href"); continue; } if (oldAttrs[name] !== value) element.setAttribute(name, String(value)); } this._patchEvents(element, oldNode.events, newNode.events, pluginId); // Custom elements may have internal state derived from selectors (e.g. // optimistic like state). Give them a chance to re-render now that the // top-down patch has propagated. if (tag.includes("-") && typeof element.refresh === "function") { element.refresh(); } const oldChildren = Array.isArray(oldNode.children) ? oldNode.children : []; const newChildren = Array.isArray(newNode.children) ? newNode.children : []; const oldHasText = oldNode.text != null && oldNode.text !== ""; const newHasText = newNode.text != null && newNode.text !== ""; // Fast path: text-only on both sides. if (oldChildren.length === 0 && newChildren.length === 0) { if (newHasText) { if (newNode.text !== oldNode.text) element.textContent = newNode.text; } else if (oldHasText) { element.textContent = ""; } return; } // If the children-vs-text shape changed dramatically, rebuild content. const oldHadOnlyText = oldChildren.length === 0 && oldHasText; const newHasOnlyText = newChildren.length === 0 && newHasText; if (oldHadOnlyText || newHasOnlyText) { element.textContent = ""; if (newHasText) { element.appendChild(document.createTextNode(newNode.text)); } for (const child of newChildren) { element.appendChild(this._create(child, pluginId)); } return; } // Both sides have element children — manage the optional leading text node. let textOffset = 0; const firstIsTextNode = element.firstChild && element.firstChild.nodeType === Node.TEXT_NODE; if (newHasText) { if (firstIsTextNode) { if (element.firstChild.textContent !== newNode.text) { element.firstChild.textContent = newNode.text; } } else { element.insertBefore( document.createTextNode(newNode.text), element.firstChild, ); } textOffset = 1; } else if (oldHasText && firstIsTextNode) { element.removeChild(element.firstChild); } const domChildren = Array.from(element.childNodes); const max = Math.max(oldChildren.length, newChildren.length); for (let index = 0; index < max; index++) { const oldChild = oldChildren[index]; const newChild = newChildren[index]; const domChild = domChildren[index + textOffset]; if (!oldChild && newChild) { element.appendChild(this._create(newChild, pluginId)); } else if (oldChild && !newChild) { if (domChild) element.removeChild(domChild); } else if (this._sameType(oldChild, newChild)) { this._patch(domChild, oldChild, newChild, pluginId); } else { element.replaceChild(this._create(newChild, pluginId), domChild); } } } _patchEvents(element, oldEvents, newEvents, pluginId) { const map = (element[HANDLER_MAP] ??= {}); const next = newEvents && typeof newEvents === "object" ? newEvents : {}; if (oldEvents) { for (const name of Object.keys(oldEvents)) { if (!(name in next)) delete map[name]; } } for (const [name, handlerId] of Object.entries(next)) { if (!ALLOWED_EVENTS.includes(name)) { console.warn( `[plugins] "${pluginId}" tried to bind disallowed event "${name}"`, ); continue; } const isNew = !(name in map); map[name] = handlerId; if (isNew) { element.addEventListener(name, (event) => { const currentId = element[HANDLER_MAP]?.[name]; if (currentId == null) return; this.pluginBridge.handleNodeEvent( pluginId, currentId, createVirtualEvent(event), ); }); } } } isEmptyNode(node) { if (!node) return true; if (node.text != null && node.text !== "") return false; if (Array.isArray(node.children) && node.children.length > 0) return false; return true; } }