/** * @license * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ (() => { // Only process the polyfill if needed if ('htmlFor' in HTMLTemplateElement.prototype) return; console.log('Loading templates for polyfill...'); // Helper function to actually insert the content and cleanup the // processing instructions const replaceContentWithTemplate = ( type: string, templateNode: HTMLTemplateElement, startNode: Node, endNode: Node | null = null, target: HTMLElement ): void => { // Handle streaming parser. // If the document is still loading and either the template or the // processing instruction is the last element has siblings then it may be // incomplete. Return early and rely on the mutation observer and DCL // checks to reprocess later. if ( target.parentNode instanceof Document && document.readyState === 'loading' && (!templateNode.nextElementSibling || !(startNode as HTMLElement).nextElementSibling || (endNode && !(endNode as HTMLElement).nextElementSibling)) ) { return; } if (type !== 'marker') { // Remove every sibling after the startNode until the endNode. // If we don't have an end node, then we'll remove all siblings. let current = startNode.nextSibling; while (current) { if (current === endNode) { current.remove(); break; } const next = current.nextSibling; current.remove(); current = next; } } // Now actually replace the node (startNode as HTMLElement).replaceWith( templateNode.content.cloneNode(true) ); // Finally remove the template templateNode.remove(); }; const processTemplate = (template: HTMLTemplateElement) => { if ( !template || template.hasAttribute('data-no-patch') || !template.parentElement ) return; const name = template.getAttribute('for'); if (!name) return; // If the template is in the body then it has access to the whole document // including the
const parent = template.parentElement === document.body ? document.documentElement : template.parentElement; // We use a TreeWalker instead of regular query selectors to // handle comments and processing instructions const walker = document.createTreeWalker( parent, // Processing Instructions usually are comments in non-supporting // browser, but we also handle the case of actual Processing // Instructions in case browsers ever introduce them for other // reasons without shipping support NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_PROCESSING_INSTRUCTION ); let node: Node | null; let startNode: Node | null = null; let depth = 0; let startNodeDepth = 0; while ((node = walker.nextNode())) { let processingInstructionText: string | null = null; if (node.nodeType === Node.COMMENT_NODE) { // Processing Instructions are usually handled as comments if // is not supported. Patching adds Processing // Instructions to HTML for the first time. processingInstructionText = (node as Comment).data.replace( /^\?(start|end|marker)\b/gi, (m) => m.toLowerCase() ); } else if (node.nodeType === Node.PROCESSING_INSTRUCTION_NODE) { // If the browser supports processing instructions but not patching // (currently no browsers support this, but never say never!) // then they are in a slightly different format to comments, so // reformat them to be the same to make later processing easier. processingInstructionText = `?${(node as ProcessingInstruction).target.toLowerCase()}`; if ((node as ProcessingInstruction).data) processingInstructionText = `${processingInstructionText} ${(node as ProcessingInstruction).data}`; } // We should now have a processingInstruction beginning with `?` // If not, then ignore and carry on to the next node. if (!processingInstructionText || processingInstructionText.length <= 1) { continue; } // CASE 1: We are looking at a simple marker if (processingInstructionText.toLowerCase().startsWith('?marker')) { // Check if the hash matches (including if there is no hash) const regex = new RegExp(`\\bname *= *(["']?)${name}\\1`); const isMatch = regex.test(processingInstructionText); if (isMatch) { // Simple replacement, no range to track replaceContentWithTemplate('marker', template, node, null, parent); return; } } // CASE 2: We are looking for the start of a range else if (processingInstructionText.toLowerCase().startsWith('?start')) { depth++; // Only match if we haven't already got a startNode // to handle duplicate names const regex = new RegExp(`\\bname *= *(["']?)${name}\\1`); const isMatch = !startNode && regex.test(processingInstructionText); if (isMatch) { // Start of a range found; track it and keep walking startNode = node; startNodeDepth = depth; } } // CASE 3: We have found the end tag else if ( processingInstructionText.toLowerCase().startsWith('?end') && startNode ) { // Only replace content if we're at a depth of less than or equal to // this start tag. We'd only be less than if our start tag was nested // in other HTML elements and missing an end tag. if (depth <= startNodeDepth) { // Check if the endNode is for this one (it might be missing) const endNode = startNode.parentElement === node.parentElement ? node : null; replaceContentWithTemplate( 'range', template, startNode, endNode, parent ); return; } else { // If we see an end tag but we're at a deeper depth then decrement // the depth. if (depth > 1) depth--; // If we're at a depth < 1, so not covered by previous two cases, // then it's a rogue `` tag, so should just ignore it. } } } // If we reach the end of the TreeWalker and still are at a depth > 0 then // we're missing the endNode. Process with no endNode so all the startNode // siblings are replaced until the end of the element. if (depth > 0 && startNode) { // Remove everything between startNode and the closing tag of the element replaceContentWithTemplate('range', template, startNode, null, parent); } }; // Add a setHTML monkeypatch to process before // inserting into the DOM const preprocessHTML = (html: string) => { const parser = new DOMParser(); const parsedHTML = parser.parseFromString(html, 'text/html'); parsedHTML.querySelectorAll('template[for]').forEach((t) => { processTemplate(t as HTMLTemplateElement); }); parsedHTML .querySelectorAll('template[for]') .forEach((t) => t.setAttribute('data-no-patch', '')); return parsedHTML.body.innerHTML; }; const originalSetHTML = Element.prototype.setHTML; Element.prototype.setHTML = function (html: string) { const processedHTML = preprocessHTML(html); originalSetHTML.call(this, processedHTML); }; function processAllOutStandingTemplates() { // Handle all existing templates in the HTML document.querySelectorAll('template[for]').forEach((t) => { processTemplate(t as HTMLTemplateElement); }); // Handle any open shadow roots document.querySelectorAll('*').forEach((s) => { if (s.shadowRoot) s.shadowRoot.querySelectorAll('template[for]').forEach((t) => { if (t.parentElement) { processTemplate(t as HTMLTemplateElement); } }); }); } // Process all templates once now processAllOutStandingTemplates(); // As we check for streaming HTML and don't process the last elements while // document is loading, process one more time on DOMContentLoaded to catch // any child templates or processing instructions we missed that were the // last elements. window.addEventListener('DOMContentLoaded', () => { processAllOutStandingTemplates(); }); // Watch for, and handle, newly inserted templates or processing instructions in the HTML const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of Array.from(mutation.addedNodes)) { if (node instanceof HTMLTemplateElement && node.hasAttribute('for')) { // New template - process that if (node.parentElement) { processTemplate(node); } } else if (node instanceof HTMLElement && node.shadowRoot) { // New shadow root - process any templates in it node.shadowRoot.querySelectorAll('template[for]').forEach((t) => { if (t.parentElement) { processTemplate(t as HTMLTemplateElement); } }); } else { // Process any outstanding templates document.querySelectorAll('template[for]').forEach((t) => { if (t.parentElement) { processTemplate(t as HTMLTemplateElement); } }); } } } }); observer.observe(document, {childList: true, subtree: true}); })();