From dedfbba27a24ce2706c96fd577e0464f8f4256c1 Mon Sep 17 00:00:00 2001 From: sbwalker Date: Fri, 13 Dec 2024 16:46:08 -0500 Subject: [PATCH] page-script enhancements --- Oqtane.Client/UI/Head.razor | 24 ++- Oqtane.Server/Components/App.razor | 41 ++-- .../wwwroot/Oqtane.Server.lib.module.js | 180 +++++++++++++----- 3 files changed, 163 insertions(+), 82 deletions(-) diff --git a/Oqtane.Client/UI/Head.razor b/Oqtane.Client/UI/Head.razor index fa06fc0e..1b030789 100644 --- a/Oqtane.Client/UI/Head.razor +++ b/Oqtane.Client/UI/Head.razor @@ -41,7 +41,7 @@ } break; case "HeadContent": - var content = RemoveScripts(SiteState.Properties.HeadContent) + "\n"; + var content = FormatScripts(SiteState.Properties.HeadContent) + "\n"; if (content != _content) { _content = content; @@ -51,14 +51,30 @@ } } - private string RemoveScripts(string headcontent) + private string FormatScripts(string headcontent) { - if (!string.IsNullOrEmpty(headcontent) && RenderMode == RenderModes.Interactive) + if (!string.IsNullOrEmpty(headcontent)) { var index = headcontent.IndexOf("= 0) { - headcontent = headcontent.Remove(index, headcontent.IndexOf("") + 9 - index); + var script = headcontent.Substring(index, headcontent.IndexOf("") + 9 - index); + if (RenderMode == RenderModes.Interactive) + { + // remove scripts when interactive rendering + headcontent = headcontent.Remove(index, script.Length); + } + else + { + // transform scripts into page-scripts when static rendering + var pagescript = script.Replace("script", "page-script"); + if (!pagescript.Contains("><")) + { + var content = pagescript.Substring(pagescript.IndexOf(">") + 1, pagescript.LastIndexOf("<") - pagescript.IndexOf(">") - 1); + pagescript = pagescript.Replace(">" + content, " content=\"" + content.Replace("\"","'") + "\">"); + } + headcontent = headcontent.Replace(script, pagescript); + } index = headcontent.IndexOf(""; // src at end of element due to enhanced navigation patch algorithm - } - else - { - // use custom element which can execute script on every page transition - @if (string.IsNullOrEmpty(resource.Integrity) && string.IsNullOrEmpty(resource.CrossOrigin)) - { - return ""; - } - else - { - // use modulepreload for external resources - return "\n" + - ""; - } - } - } - else - { - // inline script - return ""; + src = (resource.Url.Contains("://")) ? resource.Url : alias.BaseUrl + resource.Url; } + + return ""; } private void SetLocalizationCookie(string cookieValue) diff --git a/Oqtane.Server/wwwroot/Oqtane.Server.lib.module.js b/Oqtane.Server/wwwroot/Oqtane.Server.lib.module.js index 508f42a0..25d1e4b8 100644 --- a/Oqtane.Server/wwwroot/Oqtane.Server.lib.module.js +++ b/Oqtane.Server/wwwroot/Oqtane.Server.lib.module.js @@ -1,88 +1,170 @@ const pageScriptInfoBySrc = new Map(); -function registerPageScriptElement(src) { - if (!src) { - throw new Error('Must provide a non-empty value for the "src" attribute.'); +function getKey(element) { + if (element.src !== "") { + return element.src; + } else { + return element.content; } +} - let pageScriptInfo = pageScriptInfoBySrc.get(src); +function registerPageScriptElement(element) { + let key = getKey(element); + let pageScriptInfo = pageScriptInfoBySrc.get(key); if (pageScriptInfo) { pageScriptInfo.referenceCount++; } else { - pageScriptInfo = { referenceCount: 1, module: null }; - pageScriptInfoBySrc.set(src, pageScriptInfo); - initializePageScriptModule(src, pageScriptInfo); + if (element.src.startsWith("./")) { + element.src = new URL(element.src.substr(2), document.baseURI).toString(); + } + pageScriptInfo = { src: element.src, type: element.type, integrity: element.integrity, crossorigin: element.crossorigin, content: element.content, location: element.location, reload: element.reload, module: null, referenceCount: 1 }; + pageScriptInfoBySrc.set(key, pageScriptInfo); + initializePageScript(pageScriptInfo); } } -function unregisterPageScriptElement(src) { - if (!src) { - return; - } - - const pageScriptInfo = pageScriptInfoBySrc.get(src); +function unregisterPageScriptElement(element) { + const pageScriptInfo = pageScriptInfoBySrc.get(getKey(element)); if (!pageScriptInfo) { return; } - pageScriptInfo.referenceCount--; } -async function initializePageScriptModule(src, pageScriptInfo) { - // If the path is relative, normalize it by by making it an absolute URL - // with document's the base HREF. - if (src.startsWith("./")) { - src = new URL(src.substr(2), document.baseURI).toString(); +async function initializePageScript(pageScriptInfo) { + if (pageScriptInfo.reload) { + const module = await import(pageScriptInfo.src); + pageScriptInfo.module = module; + module.onLoad?.(); + module.onUpdate?.(); + } else { + try { + injectScript(pageScriptInfo); + } catch (error) { + console.error("Failed to load script: ${pageScriptInfo.src}", error); + } } - - const module = await import(src); - - if (pageScriptInfo.referenceCount <= 0) { - // All page-script elements with the same 'src' were - // unregistered while we were loading the module. - return; - } - - pageScriptInfo.module = module; - module.onLoad?.(); - module.onUpdate?.(); } function onEnhancedLoad() { - // Start by invoking 'onDispose' on any modules that are no longer referenced. - for (const [src, { module, referenceCount }] of pageScriptInfoBySrc) { - if (referenceCount <= 0) { - module?.onDispose?.(); - pageScriptInfoBySrc.delete(src); + for (const [key, pageScriptInfo] of pageScriptInfoBySrc) { + if (pageScriptInfo.referenceCount <= 0) { + if (pageScriptInfo.module) { + pageScriptInfo.module.onDispose?.(); + } + pageScriptInfoBySrc.delete(key); } } - // Then invoke 'onUpdate' on the remaining modules. - for (const { module } of pageScriptInfoBySrc.values()) { - module?.onUpdate?.(); + for (const [key, pageScriptInfo] of pageScriptInfoBySrc) { + if (pageScriptInfo.module) { + pageScriptInfo.module.onUpdate?.(); + } else { + try { + injectScript(pageScriptInfo); + } catch (error) { + if (pageScriptInfo.src !== "") { + console.error("Failed to load script library: ${pageScriptInfo.src}", error); + } else { + console.error("Failed to load inline script: ${pageScriptInfo.content}", error); + } + } + } } } +function injectScript(pageScriptInfo) { + return new Promise((resolve, reject) => { + var pageScript; + var script = document.createElement("script"); + script.async = false; + + if (pageScriptInfo.src !== "") { + pageScript = document.querySelector("page-script[src=\"" + pageScriptInfo.src + "\"]"); + script.src = pageScriptInfo.src; + if (pageScriptInfo.type !== "") { + script.type = pageScriptInfo.type; + } + if (pageScriptInfo.integrity !== "") { + script.integrity = pageScriptInfo.integrity; + } + if (pageScriptInfo.crossorigin !== "") { + script.crossOrigin = pageScriptInfo.crossorigin; + } + } else { + pageScript = document.querySelector("page-script[content=\"" + CSS.escape(pageScriptInfo.content) + "\"]"); + script.innerHTML = pageScriptInfo.content; + } + + script.onload = () => resolve(); + script.onerror = (error) => reject(error); + + // add script to page + if (pageScriptInfo.location === "head") { + document.head.appendChild(script); + } else { + document.body.appendChild(script); + // note this throws an exception when page-script is on a page which has interactive components (ie Counter.razor) + // Error: Uncaught (in promise) TypeError: can't access property "attributes" of null + // this seems to be related to Blazor-Server-Component-State which is also injected at the end of the body + } + + // remove page-script element from page + if (pageScript !== null) { + pageScript.remove(); + } + }); +} + export function afterWebStarted(blazor) { customElements.define('page-script', class extends HTMLElement { - static observedAttributes = ['src']; + static observedAttributes = ['src', 'type', 'integrity', 'crossorigin', 'content', 'location', 'reload']; + + constructor() { + super(); + + this.src = ""; + this.type = ""; + this.integrity = ""; + this.crossorigin = ""; + this.content = ""; + this.location = "head"; + this.reload = false; + } - // We use attributeChangedCallback instead of connectedCallback - // because a page-script element might get reused between enhanced - // navigations. attributeChangedCallback(name, oldValue, newValue) { - if (name !== 'src') { - return; + switch (name) { + case "src": + this.src = newValue; + break; + case "type": + this.type = newValue; + break; + case "integrity": + this.integrity = newValue; + break; + case "crossorigin": + this.crossorigin = newValue; + break; + case "content": + this.content = newValue; + break; + case "location": + this.location = newValue; + break; + case "reload": + this.reload = newValue; + break; } + } - this.src = newValue; - unregisterPageScriptElement(oldValue); - registerPageScriptElement(newValue); + connectedCallback() { + registerPageScriptElement(this); } disconnectedCallback() { - unregisterPageScriptElement(this.src); + unregisterPageScriptElement(this); } });