diff --git a/Gemfile b/Gemfile
index 433c0415..1ed391ed 100644
--- a/Gemfile
+++ b/Gemfile
@@ -19,7 +19,7 @@ gem "tailwindcss-rails", "~> 2.6"
# Hotwire
gem "stimulus-rails"
-gem "turbo-rails"
+gem "turbo-rails", github: "rosa/turbo-rails", branch: "offline-cache"
# Deployment
gem "kamal", require: false
diff --git a/Gemfile.lock b/Gemfile.lock
index b1eed6b4..ac93ec94 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,3 +1,12 @@
+GIT
+ remote: https://github.com/rosa/turbo-rails.git
+ revision: 25538ece2934237200c36e5ef49cff932463aed9
+ branch: offline-cache
+ specs:
+ turbo-rails (2.0.16)
+ actionpack (>= 7.1.0)
+ railties (>= 7.1.0)
+
GIT
remote: https://github.com/ruby/net-pop.git
revision: e8d0afe2773b9eb6a23c39e9e437f6fc0fc7c733
@@ -622,10 +631,6 @@ GEM
timeliness (0.5.2)
timeout (0.4.3)
tty-which (0.5.0)
- turbo-rails (2.0.6)
- actionpack (>= 6.0.0)
- activejob (>= 6.0.0)
- railties (>= 6.0.0)
turbo_power (0.7.0)
turbo-rails (>= 1.3.0)
tzinfo (2.0.6)
@@ -738,7 +743,7 @@ DEPENDENCIES
stimulus-rails
tailwindcss-rails (~> 2.6)
timecop
- turbo-rails
+ turbo-rails!
tzinfo-data
validate_url
validates_timeliness (~> 8.0)
diff --git a/app/controllers/service_worker_controller.rb b/app/controllers/service_worker_controller.rb
index f1ddc25a..a095928b 100644
--- a/app/controllers/service_worker_controller.rb
+++ b/app/controllers/service_worker_controller.rb
@@ -8,7 +8,4 @@ def service_worker
def manifest
end
-
- def offline
- end
end
diff --git a/app/javascript/application.js b/app/javascript/application.js
index 73337f77..b482eae3 100644
--- a/app/javascript/application.js
+++ b/app/javascript/application.js
@@ -1,6 +1,12 @@
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
-import '@hotwired/turbo-rails'
+import { Turbo } from '@hotwired/turbo-rails'
import 'controllers'
import 'custom/companion'
import 'trix'
import '@rails/actiontext'
+
+Turbo.offline.start('/service-worker.js', {
+ scope: '/',
+ type: 'classic',
+ native: true
+})
diff --git a/app/javascript/controllers/offline_banner_controller.js b/app/javascript/controllers/offline_banner_controller.js
new file mode 100644
index 00000000..3a9c4fc4
--- /dev/null
+++ b/app/javascript/controllers/offline_banner_controller.js
@@ -0,0 +1,47 @@
+import { Controller } from '@hotwired/stimulus'
+
+export default class extends Controller {
+ static targets = ['banner']
+
+ connect () {
+ this.handleOnlineStatus = this.handleOnlineStatus.bind(this)
+
+ window.addEventListener('online', this.handleOnlineStatus)
+ window.addEventListener('offline', this.handleOnlineStatus)
+
+ this.handleOnlineStatus()
+ }
+
+ disconnect () {
+ window.removeEventListener('online', this.handleOnlineStatus)
+ window.removeEventListener('offline', this.handleOnlineStatus)
+ }
+
+ handleOnlineStatus () {
+ if (navigator.onLine) {
+ this.hideBanner()
+ } else {
+ this.showBanner()
+ }
+ }
+
+ showBanner () {
+ this.bannerTarget.classList.remove('hidden', '-translate-y-full')
+ this.bannerTarget.classList.add('translate-y-0')
+ }
+
+ hideBanner () {
+ this.bannerTarget.classList.remove('translate-y-0')
+ this.bannerTarget.classList.add('-translate-y-full')
+
+ setTimeout(() => {
+ if (navigator.onLine) {
+ this.bannerTarget.classList.add('hidden')
+ }
+ }, 300)
+ }
+
+ dismiss () {
+ this.hideBanner()
+ }
+}
diff --git a/app/views/layouts/_offline_banner.html.erb b/app/views/layouts/_offline_banner.html.erb
new file mode 100644
index 00000000..790cf2bd
--- /dev/null
+++ b/app/views/layouts/_offline_banner.html.erb
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+ You're currently offline
+
+
+ The app may not work properly until your connection is restored.
+
+
+
+
+
+
+
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 79d57d24..139fee22 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -1,7 +1,7 @@
-
- <%= page_title %>
+
+ <%= page_title %>
@@ -9,6 +9,7 @@
+
">
@@ -48,6 +49,8 @@
data-web-push-notifications-vapid-key-value=#{vapid_public_key}
data-web-push-notifications-notifications-enabled-value=#{current_profile.web_push_notifications}" : '' %>>
+ <%= render 'layouts/offline_banner' %>
+
<%= yield :top_nav %>
diff --git a/app/views/service_worker/offline.html.erb b/app/views/service_worker/offline.html.erb
deleted file mode 100644
index fe7b3e15..00000000
--- a/app/views/service_worker/offline.html.erb
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
- <%= inline_svg_tag "icons/no_wifi.svg", size: "56", class: "fill-gray-400" %>
-
-
You're offline
-
Check your connection
and try again.
- <%= link_to(
- 'Refresh the page', "",
- class: "text-red font-bold text-center rounded-sm underline italic text-lg"
- ) %>
-
diff --git a/app/views/service_worker/service_worker.js b/app/views/service_worker/service_worker.js.erb
similarity index 50%
rename from app/views/service_worker/service_worker.js
rename to app/views/service_worker/service_worker.js.erb
index e9f43301..9eb9b6a1 100644
--- a/app/views/service_worker/service_worker.js
+++ b/app/views/service_worker/service_worker.js.erb
@@ -3,9 +3,9 @@
importScripts(
'https://storage.googleapis.com/workbox-cdn/releases/6.4.1/workbox-sw.js'
)
+importScripts("<%= javascript_url('turbo-offline-umd.js') %>")
// ------------- Push Notifications -------------
-
self.addEventListener('push', async (event) => {
let data
try {
@@ -48,50 +48,14 @@ self.addEventListener('notificationclick', function (event) {
)
})
-// ------------- Request handling -------------
-
-const { CacheFirst, NetworkFirst } = workbox.strategies
-const { registerRoute } = workbox.routing
-
-// For assets (scripts and images), cache first
-registerRoute(
- ({ request }) => request.destination === 'script' ||
- request.destination === 'style',
- new CacheFirst({
- cacheName: 'assets-styles-and-scripts'
- })
-)
-
-registerRoute(
- ({ request }) => request.destination === 'image',
- new CacheFirst({
- cacheName: 'assets-images'
+// ------------- Turbo Offline -------------
+TurboOffline.addRule({
+ match: /.*/,
+ handler: TurboOffline.handlers.networkFirst({
+ cacheName: "general",
+ maxAge: 60 * 60 * 24 * 7,
+ networkTimeout: 1
})
-)
-
-// For pages, network first
-registerRoute(
- ({ request, url }) => request.destination === 'document' ||
- request.destination === '',
- new NetworkFirst()
-)
-
-// Offline fallback
-
-const { warmStrategyCache } = workbox.recipes
-const { setCatchHandler } = workbox.routing
-const strategy = new NetworkFirst()
-const urls = ['/offline.html']
-
-// Warm the runtime cache with a list of asset URLs
-warmStrategyCache({ urls, strategy })
-
-// Trigger a 'catch' handler when any of the other routes fail to generate a response
-setCatchHandler(async ({ event }) => {
- switch (event.request.destination) {
- case 'document':
- return strategy.handle({ event, request: urls[0] })
- default:
- return Response.error()
- }
})
+
+TurboOffline.start()
\ No newline at end of file
diff --git a/config/importmap.rb b/config/importmap.rb
index 6b83bbfa..f97b3d2e 100644
--- a/config/importmap.rb
+++ b/config/importmap.rb
@@ -1,7 +1,8 @@
# Pin npm packages by running ./bin/importmap
pin "application"
-pin "@hotwired/turbo-rails", to: "turbo.min.js"
+pin "@hotwired/turbo-rails", to: "turbo.min.js", preload: true
+pin "@hotwired/turbo/offline", to: "turbo-offline-umd.js", preload: true
pin "@hotwired/stimulus", to: "@hotwired--stimulus.js" # @3.2.2
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
diff --git a/vendor/javascript/turbo-offline-umd.js b/vendor/javascript/turbo-offline-umd.js
new file mode 100644
index 00000000..538e6210
--- /dev/null
+++ b/vendor/javascript/turbo-offline-umd.js
@@ -0,0 +1,548 @@
+/*!
+Turbo 8.0.13
+Copyright © 2025 37signals LLC
+ */
+(function (global, factory) {
+ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
+ typeof define === 'function' && define.amd ? define(['exports'], factory) :
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.TurboOffline = {}));
+})(this, (function (exports) { 'use strict';
+
+ class Rule {
+ constructor({ handler, match = /.*/ } = {}) {
+ this.handler = handler;
+ this.match = match;
+ }
+
+ matches(request) {
+ if (typeof this.match === 'function') return this.match(request)
+
+ const regexes = Array.isArray(this.match) ? this.match : [this.match];
+ return regexes.some(regex => regex.test(request.url))
+ }
+
+ async handle(event) {
+ const { response, afterHandlePromise } = await this.handler.handle(event.request);
+ event.waitUntil(afterHandlePromise);
+
+ return response
+ }
+ }
+
+ class ServiceWorker {
+ #started = false
+ #rules = []
+
+ addRule(rule) {
+ this.#rules.push(new Rule(rule));
+ }
+
+ start() {
+ this.#warnIfNoRulesConfigured();
+
+ if (!this.#started) {
+ self.addEventListener("install", this.installed);
+ self.addEventListener("message", this.messageReceived);
+ self.addEventListener("fetch", this.fetch);
+ this.#started = true;
+ }
+ }
+
+ installed = (event) => {
+ // Just log for now
+ console.log("Service worker installed");
+ }
+
+ messageReceived = (event) => {
+ if (this[event.data.action]) {
+ const actionCall = this[event.data.action](event.data.params);
+ event.waitUntil(actionCall);
+ }
+ }
+
+ fetch = (event) => {
+ if (this.#canInterceptRequest(event.request)) {
+ const rule = this.#findMatchingRule(event.request);
+ if (!rule) return
+
+ const response = rule.handle(event);
+ event.respondWith(response);
+ }
+ }
+
+ #warnIfNoRulesConfigured() {
+ if (this.#rules.length === 0) {
+ console.warn("No rules configured for service worker. No requests will be intercepted.");
+ }
+ }
+
+ #canInterceptRequest(request) {
+ const url = new URL(request.url, location.href);
+ return request.method === "GET" && url.protocol.startsWith('http')
+ }
+
+ #findMatchingRule(request) {
+ return this.#rules.find((rule) => rule.matches(request))
+ }
+ }
+
+ const DATABASE_NAME = "turbo-offline-database";
+ const DATABASE_VERSION = 1;
+ const STORE_NAME = "cache-registry";
+
+ class CacheRegistryDatabase {
+ get(cacheName, key) {
+ const getOp = (store) => this.#requestToPromise(store.get(key));
+ return this.#performOperation(STORE_NAME, getOp, "readonly")
+ }
+
+ has(cacheName, key) {
+ const countOp = (store) => this.#requestToPromise(store.count(key));
+ return this.#performOperation(STORE_NAME, countOp, "readonly").then((result) => result === 1)
+ }
+
+ put(cacheName, key, value) {
+ const putOp = (store) => {
+ const item = { key: key, cacheName: cacheName, timestamp: Date.now(), ...value };
+ store.put(item);
+ return this.#requestToPromise(store.transaction)
+ };
+
+ return this.#performOperation(STORE_NAME, putOp, "readwrite")
+ }
+
+ getTimestamp(cacheName, key) {
+ return this.get(cacheName, key).then((result) => result?.timestamp)
+ }
+
+ getOlderThan(cacheName, timestamp) {
+ const getOlderThanOp = (store) => {
+ const index = store.index("cacheNameAndTimestamp");
+ // Use compound key range: [cacheName, timestamp]
+ const range = IDBKeyRange.bound(
+ [cacheName, 0], // start of range
+ [cacheName, timestamp], // end of range
+ false, // lowerOpen: include lower bound
+ true // upperOpen: exclude upper bound
+ );
+ const cursorRequest = index.openCursor(range);
+
+ return this.#cursorRequestToPromise(cursorRequest)
+ };
+ return this.#performOperation(STORE_NAME, getOlderThanOp, "readonly")
+ }
+
+ delete(cacheName, key) {
+ const deleteOp = (store) => this.#requestToPromise(store.delete(key));
+ return this.#performOperation(STORE_NAME, deleteOp, "readwrite")
+ }
+
+ #performOperation(storeName, operation, mode) {
+ return this.#openDatabase().then((database) => {
+ const transaction = database.transaction(storeName, mode);
+ const store = transaction.objectStore(storeName);
+ return operation(store)
+ })
+ }
+
+ #openDatabase() {
+ const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
+ request.onupgradeneeded = () => {
+ const cacheMetadataStore = request.result.createObjectStore(STORE_NAME, { keyPath: "key" });
+ cacheMetadataStore.createIndex("cacheNameAndTimestamp", [ "cacheName", "timestamp" ]);
+ };
+
+ return this.#requestToPromise(request)
+ }
+
+ #requestToPromise(request) {
+ return new Promise((resolve, reject) => {
+ request.oncomplete = request.onsuccess = () => resolve(request.result);
+ request.onabort = request.onerror = () => reject(request.error);
+ })
+ }
+
+ #cursorRequestToPromise(request) {
+ return new Promise((resolve, reject) => {
+ const results = [];
+
+ request.onsuccess = (event) => {
+ const cursor = event.target.result;
+ if (cursor) {
+ results.push(cursor.value);
+ cursor.continue();
+ } else {
+ resolve(results);
+ }
+ };
+
+ request.onerror = () => reject(request.error);
+ })
+ }
+ }
+
+ let cacheRegistryDatabase = null;
+
+ function getDatabase() {
+ if (!cacheRegistryDatabase) {
+ cacheRegistryDatabase = new CacheRegistryDatabase();
+ }
+ return cacheRegistryDatabase
+ }
+
+ class CacheRegistry {
+ constructor(cacheName) {
+ this.cacheName = cacheName;
+ this.database = getDatabase();
+ }
+
+ get(key) {
+ return this.database.get(this.cacheName, key)
+ }
+
+ has(key) {
+ return this.database.has(this.cacheName, key)
+ }
+
+ put(key, value = {}) {
+ return this.database.put(this.cacheName, key, value)
+ }
+
+ getTimestamp(key) {
+ return this.database.getTimestamp(this.cacheName, key)
+ }
+
+ getOlderThan(timestamp) {
+ return this.database.getOlderThan(this.cacheName, timestamp)
+ }
+
+ delete(key) {
+ return this.database.delete(this.cacheName, key)
+ }
+ }
+
+ class CacheTrimmer {
+ #isRunning = false
+
+ constructor(cacheName, cacheRegistry, options = {}) {
+ this.cacheName = cacheName;
+ this.cacheRegistry = cacheRegistry;
+ this.options = options;
+ }
+
+ async trim() {
+ if (this.#isRunning) {
+ return
+ }
+
+ if (!this.#shouldTrim()) {
+ return
+ }
+
+ this.#isRunning = true;
+
+ try {
+ await this.deleteEntries();
+ } finally {
+ this.#isRunning = false;
+ }
+ }
+
+ #shouldTrim() {
+ // For now, only check maxAge. To be extended for maxEntries, maxStorage, etc.
+ return this.options.maxAge && this.options.maxAge > 0
+ }
+
+ async deleteEntries() {
+ if (this.options.maxAge) {
+ await this.deleteEntriesByAge();
+ }
+ // To be extended with other options
+ }
+
+ async deleteEntriesByAge() {
+ const maxAgeMs = this.options.maxAge * 1000;
+ const cutoffTimestamp = Date.now() - maxAgeMs;
+
+ const expiredEntries = await this.cacheRegistry.getOlderThan(cutoffTimestamp);
+
+ if (expiredEntries.length === 0) {
+ return
+ }
+
+ console.debug(`Trimming ${expiredEntries.length} expired entries from cache "${this.cacheName}"`);
+
+ const cache = await caches.open(this.cacheName);
+
+ const deletePromises = expiredEntries.map(async (entry) => {
+ const cacheDeletePromise = cache.delete(entry.key);
+ const registryDeletePromise = this.cacheRegistry.delete(entry.key);
+
+ return Promise.all([cacheDeletePromise, registryDeletePromise])
+ });
+
+ await Promise.all(deletePromises);
+
+ console.debug(`Successfully trimmed ${expiredEntries.length} entries from cache "${this.cacheName}"`);
+ }
+ }
+
+ class Handler {
+ constructor({ cacheName, networkTimeout, maxAge }) {
+ this.cacheName = cacheName;
+ this.networkTimeout = networkTimeout;
+
+ this.cacheRegistry = new CacheRegistry(cacheName);
+ this.cacheTrimmer = new CacheTrimmer(cacheName, this.cacheRegistry, { maxAge });
+ }
+
+ async handle(request) {
+ // Abstract method
+ }
+
+ async fetchFromCache(request) {
+ const cacheKeyUrl = buildCacheKey(request);
+ let response = await caches.match(cacheKeyUrl, { ignoreVary: true });
+
+ if (response !== undefined && request.redirect === "manual" && response.redirected) {
+ // Can't respond with a redirected response to a request
+ // whose redirect mode is not "follow". Since we can't cache
+ // the request with manual redirect mode because these might be
+ // crawled in advance, when we don't know how these will be requested,
+ // we just return the body here.
+ response = new Response(response.body, { headers: response.headers, status: response.status, url: response.url });
+ }
+
+ return response
+ }
+
+ async fetchFromNetwork(request) {
+ // Setting the referrer so that it doesn't appear as the service worker
+ const referrer = request.referrer;
+ return await fetch(request, { referrer })
+ }
+
+ async saveToCache(request, response) {
+ if (response && this.canCacheResponse(response)) {
+ const cacheKeyUrl = buildCacheKey(request, response);
+ const cache = await caches.open(this.cacheName);
+
+ const cachePromise = cache.put(cacheKeyUrl, response);
+ const registryPromise = this.cacheRegistry.put(cacheKeyUrl);
+ const trimPromise = this.cacheTrimmer.trim();
+
+ return Promise.all([ cachePromise, registryPromise, trimPromise ])
+ }
+ }
+
+ canCacheResponse(response) {
+ // OK response and opaque responses (due to CORS), that have a 0 status
+ return response.status === 200 || response.status === 0
+ }
+ }
+
+ function buildCacheKey(requestOrUrl, response) {
+ // If the response is HTML, cache always based on the final response's request url, so
+ // that Turbo HTTP location redirects are not masked. Otherwise, cached based on the
+ // original request url, so redirected images, resources, etc are available offline.
+ // We don't have a response when responding from the cache because, so in that case,
+ // we need to use the request's URL always. This is also the case when we want to
+ // check the URL in our key-value store to see if we have a fresh version already.
+ const request = new Request(requestOrUrl);
+ const url = response && isHtmlResponse(response) ? response.url : request.url;
+ return new URL(url, location.href).href
+ }
+
+ function isHtmlResponse(response) {
+ return response.headers.get("content-type")?.includes("text/html")
+ }
+
+ /**
+ * Cache-first: try the cache. If it's a hit, return that. If it's a miss,
+ * fall back to network and then cache the response.
+ */
+
+ class CacheFirst extends Handler {
+ async handle(request) {
+ let response = await this.fetchFromCache(request);
+ let afterHandlePromise;
+
+ if (response) {
+ // Always trim the cache when using a response in cache-first strategy
+ // because if we are always returning cached content and never saving
+ // any new content, it will all eventually become older than maxAge
+ afterHandlePromise = this.cacheTrimmer.trim();
+ return { response, afterHandlePromise }
+ }
+
+ console.debug(`Cache miss for ${request.url}`);
+
+ try {
+ response = await this.fetchFromNetwork(request);
+ } catch(error) {
+ console.warn(
+ `${error} fetching from network ${request.url}`
+ );
+ }
+
+ if (response) {
+ afterHandlePromise = this.saveToCache(request, response.clone());
+ }
+
+ return { response, afterHandlePromise }
+ }
+
+ canCacheResponse(response) {
+ // Only cache OK responses, as we don't want to cache a network error
+ // by mistake, which is impossible to distinguish from an opaque
+ // response with status 0
+ return response.status === 200
+ }
+ }
+
+ /**
+ * Network-first: try the network, with an optional time out, and if that fails
+ * fallback to the cache
+ */
+
+ class NetworkFirst extends Handler {
+ async handle(request) {
+ let response;
+ let afterHandlePromise;
+ let timeoutId;
+ let cacheAttemptedOnTimeout = false;
+
+ const networkPromise = this.fetchFromNetwork(request);
+ const promises = [networkPromise];
+
+ if (this.networkTimeout) {
+ const timeoutPromise = new Promise((resolve) => {
+ timeoutId = setTimeout(async () => {
+ console.debug(`Network timeout after ${this.networkTimeout}s for ${request.url}, trying the cache...`);
+
+ const cachedResponse = await this.fetchFromCache(request);
+ cacheAttemptedOnTimeout = true;
+
+ resolve(cachedResponse);
+ }, this.networkTimeout * 1000);
+ });
+ promises.push(timeoutPromise);
+ }
+
+ try {
+ // Either the network wins or the timeout wins. The network might win
+ // with an error, though
+ response = await Promise.race(promises);
+ } catch(error) {
+ console.warn(
+ `${error} fetching from network ${request.url} with timeout`
+ );
+ }
+
+ if (timeoutId) clearTimeout(timeoutId);
+
+ if (!response && cacheAttemptedOnTimeout) {
+ // In this case we know we hit the timout and got a cache miss.
+ // We can wait for the network promise just in case, as we don't have
+ // anything to lose, knowing that we don't have the cache as fallback
+ try {
+ response = await networkPromise;
+ } catch(error) {
+ // This might be the same error we got from the promise race
+ console.warn(
+ `${error} fetching from network ${request.url}`
+ );
+ }
+ } else if (!response) {
+ // If we didn't get a response and didn't try the cache, try it now
+ // as a fallback
+ response = await this.fetchFromCache(request);
+ }
+
+ if (response) {
+ afterHandlePromise = this.saveToCache(request, response.clone());
+ } else {
+ afterHandlePromise = Promise.resolve();
+ }
+
+ return { response, afterHandlePromise }
+ }
+ }
+
+ /**
+ * Stale-while-revalidate: try the cache. If it's a hit, return that and
+ * go to the network to fetch a new response and store it. If it's a miss,
+ * just fall back to network and then store the response.
+ */
+
+ class StaleWhileRevalidate extends Handler {
+ async handle(request) {
+ let response = await this.fetchFromCache(request);
+ let afterHandlePromise;
+
+ if (response) {
+ afterHandlePromise = this.revalidateCache(request);
+ return { response, afterHandlePromise }
+ }
+
+ console.debug(`Cache miss for ${request.url}`);
+
+ try {
+ response = await this.fetchFromNetwork(request);
+ } catch(error) {
+ console.warn(
+ `${error} fetching from network ${request.url}`
+ );
+ }
+
+ if (response) {
+ afterHandlePromise = this.saveToCache(request, response.clone());
+ } else {
+ afterHandlePromise = Promise.resolve();
+ }
+
+ return { response, afterHandlePromise }
+ }
+
+ async revalidateCache(request) {
+ try {
+ const response = await this.fetchFromNetwork(request);
+ if (response) {
+ await this.saveToCache(request, response.clone());
+ }
+ } catch(error) {
+ console.debug(`${error} revalidating cache for ${request.url}`);
+ }
+ }
+ }
+
+ const cacheFirst = (config) => new CacheFirst(config);
+ const networkFirst = (config) => new NetworkFirst(config);
+ const staleWhileRevalidate = (config) => new StaleWhileRevalidate(config);
+
+ var index = /*#__PURE__*/Object.freeze({
+ __proto__: null,
+ cacheFirst: cacheFirst,
+ networkFirst: networkFirst,
+ staleWhileRevalidate: staleWhileRevalidate
+ });
+
+ const serviceWorker = new ServiceWorker();
+
+ function addRule(rule) {
+ serviceWorker.addRule(rule);
+ }
+
+ function start() {
+ serviceWorker.start();
+ }
+
+ exports.ServiceWorker = ServiceWorker;
+ exports.addRule = addRule;
+ exports.handlers = index;
+ exports.serviceWorker = serviceWorker;
+ exports.start = start;
+
+ Object.defineProperty(exports, '__esModule', { value: true });
+
+}));
diff --git a/vendor/javascript/turbo-offline.js b/vendor/javascript/turbo-offline.js
new file mode 100644
index 00000000..da4d2888
--- /dev/null
+++ b/vendor/javascript/turbo-offline.js
@@ -0,0 +1,534 @@
+/*!
+Turbo 8.0.13
+Copyright © 2025 37signals LLC
+ */
+class Rule {
+ constructor({ handler, match = /.*/ } = {}) {
+ this.handler = handler;
+ this.match = match;
+ }
+
+ matches(request) {
+ if (typeof this.match === 'function') return this.match(request)
+
+ const regexes = Array.isArray(this.match) ? this.match : [this.match];
+ return regexes.some(regex => regex.test(request.url))
+ }
+
+ async handle(event) {
+ const { response, afterHandlePromise } = await this.handler.handle(event.request);
+ event.waitUntil(afterHandlePromise);
+
+ return response
+ }
+}
+
+class ServiceWorker {
+ #started = false
+ #rules = []
+
+ addRule(rule) {
+ this.#rules.push(new Rule(rule));
+ }
+
+ start() {
+ this.#warnIfNoRulesConfigured();
+
+ if (!this.#started) {
+ self.addEventListener("install", this.installed);
+ self.addEventListener("message", this.messageReceived);
+ self.addEventListener("fetch", this.fetch);
+ this.#started = true;
+ }
+ }
+
+ installed = (event) => {
+ // Just log for now
+ console.log("Service worker installed");
+ }
+
+ messageReceived = (event) => {
+ if (this[event.data.action]) {
+ const actionCall = this[event.data.action](event.data.params);
+ event.waitUntil(actionCall);
+ }
+ }
+
+ fetch = (event) => {
+ if (this.#canInterceptRequest(event.request)) {
+ const rule = this.#findMatchingRule(event.request);
+ if (!rule) return
+
+ const response = rule.handle(event);
+ event.respondWith(response);
+ }
+ }
+
+ #warnIfNoRulesConfigured() {
+ if (this.#rules.length === 0) {
+ console.warn("No rules configured for service worker. No requests will be intercepted.");
+ }
+ }
+
+ #canInterceptRequest(request) {
+ const url = new URL(request.url, location.href);
+ return request.method === "GET" && url.protocol.startsWith('http')
+ }
+
+ #findMatchingRule(request) {
+ return this.#rules.find((rule) => rule.matches(request))
+ }
+}
+
+const DATABASE_NAME = "turbo-offline-database";
+const DATABASE_VERSION = 1;
+const STORE_NAME = "cache-registry";
+
+class CacheRegistryDatabase {
+ get(cacheName, key) {
+ const getOp = (store) => this.#requestToPromise(store.get(key));
+ return this.#performOperation(STORE_NAME, getOp, "readonly")
+ }
+
+ has(cacheName, key) {
+ const countOp = (store) => this.#requestToPromise(store.count(key));
+ return this.#performOperation(STORE_NAME, countOp, "readonly").then((result) => result === 1)
+ }
+
+ put(cacheName, key, value) {
+ const putOp = (store) => {
+ const item = { key: key, cacheName: cacheName, timestamp: Date.now(), ...value };
+ store.put(item);
+ return this.#requestToPromise(store.transaction)
+ };
+
+ return this.#performOperation(STORE_NAME, putOp, "readwrite")
+ }
+
+ getTimestamp(cacheName, key) {
+ return this.get(cacheName, key).then((result) => result?.timestamp)
+ }
+
+ getOlderThan(cacheName, timestamp) {
+ const getOlderThanOp = (store) => {
+ const index = store.index("cacheNameAndTimestamp");
+ // Use compound key range: [cacheName, timestamp]
+ const range = IDBKeyRange.bound(
+ [cacheName, 0], // start of range
+ [cacheName, timestamp], // end of range
+ false, // lowerOpen: include lower bound
+ true // upperOpen: exclude upper bound
+ );
+ const cursorRequest = index.openCursor(range);
+
+ return this.#cursorRequestToPromise(cursorRequest)
+ };
+ return this.#performOperation(STORE_NAME, getOlderThanOp, "readonly")
+ }
+
+ delete(cacheName, key) {
+ const deleteOp = (store) => this.#requestToPromise(store.delete(key));
+ return this.#performOperation(STORE_NAME, deleteOp, "readwrite")
+ }
+
+ #performOperation(storeName, operation, mode) {
+ return this.#openDatabase().then((database) => {
+ const transaction = database.transaction(storeName, mode);
+ const store = transaction.objectStore(storeName);
+ return operation(store)
+ })
+ }
+
+ #openDatabase() {
+ const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
+ request.onupgradeneeded = () => {
+ const cacheMetadataStore = request.result.createObjectStore(STORE_NAME, { keyPath: "key" });
+ cacheMetadataStore.createIndex("cacheNameAndTimestamp", [ "cacheName", "timestamp" ]);
+ };
+
+ return this.#requestToPromise(request)
+ }
+
+ #requestToPromise(request) {
+ return new Promise((resolve, reject) => {
+ request.oncomplete = request.onsuccess = () => resolve(request.result);
+ request.onabort = request.onerror = () => reject(request.error);
+ })
+ }
+
+ #cursorRequestToPromise(request) {
+ return new Promise((resolve, reject) => {
+ const results = [];
+
+ request.onsuccess = (event) => {
+ const cursor = event.target.result;
+ if (cursor) {
+ results.push(cursor.value);
+ cursor.continue();
+ } else {
+ resolve(results);
+ }
+ };
+
+ request.onerror = () => reject(request.error);
+ })
+ }
+}
+
+let cacheRegistryDatabase = null;
+
+function getDatabase() {
+ if (!cacheRegistryDatabase) {
+ cacheRegistryDatabase = new CacheRegistryDatabase();
+ }
+ return cacheRegistryDatabase
+}
+
+class CacheRegistry {
+ constructor(cacheName) {
+ this.cacheName = cacheName;
+ this.database = getDatabase();
+ }
+
+ get(key) {
+ return this.database.get(this.cacheName, key)
+ }
+
+ has(key) {
+ return this.database.has(this.cacheName, key)
+ }
+
+ put(key, value = {}) {
+ return this.database.put(this.cacheName, key, value)
+ }
+
+ getTimestamp(key) {
+ return this.database.getTimestamp(this.cacheName, key)
+ }
+
+ getOlderThan(timestamp) {
+ return this.database.getOlderThan(this.cacheName, timestamp)
+ }
+
+ delete(key) {
+ return this.database.delete(this.cacheName, key)
+ }
+}
+
+class CacheTrimmer {
+ #isRunning = false
+
+ constructor(cacheName, cacheRegistry, options = {}) {
+ this.cacheName = cacheName;
+ this.cacheRegistry = cacheRegistry;
+ this.options = options;
+ }
+
+ async trim() {
+ if (this.#isRunning) {
+ return
+ }
+
+ if (!this.#shouldTrim()) {
+ return
+ }
+
+ this.#isRunning = true;
+
+ try {
+ await this.deleteEntries();
+ } finally {
+ this.#isRunning = false;
+ }
+ }
+
+ #shouldTrim() {
+ // For now, only check maxAge. To be extended for maxEntries, maxStorage, etc.
+ return this.options.maxAge && this.options.maxAge > 0
+ }
+
+ async deleteEntries() {
+ if (this.options.maxAge) {
+ await this.deleteEntriesByAge();
+ }
+ // To be extended with other options
+ }
+
+ async deleteEntriesByAge() {
+ const maxAgeMs = this.options.maxAge * 1000;
+ const cutoffTimestamp = Date.now() - maxAgeMs;
+
+ const expiredEntries = await this.cacheRegistry.getOlderThan(cutoffTimestamp);
+
+ if (expiredEntries.length === 0) {
+ return
+ }
+
+ console.debug(`Trimming ${expiredEntries.length} expired entries from cache "${this.cacheName}"`);
+
+ const cache = await caches.open(this.cacheName);
+
+ const deletePromises = expiredEntries.map(async (entry) => {
+ const cacheDeletePromise = cache.delete(entry.key);
+ const registryDeletePromise = this.cacheRegistry.delete(entry.key);
+
+ return Promise.all([cacheDeletePromise, registryDeletePromise])
+ });
+
+ await Promise.all(deletePromises);
+
+ console.debug(`Successfully trimmed ${expiredEntries.length} entries from cache "${this.cacheName}"`);
+ }
+}
+
+class Handler {
+ constructor({ cacheName, networkTimeout, maxAge }) {
+ this.cacheName = cacheName;
+ this.networkTimeout = networkTimeout;
+
+ this.cacheRegistry = new CacheRegistry(cacheName);
+ this.cacheTrimmer = new CacheTrimmer(cacheName, this.cacheRegistry, { maxAge });
+ }
+
+ async handle(request) {
+ // Abstract method
+ }
+
+ async fetchFromCache(request) {
+ const cacheKeyUrl = buildCacheKey(request);
+ let response = await caches.match(cacheKeyUrl, { ignoreVary: true });
+
+ if (response !== undefined && request.redirect === "manual" && response.redirected) {
+ // Can't respond with a redirected response to a request
+ // whose redirect mode is not "follow". Since we can't cache
+ // the request with manual redirect mode because these might be
+ // crawled in advance, when we don't know how these will be requested,
+ // we just return the body here.
+ response = new Response(response.body, { headers: response.headers, status: response.status, url: response.url });
+ }
+
+ return response
+ }
+
+ async fetchFromNetwork(request) {
+ // Setting the referrer so that it doesn't appear as the service worker
+ const referrer = request.referrer;
+ return await fetch(request, { referrer })
+ }
+
+ async saveToCache(request, response) {
+ if (response && this.canCacheResponse(response)) {
+ const cacheKeyUrl = buildCacheKey(request, response);
+ const cache = await caches.open(this.cacheName);
+
+ const cachePromise = cache.put(cacheKeyUrl, response);
+ const registryPromise = this.cacheRegistry.put(cacheKeyUrl);
+ const trimPromise = this.cacheTrimmer.trim();
+
+ return Promise.all([ cachePromise, registryPromise, trimPromise ])
+ }
+ }
+
+ canCacheResponse(response) {
+ // OK response and opaque responses (due to CORS), that have a 0 status
+ return response.status === 200 || response.status === 0
+ }
+}
+
+function buildCacheKey(requestOrUrl, response) {
+ // If the response is HTML, cache always based on the final response's request url, so
+ // that Turbo HTTP location redirects are not masked. Otherwise, cached based on the
+ // original request url, so redirected images, resources, etc are available offline.
+ // We don't have a response when responding from the cache because, so in that case,
+ // we need to use the request's URL always. This is also the case when we want to
+ // check the URL in our key-value store to see if we have a fresh version already.
+ const request = new Request(requestOrUrl);
+ const url = response && isHtmlResponse(response) ? response.url : request.url;
+ return new URL(url, location.href).href
+}
+
+function isHtmlResponse(response) {
+ return response.headers.get("content-type")?.includes("text/html")
+}
+
+/**
+ * Cache-first: try the cache. If it's a hit, return that. If it's a miss,
+ * fall back to network and then cache the response.
+ */
+
+class CacheFirst extends Handler {
+ async handle(request) {
+ let response = await this.fetchFromCache(request);
+ let afterHandlePromise;
+
+ if (response) {
+ // Always trim the cache when using a response in cache-first strategy
+ // because if we are always returning cached content and never saving
+ // any new content, it will all eventually become older than maxAge
+ afterHandlePromise = this.cacheTrimmer.trim();
+ return { response, afterHandlePromise }
+ }
+
+ console.debug(`Cache miss for ${request.url}`);
+
+ try {
+ response = await this.fetchFromNetwork(request);
+ } catch(error) {
+ console.warn(
+ `${error} fetching from network ${request.url}`
+ );
+ }
+
+ if (response) {
+ afterHandlePromise = this.saveToCache(request, response.clone());
+ }
+
+ return { response, afterHandlePromise }
+ }
+
+ canCacheResponse(response) {
+ // Only cache OK responses, as we don't want to cache a network error
+ // by mistake, which is impossible to distinguish from an opaque
+ // response with status 0
+ return response.status === 200
+ }
+}
+
+/**
+ * Network-first: try the network, with an optional time out, and if that fails
+ * fallback to the cache
+ */
+
+class NetworkFirst extends Handler {
+ async handle(request) {
+ let response;
+ let afterHandlePromise;
+ let timeoutId;
+ let cacheAttemptedOnTimeout = false;
+
+ const networkPromise = this.fetchFromNetwork(request);
+ const promises = [networkPromise];
+
+ if (this.networkTimeout) {
+ const timeoutPromise = new Promise((resolve) => {
+ timeoutId = setTimeout(async () => {
+ console.debug(`Network timeout after ${this.networkTimeout}s for ${request.url}, trying the cache...`);
+
+ const cachedResponse = await this.fetchFromCache(request);
+ cacheAttemptedOnTimeout = true;
+
+ resolve(cachedResponse);
+ }, this.networkTimeout * 1000);
+ });
+ promises.push(timeoutPromise);
+ }
+
+ try {
+ // Either the network wins or the timeout wins. The network might win
+ // with an error, though
+ response = await Promise.race(promises);
+ } catch(error) {
+ console.warn(
+ `${error} fetching from network ${request.url} with timeout`
+ );
+ }
+
+ if (timeoutId) clearTimeout(timeoutId);
+
+ if (!response && cacheAttemptedOnTimeout) {
+ // In this case we know we hit the timout and got a cache miss.
+ // We can wait for the network promise just in case, as we don't have
+ // anything to lose, knowing that we don't have the cache as fallback
+ try {
+ response = await networkPromise;
+ } catch(error) {
+ // This might be the same error we got from the promise race
+ console.warn(
+ `${error} fetching from network ${request.url}`
+ );
+ }
+ } else if (!response) {
+ // If we didn't get a response and didn't try the cache, try it now
+ // as a fallback
+ response = await this.fetchFromCache(request);
+ }
+
+ if (response) {
+ afterHandlePromise = this.saveToCache(request, response.clone());
+ } else {
+ afterHandlePromise = Promise.resolve();
+ }
+
+ return { response, afterHandlePromise }
+ }
+}
+
+/**
+ * Stale-while-revalidate: try the cache. If it's a hit, return that and
+ * go to the network to fetch a new response and store it. If it's a miss,
+ * just fall back to network and then store the response.
+ */
+
+class StaleWhileRevalidate extends Handler {
+ async handle(request) {
+ let response = await this.fetchFromCache(request);
+ let afterHandlePromise;
+
+ if (response) {
+ afterHandlePromise = this.revalidateCache(request);
+ return { response, afterHandlePromise }
+ }
+
+ console.debug(`Cache miss for ${request.url}`);
+
+ try {
+ response = await this.fetchFromNetwork(request);
+ } catch(error) {
+ console.warn(
+ `${error} fetching from network ${request.url}`
+ );
+ }
+
+ if (response) {
+ afterHandlePromise = this.saveToCache(request, response.clone());
+ } else {
+ afterHandlePromise = Promise.resolve();
+ }
+
+ return { response, afterHandlePromise }
+ }
+
+ async revalidateCache(request) {
+ try {
+ const response = await this.fetchFromNetwork(request);
+ if (response) {
+ await this.saveToCache(request, response.clone());
+ }
+ } catch(error) {
+ console.debug(`${error} revalidating cache for ${request.url}`);
+ }
+ }
+}
+
+const cacheFirst = (config) => new CacheFirst(config);
+const networkFirst = (config) => new NetworkFirst(config);
+const staleWhileRevalidate = (config) => new StaleWhileRevalidate(config);
+
+var index = /*#__PURE__*/Object.freeze({
+ __proto__: null,
+ cacheFirst: cacheFirst,
+ networkFirst: networkFirst,
+ staleWhileRevalidate: staleWhileRevalidate
+});
+
+const serviceWorker = new ServiceWorker();
+
+function addRule(rule) {
+ serviceWorker.addRule(rule);
+}
+
+function start() {
+ serviceWorker.start();
+}
+
+export { ServiceWorker, addRule, index as handlers, serviceWorker, start };
diff --git a/vendor/javascript/turbo-offline.min.js b/vendor/javascript/turbo-offline.min.js
new file mode 100644
index 00000000..c6d99d51
--- /dev/null
+++ b/vendor/javascript/turbo-offline.min.js
@@ -0,0 +1,6 @@
+/*!
+Turbo 8.0.13
+Copyright © 2025 37signals LLC
+ */
+class e{constructor({handler:e,match:t=/.*/}={}){this.handler=e,this.match=t}matches(e){if("function"==typeof this.match)return this.match(e);return(Array.isArray(this.match)?this.match:[this.match]).some((t=>t.test(e.url)))}async handle(e){const{response:t,afterHandlePromise:s}=await this.handler.handle(e.request);return e.waitUntil(s),t}}class t{#e=!1;#t=[];addRule(t){this.#t.push(new e(t))}start(){this.#s(),this.#e||(self.addEventListener("install",this.installed),self.addEventListener("message",this.messageReceived),self.addEventListener("fetch",this.fetch),this.#e=!0)}installed=e=>{console.log("Service worker installed")};messageReceived=e=>{if(this[e.data.action]){const t=this[e.data.action](e.data.params);e.waitUntil(t)}};fetch=e=>{if(this.#r(e.request)){const t=this.#a(e.request);if(!t)return;const s=t.handle(e);e.respondWith(s)}};#s(){0===this.#t.length&&console.warn("No rules configured for service worker. No requests will be intercepted.")}#r(e){const t=new URL(e.url,location.href);return"GET"===e.method&&t.protocol.startsWith("http")}#a(e){return this.#t.find((t=>t.matches(e)))}}const s="cache-registry";class r{get(e,t){return this.#n(s,(e=>this.#i(e.get(t))),"readonly")}has(e,t){return this.#n(s,(e=>this.#i(e.count(t))),"readonly").then((e=>1===e))}put(e,t,r){return this.#n(s,(s=>{const a={key:t,cacheName:e,timestamp:Date.now(),...r};return s.put(a),this.#i(s.transaction)}),"readwrite")}getTimestamp(e,t){return this.get(e,t).then((e=>e?.timestamp))}getOlderThan(e,t){return this.#n(s,(s=>{const r=s.index("cacheNameAndTimestamp"),a=IDBKeyRange.bound([e,0],[e,t],!1,!0),n=r.openCursor(a);return this.#o(n)}),"readonly")}delete(e,t){return this.#n(s,(e=>this.#i(e.delete(t))),"readwrite")}#n(e,t,s){return this.#c().then((r=>{const a=r.transaction(e,s).objectStore(e);return t(a)}))}#c(){const e=indexedDB.open("turbo-offline-database",1);return e.onupgradeneeded=()=>{e.result.createObjectStore(s,{keyPath:"key"}).createIndex("cacheNameAndTimestamp",["cacheName","timestamp"])},this.#i(e)}#i(e){return new Promise(((t,s)=>{e.oncomplete=e.onsuccess=()=>t(e.result),e.onabort=e.onerror=()=>s(e.error)}))}#o(e){return new Promise(((t,s)=>{const r=[];e.onsuccess=e=>{const s=e.target.result;s?(r.push(s.value),s.continue()):t(r)},e.onerror=()=>s(e.error)}))}}let a=null;class n{constructor(e){this.cacheName=e,this.database=(a||(a=new r),a)}get(e){return this.database.get(this.cacheName,e)}has(e){return this.database.has(this.cacheName,e)}put(e,t={}){return this.database.put(this.cacheName,e,t)}getTimestamp(e){return this.database.getTimestamp(this.cacheName,e)}getOlderThan(e){return this.database.getOlderThan(this.cacheName,e)}delete(e){return this.database.delete(this.cacheName,e)}}class i{#h=!1;constructor(e,t,s={}){this.cacheName=e,this.cacheRegistry=t,this.options=s}async trim(){if(!this.#h&&this.#l()){this.#h=!0;try{await this.deleteEntries()}finally{this.#h=!1}}}#l(){return this.options.maxAge&&this.options.maxAge>0}async deleteEntries(){this.options.maxAge&&await this.deleteEntriesByAge()}async deleteEntriesByAge(){const e=1e3*this.options.maxAge,t=Date.now()-e,s=await this.cacheRegistry.getOlderThan(t);if(0===s.length)return;console.debug(`Trimming ${s.length} expired entries from cache "${this.cacheName}"`);const r=await caches.open(this.cacheName),a=s.map((async e=>{const t=r.delete(e.key),s=this.cacheRegistry.delete(e.key);return Promise.all([t,s])}));await Promise.all(a),console.debug(`Successfully trimmed ${s.length} entries from cache "${this.cacheName}"`)}}class o{constructor({cacheName:e,networkTimeout:t,maxAge:s}){this.cacheName=e,this.networkTimeout=t,this.cacheRegistry=new n(e),this.cacheTrimmer=new i(e,this.cacheRegistry,{maxAge:s})}async handle(e){}async fetchFromCache(e){const t=c(e);let s=await caches.match(t,{ignoreVary:!0});return void 0!==s&&"manual"===e.redirect&&s.redirected&&(s=new Response(s.body,{headers:s.headers,status:s.status,url:s.url})),s}async fetchFromNetwork(e){const t=e.referrer;return await fetch(e,{referrer:t})}async saveToCache(e,t){if(t&&this.canCacheResponse(t)){const s=c(e,t),r=(await caches.open(this.cacheName)).put(s,t),a=this.cacheRegistry.put(s),n=this.cacheTrimmer.trim();return Promise.all([r,a,n])}}canCacheResponse(e){return 200===e.status||0===e.status}}function c(e,t){const s=new Request(e),r=t&&function(e){return e.headers.get("content-type")?.includes("text/html")}(t)?t.url:s.url;return new URL(r,location.href).href}class h extends o{async handle(e){let t,s=await this.fetchFromCache(e);if(s)return t=this.cacheTrimmer.trim(),{response:s,afterHandlePromise:t};console.debug(`Cache miss for ${e.url}`);try{s=await this.fetchFromNetwork(e)}catch(t){console.warn(`${t} fetching from network ${e.url}`)}return s&&(t=this.saveToCache(e,s.clone())),{response:s,afterHandlePromise:t}}canCacheResponse(e){return 200===e.status}}class l extends o{async handle(e){let t,s,r,a=!1;const n=this.fetchFromNetwork(e),i=[n];if(this.networkTimeout){const t=new Promise((t=>{r=setTimeout((async()=>{console.debug(`Network timeout after ${this.networkTimeout}s for ${e.url}, trying the cache...`);const s=await this.fetchFromCache(e);a=!0,t(s)}),1e3*this.networkTimeout)}));i.push(t)}try{t=await Promise.race(i)}catch(t){console.warn(`${t} fetching from network ${e.url} with timeout`)}if(r&&clearTimeout(r),!t&&a)try{t=await n}catch(t){console.warn(`${t} fetching from network ${e.url}`)}else t||(t=await this.fetchFromCache(e));return s=t?this.saveToCache(e,t.clone()):Promise.resolve(),{response:t,afterHandlePromise:s}}}class u extends o{async handle(e){let t,s=await this.fetchFromCache(e);if(s)return t=this.revalidateCache(e),{response:s,afterHandlePromise:t};console.debug(`Cache miss for ${e.url}`);try{s=await this.fetchFromNetwork(e)}catch(t){console.warn(`${t} fetching from network ${e.url}`)}return t=s?this.saveToCache(e,s.clone()):Promise.resolve(),{response:s,afterHandlePromise:t}}async revalidateCache(e){try{const t=await this.fetchFromNetwork(e);t&&await this.saveToCache(e,t.clone())}catch(t){console.debug(`${t} revalidating cache for ${e.url}`)}}}var m=Object.freeze({__proto__:null,cacheFirst:e=>new h(e),networkFirst:e=>new l(e),staleWhileRevalidate:e=>new u(e)});const d=new t;function f(e){d.addRule(e)}function w(){d.start()}export{t as ServiceWorker,f as addRule,m as handlers,d as serviceWorker,w as start};
+//# sourceMappingURL=turbo-offline.min.js.map
diff --git a/vendor/javascript/turbo-offline.min.js.map b/vendor/javascript/turbo-offline.min.js.map
new file mode 100644
index 00000000..f98206c8
--- /dev/null
+++ b/vendor/javascript/turbo-offline.min.js.map
@@ -0,0 +1 @@
+{"version":3,"file":"turbo-offline.min.js","sources":["../../../../turbo/dist/turbo-offline.js"],"sourcesContent":["/*!\nTurbo 8.0.13\nCopyright © 2025 37signals LLC\n */\nclass Rule {\n constructor({ handler, match = /.*/ } = {}) {\n this.handler = handler;\n this.match = match;\n }\n\n matches(request) {\n if (typeof this.match === 'function') return this.match(request)\n\n const regexes = Array.isArray(this.match) ? this.match : [this.match];\n return regexes.some(regex => regex.test(request.url))\n }\n\n async handle(event) {\n const { response, afterHandlePromise } = await this.handler.handle(event.request);\n event.waitUntil(afterHandlePromise);\n\n return response\n }\n}\n\nclass ServiceWorker {\n #started = false\n #rules = []\n\n addRule(rule) {\n this.#rules.push(new Rule(rule));\n }\n\n start() {\n this.#warnIfNoRulesConfigured();\n\n if (!this.#started) {\n self.addEventListener(\"install\", this.installed);\n self.addEventListener(\"message\", this.messageReceived);\n self.addEventListener(\"fetch\", this.fetch);\n this.#started = true;\n }\n }\n\n installed = (event) => {\n // Just log for now\n console.log(\"Service worker installed\");\n }\n\n messageReceived = (event) => {\n if (this[event.data.action]) {\n const actionCall = this[event.data.action](event.data.params);\n event.waitUntil(actionCall);\n }\n }\n\n fetch = (event) => {\n if (this.#canInterceptRequest(event.request)) {\n const rule = this.#findMatchingRule(event.request);\n if (!rule) return\n\n const response = rule.handle(event);\n event.respondWith(response);\n }\n }\n\n #warnIfNoRulesConfigured() {\n if (this.#rules.length === 0) {\n console.warn(\"No rules configured for service worker. No requests will be intercepted.\");\n }\n }\n\n #canInterceptRequest(request) {\n const url = new URL(request.url, location.href);\n return request.method === \"GET\" && url.protocol.startsWith('http')\n }\n\n #findMatchingRule(request) {\n return this.#rules.find((rule) => rule.matches(request))\n }\n}\n\nconst DATABASE_NAME = \"turbo-offline-database\";\nconst DATABASE_VERSION = 1;\nconst STORE_NAME = \"cache-registry\";\n\nclass CacheRegistryDatabase {\n get(cacheName, key) {\n const getOp = (store) => this.#requestToPromise(store.get(key));\n return this.#performOperation(STORE_NAME, getOp, \"readonly\")\n }\n\n has(cacheName, key) {\n const countOp = (store) => this.#requestToPromise(store.count(key));\n return this.#performOperation(STORE_NAME, countOp, \"readonly\").then((result) => result === 1)\n }\n\n put(cacheName, key, value) {\n const putOp = (store) => {\n const item = { key: key, cacheName: cacheName, timestamp: Date.now(), ...value };\n store.put(item);\n return this.#requestToPromise(store.transaction)\n };\n\n return this.#performOperation(STORE_NAME, putOp, \"readwrite\")\n }\n\n getTimestamp(cacheName, key) {\n return this.get(cacheName, key).then((result) => result?.timestamp)\n }\n\n getOlderThan(cacheName, timestamp) {\n const getOlderThanOp = (store) => {\n const index = store.index(\"cacheNameAndTimestamp\");\n // Use compound key range: [cacheName, timestamp]\n const range = IDBKeyRange.bound(\n [cacheName, 0], // start of range\n [cacheName, timestamp], // end of range\n false, // lowerOpen: include lower bound\n true // upperOpen: exclude upper bound\n );\n const cursorRequest = index.openCursor(range);\n\n return this.#cursorRequestToPromise(cursorRequest)\n };\n return this.#performOperation(STORE_NAME, getOlderThanOp, \"readonly\")\n }\n\n delete(cacheName, key) {\n const deleteOp = (store) => this.#requestToPromise(store.delete(key));\n return this.#performOperation(STORE_NAME, deleteOp, \"readwrite\")\n }\n\n #performOperation(storeName, operation, mode) {\n return this.#openDatabase().then((database) => {\n const transaction = database.transaction(storeName, mode);\n const store = transaction.objectStore(storeName);\n return operation(store)\n })\n }\n\n #openDatabase() {\n const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);\n request.onupgradeneeded = () => {\n const cacheMetadataStore = request.result.createObjectStore(STORE_NAME, { keyPath: \"key\" });\n cacheMetadataStore.createIndex(\"cacheNameAndTimestamp\", [ \"cacheName\", \"timestamp\" ]);\n };\n\n return this.#requestToPromise(request)\n }\n\n #requestToPromise(request) {\n return new Promise((resolve, reject) => {\n request.oncomplete = request.onsuccess = () => resolve(request.result);\n request.onabort = request.onerror = () => reject(request.error);\n })\n }\n\n #cursorRequestToPromise(request) {\n return new Promise((resolve, reject) => {\n const results = [];\n\n request.onsuccess = (event) => {\n const cursor = event.target.result;\n if (cursor) {\n results.push(cursor.value);\n cursor.continue();\n } else {\n resolve(results);\n }\n };\n\n request.onerror = () => reject(request.error);\n })\n }\n}\n\nlet cacheRegistryDatabase = null;\n\nfunction getDatabase() {\n if (!cacheRegistryDatabase) {\n cacheRegistryDatabase = new CacheRegistryDatabase();\n }\n return cacheRegistryDatabase\n}\n\nclass CacheRegistry {\n constructor(cacheName) {\n this.cacheName = cacheName;\n this.database = getDatabase();\n }\n\n get(key) {\n return this.database.get(this.cacheName, key)\n }\n\n has(key) {\n return this.database.has(this.cacheName, key)\n }\n\n put(key, value = {}) {\n return this.database.put(this.cacheName, key, value)\n }\n\n getTimestamp(key) {\n return this.database.getTimestamp(this.cacheName, key)\n }\n\n getOlderThan(timestamp) {\n return this.database.getOlderThan(this.cacheName, timestamp)\n }\n\n delete(key) {\n return this.database.delete(this.cacheName, key)\n }\n}\n\nclass CacheTrimmer {\n #isRunning = false\n\n constructor(cacheName, cacheRegistry, options = {}) {\n this.cacheName = cacheName;\n this.cacheRegistry = cacheRegistry;\n this.options = options;\n }\n\n async trim() {\n if (this.#isRunning) {\n return\n }\n\n if (!this.#shouldTrim()) {\n return\n }\n\n this.#isRunning = true;\n\n try {\n await this.deleteEntries();\n } finally {\n this.#isRunning = false;\n }\n }\n\n #shouldTrim() {\n // For now, only check maxAge. To be extended for maxEntries, maxStorage, etc.\n return this.options.maxAge && this.options.maxAge > 0\n }\n\n async deleteEntries() {\n if (this.options.maxAge) {\n await this.deleteEntriesByAge();\n }\n // To be extended with other options\n }\n\n async deleteEntriesByAge() {\n const maxAgeMs = this.options.maxAge * 1000;\n const cutoffTimestamp = Date.now() - maxAgeMs;\n\n const expiredEntries = await this.cacheRegistry.getOlderThan(cutoffTimestamp);\n\n if (expiredEntries.length === 0) {\n return\n }\n\n console.debug(`Trimming ${expiredEntries.length} expired entries from cache \"${this.cacheName}\"`);\n\n const cache = await caches.open(this.cacheName);\n\n const deletePromises = expiredEntries.map(async (entry) => {\n const cacheDeletePromise = cache.delete(entry.key);\n const registryDeletePromise = this.cacheRegistry.delete(entry.key);\n\n return Promise.all([cacheDeletePromise, registryDeletePromise])\n });\n\n await Promise.all(deletePromises);\n\n console.debug(`Successfully trimmed ${expiredEntries.length} entries from cache \"${this.cacheName}\"`);\n }\n}\n\nclass Handler {\n constructor({ cacheName, networkTimeout, maxAge }) {\n this.cacheName = cacheName;\n this.networkTimeout = networkTimeout;\n\n this.cacheRegistry = new CacheRegistry(cacheName);\n this.cacheTrimmer = new CacheTrimmer(cacheName, this.cacheRegistry, { maxAge });\n }\n\n async handle(request) {\n // Abstract method\n }\n\n async fetchFromCache(request) {\n const cacheKeyUrl = buildCacheKey(request);\n let response = await caches.match(cacheKeyUrl, { ignoreVary: true });\n\n if (response !== undefined && request.redirect === \"manual\" && response.redirected) {\n // Can't respond with a redirected response to a request\n // whose redirect mode is not \"follow\". Since we can't cache\n // the request with manual redirect mode because these might be\n // crawled in advance, when we don't know how these will be requested,\n // we just return the body here.\n response = new Response(response.body, { headers: response.headers, status: response.status, url: response.url });\n }\n\n return response\n }\n\n async fetchFromNetwork(request) {\n // Setting the referrer so that it doesn't appear as the service worker\n const referrer = request.referrer;\n return await fetch(request, { referrer })\n }\n\n async saveToCache(request, response) {\n if (response && this.canCacheResponse(response)) {\n const cacheKeyUrl = buildCacheKey(request, response);\n const cache = await caches.open(this.cacheName);\n\n const cachePromise = cache.put(cacheKeyUrl, response);\n const registryPromise = this.cacheRegistry.put(cacheKeyUrl);\n const trimPromise = this.cacheTrimmer.trim();\n\n return Promise.all([ cachePromise, registryPromise, trimPromise ])\n }\n }\n\n canCacheResponse(response) {\n // OK response and opaque responses (due to CORS), that have a 0 status\n return response.status === 200 || response.status === 0\n }\n}\n\nfunction buildCacheKey(requestOrUrl, response) {\n // If the response is HTML, cache always based on the final response's request url, so\n // that Turbo HTTP location redirects are not masked. Otherwise, cached based on the\n // original request url, so redirected images, resources, etc are available offline.\n // We don't have a response when responding from the cache because, so in that case,\n // we need to use the request's URL always. This is also the case when we want to\n // check the URL in our key-value store to see if we have a fresh version already.\n const request = new Request(requestOrUrl);\n const url = response && isHtmlResponse(response) ? response.url : request.url;\n return new URL(url, location.href).href\n}\n\nfunction isHtmlResponse(response) {\n return response.headers.get(\"content-type\")?.includes(\"text/html\")\n}\n\n/**\n * Cache-first: try the cache. If it's a hit, return that. If it's a miss,\n * fall back to network and then cache the response.\n */\n\nclass CacheFirst extends Handler {\n async handle(request) {\n let response = await this.fetchFromCache(request);\n let afterHandlePromise;\n\n if (response) {\n // Always trim the cache when using a response in cache-first strategy\n // because if we are always returning cached content and never saving\n // any new content, it will all eventually become older than maxAge\n afterHandlePromise = this.cacheTrimmer.trim();\n return { response, afterHandlePromise }\n }\n\n console.debug(`Cache miss for ${request.url}`);\n\n try {\n response = await this.fetchFromNetwork(request);\n } catch(error) {\n console.warn(\n `${error} fetching from network ${request.url}`\n );\n }\n\n if (response) {\n afterHandlePromise = this.saveToCache(request, response.clone());\n }\n\n return { response, afterHandlePromise }\n }\n\n canCacheResponse(response) {\n // Only cache OK responses, as we don't want to cache a network error\n // by mistake, which is impossible to distinguish from an opaque\n // response with status 0\n return response.status === 200\n }\n}\n\n/**\n * Network-first: try the network, with an optional time out, and if that fails\n * fallback to the cache\n */\n\nclass NetworkFirst extends Handler {\n async handle(request) {\n let response;\n let afterHandlePromise;\n let timeoutId;\n let cacheAttemptedOnTimeout = false;\n\n const networkPromise = this.fetchFromNetwork(request);\n const promises = [networkPromise];\n\n if (this.networkTimeout) {\n const timeoutPromise = new Promise((resolve) => {\n timeoutId = setTimeout(async () => {\n console.debug(`Network timeout after ${this.networkTimeout}s for ${request.url}, trying the cache...`);\n\n const cachedResponse = await this.fetchFromCache(request);\n cacheAttemptedOnTimeout = true;\n\n resolve(cachedResponse);\n }, this.networkTimeout * 1000);\n });\n promises.push(timeoutPromise);\n }\n\n try {\n // Either the network wins or the timeout wins. The network might win\n // with an error, though\n response = await Promise.race(promises);\n } catch(error) {\n console.warn(\n `${error} fetching from network ${request.url} with timeout`\n );\n }\n\n if (timeoutId) clearTimeout(timeoutId);\n\n if (!response && cacheAttemptedOnTimeout) {\n // In this case we know we hit the timout and got a cache miss.\n // We can wait for the network promise just in case, as we don't have\n // anything to lose, knowing that we don't have the cache as fallback\n try {\n response = await networkPromise;\n } catch(error) {\n // This might be the same error we got from the promise race\n console.warn(\n `${error} fetching from network ${request.url}`\n );\n }\n } else if (!response) {\n // If we didn't get a response and didn't try the cache, try it now\n // as a fallback\n response = await this.fetchFromCache(request);\n }\n\n if (response) {\n afterHandlePromise = this.saveToCache(request, response.clone());\n } else {\n afterHandlePromise = Promise.resolve();\n }\n\n return { response, afterHandlePromise }\n }\n}\n\n/**\n * Stale-while-revalidate: try the cache. If it's a hit, return that and\n * go to the network to fetch a new response and store it. If it's a miss,\n * just fall back to network and then store the response.\n */\n\nclass StaleWhileRevalidate extends Handler {\n async handle(request) {\n let response = await this.fetchFromCache(request);\n let afterHandlePromise;\n\n if (response) {\n afterHandlePromise = this.revalidateCache(request);\n return { response, afterHandlePromise }\n }\n\n console.debug(`Cache miss for ${request.url}`);\n\n try {\n response = await this.fetchFromNetwork(request);\n } catch(error) {\n console.warn(\n `${error} fetching from network ${request.url}`\n );\n }\n\n if (response) {\n afterHandlePromise = this.saveToCache(request, response.clone());\n } else {\n afterHandlePromise = Promise.resolve();\n }\n\n return { response, afterHandlePromise }\n }\n\n async revalidateCache(request) {\n try {\n const response = await this.fetchFromNetwork(request);\n if (response) {\n await this.saveToCache(request, response.clone());\n }\n } catch(error) {\n console.debug(`${error} revalidating cache for ${request.url}`);\n }\n }\n}\n\nconst cacheFirst = (config) => new CacheFirst(config);\nconst networkFirst = (config) => new NetworkFirst(config);\nconst staleWhileRevalidate = (config) => new StaleWhileRevalidate(config);\n\nvar index = /*#__PURE__*/Object.freeze({\n __proto__: null,\n cacheFirst: cacheFirst,\n networkFirst: networkFirst,\n staleWhileRevalidate: staleWhileRevalidate\n});\n\nconst serviceWorker = new ServiceWorker();\n\nfunction addRule(rule) {\n serviceWorker.addRule(rule);\n}\n\nfunction start() {\n serviceWorker.start();\n}\n\nexport { ServiceWorker, addRule, index as handlers, serviceWorker, start };\n"],"names":["Rule","constructor","handler","match","this","matches","request","Array","isArray","some","regex","test","url","handle","event","response","afterHandlePromise","waitUntil","ServiceWorker","started","rules","addRule","rule","push","start","warnIfNoRulesConfigured","self","addEventListener","installed","messageReceived","fetch","console","log","data","action","actionCall","params","canInterceptRequest","findMatchingRule","respondWith","length","warn","URL","location","href","method","protocol","startsWith","find","STORE_NAME","CacheRegistryDatabase","get","cacheName","key","performOperation","store","requestToPromise","has","count","then","result","put","value","item","timestamp","Date","now","transaction","getTimestamp","getOlderThan","index","range","IDBKeyRange","bound","cursorRequest","openCursor","cursorRequestToPromise","delete","storeName","operation","mode","openDatabase","database","objectStore","indexedDB","open","onupgradeneeded","createObjectStore","keyPath","createIndex","Promise","resolve","reject","oncomplete","onsuccess","onabort","onerror","error","results","cursor","target","continue","cacheRegistryDatabase","CacheRegistry","CacheTrimmer","isRunning","cacheRegistry","options","trim","shouldTrim","deleteEntries","maxAge","deleteEntriesByAge","maxAgeMs","cutoffTimestamp","expiredEntries","debug","cache","caches","deletePromises","map","async","entry","cacheDeletePromise","registryDeletePromise","all","Handler","networkTimeout","cacheTrimmer","fetchFromCache","cacheKeyUrl","buildCacheKey","ignoreVary","undefined","redirect","redirected","Response","body","headers","status","fetchFromNetwork","referrer","saveToCache","canCacheResponse","cachePromise","registryPromise","trimPromise","requestOrUrl","Request","includes","isHtmlResponse","CacheFirst","clone","NetworkFirst","timeoutId","cacheAttemptedOnTimeout","networkPromise","promises","timeoutPromise","setTimeout","cachedResponse","race","clearTimeout","StaleWhileRevalidate","revalidateCache","Object","freeze","__proto__","cacheFirst","config","networkFirst","staleWhileRevalidate","serviceWorker"],"mappings":"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"}
\ No newline at end of file
diff --git a/vendor/javascript/turbo.min.js b/vendor/javascript/turbo.min.js
new file mode 100644
index 00000000..dd1c2bdd
--- /dev/null
+++ b/vendor/javascript/turbo.min.js
@@ -0,0 +1,29 @@
+/*!
+Turbo 8.0.13
+Copyright © 2025 37signals LLC
+ */
+!function(e){function t(e,t,s){throw new e("Failed to execute 'requestSubmit' on 'HTMLFormElement': "+t+".",s)}"function"!=typeof e.requestSubmit&&(e.requestSubmit=function(e){e?(!function(e,s){e instanceof HTMLElement||t(TypeError,"parameter 1 is not of type 'HTMLElement'"),"submit"==e.type||t(TypeError,"The specified element is not a submit button"),e.form==s||t(DOMException,"The specified element is not owned by this form element","NotFoundError")}(e,this),e.click()):((e=document.createElement("input")).type="submit",e.hidden=!0,this.appendChild(e),e.click(),this.removeChild(e))})}(HTMLFormElement.prototype);const e=new WeakMap;function t(t){const s=function(e){const t=e instanceof Element?e:e instanceof Node?e.parentElement:null,s=t?t.closest("input, button"):null;return"submit"==s?.type?s:null}(t.target);s&&s.form&&e.set(s.form,s)}!function(){if("submitter"in Event.prototype)return;let s=window.Event.prototype;if("SubmitEvent"in window){const e=window.SubmitEvent.prototype;if(!/Apple Computer/.test(navigator.vendor)||"submitter"in e)return;s=e}addEventListener("click",t,!0),Object.defineProperty(s,"submitter",{get(){if("submit"==this.type&&this.target instanceof HTMLFormElement)return e.get(this.target)}})}();const s={eager:"eager",lazy:"lazy"};class r extends HTMLElement{static delegateConstructor=void 0;loaded=Promise.resolve();static get observedAttributes(){return["disabled","loading","src"]}constructor(){super(),this.delegate=new r.delegateConstructor(this)}connectedCallback(){this.delegate.connect()}disconnectedCallback(){this.delegate.disconnect()}reload(){return this.delegate.sourceURLReloaded()}attributeChangedCallback(e){"loading"==e?this.delegate.loadingStyleChanged():"src"==e?this.delegate.sourceURLChanged():"disabled"==e&&this.delegate.disabledChanged()}get src(){return this.getAttribute("src")}set src(e){e?this.setAttribute("src",e):this.removeAttribute("src")}get refresh(){return this.getAttribute("refresh")}set refresh(e){e?this.setAttribute("refresh",e):this.removeAttribute("refresh")}get shouldReloadWithMorph(){return this.src&&"morph"===this.refresh}get loading(){return function(e){if("lazy"===e.toLowerCase())return s.lazy;return s.eager}(this.getAttribute("loading")||"")}set loading(e){e?this.setAttribute("loading",e):this.removeAttribute("loading")}get disabled(){return this.hasAttribute("disabled")}set disabled(e){e?this.setAttribute("disabled",""):this.removeAttribute("disabled")}get autoscroll(){return this.hasAttribute("autoscroll")}set autoscroll(e){e?this.setAttribute("autoscroll",""):this.removeAttribute("autoscroll")}get complete(){return!this.delegate.isLoading}get isActive(){return this.ownerDocument===document&&!this.isPreview}get isPreview(){return this.ownerDocument?.documentElement?.hasAttribute("data-turbo-preview")}}const i={enabled:!0,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 n(e){if("false"==e.getAttribute("data-turbo-eval"))return e;{const t=document.createElement("script"),s=E();return s&&(t.nonce=s),t.textContent=e.textContent,t.async=!1,function(e,t){for(const{name:s,value:r}of t.attributes)e.setAttribute(s,r)}(t,e),t}}function o(e,{target:t,cancelable:s,detail:r}={}){const i=new CustomEvent(e,{cancelable:s,bubbles:!0,composed:!0,detail:r});return t&&t.isConnected?t.dispatchEvent(i):document.documentElement.dispatchEvent(i),i}function a(e){e.preventDefault(),e.stopImmediatePropagation()}function c(){return"hidden"===document.visibilityState?h():l()}function l(){return new Promise((e=>requestAnimationFrame((()=>e()))))}function h(){return new Promise((e=>setTimeout((()=>e()),0)))}function d(e=""){return(new DOMParser).parseFromString(e,"text/html")}function u(e,...t){const s=function(e,t){return e.reduce(((e,s,r)=>e+s+(null==t[r]?"":t[r])),"")}(e,t).replace(/^\n/,"").split("\n"),r=s[0].match(/^\s+/),i=r?r[0].length:0;return s.map((e=>e.slice(i))).join("\n")}function m(){return Array.from({length:36}).map(((e,t)=>8==t||13==t||18==t||23==t?"-":14==t?"4":19==t?(Math.floor(4*Math.random())+8).toString(16):Math.floor(15*Math.random()).toString(16))).join("")}function p(e,...t){for(const s of t.map((t=>t?.getAttribute(e))))if("string"==typeof s)return s;return null}function f(...e){for(const t of e)"turbo-frame"==t.localName&&t.setAttribute("busy",""),t.setAttribute("aria-busy","true")}function g(...e){for(const t of e)"turbo-frame"==t.localName&&t.removeAttribute("busy"),t.removeAttribute("aria-busy")}function b(e,t=2e3){return new Promise((s=>{const r=()=>{e.removeEventListener("error",r),e.removeEventListener("load",r),s()};e.addEventListener("load",r,{once:!0}),e.addEventListener("error",r,{once:!0}),setTimeout(s,t)}))}function v(e){switch(e){case"replace":return history.replaceState;case"advance":case"restore":return history.pushState}}function S(...e){const t=p("data-turbo-action",...e);return function(e){return"advance"==e||"replace"==e||"restore"==e}(t)?t:null}function w(e){return document.querySelector(`meta[name="${e}"]`)}function y(e){const t=w(e);return t&&t.content}function E(){const e=w("csp-nonce");if(e){const{nonce:t,content:s}=e;return""==t?s:t}}function A(e,t){if(e instanceof Element)return e.closest(t)||A(e.assignedSlot||e.getRootNode()?.host,t)}function R(e){return!!e&&null==e.closest("[inert], :disabled, [hidden], details:not([open]), dialog:not([open])")&&"function"==typeof e.focus}function L(e){return Array.from(e.querySelectorAll("[autofocus]")).find(R)}function T(e){if("_blank"===e)return!1;if(e){for(const t of document.getElementsByName(e))if(t instanceof HTMLIFrameElement)return!1;return!0}return!0}function C(e){return A(e,"a[href]:not([target^=_]):not([download])")}function P(e){return I(e.getAttribute("href")||"")}const k={"aria-disabled":{beforeSubmit:e=>{e.setAttribute("aria-disabled","true"),e.addEventListener("click",a)},afterSubmit:e=>{e.removeAttribute("aria-disabled"),e.removeEventListener("click",a)}},disabled:{beforeSubmit:e=>e.disabled=!0,afterSubmit:e=>e.disabled=!1}};const M=new class{#e=null;constructor(e){Object.assign(this,e)}get submitter(){return this.#e}set submitter(e){this.#e=k[e]||e}}({mode:"on",submitter:"disabled"}),F={drive:i,forms:M};function I(e){return new URL(e.toString(),document.baseURI)}function q(e){let t;return e.hash?e.hash.slice(1):(t=e.href.match(/#(.*)$/))?t[1]:void 0}function H(e,t){return I(t?.getAttribute("formaction")||e.getAttribute("action")||e.action)}function O(e){return(function(e){return function(e){return e.pathname.split("/").slice(1)}(e).slice(-1)[0]}(e).match(/\.[^.]*$/)||[])[0]||""}function B(e,t){const s=function(e){return t=e.origin+e.pathname,t.endsWith("/")?t:t+"/";var t}(t);return e.href===I(s).href||e.href.startsWith(s)}function N(e,t){return B(e,t)&&!F.drive.unvisitableExtensions.has(O(e))}function D(e){const t=q(e);return null!=t?e.href.slice(0,-(t.length+1)):e.href}function x(e){return D(e)}function V(e,t){return I(e).href==I(t).href}class W{constructor(e){this.response=e}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 I(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(){return this.isHTML?this.response.clone().text():Promise.resolve(void 0)}header(e){return this.response.headers.get(e)}}class U extends Set{constructor(e){super(),this.maxSize=e}add(e){if(this.size>=this.maxSize){const e=this.values().next().value;this.delete(e)}super.add(e)}}const _=new U(20);function $(e,t={}){const s=new Headers(t.headers||{}),r=m();return _.add(r),s.append("X-Turbo-Request-Id",r),window.fetch(e,{...t,headers:s})}function j(e){switch(e.toLowerCase()){case"get":return z.get;case"post":return z.post;case"put":return z.put;case"patch":return z.patch;case"delete":return z.delete}}const z={get:"get",post:"post",put:"put",patch:"patch",delete:"delete"};function G(e){switch(e.toLowerCase()){case J.multipart:return J.multipart;case J.plain:return J.plain;default:return J.urlEncoded}}const J={urlEncoded:"application/x-www-form-urlencoded",multipart:"multipart/form-data",plain:"text/plain"};class X{abortController=new AbortController;#t=e=>{};constructor(e,t,s,r=new URLSearchParams,i=null,n=J.urlEncoded){const[o,a]=Q(I(s),t,r,n);this.delegate=e,this.url=o,this.target=i,this.fetchOptions={credentials:"same-origin",redirect:"follow",method:t.toUpperCase(),headers:{...this.defaultHeaders},body:a,signal:this.abortSignal,referrer:this.delegate.referrer?.href},this.enctype=n}get method(){return this.fetchOptions.method}set method(e){const t=this.isSafe?this.url.searchParams:this.fetchOptions.body||new FormData,s=j(e)||z.get;this.url.search="";const[r,i]=Q(this.url,s,t,this.enctype);this.url=r,this.fetchOptions.body=i,this.fetchOptions.method=s.toUpperCase()}get headers(){return this.fetchOptions.headers}set headers(e){this.fetchOptions.headers=e}get body(){return this.isSafe?this.url.searchParams:this.fetchOptions.body}set body(e){this.fetchOptions.body=e}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:e}=this;this.delegate.prepareRequest(this);const t=await this.#s(e);try{this.delegate.requestStarted(this),t.detail.fetchRequest?this.response=t.detail.fetchRequest.response:this.response=$(this.url.href,e);const s=await this.response;return await this.receive(s)}catch(e){if("AbortError"!==e.name)throw this.#r(e)&&this.delegate.requestErrored(this,e),e}finally{this.delegate.requestFinished(this)}}async receive(e){const t=new W(e);return o("turbo:before-fetch-response",{cancelable:!0,detail:{fetchResponse:t},target:this.target}).defaultPrevented?this.delegate.requestPreventedHandlingResponse(this,t):t.succeeded?this.delegate.requestSucceededWithResponse(this,t):this.delegate.requestFailedWithResponse(this,t),t}get defaultHeaders(){return{Accept:"text/html, application/xhtml+xml"}}get isSafe(){return K(this.method)}get abortSignal(){return this.abortController.signal}acceptResponseType(e){this.headers.Accept=[e,this.headers.Accept].join(", ")}async#s(e){const t=new Promise((e=>this.#t=e)),s=o("turbo:before-fetch-request",{cancelable:!0,detail:{fetchOptions:e,url:this.url,resume:this.#t},target:this.target});return this.url=s.detail.url,s.defaultPrevented&&await t,s}#r(e){return!o("turbo:fetch-request-error",{target:this.target,cancelable:!0,detail:{request:this,error:e}}).defaultPrevented}}function K(e){return j(e)==z.get}function Q(e,t,s,r){const i=Array.from(s).length>0?new URLSearchParams(Y(s)):e.searchParams;return K(t)?[Z(e,i),null]:r==J.urlEncoded?[e,i]:[e,s]}function Y(e){const t=[];for(const[s,r]of e)r instanceof File||t.push([s,r]);return t}function Z(e,t){const s=new URLSearchParams(Y(t));return e.search=s.toString(),e}class ee{started=!1;constructor(e,t){this.delegate=e,this.element=t,this.intersectionObserver=new IntersectionObserver(this.intersect)}start(){this.started||(this.started=!0,this.intersectionObserver.observe(this.element))}stop(){this.started&&(this.started=!1,this.intersectionObserver.unobserve(this.element))}intersect=e=>{const t=e.slice(-1)[0];t?.isIntersecting&&this.delegate.elementAppearedInViewport(this.element)}}class te{static contentType="text/vnd.turbo-stream.html";static wrap(e){return"string"==typeof e?new this(function(e){const t=document.createElement("template");return t.innerHTML=e,t.content}(e)):e}constructor(e){this.fragment=function(e){for(const t of e.querySelectorAll("turbo-stream")){const e=document.importNode(t,!0);for(const t of e.templateElement.content.querySelectorAll("script"))t.replaceWith(n(t));t.replaceWith(e)}return e}(e)}}const se=new class{#i=null;#n=null;get(e){if(this.#n&&this.#n.url===e&&this.#n.expire>Date.now())return this.#n.request}setLater(e,t,s){this.clear(),this.#i=setTimeout((()=>{t.perform(),this.set(e,t,s),this.#i=null}),100)}set(e,t,s){this.#n={url:e,request:t,expire:new Date((new Date).getTime()+s)}}clear(){this.#i&&clearTimeout(this.#i),this.#n=null}},re={initialized:"initialized",requesting:"requesting",waiting:"waiting",receiving:"receiving",stopping:"stopping",stopped:"stopped"};class ie{state=re.initialized;static confirmMethod(e){return Promise.resolve(confirm(e))}constructor(e,t,s,r=!1){const i=function(e,t){const s=t?.getAttribute("formmethod")||e.getAttribute("method")||"";return j(s.toLowerCase())||z.get}(t,s),n=function(e,t){const s=I(e);K(t)&&(s.search="");return s}(function(e,t){const s="string"==typeof e.action?e.action:null;return t?.hasAttribute("formaction")?t.getAttribute("formaction")||"":e.getAttribute("action")||s||""}(t,s),i),o=function(e,t){const s=new FormData(e),r=t?.getAttribute("name"),i=t?.getAttribute("value");r&&s.append(r,i||"");return s}(t,s),a=function(e,t){return G(t?.getAttribute("formenctype")||e.enctype)}(t,s);this.delegate=e,this.formElement=t,this.submitter=s,this.fetchRequest=new X(this,i,n,o,t,a),this.mustRedirect=r}get method(){return this.fetchRequest.method}set method(e){this.fetchRequest.method=e}get action(){return this.fetchRequest.url.toString()}set action(e){this.fetchRequest.url=I(e)}get body(){return this.fetchRequest.body}get enctype(){return this.fetchRequest.enctype}get isSafe(){return this.fetchRequest.isSafe}get location(){return this.fetchRequest.url}async start(){const{initialized:e,requesting:t}=re,s=p("data-turbo-confirm",this.submitter,this.formElement);if("string"==typeof s){const e="function"==typeof F.forms.confirm?F.forms.confirm:ie.confirmMethod;if(!await e(s,this.formElement,this.submitter))return}if(this.state==e)return this.state=t,this.fetchRequest.perform()}stop(){const{stopping:e,stopped:t}=re;if(this.state!=e&&this.state!=t)return this.state=e,this.fetchRequest.cancel(),!0}prepareRequest(e){if(!e.isSafe){const t=function(e){if(null!=e){const t=(document.cookie?document.cookie.split("; "):[]).find((t=>t.startsWith(e)));if(t){const e=t.split("=").slice(1).join("=");return e?decodeURIComponent(e):void 0}}}(y("csrf-param"))||y("csrf-token");t&&(e.headers["X-CSRF-Token"]=t)}this.requestAcceptsTurboStreamResponse(e)&&e.acceptResponseType(te.contentType)}requestStarted(e){this.state=re.waiting,this.submitter&&F.forms.submitter.beforeSubmit(this.submitter),this.setSubmitsWith(),f(this.formElement),o("turbo:submit-start",{target:this.formElement,detail:{formSubmission:this}}),this.delegate.formSubmissionStarted(this)}requestPreventedHandlingResponse(e,t){se.clear(),this.result={success:t.succeeded,fetchResponse:t}}requestSucceededWithResponse(e,t){if(t.clientError||t.serverError)this.delegate.formSubmissionFailedWithResponse(this,t);else if(se.clear(),this.requestMustRedirect(e)&&function(e){return 200==e.statusCode&&!e.redirected}(t)){const e=new Error("Form responses must redirect to another location");this.delegate.formSubmissionErrored(this,e)}else this.state=re.receiving,this.result={success:!0,fetchResponse:t},this.delegate.formSubmissionSucceededWithResponse(this,t)}requestFailedWithResponse(e,t){this.result={success:!1,fetchResponse:t},this.delegate.formSubmissionFailedWithResponse(this,t)}requestErrored(e,t){this.result={success:!1,error:t},this.delegate.formSubmissionErrored(this,t)}requestFinished(e){this.state=re.stopped,this.submitter&&F.forms.submitter.afterSubmit(this.submitter),this.resetSubmitterText(),g(this.formElement),o("turbo:submit-end",{target:this.formElement,detail:{formSubmission:this,...this.result}}),this.delegate.formSubmissionFinished(this)}setSubmitsWith(){if(this.submitter&&this.submitsWith)if(this.submitter.matches("button"))this.originalSubmitText=this.submitter.innerHTML,this.submitter.innerHTML=this.submitsWith;else if(this.submitter.matches("input")){const e=this.submitter;this.originalSubmitText=e.value,e.value=this.submitsWith}}resetSubmitterText(){if(this.submitter&&this.originalSubmitText)if(this.submitter.matches("button"))this.submitter.innerHTML=this.originalSubmitText;else if(this.submitter.matches("input")){this.submitter.value=this.originalSubmitText}}requestMustRedirect(e){return!e.isSafe&&this.mustRedirect}requestAcceptsTurboStreamResponse(e){return!e.isSafe||function(e,...t){return t.some((t=>t&&t.hasAttribute(e)))}("data-turbo-stream",this.submitter,this.formElement)}get submitsWith(){return this.submitter?.getAttribute("data-turbo-submits-with")}}class ne{constructor(e){this.element=e}get activeElement(){return this.element.ownerDocument.activeElement}get children(){return[...this.element.children]}hasAnchor(e){return null!=this.getElementForAnchor(e)}getElementForAnchor(e){return e?this.element.querySelector(`[id='${e}'], a[name='${e}']`):null}get isConnected(){return this.element.isConnected}get firstAutofocusableElement(){return L(this.element)}get permanentElements(){return ae(this.element)}getPermanentElementById(e){return oe(this.element,e)}getPermanentElementMapForSnapshot(e){const t={};for(const s of this.permanentElements){const{id:r}=s,i=e.getPermanentElementById(r);i&&(t[r]=[s,i])}return t}}function oe(e,t){return e.querySelector(`#${t}[data-turbo-permanent]`)}function ae(e){return e.querySelectorAll("[id][data-turbo-permanent]")}class ce{started=!1;constructor(e,t){this.delegate=e,this.eventTarget=t}start(){this.started||(this.eventTarget.addEventListener("submit",this.submitCaptured,!0),this.started=!0)}stop(){this.started&&(this.eventTarget.removeEventListener("submit",this.submitCaptured,!0),this.started=!1)}submitCaptured=()=>{this.eventTarget.removeEventListener("submit",this.submitBubbled,!1),this.eventTarget.addEventListener("submit",this.submitBubbled,!1)};submitBubbled=e=>{if(!e.defaultPrevented){const t=e.target instanceof HTMLFormElement?e.target:void 0,s=e.submitter||void 0;t&&function(e,t){const s=t?.getAttribute("formmethod")||e.getAttribute("method");return"dialog"!=s}(t,s)&&function(e,t){const s=t?.getAttribute("formtarget")||e.getAttribute("target");return T(s)}(t,s)&&this.delegate.willSubmitForm(t,s)&&(e.preventDefault(),e.stopImmediatePropagation(),this.delegate.formSubmitted(t,s))}}}class le{#o=e=>{};#a=e=>{};constructor(e,t){this.delegate=e,this.element=t}scrollToAnchor(e){const t=this.snapshot.getElementForAnchor(e);t?(this.focusElement(t),this.scrollToElement(t)):this.scrollToPosition({x:0,y:0})}scrollToAnchorFromLocation(e){this.scrollToAnchor(q(e))}scrollToElement(e){e.scrollIntoView()}focusElement(e){e instanceof HTMLElement&&(e.hasAttribute("tabindex")?e.focus():(e.setAttribute("tabindex","-1"),e.focus(),e.removeAttribute("tabindex")))}scrollToPosition({x:e,y:t}){this.scrollRoot.scrollTo(e,t)}scrollToTop(){this.scrollToPosition({x:0,y:0})}get scrollRoot(){return window}async render(e){const{isPreview:t,shouldRender:s,willRender:r,newSnapshot:i}=e,n=r;if(s)try{this.renderPromise=new Promise((e=>this.#o=e)),this.renderer=e,await this.prepareToRenderSnapshot(e);const s=new Promise((e=>this.#a=e)),r={resume:this.#a,render:this.renderer.renderElement,renderMethod:this.renderer.renderMethod};this.delegate.allowsImmediateRender(i,r)||await s,await this.renderSnapshot(e),this.delegate.viewRenderedSnapshot(i,t,this.renderer.renderMethod),this.delegate.preloadOnLoadLinksForView(this.element),this.finishRenderingSnapshot(e)}finally{delete this.renderer,this.#o(void 0),delete this.renderPromise}else n&&this.invalidate(e.reloadReason)}invalidate(e){this.delegate.viewInvalidated(e)}async prepareToRenderSnapshot(e){this.markAsPreview(e.isPreview),await e.prepareToRender()}markAsPreview(e){e?this.element.setAttribute("data-turbo-preview",""):this.element.removeAttribute("data-turbo-preview")}markVisitDirection(e){this.element.setAttribute("data-turbo-visit-direction",e)}unmarkVisitDirection(){this.element.removeAttribute("data-turbo-visit-direction")}async renderSnapshot(e){await e.render()}finishRenderingSnapshot(e){e.finishRendering()}}class he extends le{missing(){this.element.innerHTML='Content missing'}get snapshot(){return new ne(this.element)}}class de{constructor(e,t){this.delegate=e,this.element=t}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=e=>{this.clickEventIsSignificant(e)?this.clickEvent=e:delete this.clickEvent};linkClicked=e=>{this.clickEvent&&this.clickEventIsSignificant(e)&&this.delegate.shouldInterceptLinkClick(e.target,e.detail.url,e.detail.originalEvent)&&(this.clickEvent.preventDefault(),e.preventDefault(),this.delegate.linkClickIntercepted(e.target,e.detail.url,e.detail.originalEvent)),delete this.clickEvent};willVisit=e=>{delete this.clickEvent};clickEventIsSignificant(e){const t=e.composed?e.target?.parentElement:e.target,s=C(t)||t;return s instanceof Element&&s.closest("turbo-frame, html")==this.element}}class ue{started=!1;constructor(e,t){this.delegate=e,this.eventTarget=t}start(){this.started||(this.eventTarget.addEventListener("click",this.clickCaptured,!0),this.started=!0)}stop(){this.started&&(this.eventTarget.removeEventListener("click",this.clickCaptured,!0),this.started=!1)}clickCaptured=()=>{this.eventTarget.removeEventListener("click",this.clickBubbled,!1),this.eventTarget.addEventListener("click",this.clickBubbled,!1)};clickBubbled=e=>{if(e instanceof MouseEvent&&this.clickEventIsSignificant(e)){const t=C(e.composedPath&&e.composedPath()[0]||e.target);if(t&&T(t.target)){const s=P(t);this.delegate.willFollowLinkToLocation(t,s,e)&&(e.preventDefault(),this.delegate.followedLinkToLocation(t,s))}}};clickEventIsSignificant(e){return!(e.target&&e.target.isContentEditable||e.defaultPrevented||e.which>1||e.altKey||e.ctrlKey||e.metaKey||e.shiftKey)}}class me{constructor(e,t){this.delegate=e,this.linkInterceptor=new ue(this,t)}start(){this.linkInterceptor.start()}stop(){this.linkInterceptor.stop()}canPrefetchRequestToLocation(e,t){return!1}prefetchAndCacheRequestToLocation(e,t){}willFollowLinkToLocation(e,t,s){return this.delegate.willSubmitFormLinkToLocation(e,t,s)&&(e.hasAttribute("data-turbo-method")||e.hasAttribute("data-turbo-stream"))}followedLinkToLocation(e,t){const s=document.createElement("form");for(const[e,r]of t.searchParams)s.append(Object.assign(document.createElement("input"),{type:"hidden",name:e,value:r}));const r=Object.assign(t,{search:""});s.setAttribute("data-turbo","true"),s.setAttribute("action",r.href),s.setAttribute("hidden","");const i=e.getAttribute("data-turbo-method");i&&s.setAttribute("method",i);const n=e.getAttribute("data-turbo-frame");n&&s.setAttribute("data-turbo-frame",n);const o=S(e);o&&s.setAttribute("data-turbo-action",o);const a=e.getAttribute("data-turbo-confirm");a&&s.setAttribute("data-turbo-confirm",a);e.hasAttribute("data-turbo-stream")&&s.setAttribute("data-turbo-stream",""),this.delegate.submittedFormLinkToLocation(e,t,s),document.body.appendChild(s),s.addEventListener("turbo:submit-end",(()=>s.remove()),{once:!0}),requestAnimationFrame((()=>s.requestSubmit()))}}class pe{static async preservingPermanentElements(e,t,s){const r=new this(e,t);r.enter(),await s(),r.leave()}constructor(e,t){this.delegate=e,this.permanentElementMap=t}enter(){for(const e in this.permanentElementMap){const[t,s]=this.permanentElementMap[e];this.delegate.enteringBardo(t,s),this.replaceNewPermanentElementWithPlaceholder(s)}}leave(){for(const e in this.permanentElementMap){const[t]=this.permanentElementMap[e];this.replaceCurrentPermanentElementWithClone(t),this.replacePlaceholderWithPermanentElement(t),this.delegate.leavingBardo(t)}}replaceNewPermanentElementWithPlaceholder(e){const t=function(e){const t=document.createElement("meta");return t.setAttribute("name","turbo-permanent-placeholder"),t.setAttribute("content",e.id),t}(e);e.replaceWith(t)}replaceCurrentPermanentElementWithClone(e){const t=e.cloneNode(!0);e.replaceWith(t)}replacePlaceholderWithPermanentElement(e){const t=this.getPlaceholderById(e.id);t?.replaceWith(e)}getPlaceholderById(e){return this.placeholders.find((t=>t.content==e))}get placeholders(){return[...document.querySelectorAll("meta[name=turbo-permanent-placeholder][content]")]}}class fe{#c=null;static renderElement(e,t){}constructor(e,t,s,r=!0){this.currentSnapshot=e,this.newSnapshot=t,this.isPreview=s,this.willRender=r,this.renderElement=this.constructor.renderElement,this.promise=new Promise(((e,t)=>this.resolvingFunctions={resolve:e,reject:t}))}get shouldRender(){return!0}get shouldAutofocus(){return!0}get reloadReason(){}prepareToRender(){}render(){}finishRendering(){this.resolvingFunctions&&(this.resolvingFunctions.resolve(),delete this.resolvingFunctions)}async preservingPermanentElements(e){await pe.preservingPermanentElements(this,this.permanentElementMap,e)}focusFirstAutofocusableElement(){if(this.shouldAutofocus){const e=this.connectedSnapshot.firstAutofocusableElement;e&&e.focus()}}enteringBardo(e){this.#c||e.contains(this.currentSnapshot.activeElement)&&(this.#c=this.currentSnapshot.activeElement)}leavingBardo(e){e.contains(this.#c)&&this.#c instanceof HTMLElement&&(this.#c.focus(),this.#c=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 ge extends fe{static renderElement(e,t){const s=document.createRange();s.selectNodeContents(e),s.deleteContents();const r=t,i=r.ownerDocument?.createRange();i&&(i.selectNodeContents(r),e.appendChild(i.extractContents()))}constructor(e,t,s,r,i,n=!0){super(t,s,r,i,n),this.delegate=e}get shouldRender(){return!0}async render(){await c(),this.preservingPermanentElements((()=>{this.loadFrameElement()})),this.scrollFrameIntoView(),await c(),this.focusFirstAutofocusableElement(),await c(),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 s=this.currentElement.firstElementChild,r=(e=this.currentElement.getAttribute("data-autoscroll-block"),t="end","end"==e||"start"==e||"center"==e||"nearest"==e?e:t),i=function(e,t){return"auto"==e||"smooth"==e?e:t}(this.currentElement.getAttribute("data-autoscroll-behavior"),"auto");if(s)return s.scrollIntoView({block:r,behavior:i}),!0}var e,t;return!1}activateScriptElements(){for(const e of this.newScriptElements){const t=n(e);e.replaceWith(t)}}get newScriptElements(){return this.currentElement.querySelectorAll("script")}}var be=function(){const e=()=>{},t={morphStyle:"outerHTML",callbacks:{beforeNodeAdded:e,afterNodeAdded:e,beforeNodeMorphed:e,afterNodeMorphed:e,beforeNodeRemoved:e,afterNodeRemoved:e,beforeAttributeUpdated:e},head:{style:"merge",shouldPreserve:e=>"true"===e.getAttribute("im-preserve"),shouldReAppend:e=>"true"===e.getAttribute("im-re-append"),shouldRemove:e,afterHeadMorphed:e},restoreFocus:!0};const s=function(){function e(e,t,s,i){if(!1===i.callbacks.beforeNodeAdded(t))return null;if(i.idMap.has(t)){const n=document.createElement(t.tagName);return e.insertBefore(n,s),r(n,t,i),i.callbacks.afterNodeAdded(n),n}{const r=document.importNode(t,!0);return e.insertBefore(r,s),i.callbacks.afterNodeAdded(r),r}}const t=function(){function e(e,t,s){let r=e.idMap.get(t),i=e.idMap.get(s);if(!i||!r)return!1;for(const e of r)if(i.has(e))return!0;return!1}function t(e,t){const s=e,r=t;return s.nodeType===r.nodeType&&s.tagName===r.tagName&&(!s.id||s.id===r.id)}return function(s,r,i,n){let o=null,a=r.nextSibling,c=0,l=i;for(;l&&l!=n;){if(t(l,r)){if(e(s,l,r))return l;null===o&&(s.idMap.has(l)||(o=l))}if(null===o&&a&&t(l,a)&&(c++,a=a.nextSibling,c>=2&&(o=void 0)),l.contains(document.activeElement))break;l=l.nextSibling}return o||null}}();function s(e,t){if(e.idMap.has(t))o(e.pantry,t,null);else{if(!1===e.callbacks.beforeNodeRemoved(t))return;t.parentNode?.removeChild(t),e.callbacks.afterNodeRemoved(t)}}function i(e,t,r){let i=t;for(;i&&i!==r;){let t=i;i=i.nextSibling,s(e,t)}return i}function n(e,t,s,r){const i=r.target.querySelector(`#${t}`)||r.pantry.querySelector(`#${t}`);return function(e,t){const s=e.id;for(;e=e.parentNode;){let r=t.idMap.get(e);r&&(r.delete(s),r.size||t.idMap.delete(e))}}(i,r),o(e,i,s),i}function o(e,t,s){if(e.moveBefore)try{e.moveBefore(t,s)}catch(r){e.insertBefore(t,s)}else e.insertBefore(t,s)}return function(o,a,c,l=null,h=null){a instanceof HTMLTemplateElement&&c instanceof HTMLTemplateElement&&(a=a.content,c=c.content),l||=a.firstChild;for(const s of c.childNodes){if(l&&l!=h){const e=t(o,s,l,h);if(e){e!==l&&i(o,l,e),r(e,s,o),l=e.nextSibling;continue}}if(s instanceof Element&&o.persistentIds.has(s.id)){const e=n(a,s.id,l,o);r(e,s,o),l=e.nextSibling;continue}const c=e(a,s,l,o);c&&(l=c.nextSibling)}for(;l&&l!=h;){const e=l;l=l.nextSibling,s(o,e)}}}(),r=function(){function e(e,s,r,i){const n=s[r];if(n!==e[r]){const o=t(r,e,"update",i);o||(e[r]=s[r]),n?o||e.setAttribute(r,""):t(r,e,"remove",i)||e.removeAttribute(r)}}function t(e,t,s,r){return!("value"!==e||!r.ignoreActiveValue||t!==document.activeElement)||!1===r.callbacks.beforeAttributeUpdated(e,t,s)}function r(e,t){return!!t.ignoreActiveValue&&e===document.activeElement&&e!==document.body}return function(n,o,a){return a.ignoreActive&&n===document.activeElement?null:(!1===a.callbacks.beforeNodeMorphed(n,o)||(n instanceof HTMLHeadElement&&a.head.ignore||(n instanceof HTMLHeadElement&&"morph"!==a.head.style?i(n,o,a):(!function(s,i,n){let o=i.nodeType;if(1===o){const o=s,a=i,c=o.attributes,l=a.attributes;for(const e of l)t(e.name,o,"update",n)||o.getAttribute(e.name)!==e.value&&o.setAttribute(e.name,e.value);for(let e=c.length-1;0<=e;e--){const s=c[e];if(s&&!a.hasAttribute(s.name)){if(t(s.name,o,"remove",n))continue;o.removeAttribute(s.name)}}r(o,n)||function(s,r,i){if(s instanceof HTMLInputElement&&r instanceof HTMLInputElement&&"file"!==r.type){let n=r.value,o=s.value;e(s,r,"checked",i),e(s,r,"disabled",i),r.hasAttribute("value")?o!==n&&(t("value",s,"update",i)||(s.setAttribute("value",n),s.value=n)):t("value",s,"remove",i)||(s.value="",s.removeAttribute("value"))}else if(s instanceof HTMLOptionElement&&r instanceof HTMLOptionElement)e(s,r,"selected",i);else if(s instanceof HTMLTextAreaElement&&r instanceof HTMLTextAreaElement){let e=r.value,n=s.value;if(t("value",s,"update",i))return;e!==n&&(s.value=e),s.firstChild&&s.firstChild.nodeValue!==e&&(s.firstChild.nodeValue=e)}}(o,a,n)}8!==o&&3!==o||s.nodeValue!==i.nodeValue&&(s.nodeValue=i.nodeValue)}(n,o,a),r(n,a)||s(a,n,o))),a.callbacks.afterNodeMorphed(n,o)),n)}}();function i(e,t,s){let r=[],i=[],n=[],o=[],a=new Map;for(const e of t.children)a.set(e.outerHTML,e);for(const t of e.children){let e=a.has(t.outerHTML),r=s.head.shouldReAppend(t),c=s.head.shouldPreserve(t);e||c?r?i.push(t):(a.delete(t.outerHTML),n.push(t)):"append"===s.head.style?r&&(i.push(t),o.push(t)):!1!==s.head.shouldRemove(t)&&i.push(t)}o.push(...a.values());let c=[];for(const t of o){let i=document.createRange().createContextualFragment(t.outerHTML).firstChild;if(!1!==s.callbacks.beforeNodeAdded(i)){if("href"in i&&i.href||"src"in i&&i.src){let e,t=new Promise((function(t){e=t}));i.addEventListener("load",(function(){e()})),c.push(t)}e.appendChild(i),s.callbacks.afterNodeAdded(i),r.push(i)}}for(const t of i)!1!==s.callbacks.beforeNodeRemoved(t)&&(e.removeChild(t),s.callbacks.afterNodeRemoved(t));return s.head.afterHeadMorphed(e,{added:r,kept:n,removed:i}),c}const n=function(){function e(){const e=document.createElement("div");return e.hidden=!0,document.body.insertAdjacentElement("afterend",e),e}function s(e){let t=Array.from(e.querySelectorAll("[id]"));return e.id&&t.push(e),t}function r(e,t,s,r){for(const i of r)if(t.has(i.id)){let t=i;for(;t;){let r=e.get(t);if(null==r&&(r=new Set,e.set(t,r)),r.add(i.id),t===s)break;t=t.parentElement}}}return function(i,n,o){const{persistentIds:a,idMap:c}=function(e,t){const i=s(e),n=s(t),o=function(e,t){let s=new Set,r=new Map;for(const{id:t,tagName:i}of e)r.has(t)?s.add(t):r.set(t,i);let i=new Set;for(const{id:e,tagName:n}of t)i.has(e)?s.add(e):r.get(e)===n&&i.add(e);for(const e of s)i.delete(e);return i}(i,n);let a=new Map;r(a,o,e,i);const c=t.__idiomorphRoot||t;return r(a,o,c,n),{persistentIds:o,idMap:a}}(i,n),l=function(e){let s=Object.assign({},t);return Object.assign(s,e),s.callbacks=Object.assign({},t.callbacks,e.callbacks),s.head=Object.assign({},t.head,e.head),s}(o),h=l.morphStyle||"outerHTML";if(!["innerHTML","outerHTML"].includes(h))throw`Do not understand how to morph style ${h}`;return{target:i,newContent:n,config:l,morphStyle:h,ignoreActive:l.ignoreActive,ignoreActiveValue:l.ignoreActiveValue,restoreFocus:l.restoreFocus,idMap:c,persistentIds:a,pantry:e(),callbacks:l.callbacks,head:l.head}}}(),{normalizeElement:o,normalizeParent:a}=function(){const e=new WeakSet;return{normalizeElement:function(e){return e instanceof Document?e.documentElement:e},normalizeParent:function t(s){if(null==s)return document.createElement("div");if("string"==typeof s)return t(function(t){let s=new DOMParser,r=t.replace(/