diff --git a/TecniStamp/TecniStamp/Components/Pages/Account/Login.razor b/TecniStamp/TecniStamp/Components/Pages/Account/Login.razor
index 9cb402c..5ba9652 100644
--- a/TecniStamp/TecniStamp/Components/Pages/Account/Login.razor
+++ b/TecniStamp/TecniStamp/Components/Pages/Account/Login.razor
@@ -1,5 +1,5 @@
@layout LoginLayout
-@page "/"
+@page "/account/login"
@using System.Security.Claims
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Authentication.Cookies
@@ -87,6 +87,6 @@
var identity = new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
var principal = new ClaimsPrincipal(identity);
await httpContext.SignInAsync(principal);
- _navManager.NavigateTo("/home");
+ _navManager.NavigateTo("/");
}
}
\ No newline at end of file
diff --git a/TecniStamp/TecniStamp/Components/Pages/Home.razor b/TecniStamp/TecniStamp/Components/Pages/Home.razor
index 6c5b8f3..d2b3c0f 100644
--- a/TecniStamp/TecniStamp/Components/Pages/Home.razor
+++ b/TecniStamp/TecniStamp/Components/Pages/Home.razor
@@ -1,10 +1,28 @@
@attribute [Authorize]
-@page "/home"
+@page "/"
@using Microsoft.AspNetCore.Authorization
+@using Microsoft.EntityFrameworkCore
+@using TecniStamp.Components.Widget
+@using TecniStamp.Domain
+@using TecniStamp.Utils
-
Home
+@foreach (var item in TileList)
+{
+
+}
-
Hello, world!
+@code {
+ public List
TileList { get; set; } = new();
+
+ protected override async Task OnInitializedAsync()
+ {
+ await base.OnInitializedAsync();
-Welcome to your new app.
\ No newline at end of file
+ var roleId = await MembershipUtils.GetRoleId(_auth);
+ TileList = (await _managerService.PermissionService.RicercaQueryable(
+ x => x.RuoloId == roleId && x.Feature.Sezione.ParentId == null,
+ includi:x => x.Include(y => y.Feature).ThenInclude(z => z.Sezione),
+ ordinamento:x => x.OrderBy(y => y.Feature.Sezione.Ordinamento))).ToList();
+ }
+}
\ No newline at end of file
diff --git a/TecniStamp/TecniStamp/Components/Routes.razor b/TecniStamp/TecniStamp/Components/Routes.razor
index ae94e9e..ec8963e 100644
--- a/TecniStamp/TecniStamp/Components/Routes.razor
+++ b/TecniStamp/TecniStamp/Components/Routes.razor
@@ -1,6 +1,19 @@
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Pagina non trovata.
+
+
+
+
\ No newline at end of file
diff --git a/TecniStamp/TecniStamp/Utils/MembershipUtils.cs b/TecniStamp/TecniStamp/Utils/MembershipUtils.cs
new file mode 100644
index 0000000..8491cba
--- /dev/null
+++ b/TecniStamp/TecniStamp/Utils/MembershipUtils.cs
@@ -0,0 +1,22 @@
+using Microsoft.AspNetCore.Components.Authorization;
+
+namespace TecniStamp.Utils;
+
+public static class MembershipUtils
+{
+ public static async Task GetUserId(AuthenticationStateProvider auth)
+ {
+ var state = await auth.GetAuthenticationStateAsync();
+ var idClaim = state.User.FindFirst("UserId")?.Value;
+
+ return Guid.Parse(idClaim ?? Guid.Empty.ToString());
+ }
+
+ public static async Task GetRoleId(AuthenticationStateProvider auth)
+ {
+ var state = await auth.GetAuthenticationStateAsync();
+ var idClaim = state.User.FindFirst("RoleId")?.Value;
+
+ return Guid.Parse(idClaim ?? Guid.Empty.ToString());
+ }
+}
diff --git a/TecniStamp/TecniStamp/bin/Debug/net8.0/TecniStamp.dll b/TecniStamp/TecniStamp/bin/Debug/net8.0/TecniStamp.dll
index 633c62d..7d80558 100644
Binary files a/TecniStamp/TecniStamp/bin/Debug/net8.0/TecniStamp.dll and b/TecniStamp/TecniStamp/bin/Debug/net8.0/TecniStamp.dll differ
diff --git a/TecniStamp/TecniStamp/bin/Debug/net8.0/TecniStamp.exe b/TecniStamp/TecniStamp/bin/Debug/net8.0/TecniStamp.exe
index e8f8285..9f7b4fe 100644
Binary files a/TecniStamp/TecniStamp/bin/Debug/net8.0/TecniStamp.exe and b/TecniStamp/TecniStamp/bin/Debug/net8.0/TecniStamp.exe differ
diff --git a/TecniStamp/TecniStamp/bin/Debug/net8.0/TecniStamp.pdb b/TecniStamp/TecniStamp/bin/Debug/net8.0/TecniStamp.pdb
index 57488ed..c525421 100644
Binary files a/TecniStamp/TecniStamp/bin/Debug/net8.0/TecniStamp.pdb and b/TecniStamp/TecniStamp/bin/Debug/net8.0/TecniStamp.pdb differ
diff --git a/TecniStamp/TecniStamp/obj/Debug/net8.0/TecniStamp.AssemblyInfo.cs b/TecniStamp/TecniStamp/obj/Debug/net8.0/TecniStamp.AssemblyInfo.cs
index 44f0083..2e7ce14 100644
--- a/TecniStamp/TecniStamp/obj/Debug/net8.0/TecniStamp.AssemblyInfo.cs
+++ b/TecniStamp/TecniStamp/obj/Debug/net8.0/TecniStamp.AssemblyInfo.cs
@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("TecniStamp")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
-[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+8026daf073d66baf32321ac5ab52de8b4048a7ef")]
+[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+314cd9eaee3ba473d99f41b9199d0b448a458ada")]
[assembly: System.Reflection.AssemblyProductAttribute("TecniStamp")]
[assembly: System.Reflection.AssemblyTitleAttribute("TecniStamp")]
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
diff --git a/TecniStamp/TecniStamp/obj/Debug/net8.0/TecniStamp.AssemblyInfoInputs.cache b/TecniStamp/TecniStamp/obj/Debug/net8.0/TecniStamp.AssemblyInfoInputs.cache
index cabe78d..3c774c7 100644
--- a/TecniStamp/TecniStamp/obj/Debug/net8.0/TecniStamp.AssemblyInfoInputs.cache
+++ b/TecniStamp/TecniStamp/obj/Debug/net8.0/TecniStamp.AssemblyInfoInputs.cache
@@ -1 +1 @@
-1f7f487751bfee95cd57d1b77a4707df04c069b6f440027b707e738eff50f573
+256c08859c76d0ad37b687b6fc285fc502f575ed65c6f504adb5700405fd1dba
diff --git a/TecniStamp/TecniStamp/obj/Debug/net8.0/TecniStamp.csproj.CoreCompileInputs.cache b/TecniStamp/TecniStamp/obj/Debug/net8.0/TecniStamp.csproj.CoreCompileInputs.cache
index dd3bb18..255afbd 100644
--- a/TecniStamp/TecniStamp/obj/Debug/net8.0/TecniStamp.csproj.CoreCompileInputs.cache
+++ b/TecniStamp/TecniStamp/obj/Debug/net8.0/TecniStamp.csproj.CoreCompileInputs.cache
@@ -1 +1 @@
-6d9778cdcf6acf5332bad6ce78c8186ce6aa998b6281ef4e207f5dda3aeaa290
+69f31d796ff147eb9cc06e595ab2be7d1454b53b3cd4a832ad8bcdb0a60e327f
diff --git a/TecniStamp/TecniStamp/obj/Debug/net8.0/TecniStamp.dll b/TecniStamp/TecniStamp/obj/Debug/net8.0/TecniStamp.dll
index 633c62d..7d80558 100644
Binary files a/TecniStamp/TecniStamp/obj/Debug/net8.0/TecniStamp.dll and b/TecniStamp/TecniStamp/obj/Debug/net8.0/TecniStamp.dll differ
diff --git a/TecniStamp/TecniStamp/obj/Debug/net8.0/TecniStamp.pdb b/TecniStamp/TecniStamp/obj/Debug/net8.0/TecniStamp.pdb
index 57488ed..c525421 100644
Binary files a/TecniStamp/TecniStamp/obj/Debug/net8.0/TecniStamp.pdb and b/TecniStamp/TecniStamp/obj/Debug/net8.0/TecniStamp.pdb differ
diff --git a/TecniStamp/TecniStamp/obj/Debug/net8.0/apphost.exe b/TecniStamp/TecniStamp/obj/Debug/net8.0/apphost.exe
index e8f8285..9f7b4fe 100644
Binary files a/TecniStamp/TecniStamp/obj/Debug/net8.0/apphost.exe and b/TecniStamp/TecniStamp/obj/Debug/net8.0/apphost.exe differ
diff --git a/TecniStamp/TecniStamp/obj/Debug/net8.0/ref/TecniStamp.dll b/TecniStamp/TecniStamp/obj/Debug/net8.0/ref/TecniStamp.dll
index d7623c1..197104f 100644
Binary files a/TecniStamp/TecniStamp/obj/Debug/net8.0/ref/TecniStamp.dll and b/TecniStamp/TecniStamp/obj/Debug/net8.0/ref/TecniStamp.dll differ
diff --git a/TecniStamp/TecniStamp/obj/Debug/net8.0/refint/TecniStamp.dll b/TecniStamp/TecniStamp/obj/Debug/net8.0/refint/TecniStamp.dll
index d7623c1..197104f 100644
Binary files a/TecniStamp/TecniStamp/obj/Debug/net8.0/refint/TecniStamp.dll and b/TecniStamp/TecniStamp/obj/Debug/net8.0/refint/TecniStamp.dll differ
diff --git a/TecniStamp/TecniStamp/wwwroot/libs/@hotwired/turbo/dist/turbo.es2017-esm.js b/TecniStamp/TecniStamp/wwwroot/libs/@hotwired/turbo/dist/turbo.es2017-esm.js
new file mode 100644
index 0000000..c895234
--- /dev/null
+++ b/TecniStamp/TecniStamp/wwwroot/libs/@hotwired/turbo/dist/turbo.es2017-esm.js
@@ -0,0 +1,7178 @@
+/*!
+Turbo 8.0.13
+Copyright © 2025 37signals LLC
+ */
+/**
+ * The MIT License (MIT)
+ *
+ * Copyright (c) 2019 Javan Makhmali
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+ * THE SOFTWARE.
+ */
+
+(function (prototype) {
+ if (typeof prototype.requestSubmit == "function") return
+
+ prototype.requestSubmit = function (submitter) {
+ if (submitter) {
+ validateSubmitter(submitter, this);
+ submitter.click();
+ } else {
+ submitter = document.createElement("input");
+ submitter.type = "submit";
+ submitter.hidden = true;
+ this.appendChild(submitter);
+ submitter.click();
+ this.removeChild(submitter);
+ }
+ };
+
+ function validateSubmitter(submitter, form) {
+ submitter instanceof HTMLElement || raise(TypeError, "parameter 1 is not of type 'HTMLElement'");
+ submitter.type == "submit" || raise(TypeError, "The specified element is not a submit button");
+ submitter.form == form ||
+ raise(DOMException, "The specified element is not owned by this form element", "NotFoundError");
+ }
+
+ function raise(errorConstructor, message, name) {
+ throw new errorConstructor("Failed to execute 'requestSubmit' on 'HTMLFormElement': " + message + ".", name)
+ }
+})(HTMLFormElement.prototype);
+
+const submittersByForm = new WeakMap();
+
+function findSubmitterFromClickTarget(target) {
+ const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
+ const candidate = element ? element.closest("input, button") : null;
+ return candidate?.type == "submit" ? candidate : null
+}
+
+function clickCaptured(event) {
+ const submitter = findSubmitterFromClickTarget(event.target);
+
+ if (submitter && submitter.form) {
+ submittersByForm.set(submitter.form, submitter);
+ }
+}
+
+(function () {
+ if ("submitter" in Event.prototype) return
+
+ let prototype = window.Event.prototype;
+ // Certain versions of Safari 15 have a bug where they won't
+ // populate the submitter. This hurts TurboDrive's enable/disable detection.
+ // See https://bugs.webkit.org/show_bug.cgi?id=229660
+ if ("SubmitEvent" in window) {
+ const prototypeOfSubmitEvent = window.SubmitEvent.prototype;
+
+ if (/Apple Computer/.test(navigator.vendor) && !("submitter" in prototypeOfSubmitEvent)) {
+ prototype = prototypeOfSubmitEvent;
+ } else {
+ return // polyfill not needed
+ }
+ }
+
+ addEventListener("click", clickCaptured, true);
+
+ Object.defineProperty(prototype, "submitter", {
+ get() {
+ if (this.type == "submit" && this.target instanceof HTMLFormElement) {
+ return submittersByForm.get(this.target)
+ }
+ }
+ });
+})();
+
+const FrameLoadingStyle = {
+ eager: "eager",
+ lazy: "lazy"
+};
+
+/**
+ * Contains a fragment of HTML which is updated based on navigation within
+ * it (e.g. via links or form submissions).
+ *
+ * @customElement turbo-frame
+ * @example
+ *
+ *
+ * Show all expanded messages in this frame.
+ *
+ *
+ *
+ *
+ */
+class FrameElement extends HTMLElement {
+ static delegateConstructor = undefined
+
+ loaded = Promise.resolve()
+
+ static get observedAttributes() {
+ return ["disabled", "loading", "src"]
+ }
+
+ constructor() {
+ super();
+ this.delegate = new FrameElement.delegateConstructor(this);
+ }
+
+ connectedCallback() {
+ this.delegate.connect();
+ }
+
+ disconnectedCallback() {
+ this.delegate.disconnect();
+ }
+
+ reload() {
+ return this.delegate.sourceURLReloaded()
+ }
+
+ attributeChangedCallback(name) {
+ if (name == "loading") {
+ this.delegate.loadingStyleChanged();
+ } else if (name == "src") {
+ this.delegate.sourceURLChanged();
+ } else if (name == "disabled") {
+ this.delegate.disabledChanged();
+ }
+ }
+
+ /**
+ * Gets the URL to lazily load source HTML from
+ */
+ get src() {
+ return this.getAttribute("src")
+ }
+
+ /**
+ * Sets the URL to lazily load source HTML from
+ */
+ set src(value) {
+ if (value) {
+ this.setAttribute("src", value);
+ } else {
+ this.removeAttribute("src");
+ }
+ }
+
+ /**
+ * Gets the refresh mode for the frame.
+ */
+ get refresh() {
+ return this.getAttribute("refresh")
+ }
+
+ /**
+ * Sets the refresh mode for the frame.
+ */
+ set refresh(value) {
+ if (value) {
+ this.setAttribute("refresh", value);
+ } else {
+ this.removeAttribute("refresh");
+ }
+ }
+
+ get shouldReloadWithMorph() {
+ return this.src && this.refresh === "morph"
+ }
+
+ /**
+ * Determines if the element is loading
+ */
+ get loading() {
+ return frameLoadingStyleFromString(this.getAttribute("loading") || "")
+ }
+
+ /**
+ * Sets the value of if the element is loading
+ */
+ set loading(value) {
+ if (value) {
+ this.setAttribute("loading", value);
+ } else {
+ this.removeAttribute("loading");
+ }
+ }
+
+ /**
+ * Gets the disabled state of the frame.
+ *
+ * If disabled, no requests will be intercepted by the frame.
+ */
+ get disabled() {
+ return this.hasAttribute("disabled")
+ }
+
+ /**
+ * Sets the disabled state of the frame.
+ *
+ * If disabled, no requests will be intercepted by the frame.
+ */
+ set disabled(value) {
+ if (value) {
+ this.setAttribute("disabled", "");
+ } else {
+ this.removeAttribute("disabled");
+ }
+ }
+
+ /**
+ * Gets the autoscroll state of the frame.
+ *
+ * If true, the frame will be scrolled into view automatically on update.
+ */
+ get autoscroll() {
+ return this.hasAttribute("autoscroll")
+ }
+
+ /**
+ * Sets the autoscroll state of the frame.
+ *
+ * If true, the frame will be scrolled into view automatically on update.
+ */
+ set autoscroll(value) {
+ if (value) {
+ this.setAttribute("autoscroll", "");
+ } else {
+ this.removeAttribute("autoscroll");
+ }
+ }
+
+ /**
+ * Determines if the element has finished loading
+ */
+ get complete() {
+ return !this.delegate.isLoading
+ }
+
+ /**
+ * Gets the active state of the frame.
+ *
+ * If inactive, source changes will not be observed.
+ */
+ get isActive() {
+ return this.ownerDocument === document && !this.isPreview
+ }
+
+ /**
+ * Sets the active state of the frame.
+ *
+ * If inactive, source changes will not be observed.
+ */
+ get isPreview() {
+ return this.ownerDocument?.documentElement?.hasAttribute("data-turbo-preview")
+ }
+}
+
+function frameLoadingStyleFromString(style) {
+ switch (style.toLowerCase()) {
+ case "lazy":
+ return FrameLoadingStyle.lazy
+ default:
+ return FrameLoadingStyle.eager
+ }
+}
+
+const drive = {
+ enabled: true,
+ progressBarDelay: 500,
+ unvisitableExtensions: new Set(
+ [
+ ".7z", ".aac", ".apk", ".avi", ".bmp", ".bz2", ".css", ".csv", ".deb", ".dmg", ".doc",
+ ".docx", ".exe", ".gif", ".gz", ".heic", ".heif", ".ico", ".iso", ".jpeg", ".jpg",
+ ".js", ".json", ".m4a", ".mkv", ".mov", ".mp3", ".mp4", ".mpeg", ".mpg", ".msi",
+ ".ogg", ".ogv", ".pdf", ".pkg", ".png", ".ppt", ".pptx", ".rar", ".rtf",
+ ".svg", ".tar", ".tif", ".tiff", ".txt", ".wav", ".webm", ".webp", ".wma", ".wmv",
+ ".xls", ".xlsx", ".xml", ".zip"
+ ]
+ )
+};
+
+function activateScriptElement(element) {
+ if (element.getAttribute("data-turbo-eval") == "false") {
+ return element
+ } else {
+ const createdScriptElement = document.createElement("script");
+ const cspNonce = getCspNonce();
+ if (cspNonce) {
+ createdScriptElement.nonce = cspNonce;
+ }
+ createdScriptElement.textContent = element.textContent;
+ createdScriptElement.async = false;
+ copyElementAttributes(createdScriptElement, element);
+ return createdScriptElement
+ }
+}
+
+function copyElementAttributes(destinationElement, sourceElement) {
+ for (const { name, value } of sourceElement.attributes) {
+ destinationElement.setAttribute(name, value);
+ }
+}
+
+function createDocumentFragment(html) {
+ const template = document.createElement("template");
+ template.innerHTML = html;
+ return template.content
+}
+
+function dispatch(eventName, { target, cancelable, detail } = {}) {
+ const event = new CustomEvent(eventName, {
+ cancelable,
+ bubbles: true,
+ composed: true,
+ detail
+ });
+
+ if (target && target.isConnected) {
+ target.dispatchEvent(event);
+ } else {
+ document.documentElement.dispatchEvent(event);
+ }
+
+ return event
+}
+
+function cancelEvent(event) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+}
+
+function nextRepaint() {
+ if (document.visibilityState === "hidden") {
+ return nextEventLoopTick()
+ } else {
+ return nextAnimationFrame()
+ }
+}
+
+function nextAnimationFrame() {
+ return new Promise((resolve) => requestAnimationFrame(() => resolve()))
+}
+
+function nextEventLoopTick() {
+ return new Promise((resolve) => setTimeout(() => resolve(), 0))
+}
+
+function nextMicrotask() {
+ return Promise.resolve()
+}
+
+function parseHTMLDocument(html = "") {
+ return new DOMParser().parseFromString(html, "text/html")
+}
+
+function unindent(strings, ...values) {
+ const lines = interpolate(strings, values).replace(/^\n/, "").split("\n");
+ const match = lines[0].match(/^\s+/);
+ const indent = match ? match[0].length : 0;
+ return lines.map((line) => line.slice(indent)).join("\n")
+}
+
+function interpolate(strings, values) {
+ return strings.reduce((result, string, i) => {
+ const value = values[i] == undefined ? "" : values[i];
+ return result + string + value
+ }, "")
+}
+
+function uuid() {
+ return Array.from({ length: 36 })
+ .map((_, i) => {
+ if (i == 8 || i == 13 || i == 18 || i == 23) {
+ return "-"
+ } else if (i == 14) {
+ return "4"
+ } else if (i == 19) {
+ return (Math.floor(Math.random() * 4) + 8).toString(16)
+ } else {
+ return Math.floor(Math.random() * 15).toString(16)
+ }
+ })
+ .join("")
+}
+
+function getAttribute(attributeName, ...elements) {
+ for (const value of elements.map((element) => element?.getAttribute(attributeName))) {
+ if (typeof value == "string") return value
+ }
+
+ return null
+}
+
+function hasAttribute(attributeName, ...elements) {
+ return elements.some((element) => element && element.hasAttribute(attributeName))
+}
+
+function markAsBusy(...elements) {
+ for (const element of elements) {
+ if (element.localName == "turbo-frame") {
+ element.setAttribute("busy", "");
+ }
+ element.setAttribute("aria-busy", "true");
+ }
+}
+
+function clearBusyState(...elements) {
+ for (const element of elements) {
+ if (element.localName == "turbo-frame") {
+ element.removeAttribute("busy");
+ }
+
+ element.removeAttribute("aria-busy");
+ }
+}
+
+function waitForLoad(element, timeoutInMilliseconds = 2000) {
+ return new Promise((resolve) => {
+ const onComplete = () => {
+ element.removeEventListener("error", onComplete);
+ element.removeEventListener("load", onComplete);
+ resolve();
+ };
+
+ element.addEventListener("load", onComplete, { once: true });
+ element.addEventListener("error", onComplete, { once: true });
+ setTimeout(resolve, timeoutInMilliseconds);
+ })
+}
+
+function getHistoryMethodForAction(action) {
+ switch (action) {
+ case "replace":
+ return history.replaceState
+ case "advance":
+ case "restore":
+ return history.pushState
+ }
+}
+
+function isAction(action) {
+ return action == "advance" || action == "replace" || action == "restore"
+}
+
+function getVisitAction(...elements) {
+ const action = getAttribute("data-turbo-action", ...elements);
+
+ return isAction(action) ? action : null
+}
+
+function getMetaElement(name) {
+ return document.querySelector(`meta[name="${name}"]`)
+}
+
+function getMetaContent(name) {
+ const element = getMetaElement(name);
+ return element && element.content
+}
+
+function getCspNonce() {
+ const element = getMetaElement("csp-nonce");
+
+ if (element) {
+ const { nonce, content } = element;
+ return nonce == "" ? content : nonce
+ }
+}
+
+function setMetaContent(name, content) {
+ let element = getMetaElement(name);
+
+ if (!element) {
+ element = document.createElement("meta");
+ element.setAttribute("name", name);
+
+ document.head.appendChild(element);
+ }
+
+ element.setAttribute("content", content);
+
+ return element
+}
+
+function findClosestRecursively(element, selector) {
+ if (element instanceof Element) {
+ return (
+ element.closest(selector) || findClosestRecursively(element.assignedSlot || element.getRootNode()?.host, selector)
+ )
+ }
+}
+
+function elementIsFocusable(element) {
+ const inertDisabledOrHidden = "[inert], :disabled, [hidden], details:not([open]), dialog:not([open])";
+
+ return !!element && element.closest(inertDisabledOrHidden) == null && typeof element.focus == "function"
+}
+
+function queryAutofocusableElement(elementOrDocumentFragment) {
+ return Array.from(elementOrDocumentFragment.querySelectorAll("[autofocus]")).find(elementIsFocusable)
+}
+
+async function around(callback, reader) {
+ const before = reader();
+
+ callback();
+
+ await nextAnimationFrame();
+
+ const after = reader();
+
+ return [before, after]
+}
+
+function doesNotTargetIFrame(name) {
+ if (name === "_blank") {
+ return false
+ } else if (name) {
+ for (const element of document.getElementsByName(name)) {
+ if (element instanceof HTMLIFrameElement) return false
+ }
+
+ return true
+ } else {
+ return true
+ }
+}
+
+function findLinkFromClickTarget(target) {
+ return findClosestRecursively(target, "a[href]:not([target^=_]):not([download])")
+}
+
+function getLocationForLink(link) {
+ return expandURL(link.getAttribute("href") || "")
+}
+
+function debounce(fn, delay) {
+ let timeoutId = null;
+
+ return (...args) => {
+ const callback = () => fn.apply(this, args);
+ clearTimeout(timeoutId);
+ timeoutId = setTimeout(callback, delay);
+ }
+}
+
+const submitter = {
+ "aria-disabled": {
+ beforeSubmit: submitter => {
+ submitter.setAttribute("aria-disabled", "true");
+ submitter.addEventListener("click", cancelEvent);
+ },
+
+ afterSubmit: submitter => {
+ submitter.removeAttribute("aria-disabled");
+ submitter.removeEventListener("click", cancelEvent);
+ }
+ },
+
+ "disabled": {
+ beforeSubmit: submitter => submitter.disabled = true,
+ afterSubmit: submitter => submitter.disabled = false
+ }
+};
+
+class Config {
+ #submitter = null
+
+ constructor(config) {
+ Object.assign(this, config);
+ }
+
+ get submitter() {
+ return this.#submitter
+ }
+
+ set submitter(value) {
+ this.#submitter = submitter[value] || value;
+ }
+}
+
+const forms = new Config({
+ mode: "on",
+ submitter: "disabled"
+});
+
+const config = {
+ drive,
+ forms
+};
+
+function expandURL(locatable) {
+ return new URL(locatable.toString(), document.baseURI)
+}
+
+function getAnchor(url) {
+ let anchorMatch;
+ if (url.hash) {
+ return url.hash.slice(1)
+ // eslint-disable-next-line no-cond-assign
+ } else if ((anchorMatch = url.href.match(/#(.*)$/))) {
+ return anchorMatch[1]
+ }
+}
+
+function getAction$1(form, submitter) {
+ const action = submitter?.getAttribute("formaction") || form.getAttribute("action") || form.action;
+
+ return expandURL(action)
+}
+
+function getExtension(url) {
+ return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || ""
+}
+
+function isPrefixedBy(baseURL, url) {
+ const prefix = getPrefix(url);
+ return baseURL.href === expandURL(prefix).href || baseURL.href.startsWith(prefix)
+}
+
+function locationIsVisitable(location, rootLocation) {
+ return isPrefixedBy(location, rootLocation) && !config.drive.unvisitableExtensions.has(getExtension(location))
+}
+
+function getRequestURL(url) {
+ const anchor = getAnchor(url);
+ return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href
+}
+
+function toCacheKey(url) {
+ return getRequestURL(url)
+}
+
+function urlsAreEqual(left, right) {
+ return expandURL(left).href == expandURL(right).href
+}
+
+function getPathComponents(url) {
+ return url.pathname.split("/").slice(1)
+}
+
+function getLastPathComponent(url) {
+ return getPathComponents(url).slice(-1)[0]
+}
+
+function getPrefix(url) {
+ return addTrailingSlash(url.origin + url.pathname)
+}
+
+function addTrailingSlash(value) {
+ return value.endsWith("/") ? value : value + "/"
+}
+
+class FetchResponse {
+ constructor(response) {
+ this.response = response;
+ }
+
+ get succeeded() {
+ return this.response.ok
+ }
+
+ get failed() {
+ return !this.succeeded
+ }
+
+ get clientError() {
+ return this.statusCode >= 400 && this.statusCode <= 499
+ }
+
+ get serverError() {
+ return this.statusCode >= 500 && this.statusCode <= 599
+ }
+
+ get redirected() {
+ return this.response.redirected
+ }
+
+ get location() {
+ return expandURL(this.response.url)
+ }
+
+ get isHTML() {
+ return this.contentType && this.contentType.match(/^(?:text\/([^\s;,]+\b)?html|application\/xhtml\+xml)\b/)
+ }
+
+ get statusCode() {
+ return this.response.status
+ }
+
+ get contentType() {
+ return this.header("Content-Type")
+ }
+
+ get responseText() {
+ return this.response.clone().text()
+ }
+
+ get responseHTML() {
+ if (this.isHTML) {
+ return this.response.clone().text()
+ } else {
+ return Promise.resolve(undefined)
+ }
+ }
+
+ header(name) {
+ return this.response.headers.get(name)
+ }
+}
+
+class LimitedSet extends Set {
+ constructor(maxSize) {
+ super();
+ this.maxSize = maxSize;
+ }
+
+ add(value) {
+ if (this.size >= this.maxSize) {
+ const iterator = this.values();
+ const oldestValue = iterator.next().value;
+ this.delete(oldestValue);
+ }
+ super.add(value);
+ }
+}
+
+const recentRequests = new LimitedSet(20);
+
+const nativeFetch = window.fetch;
+
+function fetchWithTurboHeaders(url, options = {}) {
+ const modifiedHeaders = new Headers(options.headers || {});
+ const requestUID = uuid();
+ recentRequests.add(requestUID);
+ modifiedHeaders.append("X-Turbo-Request-Id", requestUID);
+
+ return nativeFetch(url, {
+ ...options,
+ headers: modifiedHeaders
+ })
+}
+
+function fetchMethodFromString(method) {
+ switch (method.toLowerCase()) {
+ case "get":
+ return FetchMethod.get
+ case "post":
+ return FetchMethod.post
+ case "put":
+ return FetchMethod.put
+ case "patch":
+ return FetchMethod.patch
+ case "delete":
+ return FetchMethod.delete
+ }
+}
+
+const FetchMethod = {
+ get: "get",
+ post: "post",
+ put: "put",
+ patch: "patch",
+ delete: "delete"
+};
+
+function fetchEnctypeFromString(encoding) {
+ switch (encoding.toLowerCase()) {
+ case FetchEnctype.multipart:
+ return FetchEnctype.multipart
+ case FetchEnctype.plain:
+ return FetchEnctype.plain
+ default:
+ return FetchEnctype.urlEncoded
+ }
+}
+
+const FetchEnctype = {
+ urlEncoded: "application/x-www-form-urlencoded",
+ multipart: "multipart/form-data",
+ plain: "text/plain"
+};
+
+class FetchRequest {
+ abortController = new AbortController()
+ #resolveRequestPromise = (_value) => {}
+
+ constructor(delegate, method, location, requestBody = new URLSearchParams(), target = null, enctype = FetchEnctype.urlEncoded) {
+ const [url, body] = buildResourceAndBody(expandURL(location), method, requestBody, enctype);
+
+ this.delegate = delegate;
+ this.url = url;
+ this.target = target;
+ this.fetchOptions = {
+ credentials: "same-origin",
+ redirect: "follow",
+ method: method.toUpperCase(),
+ headers: { ...this.defaultHeaders },
+ body: body,
+ signal: this.abortSignal,
+ referrer: this.delegate.referrer?.href
+ };
+ this.enctype = enctype;
+ }
+
+ get method() {
+ return this.fetchOptions.method
+ }
+
+ set method(value) {
+ const fetchBody = this.isSafe ? this.url.searchParams : this.fetchOptions.body || new FormData();
+ const fetchMethod = fetchMethodFromString(value) || FetchMethod.get;
+
+ this.url.search = "";
+
+ const [url, body] = buildResourceAndBody(this.url, fetchMethod, fetchBody, this.enctype);
+
+ this.url = url;
+ this.fetchOptions.body = body;
+ this.fetchOptions.method = fetchMethod.toUpperCase();
+ }
+
+ get headers() {
+ return this.fetchOptions.headers
+ }
+
+ set headers(value) {
+ this.fetchOptions.headers = value;
+ }
+
+ get body() {
+ if (this.isSafe) {
+ return this.url.searchParams
+ } else {
+ return this.fetchOptions.body
+ }
+ }
+
+ set body(value) {
+ this.fetchOptions.body = value;
+ }
+
+ get location() {
+ return this.url
+ }
+
+ get params() {
+ return this.url.searchParams
+ }
+
+ get entries() {
+ return this.body ? Array.from(this.body.entries()) : []
+ }
+
+ cancel() {
+ this.abortController.abort();
+ }
+
+ async perform() {
+ const { fetchOptions } = this;
+ this.delegate.prepareRequest(this);
+ const event = await this.#allowRequestToBeIntercepted(fetchOptions);
+ try {
+ this.delegate.requestStarted(this);
+
+ if (event.detail.fetchRequest) {
+ this.response = event.detail.fetchRequest.response;
+ } else {
+ this.response = fetchWithTurboHeaders(this.url.href, fetchOptions);
+ }
+
+ const response = await this.response;
+ return await this.receive(response)
+ } catch (error) {
+ if (error.name !== "AbortError") {
+ if (this.#willDelegateErrorHandling(error)) {
+ this.delegate.requestErrored(this, error);
+ }
+ throw error
+ }
+ } finally {
+ this.delegate.requestFinished(this);
+ }
+ }
+
+ async receive(response) {
+ const fetchResponse = new FetchResponse(response);
+ const event = dispatch("turbo:before-fetch-response", {
+ cancelable: true,
+ detail: { fetchResponse },
+ target: this.target
+ });
+ if (event.defaultPrevented) {
+ this.delegate.requestPreventedHandlingResponse(this, fetchResponse);
+ } else if (fetchResponse.succeeded) {
+ this.delegate.requestSucceededWithResponse(this, fetchResponse);
+ } else {
+ this.delegate.requestFailedWithResponse(this, fetchResponse);
+ }
+ return fetchResponse
+ }
+
+ get defaultHeaders() {
+ return {
+ Accept: "text/html, application/xhtml+xml"
+ }
+ }
+
+ get isSafe() {
+ return isSafe(this.method)
+ }
+
+ get abortSignal() {
+ return this.abortController.signal
+ }
+
+ acceptResponseType(mimeType) {
+ this.headers["Accept"] = [mimeType, this.headers["Accept"]].join(", ");
+ }
+
+ async #allowRequestToBeIntercepted(fetchOptions) {
+ const requestInterception = new Promise((resolve) => (this.#resolveRequestPromise = resolve));
+ const event = dispatch("turbo:before-fetch-request", {
+ cancelable: true,
+ detail: {
+ fetchOptions,
+ url: this.url,
+ resume: this.#resolveRequestPromise
+ },
+ target: this.target
+ });
+ this.url = event.detail.url;
+ if (event.defaultPrevented) await requestInterception;
+
+ return event
+ }
+
+ #willDelegateErrorHandling(error) {
+ const event = dispatch("turbo:fetch-request-error", {
+ target: this.target,
+ cancelable: true,
+ detail: { request: this, error: error }
+ });
+
+ return !event.defaultPrevented
+ }
+}
+
+function isSafe(fetchMethod) {
+ return fetchMethodFromString(fetchMethod) == FetchMethod.get
+}
+
+function buildResourceAndBody(resource, method, requestBody, enctype) {
+ const searchParams =
+ Array.from(requestBody).length > 0 ? new URLSearchParams(entriesExcludingFiles(requestBody)) : resource.searchParams;
+
+ if (isSafe(method)) {
+ return [mergeIntoURLSearchParams(resource, searchParams), null]
+ } else if (enctype == FetchEnctype.urlEncoded) {
+ return [resource, searchParams]
+ } else {
+ return [resource, requestBody]
+ }
+}
+
+function entriesExcludingFiles(requestBody) {
+ const entries = [];
+
+ for (const [name, value] of requestBody) {
+ if (value instanceof File) continue
+ else entries.push([name, value]);
+ }
+
+ return entries
+}
+
+function mergeIntoURLSearchParams(url, requestBody) {
+ const searchParams = new URLSearchParams(entriesExcludingFiles(requestBody));
+
+ url.search = searchParams.toString();
+
+ return url
+}
+
+class AppearanceObserver {
+ started = false
+
+ constructor(delegate, element) {
+ this.delegate = delegate;
+ this.element = element;
+ this.intersectionObserver = new IntersectionObserver(this.intersect);
+ }
+
+ start() {
+ if (!this.started) {
+ this.started = true;
+ this.intersectionObserver.observe(this.element);
+ }
+ }
+
+ stop() {
+ if (this.started) {
+ this.started = false;
+ this.intersectionObserver.unobserve(this.element);
+ }
+ }
+
+ intersect = (entries) => {
+ const lastEntry = entries.slice(-1)[0];
+ if (lastEntry?.isIntersecting) {
+ this.delegate.elementAppearedInViewport(this.element);
+ }
+ }
+}
+
+class StreamMessage {
+ static contentType = "text/vnd.turbo-stream.html"
+
+ static wrap(message) {
+ if (typeof message == "string") {
+ return new this(createDocumentFragment(message))
+ } else {
+ return message
+ }
+ }
+
+ constructor(fragment) {
+ this.fragment = importStreamElements(fragment);
+ }
+}
+
+function importStreamElements(fragment) {
+ for (const element of fragment.querySelectorAll("turbo-stream")) {
+ const streamElement = document.importNode(element, true);
+
+ for (const inertScriptElement of streamElement.templateElement.content.querySelectorAll("script")) {
+ inertScriptElement.replaceWith(activateScriptElement(inertScriptElement));
+ }
+
+ element.replaceWith(streamElement);
+ }
+
+ return fragment
+}
+
+const PREFETCH_DELAY = 100;
+
+class PrefetchCache {
+ #prefetchTimeout = null
+ #prefetched = null
+
+ get(url) {
+ if (this.#prefetched && this.#prefetched.url === url && this.#prefetched.expire > Date.now()) {
+ return this.#prefetched.request
+ }
+ }
+
+ setLater(url, request, ttl) {
+ this.clear();
+
+ this.#prefetchTimeout = setTimeout(() => {
+ request.perform();
+ this.set(url, request, ttl);
+ this.#prefetchTimeout = null;
+ }, PREFETCH_DELAY);
+ }
+
+ set(url, request, ttl) {
+ this.#prefetched = { url, request, expire: new Date(new Date().getTime() + ttl) };
+ }
+
+ clear() {
+ if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout);
+ this.#prefetched = null;
+ }
+}
+
+const cacheTtl = 10 * 1000;
+const prefetchCache = new PrefetchCache();
+
+const FormSubmissionState = {
+ initialized: "initialized",
+ requesting: "requesting",
+ waiting: "waiting",
+ receiving: "receiving",
+ stopping: "stopping",
+ stopped: "stopped"
+};
+
+class FormSubmission {
+ state = FormSubmissionState.initialized
+
+ static confirmMethod(message) {
+ return Promise.resolve(confirm(message))
+ }
+
+ constructor(delegate, formElement, submitter, mustRedirect = false) {
+ const method = getMethod(formElement, submitter);
+ const action = getAction(getFormAction(formElement, submitter), method);
+ const body = buildFormData(formElement, submitter);
+ const enctype = getEnctype(formElement, submitter);
+
+ this.delegate = delegate;
+ this.formElement = formElement;
+ this.submitter = submitter;
+ this.fetchRequest = new FetchRequest(this, method, action, body, formElement, enctype);
+ this.mustRedirect = mustRedirect;
+ }
+
+ get method() {
+ return this.fetchRequest.method
+ }
+
+ set method(value) {
+ this.fetchRequest.method = value;
+ }
+
+ get action() {
+ return this.fetchRequest.url.toString()
+ }
+
+ set action(value) {
+ this.fetchRequest.url = expandURL(value);
+ }
+
+ get body() {
+ return this.fetchRequest.body
+ }
+
+ get enctype() {
+ return this.fetchRequest.enctype
+ }
+
+ get isSafe() {
+ return this.fetchRequest.isSafe
+ }
+
+ get location() {
+ return this.fetchRequest.url
+ }
+
+ // The submission process
+
+ async start() {
+ const { initialized, requesting } = FormSubmissionState;
+ const confirmationMessage = getAttribute("data-turbo-confirm", this.submitter, this.formElement);
+
+ if (typeof confirmationMessage === "string") {
+ const confirmMethod = typeof config.forms.confirm === "function" ?
+ config.forms.confirm :
+ FormSubmission.confirmMethod;
+
+ const answer = await confirmMethod(confirmationMessage, this.formElement, this.submitter);
+ if (!answer) {
+ return
+ }
+ }
+
+ if (this.state == initialized) {
+ this.state = requesting;
+ return this.fetchRequest.perform()
+ }
+ }
+
+ stop() {
+ const { stopping, stopped } = FormSubmissionState;
+ if (this.state != stopping && this.state != stopped) {
+ this.state = stopping;
+ this.fetchRequest.cancel();
+ return true
+ }
+ }
+
+ // Fetch request delegate
+
+ prepareRequest(request) {
+ if (!request.isSafe) {
+ const token = getCookieValue(getMetaContent("csrf-param")) || getMetaContent("csrf-token");
+ if (token) {
+ request.headers["X-CSRF-Token"] = token;
+ }
+ }
+
+ if (this.requestAcceptsTurboStreamResponse(request)) {
+ request.acceptResponseType(StreamMessage.contentType);
+ }
+ }
+
+ requestStarted(_request) {
+ this.state = FormSubmissionState.waiting;
+ if (this.submitter) config.forms.submitter.beforeSubmit(this.submitter);
+ this.setSubmitsWith();
+ markAsBusy(this.formElement);
+ dispatch("turbo:submit-start", {
+ target: this.formElement,
+ detail: { formSubmission: this }
+ });
+ this.delegate.formSubmissionStarted(this);
+ }
+
+ requestPreventedHandlingResponse(request, response) {
+ prefetchCache.clear();
+
+ this.result = { success: response.succeeded, fetchResponse: response };
+ }
+
+ requestSucceededWithResponse(request, response) {
+ if (response.clientError || response.serverError) {
+ this.delegate.formSubmissionFailedWithResponse(this, response);
+ return
+ }
+
+ prefetchCache.clear();
+
+ if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) {
+ const error = new Error("Form responses must redirect to another location");
+ this.delegate.formSubmissionErrored(this, error);
+ } else {
+ this.state = FormSubmissionState.receiving;
+ this.result = { success: true, fetchResponse: response };
+ this.delegate.formSubmissionSucceededWithResponse(this, response);
+ }
+ }
+
+ requestFailedWithResponse(request, response) {
+ this.result = { success: false, fetchResponse: response };
+ this.delegate.formSubmissionFailedWithResponse(this, response);
+ }
+
+ requestErrored(request, error) {
+ this.result = { success: false, error };
+ this.delegate.formSubmissionErrored(this, error);
+ }
+
+ requestFinished(_request) {
+ this.state = FormSubmissionState.stopped;
+ if (this.submitter) config.forms.submitter.afterSubmit(this.submitter);
+ this.resetSubmitterText();
+ clearBusyState(this.formElement);
+ dispatch("turbo:submit-end", {
+ target: this.formElement,
+ detail: { formSubmission: this, ...this.result }
+ });
+ this.delegate.formSubmissionFinished(this);
+ }
+
+ // Private
+
+ setSubmitsWith() {
+ if (!this.submitter || !this.submitsWith) return
+
+ if (this.submitter.matches("button")) {
+ this.originalSubmitText = this.submitter.innerHTML;
+ this.submitter.innerHTML = this.submitsWith;
+ } else if (this.submitter.matches("input")) {
+ const input = this.submitter;
+ this.originalSubmitText = input.value;
+ input.value = this.submitsWith;
+ }
+ }
+
+ resetSubmitterText() {
+ if (!this.submitter || !this.originalSubmitText) return
+
+ if (this.submitter.matches("button")) {
+ this.submitter.innerHTML = this.originalSubmitText;
+ } else if (this.submitter.matches("input")) {
+ const input = this.submitter;
+ input.value = this.originalSubmitText;
+ }
+ }
+
+ requestMustRedirect(request) {
+ return !request.isSafe && this.mustRedirect
+ }
+
+ requestAcceptsTurboStreamResponse(request) {
+ return !request.isSafe || hasAttribute("data-turbo-stream", this.submitter, this.formElement)
+ }
+
+ get submitsWith() {
+ return this.submitter?.getAttribute("data-turbo-submits-with")
+ }
+}
+
+function buildFormData(formElement, submitter) {
+ const formData = new FormData(formElement);
+ const name = submitter?.getAttribute("name");
+ const value = submitter?.getAttribute("value");
+
+ if (name) {
+ formData.append(name, value || "");
+ }
+
+ return formData
+}
+
+function getCookieValue(cookieName) {
+ if (cookieName != null) {
+ const cookies = document.cookie ? document.cookie.split("; ") : [];
+ const cookie = cookies.find((cookie) => cookie.startsWith(cookieName));
+ if (cookie) {
+ const value = cookie.split("=").slice(1).join("=");
+ return value ? decodeURIComponent(value) : undefined
+ }
+ }
+}
+
+function responseSucceededWithoutRedirect(response) {
+ return response.statusCode == 200 && !response.redirected
+}
+
+function getFormAction(formElement, submitter) {
+ const formElementAction = typeof formElement.action === "string" ? formElement.action : null;
+
+ if (submitter?.hasAttribute("formaction")) {
+ return submitter.getAttribute("formaction") || ""
+ } else {
+ return formElement.getAttribute("action") || formElementAction || ""
+ }
+}
+
+function getAction(formAction, fetchMethod) {
+ const action = expandURL(formAction);
+
+ if (isSafe(fetchMethod)) {
+ action.search = "";
+ }
+
+ return action
+}
+
+function getMethod(formElement, submitter) {
+ const method = submitter?.getAttribute("formmethod") || formElement.getAttribute("method") || "";
+ return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get
+}
+
+function getEnctype(formElement, submitter) {
+ return fetchEnctypeFromString(submitter?.getAttribute("formenctype") || formElement.enctype)
+}
+
+class Snapshot {
+ constructor(element) {
+ this.element = element;
+ }
+
+ get activeElement() {
+ return this.element.ownerDocument.activeElement
+ }
+
+ get children() {
+ return [...this.element.children]
+ }
+
+ hasAnchor(anchor) {
+ return this.getElementForAnchor(anchor) != null
+ }
+
+ getElementForAnchor(anchor) {
+ return anchor ? this.element.querySelector(`[id='${anchor}'], a[name='${anchor}']`) : null
+ }
+
+ get isConnected() {
+ return this.element.isConnected
+ }
+
+ get firstAutofocusableElement() {
+ return queryAutofocusableElement(this.element)
+ }
+
+ get permanentElements() {
+ return queryPermanentElementsAll(this.element)
+ }
+
+ getPermanentElementById(id) {
+ return getPermanentElementById(this.element, id)
+ }
+
+ getPermanentElementMapForSnapshot(snapshot) {
+ const permanentElementMap = {};
+
+ for (const currentPermanentElement of this.permanentElements) {
+ const { id } = currentPermanentElement;
+ const newPermanentElement = snapshot.getPermanentElementById(id);
+ if (newPermanentElement) {
+ permanentElementMap[id] = [currentPermanentElement, newPermanentElement];
+ }
+ }
+
+ return permanentElementMap
+ }
+}
+
+function getPermanentElementById(node, id) {
+ return node.querySelector(`#${id}[data-turbo-permanent]`)
+}
+
+function queryPermanentElementsAll(node) {
+ return node.querySelectorAll("[id][data-turbo-permanent]")
+}
+
+class FormSubmitObserver {
+ started = false
+
+ constructor(delegate, eventTarget) {
+ this.delegate = delegate;
+ this.eventTarget = eventTarget;
+ }
+
+ start() {
+ if (!this.started) {
+ this.eventTarget.addEventListener("submit", this.submitCaptured, true);
+ this.started = true;
+ }
+ }
+
+ stop() {
+ if (this.started) {
+ this.eventTarget.removeEventListener("submit", this.submitCaptured, true);
+ this.started = false;
+ }
+ }
+
+ submitCaptured = () => {
+ this.eventTarget.removeEventListener("submit", this.submitBubbled, false);
+ this.eventTarget.addEventListener("submit", this.submitBubbled, false);
+ }
+
+ submitBubbled = (event) => {
+ if (!event.defaultPrevented) {
+ const form = event.target instanceof HTMLFormElement ? event.target : undefined;
+ const submitter = event.submitter || undefined;
+
+ if (
+ form &&
+ submissionDoesNotDismissDialog(form, submitter) &&
+ submissionDoesNotTargetIFrame(form, submitter) &&
+ this.delegate.willSubmitForm(form, submitter)
+ ) {
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ this.delegate.formSubmitted(form, submitter);
+ }
+ }
+ }
+}
+
+function submissionDoesNotDismissDialog(form, submitter) {
+ const method = submitter?.getAttribute("formmethod") || form.getAttribute("method");
+
+ return method != "dialog"
+}
+
+function submissionDoesNotTargetIFrame(form, submitter) {
+ const target = submitter?.getAttribute("formtarget") || form.getAttribute("target");
+
+ return doesNotTargetIFrame(target)
+}
+
+class View {
+ #resolveRenderPromise = (_value) => {}
+ #resolveInterceptionPromise = (_value) => {}
+
+ constructor(delegate, element) {
+ this.delegate = delegate;
+ this.element = element;
+ }
+
+ // Scrolling
+
+ scrollToAnchor(anchor) {
+ const element = this.snapshot.getElementForAnchor(anchor);
+ if (element) {
+ this.scrollToElement(element);
+ this.focusElement(element);
+ } else {
+ this.scrollToPosition({ x: 0, y: 0 });
+ }
+ }
+
+ scrollToAnchorFromLocation(location) {
+ this.scrollToAnchor(getAnchor(location));
+ }
+
+ scrollToElement(element) {
+ element.scrollIntoView();
+ }
+
+ focusElement(element) {
+ if (element instanceof HTMLElement) {
+ if (element.hasAttribute("tabindex")) {
+ element.focus();
+ } else {
+ element.setAttribute("tabindex", "-1");
+ element.focus();
+ element.removeAttribute("tabindex");
+ }
+ }
+ }
+
+ scrollToPosition({ x, y }) {
+ this.scrollRoot.scrollTo(x, y);
+ }
+
+ scrollToTop() {
+ this.scrollToPosition({ x: 0, y: 0 });
+ }
+
+ get scrollRoot() {
+ return window
+ }
+
+ // Rendering
+
+ async render(renderer) {
+ const { isPreview, shouldRender, willRender, newSnapshot: snapshot } = renderer;
+
+ // A workaround to ignore tracked element mismatch reloads when performing
+ // a promoted Visit from a frame navigation
+ const shouldInvalidate = willRender;
+
+ if (shouldRender) {
+ try {
+ this.renderPromise = new Promise((resolve) => (this.#resolveRenderPromise = resolve));
+ this.renderer = renderer;
+ await this.prepareToRenderSnapshot(renderer);
+
+ const renderInterception = new Promise((resolve) => (this.#resolveInterceptionPromise = resolve));
+ const options = { resume: this.#resolveInterceptionPromise, render: this.renderer.renderElement, renderMethod: this.renderer.renderMethod };
+ const immediateRender = this.delegate.allowsImmediateRender(snapshot, options);
+ if (!immediateRender) await renderInterception;
+
+ await this.renderSnapshot(renderer);
+ this.delegate.viewRenderedSnapshot(snapshot, isPreview, this.renderer.renderMethod);
+ this.delegate.preloadOnLoadLinksForView(this.element);
+ this.finishRenderingSnapshot(renderer);
+ } finally {
+ delete this.renderer;
+ this.#resolveRenderPromise(undefined);
+ delete this.renderPromise;
+ }
+ } else if (shouldInvalidate) {
+ this.invalidate(renderer.reloadReason);
+ }
+ }
+
+ invalidate(reason) {
+ this.delegate.viewInvalidated(reason);
+ }
+
+ async prepareToRenderSnapshot(renderer) {
+ this.markAsPreview(renderer.isPreview);
+ await renderer.prepareToRender();
+ }
+
+ markAsPreview(isPreview) {
+ if (isPreview) {
+ this.element.setAttribute("data-turbo-preview", "");
+ } else {
+ this.element.removeAttribute("data-turbo-preview");
+ }
+ }
+
+ markVisitDirection(direction) {
+ this.element.setAttribute("data-turbo-visit-direction", direction);
+ }
+
+ unmarkVisitDirection() {
+ this.element.removeAttribute("data-turbo-visit-direction");
+ }
+
+ async renderSnapshot(renderer) {
+ await renderer.render();
+ }
+
+ finishRenderingSnapshot(renderer) {
+ renderer.finishRendering();
+ }
+}
+
+class FrameView extends View {
+ missing() {
+ this.element.innerHTML = `Content missing`;
+ }
+
+ get snapshot() {
+ return new Snapshot(this.element)
+ }
+}
+
+class LinkInterceptor {
+ constructor(delegate, element) {
+ this.delegate = delegate;
+ this.element = element;
+ }
+
+ start() {
+ this.element.addEventListener("click", this.clickBubbled);
+ document.addEventListener("turbo:click", this.linkClicked);
+ document.addEventListener("turbo:before-visit", this.willVisit);
+ }
+
+ stop() {
+ this.element.removeEventListener("click", this.clickBubbled);
+ document.removeEventListener("turbo:click", this.linkClicked);
+ document.removeEventListener("turbo:before-visit", this.willVisit);
+ }
+
+ clickBubbled = (event) => {
+ if (this.clickEventIsSignificant(event)) {
+ this.clickEvent = event;
+ } else {
+ delete this.clickEvent;
+ }
+ }
+
+ linkClicked = (event) => {
+ if (this.clickEvent && this.clickEventIsSignificant(event)) {
+ if (this.delegate.shouldInterceptLinkClick(event.target, event.detail.url, event.detail.originalEvent)) {
+ this.clickEvent.preventDefault();
+ event.preventDefault();
+ this.delegate.linkClickIntercepted(event.target, event.detail.url, event.detail.originalEvent);
+ }
+ }
+ delete this.clickEvent;
+ }
+
+ willVisit = (_event) => {
+ delete this.clickEvent;
+ }
+
+ clickEventIsSignificant(event) {
+ const target = event.composed ? event.target?.parentElement : event.target;
+ const element = findLinkFromClickTarget(target) || target;
+
+ return element instanceof Element && element.closest("turbo-frame, html") == this.element
+ }
+}
+
+class LinkClickObserver {
+ started = false
+
+ constructor(delegate, eventTarget) {
+ this.delegate = delegate;
+ this.eventTarget = eventTarget;
+ }
+
+ start() {
+ if (!this.started) {
+ this.eventTarget.addEventListener("click", this.clickCaptured, true);
+ this.started = true;
+ }
+ }
+
+ stop() {
+ if (this.started) {
+ this.eventTarget.removeEventListener("click", this.clickCaptured, true);
+ this.started = false;
+ }
+ }
+
+ clickCaptured = () => {
+ this.eventTarget.removeEventListener("click", this.clickBubbled, false);
+ this.eventTarget.addEventListener("click", this.clickBubbled, false);
+ }
+
+ clickBubbled = (event) => {
+ if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) {
+ const target = (event.composedPath && event.composedPath()[0]) || event.target;
+ const link = findLinkFromClickTarget(target);
+ if (link && doesNotTargetIFrame(link.target)) {
+ const location = getLocationForLink(link);
+ if (this.delegate.willFollowLinkToLocation(link, location, event)) {
+ event.preventDefault();
+ this.delegate.followedLinkToLocation(link, location);
+ }
+ }
+ }
+ }
+
+ clickEventIsSignificant(event) {
+ return !(
+ (event.target && event.target.isContentEditable) ||
+ event.defaultPrevented ||
+ event.which > 1 ||
+ event.altKey ||
+ event.ctrlKey ||
+ event.metaKey ||
+ event.shiftKey
+ )
+ }
+}
+
+class FormLinkClickObserver {
+ constructor(delegate, element) {
+ this.delegate = delegate;
+ this.linkInterceptor = new LinkClickObserver(this, element);
+ }
+
+ start() {
+ this.linkInterceptor.start();
+ }
+
+ stop() {
+ this.linkInterceptor.stop();
+ }
+
+ // Link hover observer delegate
+
+ canPrefetchRequestToLocation(link, location) {
+ return false
+ }
+
+ prefetchAndCacheRequestToLocation(link, location) {
+ return
+ }
+
+ // Link click observer delegate
+
+ willFollowLinkToLocation(link, location, originalEvent) {
+ return (
+ this.delegate.willSubmitFormLinkToLocation(link, location, originalEvent) &&
+ (link.hasAttribute("data-turbo-method") || link.hasAttribute("data-turbo-stream"))
+ )
+ }
+
+ followedLinkToLocation(link, location) {
+ const form = document.createElement("form");
+
+ const type = "hidden";
+ for (const [name, value] of location.searchParams) {
+ form.append(Object.assign(document.createElement("input"), { type, name, value }));
+ }
+
+ const action = Object.assign(location, { search: "" });
+ form.setAttribute("data-turbo", "true");
+ form.setAttribute("action", action.href);
+ form.setAttribute("hidden", "");
+
+ const method = link.getAttribute("data-turbo-method");
+ if (method) form.setAttribute("method", method);
+
+ const turboFrame = link.getAttribute("data-turbo-frame");
+ if (turboFrame) form.setAttribute("data-turbo-frame", turboFrame);
+
+ const turboAction = getVisitAction(link);
+ if (turboAction) form.setAttribute("data-turbo-action", turboAction);
+
+ const turboConfirm = link.getAttribute("data-turbo-confirm");
+ if (turboConfirm) form.setAttribute("data-turbo-confirm", turboConfirm);
+
+ const turboStream = link.hasAttribute("data-turbo-stream");
+ if (turboStream) form.setAttribute("data-turbo-stream", "");
+
+ this.delegate.submittedFormLinkToLocation(link, location, form);
+
+ document.body.appendChild(form);
+ form.addEventListener("turbo:submit-end", () => form.remove(), { once: true });
+ requestAnimationFrame(() => form.requestSubmit());
+ }
+}
+
+class Bardo {
+ static async preservingPermanentElements(delegate, permanentElementMap, callback) {
+ const bardo = new this(delegate, permanentElementMap);
+ bardo.enter();
+ await callback();
+ bardo.leave();
+ }
+
+ constructor(delegate, permanentElementMap) {
+ this.delegate = delegate;
+ this.permanentElementMap = permanentElementMap;
+ }
+
+ enter() {
+ for (const id in this.permanentElementMap) {
+ const [currentPermanentElement, newPermanentElement] = this.permanentElementMap[id];
+ this.delegate.enteringBardo(currentPermanentElement, newPermanentElement);
+ this.replaceNewPermanentElementWithPlaceholder(newPermanentElement);
+ }
+ }
+
+ leave() {
+ for (const id in this.permanentElementMap) {
+ const [currentPermanentElement] = this.permanentElementMap[id];
+ this.replaceCurrentPermanentElementWithClone(currentPermanentElement);
+ this.replacePlaceholderWithPermanentElement(currentPermanentElement);
+ this.delegate.leavingBardo(currentPermanentElement);
+ }
+ }
+
+ replaceNewPermanentElementWithPlaceholder(permanentElement) {
+ const placeholder = createPlaceholderForPermanentElement(permanentElement);
+ permanentElement.replaceWith(placeholder);
+ }
+
+ replaceCurrentPermanentElementWithClone(permanentElement) {
+ const clone = permanentElement.cloneNode(true);
+ permanentElement.replaceWith(clone);
+ }
+
+ replacePlaceholderWithPermanentElement(permanentElement) {
+ const placeholder = this.getPlaceholderById(permanentElement.id);
+ placeholder?.replaceWith(permanentElement);
+ }
+
+ getPlaceholderById(id) {
+ return this.placeholders.find((element) => element.content == id)
+ }
+
+ get placeholders() {
+ return [...document.querySelectorAll("meta[name=turbo-permanent-placeholder][content]")]
+ }
+}
+
+function createPlaceholderForPermanentElement(permanentElement) {
+ const element = document.createElement("meta");
+ element.setAttribute("name", "turbo-permanent-placeholder");
+ element.setAttribute("content", permanentElement.id);
+ return element
+}
+
+class Renderer {
+ #activeElement = null
+
+ static renderElement(currentElement, newElement) {
+ // Abstract method
+ }
+
+ constructor(currentSnapshot, newSnapshot, isPreview, willRender = true) {
+ this.currentSnapshot = currentSnapshot;
+ this.newSnapshot = newSnapshot;
+ this.isPreview = isPreview;
+ this.willRender = willRender;
+ this.renderElement = this.constructor.renderElement;
+ this.promise = new Promise((resolve, reject) => (this.resolvingFunctions = { resolve, reject }));
+ }
+
+ get shouldRender() {
+ return true
+ }
+
+ get shouldAutofocus() {
+ return true
+ }
+
+ get reloadReason() {
+ return
+ }
+
+ prepareToRender() {
+ return
+ }
+
+ render() {
+ // Abstract method
+ }
+
+ finishRendering() {
+ if (this.resolvingFunctions) {
+ this.resolvingFunctions.resolve();
+ delete this.resolvingFunctions;
+ }
+ }
+
+ async preservingPermanentElements(callback) {
+ await Bardo.preservingPermanentElements(this, this.permanentElementMap, callback);
+ }
+
+ focusFirstAutofocusableElement() {
+ if (this.shouldAutofocus) {
+ const element = this.connectedSnapshot.firstAutofocusableElement;
+ if (element) {
+ element.focus();
+ }
+ }
+ }
+
+ // Bardo delegate
+
+ enteringBardo(currentPermanentElement) {
+ if (this.#activeElement) return
+
+ if (currentPermanentElement.contains(this.currentSnapshot.activeElement)) {
+ this.#activeElement = this.currentSnapshot.activeElement;
+ }
+ }
+
+ leavingBardo(currentPermanentElement) {
+ if (currentPermanentElement.contains(this.#activeElement) && this.#activeElement instanceof HTMLElement) {
+ this.#activeElement.focus();
+
+ this.#activeElement = null;
+ }
+ }
+
+ get connectedSnapshot() {
+ return this.newSnapshot.isConnected ? this.newSnapshot : this.currentSnapshot
+ }
+
+ get currentElement() {
+ return this.currentSnapshot.element
+ }
+
+ get newElement() {
+ return this.newSnapshot.element
+ }
+
+ get permanentElementMap() {
+ return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot)
+ }
+
+ get renderMethod() {
+ return "replace"
+ }
+}
+
+class FrameRenderer extends Renderer {
+ static renderElement(currentElement, newElement) {
+ const destinationRange = document.createRange();
+ destinationRange.selectNodeContents(currentElement);
+ destinationRange.deleteContents();
+
+ const frameElement = newElement;
+ const sourceRange = frameElement.ownerDocument?.createRange();
+ if (sourceRange) {
+ sourceRange.selectNodeContents(frameElement);
+ currentElement.appendChild(sourceRange.extractContents());
+ }
+ }
+
+ constructor(delegate, currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) {
+ super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender);
+ this.delegate = delegate;
+ }
+
+ get shouldRender() {
+ return true
+ }
+
+ async render() {
+ await nextRepaint();
+ this.preservingPermanentElements(() => {
+ this.loadFrameElement();
+ });
+ this.scrollFrameIntoView();
+ await nextRepaint();
+ this.focusFirstAutofocusableElement();
+ await nextRepaint();
+ this.activateScriptElements();
+ }
+
+ loadFrameElement() {
+ this.delegate.willRenderFrame(this.currentElement, this.newElement);
+ this.renderElement(this.currentElement, this.newElement);
+ }
+
+ scrollFrameIntoView() {
+ if (this.currentElement.autoscroll || this.newElement.autoscroll) {
+ const element = this.currentElement.firstElementChild;
+ const block = readScrollLogicalPosition(this.currentElement.getAttribute("data-autoscroll-block"), "end");
+ const behavior = readScrollBehavior(this.currentElement.getAttribute("data-autoscroll-behavior"), "auto");
+
+ if (element) {
+ element.scrollIntoView({ block, behavior });
+ return true
+ }
+ }
+ return false
+ }
+
+ activateScriptElements() {
+ for (const inertScriptElement of this.newScriptElements) {
+ const activatedScriptElement = activateScriptElement(inertScriptElement);
+ inertScriptElement.replaceWith(activatedScriptElement);
+ }
+ }
+
+ get newScriptElements() {
+ return this.currentElement.querySelectorAll("script")
+ }
+}
+
+function readScrollLogicalPosition(value, defaultValue) {
+ if (value == "end" || value == "start" || value == "center" || value == "nearest") {
+ return value
+ } else {
+ return defaultValue
+ }
+}
+
+function readScrollBehavior(value, defaultValue) {
+ if (value == "auto" || value == "smooth") {
+ return value
+ } else {
+ return defaultValue
+ }
+}
+
+/**
+ * @typedef {object} ConfigHead
+ *
+ * @property {'merge' | 'append' | 'morph' | 'none'} [style]
+ * @property {boolean} [block]
+ * @property {boolean} [ignore]
+ * @property {function(Element): boolean} [shouldPreserve]
+ * @property {function(Element): boolean} [shouldReAppend]
+ * @property {function(Element): boolean} [shouldRemove]
+ * @property {function(Element, {added: Node[], kept: Element[], removed: Element[]}): void} [afterHeadMorphed]
+ */
+
+/**
+ * @typedef {object} ConfigCallbacks
+ *
+ * @property {function(Node): boolean} [beforeNodeAdded]
+ * @property {function(Node): void} [afterNodeAdded]
+ * @property {function(Element, Node): boolean} [beforeNodeMorphed]
+ * @property {function(Element, Node): void} [afterNodeMorphed]
+ * @property {function(Element): boolean} [beforeNodeRemoved]
+ * @property {function(Element): void} [afterNodeRemoved]
+ * @property {function(string, Element, "update" | "remove"): boolean} [beforeAttributeUpdated]
+ */
+
+/**
+ * @typedef {object} Config
+ *
+ * @property {'outerHTML' | 'innerHTML'} [morphStyle]
+ * @property {boolean} [ignoreActive]
+ * @property {boolean} [ignoreActiveValue]
+ * @property {boolean} [restoreFocus]
+ * @property {ConfigCallbacks} [callbacks]
+ * @property {ConfigHead} [head]
+ */
+
+/**
+ * @typedef {function} NoOp
+ *
+ * @returns {void}
+ */
+
+/**
+ * @typedef {object} ConfigHeadInternal
+ *
+ * @property {'merge' | 'append' | 'morph' | 'none'} style
+ * @property {boolean} [block]
+ * @property {boolean} [ignore]
+ * @property {(function(Element): boolean) | NoOp} shouldPreserve
+ * @property {(function(Element): boolean) | NoOp} shouldReAppend
+ * @property {(function(Element): boolean) | NoOp} shouldRemove
+ * @property {(function(Element, {added: Node[], kept: Element[], removed: Element[]}): void) | NoOp} afterHeadMorphed
+ */
+
+/**
+ * @typedef {object} ConfigCallbacksInternal
+ *
+ * @property {(function(Node): boolean) | NoOp} beforeNodeAdded
+ * @property {(function(Node): void) | NoOp} afterNodeAdded
+ * @property {(function(Node, Node): boolean) | NoOp} beforeNodeMorphed
+ * @property {(function(Node, Node): void) | NoOp} afterNodeMorphed
+ * @property {(function(Node): boolean) | NoOp} beforeNodeRemoved
+ * @property {(function(Node): void) | NoOp} afterNodeRemoved
+ * @property {(function(string, Element, "update" | "remove"): boolean) | NoOp} beforeAttributeUpdated
+ */
+
+/**
+ * @typedef {object} ConfigInternal
+ *
+ * @property {'outerHTML' | 'innerHTML'} morphStyle
+ * @property {boolean} [ignoreActive]
+ * @property {boolean} [ignoreActiveValue]
+ * @property {boolean} [restoreFocus]
+ * @property {ConfigCallbacksInternal} callbacks
+ * @property {ConfigHeadInternal} head
+ */
+
+/**
+ * @typedef {Object} IdSets
+ * @property {Set} persistentIds
+ * @property {Map>} idMap
+ */
+
+/**
+ * @typedef {Function} Morph
+ *
+ * @param {Element | Document} oldNode
+ * @param {Element | Node | HTMLCollection | Node[] | string | null} newContent
+ * @param {Config} [config]
+ * @returns {undefined | Node[]}
+ */
+
+// base IIFE to define idiomorph
+/**
+ *
+ * @type {{defaults: ConfigInternal, morph: Morph}}
+ */
+var Idiomorph = (function () {
+
+ /**
+ * @typedef {object} MorphContext
+ *
+ * @property {Element} target
+ * @property {Element} newContent
+ * @property {ConfigInternal} config
+ * @property {ConfigInternal['morphStyle']} morphStyle
+ * @property {ConfigInternal['ignoreActive']} ignoreActive
+ * @property {ConfigInternal['ignoreActiveValue']} ignoreActiveValue
+ * @property {ConfigInternal['restoreFocus']} restoreFocus
+ * @property {Map>} idMap
+ * @property {Set} persistentIds
+ * @property {ConfigInternal['callbacks']} callbacks
+ * @property {ConfigInternal['head']} head
+ * @property {HTMLDivElement} pantry
+ */
+
+ //=============================================================================
+ // AND NOW IT BEGINS...
+ //=============================================================================
+
+ const noOp = () => {};
+ /**
+ * Default configuration values, updatable by users now
+ * @type {ConfigInternal}
+ */
+ const defaults = {
+ morphStyle: "outerHTML",
+ callbacks: {
+ beforeNodeAdded: noOp,
+ afterNodeAdded: noOp,
+ beforeNodeMorphed: noOp,
+ afterNodeMorphed: noOp,
+ beforeNodeRemoved: noOp,
+ afterNodeRemoved: noOp,
+ beforeAttributeUpdated: noOp,
+ },
+ head: {
+ style: "merge",
+ shouldPreserve: (elt) => elt.getAttribute("im-preserve") === "true",
+ shouldReAppend: (elt) => elt.getAttribute("im-re-append") === "true",
+ shouldRemove: noOp,
+ afterHeadMorphed: noOp,
+ },
+ restoreFocus: true,
+ };
+
+ /**
+ * Core idiomorph function for morphing one DOM tree to another
+ *
+ * @param {Element | Document} oldNode
+ * @param {Element | Node | HTMLCollection | Node[] | string | null} newContent
+ * @param {Config} [config]
+ * @returns {Promise | Node[]}
+ */
+ function morph(oldNode, newContent, config = {}) {
+ oldNode = normalizeElement(oldNode);
+ const newNode = normalizeParent(newContent);
+ const ctx = createMorphContext(oldNode, newNode, config);
+
+ const morphedNodes = saveAndRestoreFocus(ctx, () => {
+ return withHeadBlocking(
+ ctx,
+ oldNode,
+ newNode,
+ /** @param {MorphContext} ctx */ (ctx) => {
+ if (ctx.morphStyle === "innerHTML") {
+ morphChildren(ctx, oldNode, newNode);
+ return Array.from(oldNode.childNodes);
+ } else {
+ return morphOuterHTML(ctx, oldNode, newNode);
+ }
+ },
+ );
+ });
+
+ ctx.pantry.remove();
+ return morphedNodes;
+ }
+
+ /**
+ * Morph just the outerHTML of the oldNode to the newContent
+ * We have to be careful because the oldNode could have siblings which need to be untouched
+ * @param {MorphContext} ctx
+ * @param {Element} oldNode
+ * @param {Element} newNode
+ * @returns {Node[]}
+ */
+ function morphOuterHTML(ctx, oldNode, newNode) {
+ const oldParent = normalizeParent(oldNode);
+
+ // basis for calulating which nodes were morphed
+ // since there may be unmorphed sibling nodes
+ let childNodes = Array.from(oldParent.childNodes);
+ const index = childNodes.indexOf(oldNode);
+ // how many elements are to the right of the oldNode
+ const rightMargin = childNodes.length - (index + 1);
+
+ morphChildren(
+ ctx,
+ oldParent,
+ newNode,
+ // these two optional params are the secret sauce
+ oldNode, // start point for iteration
+ oldNode.nextSibling, // end point for iteration
+ );
+
+ // return just the morphed nodes
+ childNodes = Array.from(oldParent.childNodes);
+ return childNodes.slice(index, childNodes.length - rightMargin);
+ }
+
+ /**
+ * @param {MorphContext} ctx
+ * @param {Function} fn
+ * @returns {Promise | Node[]}
+ */
+ function saveAndRestoreFocus(ctx, fn) {
+ if (!ctx.config.restoreFocus) return fn();
+ let activeElement =
+ /** @type {HTMLInputElement|HTMLTextAreaElement|null} */ (
+ document.activeElement
+ );
+
+ // don't bother if the active element is not an input or textarea
+ if (
+ !(
+ activeElement instanceof HTMLInputElement ||
+ activeElement instanceof HTMLTextAreaElement
+ )
+ ) {
+ return fn();
+ }
+
+ const { id: activeElementId, selectionStart, selectionEnd } = activeElement;
+
+ const results = fn();
+
+ if (activeElementId && activeElementId !== document.activeElement?.id) {
+ activeElement = ctx.target.querySelector(`#${activeElementId}`);
+ activeElement?.focus();
+ }
+ if (activeElement && !activeElement.selectionEnd && selectionEnd) {
+ activeElement.setSelectionRange(selectionStart, selectionEnd);
+ }
+
+ return results;
+ }
+
+ const morphChildren = (function () {
+ /**
+ * This is the core algorithm for matching up children. The idea is to use id sets to try to match up
+ * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
+ * by using id sets, we are able to better match up with content deeper in the DOM.
+ *
+ * Basic algorithm:
+ * - for each node in the new content:
+ * - search self and siblings for an id set match, falling back to a soft match
+ * - if match found
+ * - remove any nodes up to the match:
+ * - pantry persistent nodes
+ * - delete the rest
+ * - morph the match
+ * - elsif no match found, and node is persistent
+ * - find its match by querying the old root (future) and pantry (past)
+ * - move it and its children here
+ * - morph it
+ * - else
+ * - create a new node from scratch as a last result
+ *
+ * @param {MorphContext} ctx the merge context
+ * @param {Element} oldParent the old content that we are merging the new content into
+ * @param {Element} newParent the parent element of the new content
+ * @param {Node|null} [insertionPoint] the point in the DOM we start morphing at (defaults to first child)
+ * @param {Node|null} [endPoint] the point in the DOM we stop morphing at (defaults to after last child)
+ */
+ function morphChildren(
+ ctx,
+ oldParent,
+ newParent,
+ insertionPoint = null,
+ endPoint = null,
+ ) {
+ // normalize
+ if (
+ oldParent instanceof HTMLTemplateElement &&
+ newParent instanceof HTMLTemplateElement
+ ) {
+ // @ts-ignore we can pretend the DocumentFragment is an Element
+ oldParent = oldParent.content;
+ // @ts-ignore ditto
+ newParent = newParent.content;
+ }
+ insertionPoint ||= oldParent.firstChild;
+
+ // run through all the new content
+ for (const newChild of newParent.childNodes) {
+ // once we reach the end of the old parent content skip to the end and insert the rest
+ if (insertionPoint && insertionPoint != endPoint) {
+ const bestMatch = findBestMatch(
+ ctx,
+ newChild,
+ insertionPoint,
+ endPoint,
+ );
+ if (bestMatch) {
+ // if the node to morph is not at the insertion point then remove/move up to it
+ if (bestMatch !== insertionPoint) {
+ removeNodesBetween(ctx, insertionPoint, bestMatch);
+ }
+ morphNode(bestMatch, newChild, ctx);
+ insertionPoint = bestMatch.nextSibling;
+ continue;
+ }
+ }
+
+ // if the matching node is elsewhere in the original content
+ if (newChild instanceof Element && ctx.persistentIds.has(newChild.id)) {
+ // move it and all its children here and morph
+ const movedChild = moveBeforeById(
+ oldParent,
+ newChild.id,
+ insertionPoint,
+ ctx,
+ );
+ morphNode(movedChild, newChild, ctx);
+ insertionPoint = movedChild.nextSibling;
+ continue;
+ }
+
+ // last resort: insert the new node from scratch
+ const insertedNode = createNode(
+ oldParent,
+ newChild,
+ insertionPoint,
+ ctx,
+ );
+ // could be null if beforeNodeAdded prevented insertion
+ if (insertedNode) {
+ insertionPoint = insertedNode.nextSibling;
+ }
+ }
+
+ // remove any remaining old nodes that didn't match up with new content
+ while (insertionPoint && insertionPoint != endPoint) {
+ const tempNode = insertionPoint;
+ insertionPoint = insertionPoint.nextSibling;
+ removeNode(ctx, tempNode);
+ }
+ }
+
+ /**
+ * This performs the action of inserting a new node while handling situations where the node contains
+ * elements with persistent ids and possible state info we can still preserve by moving in and then morphing
+ *
+ * @param {Element} oldParent
+ * @param {Node} newChild
+ * @param {Node|null} insertionPoint
+ * @param {MorphContext} ctx
+ * @returns {Node|null}
+ */
+ function createNode(oldParent, newChild, insertionPoint, ctx) {
+ if (ctx.callbacks.beforeNodeAdded(newChild) === false) return null;
+ if (ctx.idMap.has(newChild)) {
+ // node has children with ids with possible state so create a dummy elt of same type and apply full morph algorithm
+ const newEmptyChild = document.createElement(
+ /** @type {Element} */ (newChild).tagName,
+ );
+ oldParent.insertBefore(newEmptyChild, insertionPoint);
+ morphNode(newEmptyChild, newChild, ctx);
+ ctx.callbacks.afterNodeAdded(newEmptyChild);
+ return newEmptyChild;
+ } else {
+ // optimisation: no id state to preserve so we can just insert a clone of the newChild and its descendants
+ const newClonedChild = document.importNode(newChild, true); // importNode to not mutate newParent
+ oldParent.insertBefore(newClonedChild, insertionPoint);
+ ctx.callbacks.afterNodeAdded(newClonedChild);
+ return newClonedChild;
+ }
+ }
+
+ //=============================================================================
+ // Matching Functions
+ //=============================================================================
+ const findBestMatch = (function () {
+ /**
+ * Scans forward from the startPoint to the endPoint looking for a match
+ * for the node. It looks for an id set match first, then a soft match.
+ * We abort softmatching if we find two future soft matches, to reduce churn.
+ * @param {Node} node
+ * @param {MorphContext} ctx
+ * @param {Node | null} startPoint
+ * @param {Node | null} endPoint
+ * @returns {Node | null}
+ */
+ function findBestMatch(ctx, node, startPoint, endPoint) {
+ let softMatch = null;
+ let nextSibling = node.nextSibling;
+ let siblingSoftMatchCount = 0;
+
+ let cursor = startPoint;
+ while (cursor && cursor != endPoint) {
+ // soft matching is a prerequisite for id set matching
+ if (isSoftMatch(cursor, node)) {
+ if (isIdSetMatch(ctx, cursor, node)) {
+ return cursor; // found an id set match, we're done!
+ }
+
+ // we haven't yet saved a soft match fallback
+ if (softMatch === null) {
+ // the current soft match will hard match something else in the future, leave it
+ if (!ctx.idMap.has(cursor)) {
+ // save this as the fallback if we get through the loop without finding a hard match
+ softMatch = cursor;
+ }
+ }
+ }
+ if (
+ softMatch === null &&
+ nextSibling &&
+ isSoftMatch(cursor, nextSibling)
+ ) {
+ // The next new node has a soft match with this node, so
+ // increment the count of future soft matches
+ siblingSoftMatchCount++;
+ nextSibling = nextSibling.nextSibling;
+
+ // If there are two future soft matches, block soft matching for this node to allow
+ // future siblings to soft match. This is to reduce churn in the DOM when an element
+ // is prepended.
+ if (siblingSoftMatchCount >= 2) {
+ softMatch = undefined;
+ }
+ }
+
+ // if the current node contains active element, stop looking for better future matches,
+ // because if one is found, this node will be moved to the pantry, reparenting it and thus losing focus
+ if (cursor.contains(document.activeElement)) break;
+
+ cursor = cursor.nextSibling;
+ }
+
+ return softMatch || null;
+ }
+
+ /**
+ *
+ * @param {MorphContext} ctx
+ * @param {Node} oldNode
+ * @param {Node} newNode
+ * @returns {boolean}
+ */
+ function isIdSetMatch(ctx, oldNode, newNode) {
+ let oldSet = ctx.idMap.get(oldNode);
+ let newSet = ctx.idMap.get(newNode);
+
+ if (!newSet || !oldSet) return false;
+
+ for (const id of oldSet) {
+ // a potential match is an id in the new and old nodes that
+ // has not already been merged into the DOM
+ // But the newNode content we call this on has not been
+ // merged yet and we don't allow duplicate IDs so it is simple
+ if (newSet.has(id)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ *
+ * @param {Node} oldNode
+ * @param {Node} newNode
+ * @returns {boolean}
+ */
+ function isSoftMatch(oldNode, newNode) {
+ // ok to cast: if one is not element, `id` and `tagName` will be undefined and we'll just compare that.
+ const oldElt = /** @type {Element} */ (oldNode);
+ const newElt = /** @type {Element} */ (newNode);
+
+ return (
+ oldElt.nodeType === newElt.nodeType &&
+ oldElt.tagName === newElt.tagName &&
+ // If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing.
+ // We'll still match an anonymous node with an IDed newElt, though, because if it got this far,
+ // its not persistent, and new nodes can't have any hidden state.
+ (!oldElt.id || oldElt.id === newElt.id)
+ );
+ }
+
+ return findBestMatch;
+ })();
+
+ //=============================================================================
+ // DOM Manipulation Functions
+ //=============================================================================
+
+ /**
+ * Gets rid of an unwanted DOM node; strategy depends on nature of its reuse:
+ * - Persistent nodes will be moved to the pantry for later reuse
+ * - Other nodes will have their hooks called, and then are removed
+ * @param {MorphContext} ctx
+ * @param {Node} node
+ */
+ function removeNode(ctx, node) {
+ // are we going to id set match this later?
+ if (ctx.idMap.has(node)) {
+ // skip callbacks and move to pantry
+ moveBefore(ctx.pantry, node, null);
+ } else {
+ // remove for realsies
+ if (ctx.callbacks.beforeNodeRemoved(node) === false) return;
+ node.parentNode?.removeChild(node);
+ ctx.callbacks.afterNodeRemoved(node);
+ }
+ }
+
+ /**
+ * Remove nodes between the start and end nodes
+ * @param {MorphContext} ctx
+ * @param {Node} startInclusive
+ * @param {Node} endExclusive
+ * @returns {Node|null}
+ */
+ function removeNodesBetween(ctx, startInclusive, endExclusive) {
+ /** @type {Node | null} */
+ let cursor = startInclusive;
+ // remove nodes until the endExclusive node
+ while (cursor && cursor !== endExclusive) {
+ let tempNode = /** @type {Node} */ (cursor);
+ cursor = cursor.nextSibling;
+ removeNode(ctx, tempNode);
+ }
+ return cursor;
+ }
+
+ /**
+ * Search for an element by id within the document and pantry, and move it using moveBefore.
+ *
+ * @param {Element} parentNode - The parent node to which the element will be moved.
+ * @param {string} id - The ID of the element to be moved.
+ * @param {Node | null} after - The reference node to insert the element before.
+ * If `null`, the element is appended as the last child.
+ * @param {MorphContext} ctx
+ * @returns {Element} The found element
+ */
+ function moveBeforeById(parentNode, id, after, ctx) {
+ const target =
+ /** @type {Element} - will always be found */
+ (
+ ctx.target.querySelector(`#${id}`) ||
+ ctx.pantry.querySelector(`#${id}`)
+ );
+ removeElementFromAncestorsIdMaps(target, ctx);
+ moveBefore(parentNode, target, after);
+ return target;
+ }
+
+ /**
+ * Removes an element from its ancestors' id maps. This is needed when an element is moved from the
+ * "future" via `moveBeforeId`. Otherwise, its erstwhile ancestors could be mistakenly moved to the
+ * pantry rather than being deleted, preventing their removal hooks from being called.
+ *
+ * @param {Element} element - element to remove from its ancestors' id maps
+ * @param {MorphContext} ctx
+ */
+ function removeElementFromAncestorsIdMaps(element, ctx) {
+ const id = element.id;
+ /** @ts-ignore - safe to loop in this way **/
+ while ((element = element.parentNode)) {
+ let idSet = ctx.idMap.get(element);
+ if (idSet) {
+ idSet.delete(id);
+ if (!idSet.size) {
+ ctx.idMap.delete(element);
+ }
+ }
+ }
+ }
+
+ /**
+ * Moves an element before another element within the same parent.
+ * Uses the proposed `moveBefore` API if available (and working), otherwise falls back to `insertBefore`.
+ * This is essentialy a forward-compat wrapper.
+ *
+ * @param {Element} parentNode - The parent node containing the after element.
+ * @param {Node} element - The element to be moved.
+ * @param {Node | null} after - The reference node to insert `element` before.
+ * If `null`, `element` is appended as the last child.
+ */
+ function moveBefore(parentNode, element, after) {
+ // @ts-ignore - use proposed moveBefore feature
+ if (parentNode.moveBefore) {
+ try {
+ // @ts-ignore - use proposed moveBefore feature
+ parentNode.moveBefore(element, after);
+ } catch (e) {
+ // fall back to insertBefore as some browsers may fail on moveBefore when trying to move Dom disconnected nodes to pantry
+ parentNode.insertBefore(element, after);
+ }
+ } else {
+ parentNode.insertBefore(element, after);
+ }
+ }
+
+ return morphChildren;
+ })();
+
+ //=============================================================================
+ // Single Node Morphing Code
+ //=============================================================================
+ const morphNode = (function () {
+ /**
+ * @param {Node} oldNode root node to merge content into
+ * @param {Node} newContent new content to merge
+ * @param {MorphContext} ctx the merge context
+ * @returns {Node | null} the element that ended up in the DOM
+ */
+ function morphNode(oldNode, newContent, ctx) {
+ if (ctx.ignoreActive && oldNode === document.activeElement) {
+ // don't morph focused element
+ return null;
+ }
+
+ if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) {
+ return oldNode;
+ }
+
+ if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) ; else if (
+ oldNode instanceof HTMLHeadElement &&
+ ctx.head.style !== "morph"
+ ) {
+ // ok to cast: if newContent wasn't also a , it would've got caught in the `!isSoftMatch` branch above
+ handleHeadElement(
+ oldNode,
+ /** @type {HTMLHeadElement} */ (newContent),
+ ctx,
+ );
+ } else {
+ morphAttributes(oldNode, newContent, ctx);
+ if (!ignoreValueOfActiveElement(oldNode, ctx)) {
+ // @ts-ignore newContent can be a node here because .firstChild will be null
+ morphChildren(ctx, oldNode, newContent);
+ }
+ }
+ ctx.callbacks.afterNodeMorphed(oldNode, newContent);
+ return oldNode;
+ }
+
+ /**
+ * syncs the oldNode to the newNode, copying over all attributes and
+ * inner element state from the newNode to the oldNode
+ *
+ * @param {Node} oldNode the node to copy attributes & state to
+ * @param {Node} newNode the node to copy attributes & state from
+ * @param {MorphContext} ctx the merge context
+ */
+ function morphAttributes(oldNode, newNode, ctx) {
+ let type = newNode.nodeType;
+
+ // if is an element type, sync the attributes from the
+ // new node into the new node
+ if (type === 1 /* element type */) {
+ const oldElt = /** @type {Element} */ (oldNode);
+ const newElt = /** @type {Element} */ (newNode);
+
+ const oldAttributes = oldElt.attributes;
+ const newAttributes = newElt.attributes;
+ for (const newAttribute of newAttributes) {
+ if (ignoreAttribute(newAttribute.name, oldElt, "update", ctx)) {
+ continue;
+ }
+ if (oldElt.getAttribute(newAttribute.name) !== newAttribute.value) {
+ oldElt.setAttribute(newAttribute.name, newAttribute.value);
+ }
+ }
+ // iterate backwards to avoid skipping over items when a delete occurs
+ for (let i = oldAttributes.length - 1; 0 <= i; i--) {
+ const oldAttribute = oldAttributes[i];
+
+ // toAttributes is a live NamedNodeMap, so iteration+mutation is unsafe
+ // e.g. custom element attribute callbacks can remove other attributes
+ if (!oldAttribute) continue;
+
+ if (!newElt.hasAttribute(oldAttribute.name)) {
+ if (ignoreAttribute(oldAttribute.name, oldElt, "remove", ctx)) {
+ continue;
+ }
+ oldElt.removeAttribute(oldAttribute.name);
+ }
+ }
+
+ if (!ignoreValueOfActiveElement(oldElt, ctx)) {
+ syncInputValue(oldElt, newElt, ctx);
+ }
+ }
+
+ // sync text nodes
+ if (type === 8 /* comment */ || type === 3 /* text */) {
+ if (oldNode.nodeValue !== newNode.nodeValue) {
+ oldNode.nodeValue = newNode.nodeValue;
+ }
+ }
+ }
+
+ /**
+ * NB: many bothans died to bring us information:
+ *
+ * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
+ * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
+ *
+ * @param {Element} oldElement the element to sync the input value to
+ * @param {Element} newElement the element to sync the input value from
+ * @param {MorphContext} ctx the merge context
+ */
+ function syncInputValue(oldElement, newElement, ctx) {
+ if (
+ oldElement instanceof HTMLInputElement &&
+ newElement instanceof HTMLInputElement &&
+ newElement.type !== "file"
+ ) {
+ let newValue = newElement.value;
+ let oldValue = oldElement.value;
+
+ // sync boolean attributes
+ syncBooleanAttribute(oldElement, newElement, "checked", ctx);
+ syncBooleanAttribute(oldElement, newElement, "disabled", ctx);
+
+ if (!newElement.hasAttribute("value")) {
+ if (!ignoreAttribute("value", oldElement, "remove", ctx)) {
+ oldElement.value = "";
+ oldElement.removeAttribute("value");
+ }
+ } else if (oldValue !== newValue) {
+ if (!ignoreAttribute("value", oldElement, "update", ctx)) {
+ oldElement.setAttribute("value", newValue);
+ oldElement.value = newValue;
+ }
+ }
+ // TODO: QUESTION(1cg): this used to only check `newElement` unlike the other branches -- why?
+ // did I break something?
+ } else if (
+ oldElement instanceof HTMLOptionElement &&
+ newElement instanceof HTMLOptionElement
+ ) {
+ syncBooleanAttribute(oldElement, newElement, "selected", ctx);
+ } else if (
+ oldElement instanceof HTMLTextAreaElement &&
+ newElement instanceof HTMLTextAreaElement
+ ) {
+ let newValue = newElement.value;
+ let oldValue = oldElement.value;
+ if (ignoreAttribute("value", oldElement, "update", ctx)) {
+ return;
+ }
+ if (newValue !== oldValue) {
+ oldElement.value = newValue;
+ }
+ if (
+ oldElement.firstChild &&
+ oldElement.firstChild.nodeValue !== newValue
+ ) {
+ oldElement.firstChild.nodeValue = newValue;
+ }
+ }
+ }
+
+ /**
+ * @param {Element} oldElement element to write the value to
+ * @param {Element} newElement element to read the value from
+ * @param {string} attributeName the attribute name
+ * @param {MorphContext} ctx the merge context
+ */
+ function syncBooleanAttribute(oldElement, newElement, attributeName, ctx) {
+ // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties
+ const newLiveValue = newElement[attributeName],
+ // @ts-ignore ditto
+ oldLiveValue = oldElement[attributeName];
+ if (newLiveValue !== oldLiveValue) {
+ const ignoreUpdate = ignoreAttribute(
+ attributeName,
+ oldElement,
+ "update",
+ ctx,
+ );
+ if (!ignoreUpdate) {
+ // update attribute's associated DOM property
+ // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties
+ oldElement[attributeName] = newElement[attributeName];
+ }
+ if (newLiveValue) {
+ if (!ignoreUpdate) {
+ // https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML
+ // this is the correct way to set a boolean attribute to "true"
+ oldElement.setAttribute(attributeName, "");
+ }
+ } else {
+ if (!ignoreAttribute(attributeName, oldElement, "remove", ctx)) {
+ oldElement.removeAttribute(attributeName);
+ }
+ }
+ }
+ }
+
+ /**
+ * @param {string} attr the attribute to be mutated
+ * @param {Element} element the element that is going to be updated
+ * @param {"update" | "remove"} updateType
+ * @param {MorphContext} ctx the merge context
+ * @returns {boolean} true if the attribute should be ignored, false otherwise
+ */
+ function ignoreAttribute(attr, element, updateType, ctx) {
+ if (
+ attr === "value" &&
+ ctx.ignoreActiveValue &&
+ element === document.activeElement
+ ) {
+ return true;
+ }
+ return (
+ ctx.callbacks.beforeAttributeUpdated(attr, element, updateType) ===
+ false
+ );
+ }
+
+ /**
+ * @param {Node} possibleActiveElement
+ * @param {MorphContext} ctx
+ * @returns {boolean}
+ */
+ function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
+ return (
+ !!ctx.ignoreActiveValue &&
+ possibleActiveElement === document.activeElement &&
+ possibleActiveElement !== document.body
+ );
+ }
+
+ return morphNode;
+ })();
+
+ //=============================================================================
+ // Head Management Functions
+ //=============================================================================
+ /**
+ * @param {MorphContext} ctx
+ * @param {Element} oldNode
+ * @param {Element} newNode
+ * @param {function} callback
+ * @returns {Node[] | Promise}
+ */
+ function withHeadBlocking(ctx, oldNode, newNode, callback) {
+ if (ctx.head.block) {
+ const oldHead = oldNode.querySelector("head");
+ const newHead = newNode.querySelector("head");
+ if (oldHead && newHead) {
+ const promises = handleHeadElement(oldHead, newHead, ctx);
+ // when head promises resolve, proceed ignoring the head tag
+ return Promise.all(promises).then(() => {
+ const newCtx = Object.assign(ctx, {
+ head: {
+ block: false,
+ ignore: true,
+ },
+ });
+ return callback(newCtx);
+ });
+ }
+ }
+ // just proceed if we not head blocking
+ return callback(ctx);
+ }
+
+ /**
+ * The HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
+ *
+ * @param {Element} oldHead
+ * @param {Element} newHead
+ * @param {MorphContext} ctx
+ * @returns {Promise[]}
+ */
+ function handleHeadElement(oldHead, newHead, ctx) {
+ let added = [];
+ let removed = [];
+ let preserved = [];
+ let nodesToAppend = [];
+
+ // put all new head elements into a Map, by their outerHTML
+ let srcToNewHeadNodes = new Map();
+ for (const newHeadChild of newHead.children) {
+ srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
+ }
+
+ // for each elt in the current head
+ for (const currentHeadElt of oldHead.children) {
+ // If the current head element is in the map
+ let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
+ let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
+ let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
+ if (inNewContent || isPreserved) {
+ if (isReAppended) {
+ // remove the current version and let the new version replace it and re-execute
+ removed.push(currentHeadElt);
+ } else {
+ // this element already exists and should not be re-appended, so remove it from
+ // the new content map, preserving it in the DOM
+ srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
+ preserved.push(currentHeadElt);
+ }
+ } else {
+ if (ctx.head.style === "append") {
+ // we are appending and this existing element is not new content
+ // so if and only if it is marked for re-append do we do anything
+ if (isReAppended) {
+ removed.push(currentHeadElt);
+ nodesToAppend.push(currentHeadElt);
+ }
+ } else {
+ // if this is a merge, we remove this content since it is not in the new head
+ if (ctx.head.shouldRemove(currentHeadElt) !== false) {
+ removed.push(currentHeadElt);
+ }
+ }
+ }
+ }
+
+ // Push the remaining new head elements in the Map into the
+ // nodes to append to the head tag
+ nodesToAppend.push(...srcToNewHeadNodes.values());
+
+ let promises = [];
+ for (const newNode of nodesToAppend) {
+ // TODO: This could theoretically be null, based on type
+ let newElt = /** @type {ChildNode} */ (
+ document.createRange().createContextualFragment(newNode.outerHTML)
+ .firstChild
+ );
+ if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
+ if (
+ ("href" in newElt && newElt.href) ||
+ ("src" in newElt && newElt.src)
+ ) {
+ /** @type {(result?: any) => void} */ let resolve;
+ let promise = new Promise(function (_resolve) {
+ resolve = _resolve;
+ });
+ newElt.addEventListener("load", function () {
+ resolve();
+ });
+ promises.push(promise);
+ }
+ oldHead.appendChild(newElt);
+ ctx.callbacks.afterNodeAdded(newElt);
+ added.push(newElt);
+ }
+ }
+
+ // remove all removed elements, after we have appended the new elements to avoid
+ // additional network requests for things like style sheets
+ for (const removedElement of removed) {
+ if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
+ oldHead.removeChild(removedElement);
+ ctx.callbacks.afterNodeRemoved(removedElement);
+ }
+ }
+
+ ctx.head.afterHeadMorphed(oldHead, {
+ added: added,
+ kept: preserved,
+ removed: removed,
+ });
+ return promises;
+ }
+
+ //=============================================================================
+ // Create Morph Context Functions
+ //=============================================================================
+ const createMorphContext = (function () {
+ /**
+ *
+ * @param {Element} oldNode
+ * @param {Element} newContent
+ * @param {Config} config
+ * @returns {MorphContext}
+ */
+ function createMorphContext(oldNode, newContent, config) {
+ const { persistentIds, idMap } = createIdMaps(oldNode, newContent);
+
+ const mergedConfig = mergeDefaults(config);
+ const morphStyle = mergedConfig.morphStyle || "outerHTML";
+ if (!["innerHTML", "outerHTML"].includes(morphStyle)) {
+ throw `Do not understand how to morph style ${morphStyle}`;
+ }
+
+ return {
+ target: oldNode,
+ newContent: newContent,
+ config: mergedConfig,
+ morphStyle: morphStyle,
+ ignoreActive: mergedConfig.ignoreActive,
+ ignoreActiveValue: mergedConfig.ignoreActiveValue,
+ restoreFocus: mergedConfig.restoreFocus,
+ idMap: idMap,
+ persistentIds: persistentIds,
+ pantry: createPantry(),
+ callbacks: mergedConfig.callbacks,
+ head: mergedConfig.head,
+ };
+ }
+
+ /**
+ * Deep merges the config object and the Idiomorph.defaults object to
+ * produce a final configuration object
+ * @param {Config} config
+ * @returns {ConfigInternal}
+ */
+ function mergeDefaults(config) {
+ let finalConfig = Object.assign({}, defaults);
+
+ // copy top level stuff into final config
+ Object.assign(finalConfig, config);
+
+ // copy callbacks into final config (do this to deep merge the callbacks)
+ finalConfig.callbacks = Object.assign(
+ {},
+ defaults.callbacks,
+ config.callbacks,
+ );
+
+ // copy head config into final config (do this to deep merge the head)
+ finalConfig.head = Object.assign({}, defaults.head, config.head);
+
+ return finalConfig;
+ }
+
+ /**
+ * @returns {HTMLDivElement}
+ */
+ function createPantry() {
+ const pantry = document.createElement("div");
+ pantry.hidden = true;
+ document.body.insertAdjacentElement("afterend", pantry);
+ return pantry;
+ }
+
+ /**
+ * Returns all elements with an ID contained within the root element and its descendants
+ *
+ * @param {Element} root
+ * @returns {Element[]}
+ */
+ function findIdElements(root) {
+ let elements = Array.from(root.querySelectorAll("[id]"));
+ if (root.id) {
+ elements.push(root);
+ }
+ return elements;
+ }
+
+ /**
+ * A bottom-up algorithm that populates a map of Element -> IdSet.
+ * The idSet for a given element is the set of all IDs contained within its subtree.
+ * As an optimzation, we filter these IDs through the given list of persistent IDs,
+ * because we don't need to bother considering IDed elements that won't be in the new content.
+ *
+ * @param {Map>} idMap
+ * @param {Set} persistentIds
+ * @param {Element} root
+ * @param {Element[]} elements
+ */
+ function populateIdMapWithTree(idMap, persistentIds, root, elements) {
+ for (const elt of elements) {
+ if (persistentIds.has(elt.id)) {
+ /** @type {Element|null} */
+ let current = elt;
+ // walk up the parent hierarchy of that element, adding the id
+ // of element to the parent's id set
+ while (current) {
+ let idSet = idMap.get(current);
+ // if the id set doesn't exist, create it and insert it in the map
+ if (idSet == null) {
+ idSet = new Set();
+ idMap.set(current, idSet);
+ }
+ idSet.add(elt.id);
+
+ if (current === root) break;
+ current = current.parentElement;
+ }
+ }
+ }
+ }
+
+ /**
+ * This function computes a map of nodes to all ids contained within that node (inclusive of the
+ * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
+ * for a looser definition of "matching" than tradition id matching, and allows child nodes
+ * to contribute to a parent nodes matching.
+ *
+ * @param {Element} oldContent the old content that will be morphed
+ * @param {Element} newContent the new content to morph to
+ * @returns {IdSets}
+ */
+ function createIdMaps(oldContent, newContent) {
+ const oldIdElements = findIdElements(oldContent);
+ const newIdElements = findIdElements(newContent);
+
+ const persistentIds = createPersistentIds(oldIdElements, newIdElements);
+
+ /** @type {Map>} */
+ let idMap = new Map();
+ populateIdMapWithTree(idMap, persistentIds, oldContent, oldIdElements);
+
+ /** @ts-ignore - if newContent is a duck-typed parent, pass its single child node as the root to halt upwards iteration */
+ const newRoot = newContent.__idiomorphRoot || newContent;
+ populateIdMapWithTree(idMap, persistentIds, newRoot, newIdElements);
+
+ return { persistentIds, idMap };
+ }
+
+ /**
+ * This function computes the set of ids that persist between the two contents excluding duplicates
+ *
+ * @param {Element[]} oldIdElements
+ * @param {Element[]} newIdElements
+ * @returns {Set}
+ */
+ function createPersistentIds(oldIdElements, newIdElements) {
+ let duplicateIds = new Set();
+
+ /** @type {Map} */
+ let oldIdTagNameMap = new Map();
+ for (const { id, tagName } of oldIdElements) {
+ if (oldIdTagNameMap.has(id)) {
+ duplicateIds.add(id);
+ } else {
+ oldIdTagNameMap.set(id, tagName);
+ }
+ }
+
+ let persistentIds = new Set();
+ for (const { id, tagName } of newIdElements) {
+ if (persistentIds.has(id)) {
+ duplicateIds.add(id);
+ } else if (oldIdTagNameMap.get(id) === tagName) {
+ persistentIds.add(id);
+ }
+ // skip if tag types mismatch because its not possible to morph one tag into another
+ }
+
+ for (const id of duplicateIds) {
+ persistentIds.delete(id);
+ }
+ return persistentIds;
+ }
+
+ return createMorphContext;
+ })();
+
+ //=============================================================================
+ // HTML Normalization Functions
+ //=============================================================================
+ const { normalizeElement, normalizeParent } = (function () {
+ /** @type {WeakSet} */
+ const generatedByIdiomorph = new WeakSet();
+
+ /**
+ *
+ * @param {Element | Document} content
+ * @returns {Element}
+ */
+ function normalizeElement(content) {
+ if (content instanceof Document) {
+ return content.documentElement;
+ } else {
+ return content;
+ }
+ }
+
+ /**
+ *
+ * @param {null | string | Node | HTMLCollection | Node[] | Document & {generatedByIdiomorph:boolean}} newContent
+ * @returns {Element}
+ */
+ function normalizeParent(newContent) {
+ if (newContent == null) {
+ return document.createElement("div"); // dummy parent element
+ } else if (typeof newContent === "string") {
+ return normalizeParent(parseContent(newContent));
+ } else if (
+ generatedByIdiomorph.has(/** @type {Element} */ (newContent))
+ ) {
+ // the template tag created by idiomorph parsing can serve as a dummy parent
+ return /** @type {Element} */ (newContent);
+ } else if (newContent instanceof Node) {
+ if (newContent.parentNode) {
+ // we can't use the parent directly because newContent may have siblings
+ // that we don't want in the morph, and reparenting might be expensive (TODO is it?),
+ // so we create a duck-typed parent node instead.
+ return createDuckTypedParent(newContent);
+ } else {
+ // a single node is added as a child to a dummy parent
+ const dummyParent = document.createElement("div");
+ dummyParent.append(newContent);
+ return dummyParent;
+ }
+ } else {
+ // all nodes in the array or HTMLElement collection are consolidated under
+ // a single dummy parent element
+ const dummyParent = document.createElement("div");
+ for (const elt of [...newContent]) {
+ dummyParent.append(elt);
+ }
+ return dummyParent;
+ }
+ }
+
+ /**
+ * Creates a fake duck-typed parent element to wrap a single node, without actually reparenting it.
+ * "If it walks like a duck, and quacks like a duck, then it must be a duck!" -- James Whitcomb Riley (1849–1916)
+ *
+ * @param {Node} newContent
+ * @returns {Element}
+ */
+ function createDuckTypedParent(newContent) {
+ return /** @type {Element} */ (
+ /** @type {unknown} */ ({
+ childNodes: [newContent],
+ /** @ts-ignore - cover your eyes for a minute, tsc */
+ querySelectorAll: (s) => {
+ /** @ts-ignore */
+ const elements = newContent.querySelectorAll(s);
+ /** @ts-ignore */
+ return newContent.matches(s) ? [newContent, ...elements] : elements;
+ },
+ /** @ts-ignore */
+ insertBefore: (n, r) => newContent.parentNode.insertBefore(n, r),
+ /** @ts-ignore */
+ moveBefore: (n, r) => newContent.parentNode.moveBefore(n, r),
+ // for later use with populateIdMapWithTree to halt upwards iteration
+ get __idiomorphRoot() {
+ return newContent;
+ },
+ })
+ );
+ }
+
+ /**
+ *
+ * @param {string} newContent
+ * @returns {Node | null | DocumentFragment}
+ */
+ function parseContent(newContent) {
+ let parser = new DOMParser();
+
+ // remove svgs to avoid false-positive matches on head, etc.
+ let contentWithSvgsRemoved = newContent.replace(
+ /