/*!
Turbo 8.0.4
Copyright © 2024 37signals LLC
*/
/**
* The MIT License (MIT)
*
* Copyright (c) 2019 Javan Makhmali
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
;(function (prototype) {
if (typeof prototype.requestSubmit == 'function') return
prototype.requestSubmit = function (submitter) {
if (submitter) {
validateSubmitter(submitter, this)
submitter.click()
} else {
submitter = document.createElement('input')
submitter.type = 'submit'
submitter.hidden = true
this.appendChild(submitter)
submitter.click()
this.removeChild(submitter)
}
}
function validateSubmitter(submitter, form) {
submitter instanceof HTMLElement || raise(TypeError, "parameter 1 is not of type 'HTMLElement'")
submitter.type == 'submit' || raise(TypeError, 'The specified element is not a submit button')
submitter.form == form ||
raise(DOMException, 'The specified element is not owned by this form element', 'NotFoundError')
}
function raise(errorConstructor, message, name) {
throw new errorConstructor("Failed to execute 'requestSubmit' on 'HTMLFormElement': " + message + '.', name)
}
})(HTMLFormElement.prototype)
const submittersByForm = new WeakMap()
function findSubmitterFromClickTarget(target) {
const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null
const candidate = element ? element.closest('input, button') : null
return candidate?.type == 'submit' ? candidate : null
}
function clickCaptured(event) {
const submitter = findSubmitterFromClickTarget(event.target)
if (submitter && submitter.form) {
submittersByForm.set(submitter.form, submitter)
}
}
;(function () {
if ('submitter' in Event.prototype) return
let prototype = window.Event.prototype
// Certain versions of Safari 15 have a bug where they won't
// populate the submitter. This hurts TurboDrive's enable/disable detection.
// See https://bugs.webkit.org/show_bug.cgi?id=229660
if ('SubmitEvent' in window) {
const prototypeOfSubmitEvent = window.SubmitEvent.prototype
if (/Apple Computer/.test(navigator.vendor) && !('submitter' in prototypeOfSubmitEvent)) {
prototype = prototypeOfSubmitEvent
} else {
return // polyfill not needed
}
}
addEventListener('click', clickCaptured, true)
Object.defineProperty(prototype, 'submitter', {
get() {
if (this.type == 'submit' && this.target instanceof HTMLFormElement) {
return submittersByForm.get(this.target)
}
}
})
})()
const FrameLoadingStyle = {
eager: 'eager',
lazy: 'lazy'
}
/**
* Contains a fragment of HTML which is updated based on navigation within
* it (e.g. via links or form submissions).
*
* @customElement turbo-frame
* @example
*
*
* Show all expanded messages in this frame.
*
*
*
*
*/
class FrameElement extends HTMLElement {
static delegateConstructor = undefined
loaded = Promise.resolve()
static get observedAttributes() {
return ['disabled', 'loading', 'src']
}
constructor() {
super()
this.delegate = new FrameElement.delegateConstructor(this)
}
connectedCallback() {
this.delegate.connect()
}
disconnectedCallback() {
this.delegate.disconnect()
}
reload() {
return this.delegate.sourceURLReloaded()
}
attributeChangedCallback(name) {
if (name == 'loading') {
this.delegate.loadingStyleChanged()
} else if (name == 'src') {
this.delegate.sourceURLChanged()
} else if (name == 'disabled') {
this.delegate.disabledChanged()
}
}
/**
* Gets the URL to lazily load source HTML from
*/
get src() {
return this.getAttribute('src')
}
/**
* Sets the URL to lazily load source HTML from
*/
set src(value) {
if (value) {
this.setAttribute('src', value)
} else {
this.removeAttribute('src')
}
}
/**
* Gets the refresh mode for the frame.
*/
get refresh() {
return this.getAttribute('refresh')
}
/**
* Sets the refresh mode for the frame.
*/
set refresh(value) {
if (value) {
this.setAttribute('refresh', value)
} else {
this.removeAttribute('refresh')
}
}
/**
* Determines if the element is loading
*/
get loading() {
return frameLoadingStyleFromString(this.getAttribute('loading') || '')
}
/**
* Sets the value of if the element is loading
*/
set loading(value) {
if (value) {
this.setAttribute('loading', value)
} else {
this.removeAttribute('loading')
}
}
/**
* Gets the disabled state of the frame.
*
* If disabled, no requests will be intercepted by the frame.
*/
get disabled() {
return this.hasAttribute('disabled')
}
/**
* Sets the disabled state of the frame.
*
* If disabled, no requests will be intercepted by the frame.
*/
set disabled(value) {
if (value) {
this.setAttribute('disabled', '')
} else {
this.removeAttribute('disabled')
}
}
/**
* Gets the autoscroll state of the frame.
*
* If true, the frame will be scrolled into view automatically on update.
*/
get autoscroll() {
return this.hasAttribute('autoscroll')
}
/**
* Sets the autoscroll state of the frame.
*
* If true, the frame will be scrolled into view automatically on update.
*/
set autoscroll(value) {
if (value) {
this.setAttribute('autoscroll', '')
} else {
this.removeAttribute('autoscroll')
}
}
/**
* Determines if the element has finished loading
*/
get complete() {
return !this.delegate.isLoading
}
/**
* Gets the active state of the frame.
*
* If inactive, source changes will not be observed.
*/
get isActive() {
return this.ownerDocument === document && !this.isPreview
}
/**
* Sets the active state of the frame.
*
* If inactive, source changes will not be observed.
*/
get isPreview() {
return this.ownerDocument?.documentElement?.hasAttribute('data-turbo-preview')
}
}
function frameLoadingStyleFromString(style) {
switch (style.toLowerCase()) {
case 'lazy':
return FrameLoadingStyle.lazy
default:
return FrameLoadingStyle.eager
}
}
function expandURL(locatable) {
return new URL(locatable.toString(), document.baseURI)
}
function getAnchor(url) {
let anchorMatch
if (url.hash) {
return url.hash.slice(1)
// eslint-disable-next-line no-cond-assign
} else if ((anchorMatch = url.href.match(/#(.*)$/))) {
return anchorMatch[1]
}
}
function getAction$1(form, submitter) {
const action = submitter?.getAttribute('formaction') || form.getAttribute('action') || form.action
return expandURL(action)
}
function getExtension(url) {
return (getLastPathComponent(url).match(/\.[^.]*$/) || [])[0] || ''
}
function isHTML(url) {
return !!getExtension(url).match(/^(?:|\.(?:htm|html|xhtml|php))$/)
}
function isPrefixedBy(baseURL, url) {
const prefix = getPrefix(url)
return baseURL.href === expandURL(prefix).href || baseURL.href.startsWith(prefix)
}
function locationIsVisitable(location, rootLocation) {
return isPrefixedBy(location, rootLocation) && isHTML(location)
}
function getRequestURL(url) {
const anchor = getAnchor(url)
return anchor != null ? url.href.slice(0, -(anchor.length + 1)) : url.href
}
function toCacheKey(url) {
return getRequestURL(url)
}
function urlsAreEqual(left, right) {
return expandURL(left).href == expandURL(right).href
}
function getPathComponents(url) {
return url.pathname.split('/').slice(1)
}
function getLastPathComponent(url) {
return getPathComponents(url).slice(-1)[0]
}
function getPrefix(url) {
return addTrailingSlash(url.origin + url.pathname)
}
function addTrailingSlash(value) {
return value.endsWith('/') ? value : value + '/'
}
class FetchResponse {
constructor(response) {
this.response = response
}
get succeeded() {
return this.response.ok
}
get failed() {
return !this.succeeded
}
get clientError() {
return this.statusCode >= 400 && this.statusCode <= 499
}
get serverError() {
return this.statusCode >= 500 && this.statusCode <= 599
}
get redirected() {
return this.response.redirected
}
get location() {
return expandURL(this.response.url)
}
get isHTML() {
return this.contentType && this.contentType.match(/^(?:text\/([^\s;,]+\b)?html|application\/xhtml\+xml)\b/)
}
get statusCode() {
return this.response.status
}
get contentType() {
return this.header('Content-Type')
}
get responseText() {
return this.response.clone().text()
}
get responseHTML() {
if (this.isHTML) {
return this.response.clone().text()
} else {
return Promise.resolve(undefined)
}
}
header(name) {
return this.response.headers.get(name)
}
}
function activateScriptElement(element) {
if (element.getAttribute('data-turbo-eval') == 'false') {
return element
} else {
const createdScriptElement = document.createElement('script')
const cspNonce = getMetaContent('csp-nonce')
if (cspNonce) {
createdScriptElement.nonce = cspNonce
}
createdScriptElement.textContent = element.textContent
createdScriptElement.async = false
copyElementAttributes(createdScriptElement, element)
return createdScriptElement
}
}
function copyElementAttributes(destinationElement, sourceElement) {
for (const { name, value } of sourceElement.attributes) {
destinationElement.setAttribute(name, value)
}
}
function createDocumentFragment(html) {
const template = document.createElement('template')
template.innerHTML = html
return template.content
}
function dispatch(eventName, { target, cancelable, detail } = {}) {
const event = new CustomEvent(eventName, {
cancelable,
bubbles: true,
composed: true,
detail
})
if (target && target.isConnected) {
target.dispatchEvent(event)
} else {
document.documentElement.dispatchEvent(event)
}
return event
}
function nextRepaint() {
if (document.visibilityState === 'hidden') {
return nextEventLoopTick()
} else {
return nextAnimationFrame()
}
}
function nextAnimationFrame() {
return new Promise(resolve => requestAnimationFrame(() => resolve()))
}
function nextEventLoopTick() {
return new Promise(resolve => setTimeout(() => resolve(), 0))
}
function nextMicrotask() {
return Promise.resolve()
}
function parseHTMLDocument(html = '') {
return new DOMParser().parseFromString(html, 'text/html')
}
function unindent(strings, ...values) {
const lines = interpolate(strings, values).replace(/^\n/, '').split('\n')
const match = lines[0].match(/^\s+/)
const indent = match ? match[0].length : 0
return lines.map(line => line.slice(indent)).join('\n')
}
function interpolate(strings, values) {
return strings.reduce((result, string, i) => {
const value = values[i] == undefined ? '' : values[i]
return result + string + value
}, '')
}
function uuid() {
return Array.from({ length: 36 })
.map((_, i) => {
if (i == 8 || i == 13 || i == 18 || i == 23) {
return '-'
} else if (i == 14) {
return '4'
} else if (i == 19) {
return (Math.floor(Math.random() * 4) + 8).toString(16)
} else {
return Math.floor(Math.random() * 15).toString(16)
}
})
.join('')
}
function getAttribute(attributeName, ...elements) {
for (const value of elements.map(element => element?.getAttribute(attributeName))) {
if (typeof value == 'string') return value
}
return null
}
function hasAttribute(attributeName, ...elements) {
return elements.some(element => element && element.hasAttribute(attributeName))
}
function markAsBusy(...elements) {
for (const element of elements) {
if (element.localName == 'turbo-frame') {
element.setAttribute('busy', '')
}
element.setAttribute('aria-busy', 'true')
}
}
function clearBusyState(...elements) {
for (const element of elements) {
if (element.localName == 'turbo-frame') {
element.removeAttribute('busy')
}
element.removeAttribute('aria-busy')
}
}
function waitForLoad(element, timeoutInMilliseconds = 2000) {
return new Promise(resolve => {
const onComplete = () => {
element.removeEventListener('error', onComplete)
element.removeEventListener('load', onComplete)
resolve()
}
element.addEventListener('load', onComplete, { once: true })
element.addEventListener('error', onComplete, { once: true })
setTimeout(resolve, timeoutInMilliseconds)
})
}
function getHistoryMethodForAction(action) {
switch (action) {
case 'replace':
return history.replaceState
case 'advance':
case 'restore':
return history.pushState
}
}
function isAction(action) {
return action == 'advance' || action == 'replace' || action == 'restore'
}
function getVisitAction(...elements) {
const action = getAttribute('data-turbo-action', ...elements)
return isAction(action) ? action : null
}
function getMetaElement(name) {
return document.querySelector(`meta[name="${name}"]`)
}
function getMetaContent(name) {
const element = getMetaElement(name)
return element && element.content
}
function setMetaContent(name, content) {
let element = getMetaElement(name)
if (!element) {
element = document.createElement('meta')
element.setAttribute('name', name)
document.head.appendChild(element)
}
element.setAttribute('content', content)
return element
}
function findClosestRecursively(element, selector) {
if (element instanceof Element) {
return (
element.closest(selector) ||
findClosestRecursively(element.assignedSlot || element.getRootNode()?.host, selector)
)
}
}
function elementIsFocusable(element) {
const inertDisabledOrHidden = '[inert], :disabled, [hidden], details:not([open]), dialog:not([open])'
return !!element && element.closest(inertDisabledOrHidden) == null && typeof element.focus == 'function'
}
function queryAutofocusableElement(elementOrDocumentFragment) {
return Array.from(elementOrDocumentFragment.querySelectorAll('[autofocus]')).find(elementIsFocusable)
}
async function around(callback, reader) {
const before = reader()
callback()
await nextAnimationFrame()
const after = reader()
return [before, after]
}
function doesNotTargetIFrame(anchor) {
if (anchor.hasAttribute('target')) {
for (const element of document.getElementsByName(anchor.target)) {
if (element instanceof HTMLIFrameElement) return false
}
}
return true
}
function findLinkFromClickTarget(target) {
return findClosestRecursively(target, 'a[href]:not([target^=_]):not([download])')
}
function getLocationForLink(link) {
return expandURL(link.getAttribute('href') || '')
}
function debounce(fn, delay) {
let timeoutId = null
return (...args) => {
const callback = () => fn.apply(this, args)
clearTimeout(timeoutId)
timeoutId = setTimeout(callback, delay)
}
}
class LimitedSet extends Set {
constructor(maxSize) {
super()
this.maxSize = maxSize
}
add(value) {
if (this.size >= this.maxSize) {
const iterator = this.values()
const oldestValue = iterator.next().value
this.delete(oldestValue)
}
super.add(value)
}
}
const recentRequests = new LimitedSet(20)
const nativeFetch = window.fetch
function fetchWithTurboHeaders(url, options = {}) {
const modifiedHeaders = new Headers(options.headers || {})
const requestUID = uuid()
recentRequests.add(requestUID)
modifiedHeaders.append('X-Turbo-Request-Id', requestUID)
return nativeFetch(url, {
...options,
headers: modifiedHeaders
})
}
function fetchMethodFromString(method) {
switch (method.toLowerCase()) {
case 'get':
return FetchMethod.get
case 'post':
return FetchMethod.post
case 'put':
return FetchMethod.put
case 'patch':
return FetchMethod.patch
case 'delete':
return FetchMethod.delete
}
}
const FetchMethod = {
get: 'get',
post: 'post',
put: 'put',
patch: 'patch',
delete: 'delete'
}
function fetchEnctypeFromString(encoding) {
switch (encoding.toLowerCase()) {
case FetchEnctype.multipart:
return FetchEnctype.multipart
case FetchEnctype.plain:
return FetchEnctype.plain
default:
return FetchEnctype.urlEncoded
}
}
const FetchEnctype = {
urlEncoded: 'application/x-www-form-urlencoded',
multipart: 'multipart/form-data',
plain: 'text/plain'
}
class FetchRequest {
abortController = new AbortController()
#resolveRequestPromise = _value => {}
constructor(
delegate,
method,
location,
requestBody = new URLSearchParams(),
target = null,
enctype = FetchEnctype.urlEncoded
) {
const [url, body] = buildResourceAndBody(expandURL(location), method, requestBody, enctype)
this.delegate = delegate
this.url = url
this.target = target
this.fetchOptions = {
credentials: 'same-origin',
redirect: 'follow',
method: method,
headers: { ...this.defaultHeaders },
body: body,
signal: this.abortSignal,
referrer: this.delegate.referrer?.href
}
this.enctype = enctype
}
get method() {
return this.fetchOptions.method
}
set method(value) {
const fetchBody = this.isSafe ? this.url.searchParams : this.fetchOptions.body || new FormData()
const fetchMethod = fetchMethodFromString(value) || FetchMethod.get
this.url.search = ''
const [url, body] = buildResourceAndBody(this.url, fetchMethod, fetchBody, this.enctype)
this.url = url
this.fetchOptions.body = body
this.fetchOptions.method = fetchMethod
}
get headers() {
return this.fetchOptions.headers
}
set headers(value) {
this.fetchOptions.headers = value
}
get body() {
if (this.isSafe) {
return this.url.searchParams
} else {
return this.fetchOptions.body
}
}
set body(value) {
this.fetchOptions.body = value
}
get location() {
return this.url
}
get params() {
return this.url.searchParams
}
get entries() {
return this.body ? Array.from(this.body.entries()) : []
}
cancel() {
this.abortController.abort()
}
async perform() {
const { fetchOptions } = this
this.delegate.prepareRequest(this)
const event = await this.#allowRequestToBeIntercepted(fetchOptions)
try {
this.delegate.requestStarted(this)
if (event.detail.fetchRequest) {
this.response = event.detail.fetchRequest.response
} else {
this.response = fetchWithTurboHeaders(this.url.href, fetchOptions)
}
const response = await this.response
return await this.receive(response)
} catch (error) {
if (error.name !== 'AbortError') {
if (this.#willDelegateErrorHandling(error)) {
this.delegate.requestErrored(this, error)
}
throw error
}
} finally {
this.delegate.requestFinished(this)
}
}
async receive(response) {
const fetchResponse = new FetchResponse(response)
const event = dispatch('turbo:before-fetch-response', {
cancelable: true,
detail: { fetchResponse },
target: this.target
})
if (event.defaultPrevented) {
this.delegate.requestPreventedHandlingResponse(this, fetchResponse)
} else if (fetchResponse.succeeded) {
this.delegate.requestSucceededWithResponse(this, fetchResponse)
} else {
this.delegate.requestFailedWithResponse(this, fetchResponse)
}
return fetchResponse
}
get defaultHeaders() {
return {
Accept: 'text/html, application/xhtml+xml'
}
}
get isSafe() {
return isSafe(this.method)
}
get abortSignal() {
return this.abortController.signal
}
acceptResponseType(mimeType) {
this.headers['Accept'] = [mimeType, this.headers['Accept']].join(', ')
}
async #allowRequestToBeIntercepted(fetchOptions) {
const requestInterception = new Promise(resolve => (this.#resolveRequestPromise = resolve))
const event = dispatch('turbo:before-fetch-request', {
cancelable: true,
detail: {
fetchOptions,
url: this.url,
resume: this.#resolveRequestPromise
},
target: this.target
})
this.url = event.detail.url
if (event.defaultPrevented) await requestInterception
return event
}
#willDelegateErrorHandling(error) {
const event = dispatch('turbo:fetch-request-error', {
target: this.target,
cancelable: true,
detail: { request: this, error: error }
})
return !event.defaultPrevented
}
}
function isSafe(fetchMethod) {
return fetchMethodFromString(fetchMethod) == FetchMethod.get
}
function buildResourceAndBody(resource, method, requestBody, enctype) {
const searchParams =
Array.from(requestBody).length > 0
? new URLSearchParams(entriesExcludingFiles(requestBody))
: resource.searchParams
if (isSafe(method)) {
return [mergeIntoURLSearchParams(resource, searchParams), null]
} else if (enctype == FetchEnctype.urlEncoded) {
return [resource, searchParams]
} else {
return [resource, requestBody]
}
}
function entriesExcludingFiles(requestBody) {
const entries = []
for (const [name, value] of requestBody) {
if (value instanceof File) continue
else entries.push([name, value])
}
return entries
}
function mergeIntoURLSearchParams(url, requestBody) {
const searchParams = new URLSearchParams(entriesExcludingFiles(requestBody))
url.search = searchParams.toString()
return url
}
class AppearanceObserver {
started = false
constructor(delegate, element) {
this.delegate = delegate
this.element = element
this.intersectionObserver = new IntersectionObserver(this.intersect)
}
start() {
if (!this.started) {
this.started = true
this.intersectionObserver.observe(this.element)
}
}
stop() {
if (this.started) {
this.started = false
this.intersectionObserver.unobserve(this.element)
}
}
intersect = entries => {
const lastEntry = entries.slice(-1)[0]
if (lastEntry?.isIntersecting) {
this.delegate.elementAppearedInViewport(this.element)
}
}
}
class StreamMessage {
static contentType = 'text/vnd.turbo-stream.html'
static wrap(message) {
if (typeof message == 'string') {
return new this(createDocumentFragment(message))
} else {
return message
}
}
constructor(fragment) {
this.fragment = importStreamElements(fragment)
}
}
function importStreamElements(fragment) {
for (const element of fragment.querySelectorAll('turbo-stream')) {
const streamElement = document.importNode(element, true)
for (const inertScriptElement of streamElement.templateElement.content.querySelectorAll('script')) {
inertScriptElement.replaceWith(activateScriptElement(inertScriptElement))
}
element.replaceWith(streamElement)
}
return fragment
}
const PREFETCH_DELAY = 100
class PrefetchCache {
#prefetchTimeout = null
#prefetched = null
get(url) {
if (this.#prefetched && this.#prefetched.url === url && this.#prefetched.expire > Date.now()) {
return this.#prefetched.request
}
}
setLater(url, request, ttl) {
this.clear()
this.#prefetchTimeout = setTimeout(() => {
request.perform()
this.set(url, request, ttl)
this.#prefetchTimeout = null
}, PREFETCH_DELAY)
}
set(url, request, ttl) {
this.#prefetched = { url, request, expire: new Date(new Date().getTime() + ttl) }
}
clear() {
if (this.#prefetchTimeout) clearTimeout(this.#prefetchTimeout)
this.#prefetched = null
}
}
const cacheTtl = 10 * 1000
const prefetchCache = new PrefetchCache()
const FormSubmissionState = {
initialized: 'initialized',
requesting: 'requesting',
waiting: 'waiting',
receiving: 'receiving',
stopping: 'stopping',
stopped: 'stopped'
}
class FormSubmission {
state = FormSubmissionState.initialized
static confirmMethod(message, _element, _submitter) {
return Promise.resolve(confirm(message))
}
constructor(delegate, formElement, submitter, mustRedirect = false) {
const method = getMethod(formElement, submitter)
const action = getAction(getFormAction(formElement, submitter), method)
const body = buildFormData(formElement, submitter)
const enctype = getEnctype(formElement, submitter)
this.delegate = delegate
this.formElement = formElement
this.submitter = submitter
this.fetchRequest = new FetchRequest(this, method, action, body, formElement, enctype)
this.mustRedirect = mustRedirect
}
get method() {
return this.fetchRequest.method
}
set method(value) {
this.fetchRequest.method = value
}
get action() {
return this.fetchRequest.url.toString()
}
set action(value) {
this.fetchRequest.url = expandURL(value)
}
get body() {
return this.fetchRequest.body
}
get enctype() {
return this.fetchRequest.enctype
}
get isSafe() {
return this.fetchRequest.isSafe
}
get location() {
return this.fetchRequest.url
}
// The submission process
async start() {
const { initialized, requesting } = FormSubmissionState
const confirmationMessage = getAttribute('data-turbo-confirm', this.submitter, this.formElement)
if (typeof confirmationMessage === 'string') {
const answer = await FormSubmission.confirmMethod(confirmationMessage, this.formElement, this.submitter)
if (!answer) {
return
}
}
if (this.state == initialized) {
this.state = requesting
return this.fetchRequest.perform()
}
}
stop() {
const { stopping, stopped } = FormSubmissionState
if (this.state != stopping && this.state != stopped) {
this.state = stopping
this.fetchRequest.cancel()
return true
}
}
// Fetch request delegate
prepareRequest(request) {
if (!request.isSafe) {
const token = getCookieValue(getMetaContent('csrf-param')) || getMetaContent('csrf-token')
if (token) {
request.headers['X-CSRF-Token'] = token
}
}
if (this.requestAcceptsTurboStreamResponse(request)) {
request.acceptResponseType(StreamMessage.contentType)
}
}
requestStarted(_request) {
this.state = FormSubmissionState.waiting
this.submitter?.setAttribute('disabled', '')
this.setSubmitsWith()
markAsBusy(this.formElement)
dispatch('turbo:submit-start', {
target: this.formElement,
detail: { formSubmission: this }
})
this.delegate.formSubmissionStarted(this)
}
requestPreventedHandlingResponse(request, response) {
prefetchCache.clear()
this.result = { success: response.succeeded, fetchResponse: response }
}
requestSucceededWithResponse(request, response) {
if (response.clientError || response.serverError) {
this.delegate.formSubmissionFailedWithResponse(this, response)
return
}
prefetchCache.clear()
if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) {
const error = new Error('Form responses must redirect to another location')
this.delegate.formSubmissionErrored(this, error)
} else {
this.state = FormSubmissionState.receiving
this.result = { success: true, fetchResponse: response }
this.delegate.formSubmissionSucceededWithResponse(this, response)
}
}
requestFailedWithResponse(request, response) {
this.result = { success: false, fetchResponse: response }
this.delegate.formSubmissionFailedWithResponse(this, response)
}
requestErrored(request, error) {
this.result = { success: false, error }
this.delegate.formSubmissionErrored(this, error)
}
requestFinished(_request) {
this.state = FormSubmissionState.stopped
this.submitter?.removeAttribute('disabled')
this.resetSubmitterText()
clearBusyState(this.formElement)
dispatch('turbo:submit-end', {
target: this.formElement,
detail: { formSubmission: this, ...this.result }
})
this.delegate.formSubmissionFinished(this)
}
// Private
setSubmitsWith() {
if (!this.submitter || !this.submitsWith) return
if (this.submitter.matches('button')) {
this.originalSubmitText = this.submitter.innerHTML
this.submitter.innerHTML = this.submitsWith
} else if (this.submitter.matches('input')) {
const input = this.submitter
this.originalSubmitText = input.value
input.value = this.submitsWith
}
}
resetSubmitterText() {
if (!this.submitter || !this.originalSubmitText) return
if (this.submitter.matches('button')) {
this.submitter.innerHTML = this.originalSubmitText
} else if (this.submitter.matches('input')) {
const input = this.submitter
input.value = this.originalSubmitText
}
}
requestMustRedirect(request) {
return !request.isSafe && this.mustRedirect
}
requestAcceptsTurboStreamResponse(request) {
return !request.isSafe || hasAttribute('data-turbo-stream', this.submitter, this.formElement)
}
get submitsWith() {
return this.submitter?.getAttribute('data-turbo-submits-with')
}
}
function buildFormData(formElement, submitter) {
const formData = new FormData(formElement)
const name = submitter?.getAttribute('name')
const value = submitter?.getAttribute('value')
if (name) {
formData.append(name, value || '')
}
return formData
}
function getCookieValue(cookieName) {
if (cookieName != null) {
const cookies = document.cookie ? document.cookie.split('; ') : []
const cookie = cookies.find(cookie => cookie.startsWith(cookieName))
if (cookie) {
const value = cookie.split('=').slice(1).join('=')
return value ? decodeURIComponent(value) : undefined
}
}
}
function responseSucceededWithoutRedirect(response) {
return response.statusCode == 200 && !response.redirected
}
function getFormAction(formElement, submitter) {
const formElementAction = typeof formElement.action === 'string' ? formElement.action : null
if (submitter?.hasAttribute('formaction')) {
return submitter.getAttribute('formaction') || ''
} else {
return formElement.getAttribute('action') || formElementAction || ''
}
}
function getAction(formAction, fetchMethod) {
const action = expandURL(formAction)
if (isSafe(fetchMethod)) {
action.search = ''
}
return action
}
function getMethod(formElement, submitter) {
const method = submitter?.getAttribute('formmethod') || formElement.getAttribute('method') || ''
return fetchMethodFromString(method.toLowerCase()) || FetchMethod.get
}
function getEnctype(formElement, submitter) {
return fetchEnctypeFromString(submitter?.getAttribute('formenctype') || formElement.enctype)
}
class Snapshot {
constructor(element) {
this.element = element
}
get activeElement() {
return this.element.ownerDocument.activeElement
}
get children() {
return [...this.element.children]
}
hasAnchor(anchor) {
return this.getElementForAnchor(anchor) != null
}
getElementForAnchor(anchor) {
return anchor ? this.element.querySelector(`[id='${anchor}'], a[name='${anchor}']`) : null
}
get isConnected() {
return this.element.isConnected
}
get firstAutofocusableElement() {
return queryAutofocusableElement(this.element)
}
get permanentElements() {
return queryPermanentElementsAll(this.element)
}
getPermanentElementById(id) {
return getPermanentElementById(this.element, id)
}
getPermanentElementMapForSnapshot(snapshot) {
const permanentElementMap = {}
for (const currentPermanentElement of this.permanentElements) {
const { id } = currentPermanentElement
const newPermanentElement = snapshot.getPermanentElementById(id)
if (newPermanentElement) {
permanentElementMap[id] = [currentPermanentElement, newPermanentElement]
}
}
return permanentElementMap
}
}
function getPermanentElementById(node, id) {
return node.querySelector(`#${id}[data-turbo-permanent]`)
}
function queryPermanentElementsAll(node) {
return node.querySelectorAll('[id][data-turbo-permanent]')
}
class FormSubmitObserver {
started = false
constructor(delegate, eventTarget) {
this.delegate = delegate
this.eventTarget = eventTarget
}
start() {
if (!this.started) {
this.eventTarget.addEventListener('submit', this.submitCaptured, true)
this.started = true
}
}
stop() {
if (this.started) {
this.eventTarget.removeEventListener('submit', this.submitCaptured, true)
this.started = false
}
}
submitCaptured = () => {
this.eventTarget.removeEventListener('submit', this.submitBubbled, false)
this.eventTarget.addEventListener('submit', this.submitBubbled, false)
}
submitBubbled = event => {
if (!event.defaultPrevented) {
const form = event.target instanceof HTMLFormElement ? event.target : undefined
const submitter = event.submitter || undefined
if (
form &&
submissionDoesNotDismissDialog(form, submitter) &&
submissionDoesNotTargetIFrame(form, submitter) &&
this.delegate.willSubmitForm(form, submitter)
) {
event.preventDefault()
event.stopImmediatePropagation()
this.delegate.formSubmitted(form, submitter)
}
}
}
}
function submissionDoesNotDismissDialog(form, submitter) {
const method = submitter?.getAttribute('formmethod') || form.getAttribute('method')
return method != 'dialog'
}
function submissionDoesNotTargetIFrame(form, submitter) {
if (submitter?.hasAttribute('formtarget') || form.hasAttribute('target')) {
const target = submitter?.getAttribute('formtarget') || form.target
for (const element of document.getElementsByName(target)) {
if (element instanceof HTMLIFrameElement) return false
}
return true
} else {
return true
}
}
class View {
#resolveRenderPromise = _value => {}
#resolveInterceptionPromise = _value => {}
constructor(delegate, element) {
this.delegate = delegate
this.element = element
}
// Scrolling
scrollToAnchor(anchor) {
const element = this.snapshot.getElementForAnchor(anchor)
if (element) {
this.scrollToElement(element)
this.focusElement(element)
} else {
this.scrollToPosition({ x: 0, y: 0 })
}
}
scrollToAnchorFromLocation(location) {
this.scrollToAnchor(getAnchor(location))
}
scrollToElement(element) {
element.scrollIntoView()
}
focusElement(element) {
if (element instanceof HTMLElement) {
if (element.hasAttribute('tabindex')) {
element.focus()
} else {
element.setAttribute('tabindex', '-1')
element.focus()
element.removeAttribute('tabindex')
}
}
}
scrollToPosition({ x, y }) {
this.scrollRoot.scrollTo(x, y)
}
scrollToTop() {
this.scrollToPosition({ x: 0, y: 0 })
}
get scrollRoot() {
return window
}
// Rendering
async render(renderer) {
const { isPreview, shouldRender, willRender, newSnapshot: snapshot } = renderer
// A workaround to ignore tracked element mismatch reloads when performing
// a promoted Visit from a frame navigation
const shouldInvalidate = willRender
if (shouldRender) {
try {
this.renderPromise = new Promise(resolve => (this.#resolveRenderPromise = resolve))
this.renderer = renderer
await this.prepareToRenderSnapshot(renderer)
const renderInterception = new Promise(resolve => (this.#resolveInterceptionPromise = resolve))
const options = {
resume: this.#resolveInterceptionPromise,
render: this.renderer.renderElement,
renderMethod: this.renderer.renderMethod
}
const immediateRender = this.delegate.allowsImmediateRender(snapshot, options)
if (!immediateRender) await renderInterception
await this.renderSnapshot(renderer)
this.delegate.viewRenderedSnapshot(snapshot, isPreview, this.renderer.renderMethod)
this.delegate.preloadOnLoadLinksForView(this.element)
this.finishRenderingSnapshot(renderer)
} finally {
delete this.renderer
this.#resolveRenderPromise(undefined)
delete this.renderPromise
}
} else if (shouldInvalidate) {
this.invalidate(renderer.reloadReason)
}
}
invalidate(reason) {
this.delegate.viewInvalidated(reason)
}
async prepareToRenderSnapshot(renderer) {
this.markAsPreview(renderer.isPreview)
await renderer.prepareToRender()
}
markAsPreview(isPreview) {
if (isPreview) {
this.element.setAttribute('data-turbo-preview', '')
} else {
this.element.removeAttribute('data-turbo-preview')
}
}
markVisitDirection(direction) {
this.element.setAttribute('data-turbo-visit-direction', direction)
}
unmarkVisitDirection() {
this.element.removeAttribute('data-turbo-visit-direction')
}
async renderSnapshot(renderer) {
await renderer.render()
}
finishRenderingSnapshot(renderer) {
renderer.finishRendering()
}
}
class FrameView extends View {
missing() {
this.element.innerHTML = `Content missing`
}
get snapshot() {
return new Snapshot(this.element)
}
}
class LinkInterceptor {
constructor(delegate, element) {
this.delegate = delegate
this.element = element
}
start() {
this.element.addEventListener('click', this.clickBubbled)
document.addEventListener('turbo:click', this.linkClicked)
document.addEventListener('turbo:before-visit', this.willVisit)
}
stop() {
this.element.removeEventListener('click', this.clickBubbled)
document.removeEventListener('turbo:click', this.linkClicked)
document.removeEventListener('turbo:before-visit', this.willVisit)
}
clickBubbled = event => {
if (this.respondsToEventTarget(event.target)) {
this.clickEvent = event
} else {
delete this.clickEvent
}
}
linkClicked = event => {
if (this.clickEvent && this.respondsToEventTarget(event.target) && event.target instanceof Element) {
if (this.delegate.shouldInterceptLinkClick(event.target, event.detail.url, event.detail.originalEvent)) {
this.clickEvent.preventDefault()
event.preventDefault()
this.delegate.linkClickIntercepted(event.target, event.detail.url, event.detail.originalEvent)
}
}
delete this.clickEvent
}
willVisit = _event => {
delete this.clickEvent
}
respondsToEventTarget(target) {
const element = target instanceof Element ? target : target instanceof Node ? target.parentElement : null
return element && element.closest('turbo-frame, html') == this.element
}
}
class LinkClickObserver {
started = false
constructor(delegate, eventTarget) {
this.delegate = delegate
this.eventTarget = eventTarget
}
start() {
if (!this.started) {
this.eventTarget.addEventListener('click', this.clickCaptured, true)
this.started = true
}
}
stop() {
if (this.started) {
this.eventTarget.removeEventListener('click', this.clickCaptured, true)
this.started = false
}
}
clickCaptured = () => {
this.eventTarget.removeEventListener('click', this.clickBubbled, false)
this.eventTarget.addEventListener('click', this.clickBubbled, false)
}
clickBubbled = event => {
if (event instanceof MouseEvent && this.clickEventIsSignificant(event)) {
const target = (event.composedPath && event.composedPath()[0]) || event.target
const link = findLinkFromClickTarget(target)
if (link && doesNotTargetIFrame(link)) {
const location = getLocationForLink(link)
if (this.delegate.willFollowLinkToLocation(link, location, event)) {
event.preventDefault()
this.delegate.followedLinkToLocation(link, location)
}
}
}
}
clickEventIsSignificant(event) {
return !(
(event.target && event.target.isContentEditable) ||
event.defaultPrevented ||
event.which > 1 ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
)
}
}
class FormLinkClickObserver {
constructor(delegate, element) {
this.delegate = delegate
this.linkInterceptor = new LinkClickObserver(this, element)
}
start() {
this.linkInterceptor.start()
}
stop() {
this.linkInterceptor.stop()
}
// Link hover observer delegate
canPrefetchRequestToLocation(link, location) {
return false
}
prefetchAndCacheRequestToLocation(link, location) {
return
}
// Link click observer delegate
willFollowLinkToLocation(link, location, originalEvent) {
return (
this.delegate.willSubmitFormLinkToLocation(link, location, originalEvent) &&
(link.hasAttribute('data-turbo-method') || link.hasAttribute('data-turbo-stream'))
)
}
followedLinkToLocation(link, location) {
const form = document.createElement('form')
const type = 'hidden'
for (const [name, value] of location.searchParams) {
form.append(Object.assign(document.createElement('input'), { type, name, value }))
}
const action = Object.assign(location, { search: '' })
form.setAttribute('data-turbo', 'true')
form.setAttribute('action', action.href)
form.setAttribute('hidden', '')
const method = link.getAttribute('data-turbo-method')
if (method) form.setAttribute('method', method)
const turboFrame = link.getAttribute('data-turbo-frame')
if (turboFrame) form.setAttribute('data-turbo-frame', turboFrame)
const turboAction = getVisitAction(link)
if (turboAction) form.setAttribute('data-turbo-action', turboAction)
const turboConfirm = link.getAttribute('data-turbo-confirm')
if (turboConfirm) form.setAttribute('data-turbo-confirm', turboConfirm)
const turboStream = link.hasAttribute('data-turbo-stream')
if (turboStream) form.setAttribute('data-turbo-stream', '')
this.delegate.submittedFormLinkToLocation(link, location, form)
document.body.appendChild(form)
form.addEventListener('turbo:submit-end', () => form.remove(), { once: true })
requestAnimationFrame(() => form.requestSubmit())
}
}
class Bardo {
static async preservingPermanentElements(delegate, permanentElementMap, callback) {
const bardo = new this(delegate, permanentElementMap)
bardo.enter()
await callback()
bardo.leave()
}
constructor(delegate, permanentElementMap) {
this.delegate = delegate
this.permanentElementMap = permanentElementMap
}
enter() {
for (const id in this.permanentElementMap) {
const [currentPermanentElement, newPermanentElement] = this.permanentElementMap[id]
this.delegate.enteringBardo(currentPermanentElement, newPermanentElement)
this.replaceNewPermanentElementWithPlaceholder(newPermanentElement)
}
}
leave() {
for (const id in this.permanentElementMap) {
const [currentPermanentElement] = this.permanentElementMap[id]
this.replaceCurrentPermanentElementWithClone(currentPermanentElement)
this.replacePlaceholderWithPermanentElement(currentPermanentElement)
this.delegate.leavingBardo(currentPermanentElement)
}
}
replaceNewPermanentElementWithPlaceholder(permanentElement) {
const placeholder = createPlaceholderForPermanentElement(permanentElement)
permanentElement.replaceWith(placeholder)
}
replaceCurrentPermanentElementWithClone(permanentElement) {
const clone = permanentElement.cloneNode(true)
permanentElement.replaceWith(clone)
}
replacePlaceholderWithPermanentElement(permanentElement) {
const placeholder = this.getPlaceholderById(permanentElement.id)
placeholder?.replaceWith(permanentElement)
}
getPlaceholderById(id) {
return this.placeholders.find(element => element.content == id)
}
get placeholders() {
return [...document.querySelectorAll('meta[name=turbo-permanent-placeholder][content]')]
}
}
function createPlaceholderForPermanentElement(permanentElement) {
const element = document.createElement('meta')
element.setAttribute('name', 'turbo-permanent-placeholder')
element.setAttribute('content', permanentElement.id)
return element
}
class Renderer {
#activeElement = null
constructor(currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) {
this.currentSnapshot = currentSnapshot
this.newSnapshot = newSnapshot
this.isPreview = isPreview
this.willRender = willRender
this.renderElement = renderElement
this.promise = new Promise((resolve, reject) => (this.resolvingFunctions = { resolve, reject }))
}
get shouldRender() {
return true
}
get reloadReason() {
return
}
prepareToRender() {
return
}
render() {
// Abstract method
}
finishRendering() {
if (this.resolvingFunctions) {
this.resolvingFunctions.resolve()
delete this.resolvingFunctions
}
}
async preservingPermanentElements(callback) {
await Bardo.preservingPermanentElements(this, this.permanentElementMap, callback)
}
focusFirstAutofocusableElement() {
const element = this.connectedSnapshot.firstAutofocusableElement
if (element) {
element.focus()
}
}
// Bardo delegate
enteringBardo(currentPermanentElement) {
if (this.#activeElement) return
if (currentPermanentElement.contains(this.currentSnapshot.activeElement)) {
this.#activeElement = this.currentSnapshot.activeElement
}
}
leavingBardo(currentPermanentElement) {
if (currentPermanentElement.contains(this.#activeElement) && this.#activeElement instanceof HTMLElement) {
this.#activeElement.focus()
this.#activeElement = null
}
}
get connectedSnapshot() {
return this.newSnapshot.isConnected ? this.newSnapshot : this.currentSnapshot
}
get currentElement() {
return this.currentSnapshot.element
}
get newElement() {
return this.newSnapshot.element
}
get permanentElementMap() {
return this.currentSnapshot.getPermanentElementMapForSnapshot(this.newSnapshot)
}
get renderMethod() {
return 'replace'
}
}
class FrameRenderer extends Renderer {
static renderElement(currentElement, newElement) {
const destinationRange = document.createRange()
destinationRange.selectNodeContents(currentElement)
destinationRange.deleteContents()
const frameElement = newElement
const sourceRange = frameElement.ownerDocument?.createRange()
if (sourceRange) {
sourceRange.selectNodeContents(frameElement)
currentElement.appendChild(sourceRange.extractContents())
}
}
constructor(delegate, currentSnapshot, newSnapshot, renderElement, isPreview, willRender = true) {
super(currentSnapshot, newSnapshot, renderElement, isPreview, willRender)
this.delegate = delegate
}
get shouldRender() {
return true
}
async render() {
await nextRepaint()
this.preservingPermanentElements(() => {
this.loadFrameElement()
})
this.scrollFrameIntoView()
await nextRepaint()
this.focusFirstAutofocusableElement()
await nextRepaint()
this.activateScriptElements()
}
loadFrameElement() {
this.delegate.willRenderFrame(this.currentElement, this.newElement)
this.renderElement(this.currentElement, this.newElement)
}
scrollFrameIntoView() {
if (this.currentElement.autoscroll || this.newElement.autoscroll) {
const element = this.currentElement.firstElementChild
const block = readScrollLogicalPosition(this.currentElement.getAttribute('data-autoscroll-block'), 'end')
const behavior = readScrollBehavior(this.currentElement.getAttribute('data-autoscroll-behavior'), 'auto')
if (element) {
element.scrollIntoView({ block, behavior })
return true
}
}
return false
}
activateScriptElements() {
for (const inertScriptElement of this.newScriptElements) {
const activatedScriptElement = activateScriptElement(inertScriptElement)
inertScriptElement.replaceWith(activatedScriptElement)
}
}
get newScriptElements() {
return this.currentElement.querySelectorAll('script')
}
}
function readScrollLogicalPosition(value, defaultValue) {
if (value == 'end' || value == 'start' || value == 'center' || value == 'nearest') {
return value
} else {
return defaultValue
}
}
function readScrollBehavior(value, defaultValue) {
if (value == 'auto' || value == 'smooth') {
return value
} else {
return defaultValue
}
}
class ProgressBar {
static animationDuration = 300 /*ms*/
static get defaultCSS() {
return unindent`
.turbo-progress-bar {
position: fixed;
display: block;
top: 0;
left: 0;
height: 3px;
background: #0076ff;
z-index: 2147483647;
transition:
width ${ProgressBar.animationDuration}ms ease-out,
opacity ${ProgressBar.animationDuration / 2}ms ${ProgressBar.animationDuration / 2}ms ease-in;
transform: translate3d(0, 0, 0);
}
`
}
hiding = false
value = 0
visible = false
constructor() {
this.stylesheetElement = this.createStylesheetElement()
this.progressElement = this.createProgressElement()
this.installStylesheetElement()
this.setValue(0)
}
show() {
if (!this.visible) {
this.visible = true
this.installProgressElement()
this.startTrickling()
}
}
hide() {
if (this.visible && !this.hiding) {
this.hiding = true
this.fadeProgressElement(() => {
this.uninstallProgressElement()
this.stopTrickling()
this.visible = false
this.hiding = false
})
}
}
setValue(value) {
this.value = value
this.refresh()
}
// Private
installStylesheetElement() {
document.head.insertBefore(this.stylesheetElement, document.head.firstChild)
}
installProgressElement() {
this.progressElement.style.width = '0'
this.progressElement.style.opacity = '1'
document.documentElement.insertBefore(this.progressElement, document.body)
this.refresh()
}
fadeProgressElement(callback) {
this.progressElement.style.opacity = '0'
setTimeout(callback, ProgressBar.animationDuration * 1.5)
}
uninstallProgressElement() {
if (this.progressElement.parentNode) {
document.documentElement.removeChild(this.progressElement)
}
}
startTrickling() {
if (!this.trickleInterval) {
this.trickleInterval = window.setInterval(this.trickle, ProgressBar.animationDuration)
}
}
stopTrickling() {
window.clearInterval(this.trickleInterval)
delete this.trickleInterval
}
trickle = () => {
this.setValue(this.value + Math.random() / 100)
}
refresh() {
requestAnimationFrame(() => {
this.progressElement.style.width = `${10 + this.value * 90}%`
})
}
createStylesheetElement() {
const element = document.createElement('style')
element.type = 'text/css'
element.textContent = ProgressBar.defaultCSS
if (this.cspNonce) {
element.nonce = this.cspNonce
}
return element
}
createProgressElement() {
const element = document.createElement('div')
element.className = 'turbo-progress-bar'
return element
}
get cspNonce() {
return getMetaContent('csp-nonce')
}
}
class HeadSnapshot extends Snapshot {
detailsByOuterHTML = this.children
.filter(element => !elementIsNoscript(element))
.map(element => elementWithoutNonce(element))
.reduce((result, element) => {
const { outerHTML } = element
const details =
outerHTML in result
? result[outerHTML]
: {
type: elementType(element),
tracked: elementIsTracked(element),
elements: []
}
return {
...result,
[outerHTML]: {
...details,
elements: [...details.elements, element]
}
}
}, {})
get trackedElementSignature() {
return Object.keys(this.detailsByOuterHTML)
.filter(outerHTML => this.detailsByOuterHTML[outerHTML].tracked)
.join('')
}
getScriptElementsNotInSnapshot(snapshot) {
return this.getElementsMatchingTypeNotInSnapshot('script', snapshot)
}
getStylesheetElementsNotInSnapshot(snapshot) {
return this.getElementsMatchingTypeNotInSnapshot('stylesheet', snapshot)
}
getElementsMatchingTypeNotInSnapshot(matchedType, snapshot) {
return Object.keys(this.detailsByOuterHTML)
.filter(outerHTML => !(outerHTML in snapshot.detailsByOuterHTML))
.map(outerHTML => this.detailsByOuterHTML[outerHTML])
.filter(({ type }) => type == matchedType)
.map(({ elements: [element] }) => element)
}
get provisionalElements() {
return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => {
const { type, tracked, elements } = this.detailsByOuterHTML[outerHTML]
if (type == null && !tracked) {
return [...result, ...elements]
} else if (elements.length > 1) {
return [...result, ...elements.slice(1)]
} else {
return result
}
}, [])
}
getMetaValue(name) {
const element = this.findMetaElementByName(name)
return element ? element.getAttribute('content') : null
}
findMetaElementByName(name) {
return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => {
const {
elements: [element]
} = this.detailsByOuterHTML[outerHTML]
return elementIsMetaElementWithName(element, name) ? element : result
}, undefined | undefined)
}
}
function elementType(element) {
if (elementIsScript(element)) {
return 'script'
} else if (elementIsStylesheet(element)) {
return 'stylesheet'
}
}
function elementIsTracked(element) {
return element.getAttribute('data-turbo-track') == 'reload'
}
function elementIsScript(element) {
const tagName = element.localName
return tagName == 'script'
}
function elementIsNoscript(element) {
const tagName = element.localName
return tagName == 'noscript'
}
function elementIsStylesheet(element) {
const tagName = element.localName
return tagName == 'style' || (tagName == 'link' && element.getAttribute('rel') == 'stylesheet')
}
function elementIsMetaElementWithName(element, name) {
const tagName = element.localName
return tagName == 'meta' && element.getAttribute('name') == name
}
function elementWithoutNonce(element) {
if (element.hasAttribute('nonce')) {
element.setAttribute('nonce', '')
}
return element
}
class PageSnapshot extends Snapshot {
static fromHTMLString(html = '') {
return this.fromDocument(parseHTMLDocument(html))
}
static fromElement(element) {
return this.fromDocument(element.ownerDocument)
}
static fromDocument({ documentElement, body, head }) {
return new this(documentElement, body, new HeadSnapshot(head))
}
constructor(documentElement, body, headSnapshot) {
super(body)
this.documentElement = documentElement
this.headSnapshot = headSnapshot
}
clone() {
const clonedElement = this.element.cloneNode(true)
const selectElements = this.element.querySelectorAll('select')
const clonedSelectElements = clonedElement.querySelectorAll('select')
for (const [index, source] of selectElements.entries()) {
const clone = clonedSelectElements[index]
for (const option of clone.selectedOptions) option.selected = false
for (const option of source.selectedOptions) clone.options[option.index].selected = true
}
for (const clonedPasswordInput of clonedElement.querySelectorAll('input[type="password"]')) {
clonedPasswordInput.value = ''
}
return new PageSnapshot(this.documentElement, clonedElement, this.headSnapshot)
}
get lang() {
return this.documentElement.getAttribute('lang')
}
get headElement() {
return this.headSnapshot.element
}
get rootLocation() {
const root = this.getSetting('root') ?? '/'
return expandURL(root)
}
get cacheControlValue() {
return this.getSetting('cache-control')
}
get isPreviewable() {
return this.cacheControlValue != 'no-preview'
}
get isCacheable() {
return this.cacheControlValue != 'no-cache'
}
get isVisitable() {
return this.getSetting('visit-control') != 'reload'
}
get prefersViewTransitions() {
return this.headSnapshot.getMetaValue('view-transition') === 'same-origin'
}
get shouldMorphPage() {
return this.getSetting('refresh-method') === 'morph'
}
get shouldPreserveScrollPosition() {
return this.getSetting('refresh-scroll') === 'preserve'
}
// Private
getSetting(name) {
return this.headSnapshot.getMetaValue(`turbo-${name}`)
}
}
class ViewTransitioner {
#viewTransitionStarted = false
#lastOperation = Promise.resolve()
renderChange(useViewTransition, render) {
if (useViewTransition && this.viewTransitionsAvailable && !this.#viewTransitionStarted) {
this.#viewTransitionStarted = true
this.#lastOperation = this.#lastOperation.then(async () => {
await document.startViewTransition(render).finished
})
} else {
this.#lastOperation = this.#lastOperation.then(render)
}
return this.#lastOperation
}
get viewTransitionsAvailable() {
return document.startViewTransition
}
}
const defaultOptions = {
action: 'advance',
historyChanged: false,
visitCachedSnapshot: () => {},
willRender: true,
updateHistory: true,
shouldCacheSnapshot: true,
acceptsStreamResponse: false
}
const TimingMetric = {
visitStart: 'visitStart',
requestStart: 'requestStart',
requestEnd: 'requestEnd',
visitEnd: 'visitEnd'
}
const VisitState = {
initialized: 'initialized',
started: 'started',
canceled: 'canceled',
failed: 'failed',
completed: 'completed'
}
const SystemStatusCode = {
networkFailure: 0,
timeoutFailure: -1,
contentTypeMismatch: -2
}
const Direction = {
advance: 'forward',
restore: 'back',
replace: 'none'
}
class Visit {
identifier = uuid() // Required by turbo-ios
timingMetrics = {}
followedRedirect = false
historyChanged = false
scrolled = false
shouldCacheSnapshot = true
acceptsStreamResponse = false
snapshotCached = false
state = VisitState.initialized
viewTransitioner = new ViewTransitioner()
constructor(delegate, location, restorationIdentifier, options = {}) {
this.delegate = delegate
this.location = location
this.restorationIdentifier = restorationIdentifier || uuid()
const {
action,
historyChanged,
referrer,
snapshot,
snapshotHTML,
response,
visitCachedSnapshot,
willRender,
updateHistory,
shouldCacheSnapshot,
acceptsStreamResponse,
direction
} = {
...defaultOptions,
...options
}
this.action = action
this.historyChanged = historyChanged
this.referrer = referrer
this.snapshot = snapshot
this.snapshotHTML = snapshotHTML
this.response = response
this.isSamePage = this.delegate.locationWithActionIsSamePage(this.location, this.action)
this.isPageRefresh = this.view.isPageRefresh(this)
this.visitCachedSnapshot = visitCachedSnapshot
this.willRender = willRender
this.updateHistory = updateHistory
this.scrolled = !willRender
this.shouldCacheSnapshot = shouldCacheSnapshot
this.acceptsStreamResponse = acceptsStreamResponse
this.direction = direction || Direction[action]
}
get adapter() {
return this.delegate.adapter
}
get view() {
return this.delegate.view
}
get history() {
return this.delegate.history
}
get restorationData() {
return this.history.getRestorationDataForIdentifier(this.restorationIdentifier)
}
get silent() {
return this.isSamePage
}
start() {
if (this.state == VisitState.initialized) {
this.recordTimingMetric(TimingMetric.visitStart)
this.state = VisitState.started
this.adapter.visitStarted(this)
this.delegate.visitStarted(this)
}
}
cancel() {
if (this.state == VisitState.started) {
if (this.request) {
this.request.cancel()
}
this.cancelRender()
this.state = VisitState.canceled
}
}
complete() {
if (this.state == VisitState.started) {
this.recordTimingMetric(TimingMetric.visitEnd)
this.adapter.visitCompleted(this)
this.state = VisitState.completed
this.followRedirect()
if (!this.followedRedirect) {
this.delegate.visitCompleted(this)
}
}
}
fail() {
if (this.state == VisitState.started) {
this.state = VisitState.failed
this.adapter.visitFailed(this)
this.delegate.visitCompleted(this)
}
}
changeHistory() {
if (!this.historyChanged && this.updateHistory) {
const actionForHistory = this.location.href === this.referrer?.href ? 'replace' : this.action
const method = getHistoryMethodForAction(actionForHistory)
this.history.update(method, this.location, this.restorationIdentifier)
this.historyChanged = true
}
}
issueRequest() {
if (this.hasPreloadedResponse()) {
this.simulateRequest()
} else if (this.shouldIssueRequest() && !this.request) {
this.request = new FetchRequest(this, FetchMethod.get, this.location)
this.request.perform()
}
}
simulateRequest() {
if (this.response) {
this.startRequest()
this.recordResponse()
this.finishRequest()
}
}
startRequest() {
this.recordTimingMetric(TimingMetric.requestStart)
this.adapter.visitRequestStarted(this)
}
recordResponse(response = this.response) {
this.response = response
if (response) {
const { statusCode } = response
if (isSuccessful(statusCode)) {
this.adapter.visitRequestCompleted(this)
} else {
this.adapter.visitRequestFailedWithStatusCode(this, statusCode)
}
}
}
finishRequest() {
this.recordTimingMetric(TimingMetric.requestEnd)
this.adapter.visitRequestFinished(this)
}
loadResponse() {
if (this.response) {
const { statusCode, responseHTML } = this.response
this.render(async () => {
if (this.shouldCacheSnapshot) this.cacheSnapshot()
if (this.view.renderPromise) await this.view.renderPromise
if (isSuccessful(statusCode) && responseHTML != null) {
const snapshot = PageSnapshot.fromHTMLString(responseHTML)
await this.renderPageSnapshot(snapshot, false)
this.adapter.visitRendered(this)
this.complete()
} else {
await this.view.renderError(PageSnapshot.fromHTMLString(responseHTML), this)
this.adapter.visitRendered(this)
this.fail()
}
})
}
}
getCachedSnapshot() {
const snapshot = this.view.getCachedSnapshotForLocation(this.location) || this.getPreloadedSnapshot()
if (snapshot && (!getAnchor(this.location) || snapshot.hasAnchor(getAnchor(this.location)))) {
if (this.action == 'restore' || snapshot.isPreviewable) {
return snapshot
}
}
}
getPreloadedSnapshot() {
if (this.snapshotHTML) {
return PageSnapshot.fromHTMLString(this.snapshotHTML)
}
}
hasCachedSnapshot() {
return this.getCachedSnapshot() != null
}
loadCachedSnapshot() {
const snapshot = this.getCachedSnapshot()
if (snapshot) {
const isPreview = this.shouldIssueRequest()
this.render(async () => {
this.cacheSnapshot()
if (this.isSamePage || this.isPageRefresh) {
this.adapter.visitRendered(this)
} else {
if (this.view.renderPromise) await this.view.renderPromise
await this.renderPageSnapshot(snapshot, isPreview)
this.adapter.visitRendered(this)
if (!isPreview) {
this.complete()
}
}
})
}
}
followRedirect() {
if (this.redirectedToLocation && !this.followedRedirect && this.response?.redirected) {
this.adapter.visitProposedToLocation(this.redirectedToLocation, {
action: 'replace',
response: this.response,
shouldCacheSnapshot: false,
willRender: false
})
this.followedRedirect = true
}
}
goToSamePageAnchor() {
if (this.isSamePage) {
this.render(async () => {
this.cacheSnapshot()
this.performScroll()
this.changeHistory()
this.adapter.visitRendered(this)
})
}
}
// Fetch request delegate
prepareRequest(request) {
if (this.acceptsStreamResponse) {
request.acceptResponseType(StreamMessage.contentType)
}
}
requestStarted() {
this.startRequest()
}
requestPreventedHandlingResponse(_request, _response) {}
async requestSucceededWithResponse(request, response) {
const responseHTML = await response.responseHTML
const { redirected, statusCode } = response
if (responseHTML == undefined) {
this.recordResponse({
statusCode: SystemStatusCode.contentTypeMismatch,
redirected
})
} else {
this.redirectedToLocation = response.redirected ? response.location : undefined
this.recordResponse({ statusCode: statusCode, responseHTML, redirected })
}
}
async requestFailedWithResponse(request, response) {
const responseHTML = await response.responseHTML
const { redirected, statusCode } = response
if (responseHTML == undefined) {
this.recordResponse({
statusCode: SystemStatusCode.contentTypeMismatch,
redirected
})
} else {
this.recordResponse({ statusCode: statusCode, responseHTML, redirected })
}
}
requestErrored(_request, _error) {
this.recordResponse({
statusCode: SystemStatusCode.networkFailure,
redirected: false
})
}
requestFinished() {
this.finishRequest()
}
// Scrolling
performScroll() {
if (!this.scrolled && !this.view.forceReloaded && !this.view.shouldPreserveScrollPosition(this)) {
if (this.action == 'restore') {
this.scrollToRestoredPosition() || this.scrollToAnchor() || this.view.scrollToTop()
} else {
this.scrollToAnchor() || this.view.scrollToTop()
}
if (this.isSamePage) {
this.delegate.visitScrolledToSamePageLocation(this.view.lastRenderedLocation, this.location)
}
this.scrolled = true
}
}
scrollToRestoredPosition() {
const { scrollPosition } = this.restorationData
if (scrollPosition) {
this.view.scrollToPosition(scrollPosition)
return true
}
}
scrollToAnchor() {
const anchor = getAnchor(this.location)
if (anchor != null) {
this.view.scrollToAnchor(anchor)
return true
}
}
// Instrumentation
recordTimingMetric(metric) {
this.timingMetrics[metric] = new Date().getTime()
}
getTimingMetrics() {
return { ...this.timingMetrics }
}
// Private
getHistoryMethodForAction(action) {
switch (action) {
case 'replace':
return history.replaceState
case 'advance':
case 'restore':
return history.pushState
}
}
hasPreloadedResponse() {
return typeof this.response == 'object'
}
shouldIssueRequest() {
if (this.isSamePage) {
return false
} else if (this.action == 'restore') {
return !this.hasCachedSnapshot()
} else {
return this.willRender
}
}
cacheSnapshot() {
if (!this.snapshotCached) {
this.view.cacheSnapshot(this.snapshot).then(snapshot => snapshot && this.visitCachedSnapshot(snapshot))
this.snapshotCached = true
}
}
async render(callback) {
this.cancelRender()
this.frame = await nextRepaint()
await callback()
delete this.frame
}
async renderPageSnapshot(snapshot, isPreview) {
await this.viewTransitioner.renderChange(this.view.shouldTransitionTo(snapshot), async () => {
await this.view.renderPage(snapshot, isPreview, this.willRender, this)
this.performScroll()
})
}
cancelRender() {
if (this.frame) {
cancelAnimationFrame(this.frame)
delete this.frame
}
}
}
function isSuccessful(statusCode) {
return statusCode >= 200 && statusCode < 300
}
class BrowserAdapter {
progressBar = new ProgressBar()
constructor(session) {
this.session = session
}
visitProposedToLocation(location, options) {
if (locationIsVisitable(location, this.navigator.rootLocation)) {
this.navigator.startVisit(location, options?.restorationIdentifier || uuid(), options)
} else {
window.location.href = location.toString()
}
}
visitStarted(visit) {
this.location = visit.location
visit.loadCachedSnapshot()
visit.issueRequest()
visit.goToSamePageAnchor()
}
visitRequestStarted(visit) {
this.progressBar.setValue(0)
if (visit.hasCachedSnapshot() || visit.action != 'restore') {
this.showVisitProgressBarAfterDelay()
} else {
this.showProgressBar()
}
}
visitRequestCompleted(visit) {
visit.loadResponse()
}
visitRequestFailedWithStatusCode(visit, statusCode) {
switch (statusCode) {
case SystemStatusCode.networkFailure:
case SystemStatusCode.timeoutFailure:
case SystemStatusCode.contentTypeMismatch:
return this.reload({
reason: 'request_failed',
context: {
statusCode
}
})
default:
return visit.loadResponse()
}
}
visitRequestFinished(_visit) {}
visitCompleted(_visit) {
this.progressBar.setValue(1)
this.hideVisitProgressBar()
}
pageInvalidated(reason) {
this.reload(reason)
}
visitFailed(_visit) {
this.progressBar.setValue(1)
this.hideVisitProgressBar()
}
visitRendered(_visit) {}
// Form Submission Delegate
formSubmissionStarted(_formSubmission) {
this.progressBar.setValue(0)
this.showFormProgressBarAfterDelay()
}
formSubmissionFinished(_formSubmission) {
this.progressBar.setValue(1)
this.hideFormProgressBar()
}
// Private
showVisitProgressBarAfterDelay() {
this.visitProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay)
}
hideVisitProgressBar() {
this.progressBar.hide()
if (this.visitProgressBarTimeout != null) {
window.clearTimeout(this.visitProgressBarTimeout)
delete this.visitProgressBarTimeout
}
}
showFormProgressBarAfterDelay() {
if (this.formProgressBarTimeout == null) {
this.formProgressBarTimeout = window.setTimeout(this.showProgressBar, this.session.progressBarDelay)
}
}
hideFormProgressBar() {
this.progressBar.hide()
if (this.formProgressBarTimeout != null) {
window.clearTimeout(this.formProgressBarTimeout)
delete this.formProgressBarTimeout
}
}
showProgressBar = () => {
this.progressBar.show()
}
reload(reason) {
dispatch('turbo:reload', { detail: reason })
window.location.href = this.location?.toString() || window.location.href
}
get navigator() {
return this.session.navigator
}
}
class CacheObserver {
selector = '[data-turbo-temporary]'
deprecatedSelector = '[data-turbo-cache=false]'
started = false
start() {
if (!this.started) {
this.started = true
addEventListener('turbo:before-cache', this.removeTemporaryElements, false)
}
}
stop() {
if (this.started) {
this.started = false
removeEventListener('turbo:before-cache', this.removeTemporaryElements, false)
}
}
removeTemporaryElements = _event => {
for (const element of this.temporaryElements) {
element.remove()
}
}
get temporaryElements() {
return [...document.querySelectorAll(this.selector), ...this.temporaryElementsWithDeprecation]
}
get temporaryElementsWithDeprecation() {
const elements = document.querySelectorAll(this.deprecatedSelector)
if (elements.length) {
console.warn(
`The ${this.deprecatedSelector} selector is deprecated and will be removed in a future version. Use ${this.selector} instead.`
)
}
return [...elements]
}
}
class FrameRedirector {
constructor(session, element) {
this.session = session
this.element = element
this.linkInterceptor = new LinkInterceptor(this, element)
this.formSubmitObserver = new FormSubmitObserver(this, element)
}
start() {
this.linkInterceptor.start()
this.formSubmitObserver.start()
}
stop() {
this.linkInterceptor.stop()
this.formSubmitObserver.stop()
}
// Link interceptor delegate
shouldInterceptLinkClick(element, _location, _event) {
return this.#shouldRedirect(element)
}
linkClickIntercepted(element, url, event) {
const frame = this.#findFrameElement(element)
if (frame) {
frame.delegate.linkClickIntercepted(element, url, event)
}
}
// Form submit observer delegate
willSubmitForm(element, submitter) {
return (
element.closest('turbo-frame') == null &&
this.#shouldSubmit(element, submitter) &&
this.#shouldRedirect(element, submitter)
)
}
formSubmitted(element, submitter) {
const frame = this.#findFrameElement(element, submitter)
if (frame) {
frame.delegate.formSubmitted(element, submitter)
}
}
#shouldSubmit(form, submitter) {
const action = getAction$1(form, submitter)
const meta = this.element.ownerDocument.querySelector(`meta[name="turbo-root"]`)
const rootLocation = expandURL(meta?.content ?? '/')
return this.#shouldRedirect(form, submitter) && locationIsVisitable(action, rootLocation)
}
#shouldRedirect(element, submitter) {
const isNavigatable =
element instanceof HTMLFormElement
? this.session.submissionIsNavigatable(element, submitter)
: this.session.elementIsNavigatable(element)
if (isNavigatable) {
const frame = this.#findFrameElement(element, submitter)
return frame ? frame != element.closest('turbo-frame') : false
} else {
return false
}
}
#findFrameElement(element, submitter) {
const id = submitter?.getAttribute('data-turbo-frame') || element.getAttribute('data-turbo-frame')
if (id && id != '_top') {
const frame = this.element.querySelector(`#${id}:not([disabled])`)
if (frame instanceof FrameElement) {
return frame
}
}
}
}
class History {
location
restorationIdentifier = uuid()
restorationData = {}
started = false
pageLoaded = false
currentIndex = 0
constructor(delegate) {
this.delegate = delegate
}
start() {
if (!this.started) {
addEventListener('popstate', this.onPopState, false)
addEventListener('load', this.onPageLoad, false)
this.currentIndex = history.state?.turbo?.restorationIndex || 0
this.started = true
this.replace(new URL(window.location.href))
}
}
stop() {
if (this.started) {
removeEventListener('popstate', this.onPopState, false)
removeEventListener('load', this.onPageLoad, false)
this.started = false
}
}
push(location, restorationIdentifier) {
this.update(history.pushState, location, restorationIdentifier)
}
replace(location, restorationIdentifier) {
this.update(history.replaceState, location, restorationIdentifier)
}
update(method, location, restorationIdentifier = uuid()) {
if (method === history.pushState) ++this.currentIndex
const state = { turbo: { restorationIdentifier, restorationIndex: this.currentIndex } }
method.call(history, state, '', location.href)
this.location = location
this.restorationIdentifier = restorationIdentifier
}
// Restoration data
getRestorationDataForIdentifier(restorationIdentifier) {
return this.restorationData[restorationIdentifier] || {}
}
updateRestorationData(additionalData) {
const { restorationIdentifier } = this
const restorationData = this.restorationData[restorationIdentifier]
this.restorationData[restorationIdentifier] = {
...restorationData,
...additionalData
}
}
// Scroll restoration
assumeControlOfScrollRestoration() {
if (!this.previousScrollRestoration) {
this.previousScrollRestoration = history.scrollRestoration ?? 'auto'
history.scrollRestoration = 'manual'
}
}
relinquishControlOfScrollRestoration() {
if (this.previousScrollRestoration) {
history.scrollRestoration = this.previousScrollRestoration
delete this.previousScrollRestoration
}
}
// Event handlers
onPopState = event => {
if (this.shouldHandlePopState()) {
const { turbo } = event.state || {}
if (turbo) {
this.location = new URL(window.location.href)
const { restorationIdentifier, restorationIndex } = turbo
this.restorationIdentifier = restorationIdentifier
const direction = restorationIndex > this.currentIndex ? 'forward' : 'back'
this.delegate.historyPoppedToLocationWithRestorationIdentifierAndDirection(
this.location,
restorationIdentifier,
direction
)
this.currentIndex = restorationIndex
}
}
}
onPageLoad = async _event => {
await nextMicrotask()
this.pageLoaded = true
}
// Private
shouldHandlePopState() {
// Safari dispatches a popstate event after window's load event, ignore it
return this.pageIsLoaded()
}
pageIsLoaded() {
return this.pageLoaded || document.readyState == 'complete'
}
}
class LinkPrefetchObserver {
started = false
#prefetchedLink = null
constructor(delegate, eventTarget) {
this.delegate = delegate
this.eventTarget = eventTarget
}
start() {
if (this.started) return
if (this.eventTarget.readyState === 'loading') {
this.eventTarget.addEventListener('DOMContentLoaded', this.#enable, { once: true })
} else {
this.#enable()
}
}
stop() {
if (!this.started) return
this.eventTarget.removeEventListener('mouseenter', this.#tryToPrefetchRequest, {
capture: true,
passive: true
})
this.eventTarget.removeEventListener('mouseleave', this.#cancelRequestIfObsolete, {
capture: true,
passive: true
})
this.eventTarget.removeEventListener('turbo:before-fetch-request', this.#tryToUsePrefetchedRequest, true)
this.started = false
}
#enable = () => {
this.eventTarget.addEventListener('mouseenter', this.#tryToPrefetchRequest, {
capture: true,
passive: true
})
this.eventTarget.addEventListener('mouseleave', this.#cancelRequestIfObsolete, {
capture: true,
passive: true
})
this.eventTarget.addEventListener('turbo:before-fetch-request', this.#tryToUsePrefetchedRequest, true)
this.started = true
}
#tryToPrefetchRequest = event => {
if (getMetaContent('turbo-prefetch') === 'false') return
const target = event.target
const isLink = target.matches && target.matches('a[href]:not([target^=_]):not([download])')
if (isLink && this.#isPrefetchable(target)) {
const link = target
const location = getLocationForLink(link)
if (this.delegate.canPrefetchRequestToLocation(link, location)) {
this.#prefetchedLink = link
const fetchRequest = new FetchRequest(this, FetchMethod.get, location, new URLSearchParams(), target)
prefetchCache.setLater(location.toString(), fetchRequest, this.#cacheTtl)
}
}
}
#cancelRequestIfObsolete = event => {
if (event.target === this.#prefetchedLink) this.#cancelPrefetchRequest()
}
#cancelPrefetchRequest = () => {
prefetchCache.clear()
this.#prefetchedLink = null
}
#tryToUsePrefetchedRequest = event => {
if (event.target.tagName !== 'FORM' && event.detail.fetchOptions.method === 'get') {
const cached = prefetchCache.get(event.detail.url.toString())
if (cached) {
// User clicked link, use cache response
event.detail.fetchRequest = cached
}
prefetchCache.clear()
}
}
prepareRequest(request) {
const link = request.target
request.headers['X-Sec-Purpose'] = 'prefetch'
const turboFrame = link.closest('turbo-frame')
const turboFrameTarget =
link.getAttribute('data-turbo-frame') || turboFrame?.getAttribute('target') || turboFrame?.id
if (turboFrameTarget && turboFrameTarget !== '_top') {
request.headers['Turbo-Frame'] = turboFrameTarget
}
}
// Fetch request interface
requestSucceededWithResponse() {}
requestStarted(fetchRequest) {}
requestErrored(fetchRequest) {}
requestFinished(fetchRequest) {}
requestPreventedHandlingResponse(fetchRequest, fetchResponse) {}
requestFailedWithResponse(fetchRequest, fetchResponse) {}
get #cacheTtl() {
return Number(getMetaContent('turbo-prefetch-cache-time')) || cacheTtl
}
#isPrefetchable(link) {
const href = link.getAttribute('href')
if (!href) return false
if (unfetchableLink(link)) return false
if (linkToTheSamePage(link)) return false
if (linkOptsOut(link)) return false
if (nonSafeLink(link)) return false
if (eventPrevented(link)) return false
return true
}
}
const unfetchableLink = link => {
return (
link.origin !== document.location.origin ||
!['http:', 'https:'].includes(link.protocol) ||
link.hasAttribute('target')
)
}
const linkToTheSamePage = link => {
return (
link.pathname + link.search === document.location.pathname + document.location.search ||
link.href.startsWith('#')
)
}
const linkOptsOut = link => {
if (link.getAttribute('data-turbo-prefetch') === 'false') return true
if (link.getAttribute('data-turbo') === 'false') return true
const turboPrefetchParent = findClosestRecursively(link, '[data-turbo-prefetch]')
if (turboPrefetchParent && turboPrefetchParent.getAttribute('data-turbo-prefetch') === 'false') return true
return false
}
const nonSafeLink = link => {
const turboMethod = link.getAttribute('data-turbo-method')
if (turboMethod && turboMethod.toLowerCase() !== 'get') return true
if (isUJS(link)) return true
if (link.hasAttribute('data-turbo-confirm')) return true
if (link.hasAttribute('data-turbo-stream')) return true
return false
}
const isUJS = link => {
return (
link.hasAttribute('data-remote') ||
link.hasAttribute('data-behavior') ||
link.hasAttribute('data-confirm') ||
link.hasAttribute('data-method')
)
}
const eventPrevented = link => {
const event = dispatch('turbo:before-prefetch', { target: link, cancelable: true })
return event.defaultPrevented
}
class Navigator {
constructor(delegate) {
this.delegate = delegate
}
proposeVisit(location, options = {}) {
if (this.delegate.allowsVisitingLocationWithAction(location, options.action)) {
this.delegate.visitProposedToLocation(location, options)
}
}
startVisit(locatable, restorationIdentifier, options = {}) {
this.stop()
this.currentVisit = new Visit(this, expandURL(locatable), restorationIdentifier, {
referrer: this.location,
...options
})
this.currentVisit.start()
}
submitForm(form, submitter) {
this.stop()
this.formSubmission = new FormSubmission(this, form, submitter, true)
this.formSubmission.start()
}
stop() {
if (this.formSubmission) {
this.formSubmission.stop()
delete this.formSubmission
}
if (this.currentVisit) {
this.currentVisit.cancel()
delete this.currentVisit
}
}
get adapter() {
return this.delegate.adapter
}
get view() {
return this.delegate.view
}
get rootLocation() {
return this.view.snapshot.rootLocation
}
get history() {
return this.delegate.history
}
// Form submission delegate
formSubmissionStarted(formSubmission) {
// Not all adapters implement formSubmissionStarted
if (typeof this.adapter.formSubmissionStarted === 'function') {
this.adapter.formSubmissionStarted(formSubmission)
}
}
async formSubmissionSucceededWithResponse(formSubmission, fetchResponse) {
if (formSubmission == this.formSubmission) {
const responseHTML = await fetchResponse.responseHTML
if (responseHTML) {
const shouldCacheSnapshot = formSubmission.isSafe
if (!shouldCacheSnapshot) {
this.view.clearSnapshotCache()
}
const { statusCode, redirected } = fetchResponse
const action = this.#getActionForFormSubmission(formSubmission, fetchResponse)
const visitOptions = {
action,
shouldCacheSnapshot,
response: { statusCode, responseHTML, redirected }
}
this.proposeVisit(fetchResponse.location, visitOptions)
}
}
}
async formSubmissionFailedWithResponse(formSubmission, fetchResponse) {
const responseHTML = await fetchResponse.responseHTML
if (responseHTML) {
const snapshot = PageSnapshot.fromHTMLString(responseHTML)
if (fetchResponse.serverError) {
await this.view.renderError(snapshot, this.currentVisit)
} else {
await this.view.renderPage(snapshot, false, true, this.currentVisit)
}
if (!snapshot.shouldPreserveScrollPosition) {
this.view.scrollToTop()
}
this.view.clearSnapshotCache()
}
}
formSubmissionErrored(formSubmission, error) {
console.error(error)
}
formSubmissionFinished(formSubmission) {
// Not all adapters implement formSubmissionFinished
if (typeof this.adapter.formSubmissionFinished === 'function') {
this.adapter.formSubmissionFinished(formSubmission)
}
}
// Visit delegate
visitStarted(visit) {
this.delegate.visitStarted(visit)
}
visitCompleted(visit) {
this.delegate.visitCompleted(visit)
}
locationWithActionIsSamePage(location, action) {
const anchor = getAnchor(location)
const currentAnchor = getAnchor(this.view.lastRenderedLocation)
const isRestorationToTop = action === 'restore' && typeof anchor === 'undefined'
return (
action !== 'replace' &&
getRequestURL(location) === getRequestURL(this.view.lastRenderedLocation) &&
(isRestorationToTop || (anchor != null && anchor !== currentAnchor))
)
}
visitScrolledToSamePageLocation(oldURL, newURL) {
this.delegate.visitScrolledToSamePageLocation(oldURL, newURL)
}
// Visits
get location() {
return this.history.location
}
get restorationIdentifier() {
return this.history.restorationIdentifier
}
#getActionForFormSubmission(formSubmission, fetchResponse) {
const { submitter, formElement } = formSubmission
return getVisitAction(submitter, formElement) || this.#getDefaultAction(fetchResponse)
}
#getDefaultAction(fetchResponse) {
const sameLocationRedirect = fetchResponse.redirected && fetchResponse.location.href === this.location?.href
return sameLocationRedirect ? 'replace' : 'advance'
}
}
const PageStage = {
initial: 0,
loading: 1,
interactive: 2,
complete: 3
}
class PageObserver {
stage = PageStage.initial
started = false
constructor(delegate) {
this.delegate = delegate
}
start() {
if (!this.started) {
if (this.stage == PageStage.initial) {
this.stage = PageStage.loading
}
document.addEventListener('readystatechange', this.interpretReadyState, false)
addEventListener('pagehide', this.pageWillUnload, false)
this.started = true
}
}
stop() {
if (this.started) {
document.removeEventListener('readystatechange', this.interpretReadyState, false)
removeEventListener('pagehide', this.pageWillUnload, false)
this.started = false
}
}
interpretReadyState = () => {
const { readyState } = this
if (readyState == 'interactive') {
this.pageIsInteractive()
} else if (readyState == 'complete') {
this.pageIsComplete()
}
}
pageIsInteractive() {
if (this.stage == PageStage.loading) {
this.stage = PageStage.interactive
this.delegate.pageBecameInteractive()
}
}
pageIsComplete() {
this.pageIsInteractive()
if (this.stage == PageStage.interactive) {
this.stage = PageStage.complete
this.delegate.pageLoaded()
}
}
pageWillUnload = () => {
this.delegate.pageWillUnload()
}
get readyState() {
return document.readyState
}
}
class ScrollObserver {
started = false
constructor(delegate) {
this.delegate = delegate
}
start() {
if (!this.started) {
addEventListener('scroll', this.onScroll, false)
this.onScroll()
this.started = true
}
}
stop() {
if (this.started) {
removeEventListener('scroll', this.onScroll, false)
this.started = false
}
}
onScroll = () => {
this.updatePosition({ x: window.pageXOffset, y: window.pageYOffset })
}
// Private
updatePosition(position) {
this.delegate.scrollPositionChanged(position)
}
}
class StreamMessageRenderer {
render({ fragment }) {
Bardo.preservingPermanentElements(this, getPermanentElementMapForFragment(fragment), () => {
withAutofocusFromFragment(fragment, () => {
withPreservedFocus(() => {
document.documentElement.appendChild(fragment)
})
})
})
}
// Bardo delegate
enteringBardo(currentPermanentElement, newPermanentElement) {
newPermanentElement.replaceWith(currentPermanentElement.cloneNode(true))
}
leavingBardo() {}
}
function getPermanentElementMapForFragment(fragment) {
const permanentElementsInDocument = queryPermanentElementsAll(document.documentElement)
const permanentElementMap = {}
for (const permanentElementInDocument of permanentElementsInDocument) {
const { id } = permanentElementInDocument
for (const streamElement of fragment.querySelectorAll('turbo-stream')) {
const elementInStream = getPermanentElementById(streamElement.templateElement.content, id)
if (elementInStream) {
permanentElementMap[id] = [permanentElementInDocument, elementInStream]
}
}
}
return permanentElementMap
}
async function withAutofocusFromFragment(fragment, callback) {
const generatedID = `turbo-stream-autofocus-${uuid()}`
const turboStreams = fragment.querySelectorAll('turbo-stream')
const elementWithAutofocus = firstAutofocusableElementInStreams(turboStreams)
let willAutofocusId = null
if (elementWithAutofocus) {
if (elementWithAutofocus.id) {
willAutofocusId = elementWithAutofocus.id
} else {
willAutofocusId = generatedID
}
elementWithAutofocus.id = willAutofocusId
}
callback()
await nextRepaint()
const hasNoActiveElement = document.activeElement == null || document.activeElement == document.body
if (hasNoActiveElement && willAutofocusId) {
const elementToAutofocus = document.getElementById(willAutofocusId)
if (elementIsFocusable(elementToAutofocus)) {
elementToAutofocus.focus()
}
if (elementToAutofocus && elementToAutofocus.id == generatedID) {
elementToAutofocus.removeAttribute('id')
}
}
}
async function withPreservedFocus(callback) {
const [activeElementBeforeRender, activeElementAfterRender] = await around(callback, () => document.activeElement)
const restoreFocusTo = activeElementBeforeRender && activeElementBeforeRender.id
if (restoreFocusTo) {
const elementToFocus = document.getElementById(restoreFocusTo)
if (elementIsFocusable(elementToFocus) && elementToFocus != activeElementAfterRender) {
elementToFocus.focus()
}
}
}
function firstAutofocusableElementInStreams(nodeListOfStreamElements) {
for (const streamElement of nodeListOfStreamElements) {
const elementWithAutofocus = queryAutofocusableElement(streamElement.templateElement.content)
if (elementWithAutofocus) return elementWithAutofocus
}
return null
}
class StreamObserver {
sources = new Set()
#started = false
constructor(delegate) {
this.delegate = delegate
}
start() {
if (!this.#started) {
this.#started = true
addEventListener('turbo:before-fetch-response', this.inspectFetchResponse, false)
}
}
stop() {
if (this.#started) {
this.#started = false
removeEventListener('turbo:before-fetch-response', this.inspectFetchResponse, false)
}
}
connectStreamSource(source) {
if (!this.streamSourceIsConnected(source)) {
this.sources.add(source)
source.addEventListener('message', this.receiveMessageEvent, false)
}
}
disconnectStreamSource(source) {
if (this.streamSourceIsConnected(source)) {
this.sources.delete(source)
source.removeEventListener('message', this.receiveMessageEvent, false)
}
}
streamSourceIsConnected(source) {
return this.sources.has(source)
}
inspectFetchResponse = event => {
const response = fetchResponseFromEvent(event)
if (response && fetchResponseIsStream(response)) {
event.preventDefault()
this.receiveMessageResponse(response)
}
}
receiveMessageEvent = event => {
if (this.#started && typeof event.data == 'string') {
this.receiveMessageHTML(event.data)
}
}
async receiveMessageResponse(response) {
const html = await response.responseHTML
if (html) {
this.receiveMessageHTML(html)
}
}
receiveMessageHTML(html) {
this.delegate.receivedMessageFromStream(StreamMessage.wrap(html))
}
}
function fetchResponseFromEvent(event) {
const fetchResponse = event.detail?.fetchResponse
if (fetchResponse instanceof FetchResponse) {
return fetchResponse
}
}
function fetchResponseIsStream(response) {
const contentType = response.contentType ?? ''
return contentType.startsWith(StreamMessage.contentType)
}
class ErrorRenderer extends Renderer {
static renderElement(currentElement, newElement) {
const { documentElement, body } = document
documentElement.replaceChild(newElement, body)
}
async render() {
this.replaceHeadAndBody()
this.activateScriptElements()
}
replaceHeadAndBody() {
const { documentElement, head } = document
documentElement.replaceChild(this.newHead, head)
this.renderElement(this.currentElement, this.newElement)
}
activateScriptElements() {
for (const replaceableElement of this.scriptElements) {
const parentNode = replaceableElement.parentNode
if (parentNode) {
const element = activateScriptElement(replaceableElement)
parentNode.replaceChild(element, replaceableElement)
}
}
}
get newHead() {
return this.newSnapshot.headSnapshot.element
}
get scriptElements() {
return document.documentElement.querySelectorAll('script')
}
}
// base IIFE to define idiomorph
var Idiomorph = (function () {
//=============================================================================
// AND NOW IT BEGINS...
//=============================================================================
let EMPTY_SET = new Set()
// default configuration values, updatable by users now
let defaults = {
morphStyle: 'outerHTML',
callbacks: {
beforeNodeAdded: noOp,
afterNodeAdded: noOp,
beforeNodeMorphed: noOp,
afterNodeMorphed: noOp,
beforeNodeRemoved: noOp,
afterNodeRemoved: noOp,
beforeAttributeUpdated: noOp
},
head: {
style: 'merge',
shouldPreserve: function (elt) {
return elt.getAttribute('im-preserve') === 'true'
},
shouldReAppend: function (elt) {
return elt.getAttribute('im-re-append') === 'true'
},
shouldRemove: noOp,
afterHeadMorphed: noOp
}
}
//=============================================================================
// Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren
//=============================================================================
function morph(oldNode, newContent, config = {}) {
if (oldNode instanceof Document) {
oldNode = oldNode.documentElement
}
if (typeof newContent === 'string') {
newContent = parseContent(newContent)
}
let normalizedContent = normalizeContent(newContent)
let ctx = createMorphContext(oldNode, normalizedContent, config)
return morphNormalizedContent(oldNode, normalizedContent, ctx)
}
function morphNormalizedContent(oldNode, normalizedNewContent, ctx) {
if (ctx.head.block) {
let oldHead = oldNode.querySelector('head')
let newHead = normalizedNewContent.querySelector('head')
if (oldHead && newHead) {
let promises = handleHeadElement(newHead, oldHead, ctx)
// when head promises resolve, call morph again, ignoring the head tag
Promise.all(promises).then(function () {
morphNormalizedContent(
oldNode,
normalizedNewContent,
Object.assign(ctx, {
head: {
block: false,
ignore: true
}
})
)
})
return
}
}
if (ctx.morphStyle === 'innerHTML') {
// innerHTML, so we are only updating the children
morphChildren(normalizedNewContent, oldNode, ctx)
return oldNode.children
} else if (ctx.morphStyle === 'outerHTML' || ctx.morphStyle == null) {
// otherwise find the best element match in the new content, morph that, and merge its siblings
// into either side of the best match
let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx)
// stash the siblings that will need to be inserted on either side of the best match
let previousSibling = bestMatch?.previousSibling
let nextSibling = bestMatch?.nextSibling
// morph it
let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx)
if (bestMatch) {
// if there was a best match, merge the siblings in too and return the
// whole bunch
return insertSiblings(previousSibling, morphedNode, nextSibling)
} else {
// otherwise nothing was added to the DOM
return []
}
} else {
throw 'Do not understand how to morph style ' + ctx.morphStyle
}
}
/**
* @param possibleActiveElement
* @param ctx
* @returns {boolean}
*/
function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
return (
ctx.ignoreActiveValue &&
possibleActiveElement === document.activeElement &&
possibleActiveElement !== document.body
)
}
/**
* @param oldNode root node to merge content into
* @param newContent new content to merge
* @param ctx the merge context
* @returns {Element} the element that ended up in the DOM
*/
function morphOldNodeTo(oldNode, newContent, ctx) {
if (ctx.ignoreActive && oldNode === document.activeElement);
else if (newContent == null) {
if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode
oldNode.remove()
ctx.callbacks.afterNodeRemoved(oldNode)
return null
} else if (!isSoftMatch(oldNode, newContent)) {
if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode
if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode
oldNode.parentElement.replaceChild(newContent, oldNode)
ctx.callbacks.afterNodeAdded(newContent)
ctx.callbacks.afterNodeRemoved(oldNode)
return newContent
} else {
if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode
if (oldNode instanceof HTMLHeadElement && ctx.head.ignore);
else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== 'morph') {
handleHeadElement(newContent, oldNode, ctx)
} else {
syncNodeFrom(newContent, oldNode, ctx)
if (!ignoreValueOfActiveElement(oldNode, ctx)) {
morphChildren(newContent, oldNode, ctx)
}
}
ctx.callbacks.afterNodeMorphed(oldNode, newContent)
return oldNode
}
}
/**
* This is the core algorithm for matching up children. The idea is to use id sets to try to match up
* nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
* by using id sets, we are able to better match up with content deeper in the DOM.
*
* Basic algorithm is, for each node in the new content:
*
* - if we have reached the end of the old parent, append the new content
* - if the new content has an id set match with the current insertion point, morph
* - search for an id set match
* - if id set match found, morph
* - otherwise search for a "soft" match
* - if a soft match is found, morph
* - otherwise, prepend the new node before the current insertion point
*
* The two search algorithms terminate if competing node matches appear to outweigh what can be achieved
* with the current node. See findIdSetMatch() and findSoftMatch() for details.
*
* @param {Element} newParent the parent element of the new content
* @param {Element } oldParent the old content that we are merging the new content into
* @param ctx the merge context
*/
function morphChildren(newParent, oldParent, ctx) {
let nextNewChild = newParent.firstChild
let insertionPoint = oldParent.firstChild
let newChild
// run through all the new content
while (nextNewChild) {
newChild = nextNewChild
nextNewChild = newChild.nextSibling
// if we are at the end of the exiting parent's children, just append
if (insertionPoint == null) {
if (ctx.callbacks.beforeNodeAdded(newChild) === false) return
oldParent.appendChild(newChild)
ctx.callbacks.afterNodeAdded(newChild)
removeIdsFromConsideration(ctx, newChild)
continue
}
// if the current node has an id set match then morph
if (isIdSetMatch(newChild, insertionPoint, ctx)) {
morphOldNodeTo(insertionPoint, newChild, ctx)
insertionPoint = insertionPoint.nextSibling
removeIdsFromConsideration(ctx, newChild)
continue
}
// otherwise search forward in the existing old children for an id set match
let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx)
// if we found a potential match, remove the nodes until that point and morph
if (idSetMatch) {
insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx)
morphOldNodeTo(idSetMatch, newChild, ctx)
removeIdsFromConsideration(ctx, newChild)
continue
}
// no id set match found, so scan forward for a soft match for the current node
let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx)
// if we found a soft match for the current node, morph
if (softMatch) {
insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx)
morphOldNodeTo(softMatch, newChild, ctx)
removeIdsFromConsideration(ctx, newChild)
continue
}
// abandon all hope of morphing, just insert the new child before the insertion point
// and move on
if (ctx.callbacks.beforeNodeAdded(newChild) === false) return
oldParent.insertBefore(newChild, insertionPoint)
ctx.callbacks.afterNodeAdded(newChild)
removeIdsFromConsideration(ctx, newChild)
}
// remove any remaining old nodes that didn't match up with new content
while (insertionPoint !== null) {
let tempNode = insertionPoint
insertionPoint = insertionPoint.nextSibling
removeNode(tempNode, ctx)
}
}
//=============================================================================
// Attribute Syncing Code
//=============================================================================
/**
* @param attr {String} the attribute to be mutated
* @param to {Element} the element that is going to be updated
* @param updateType {("update"|"remove")}
* @param ctx the merge context
* @returns {boolean} true if the attribute should be ignored, false otherwise
*/
function ignoreAttribute(attr, to, updateType, ctx) {
if (attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement) {
return true
}
return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false
}
/**
* syncs a given node with another node, copying over all attributes and
* inner element state from the 'from' node to the 'to' node
*
* @param {Element} from the element to copy attributes & state from
* @param {Element} to the element to copy attributes & state to
* @param ctx the merge context
*/
function syncNodeFrom(from, to, ctx) {
let type = from.nodeType
// if is an element type, sync the attributes from the
// new node into the new node
if (type === 1 /* element type */) {
const fromAttributes = from.attributes
const toAttributes = to.attributes
for (const fromAttribute of fromAttributes) {
if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) {
continue
}
if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) {
to.setAttribute(fromAttribute.name, fromAttribute.value)
}
}
// iterate backwards to avoid skipping over items when a delete occurs
for (let i = toAttributes.length - 1; 0 <= i; i--) {
const toAttribute = toAttributes[i]
if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) {
continue
}
if (!from.hasAttribute(toAttribute.name)) {
to.removeAttribute(toAttribute.name)
}
}
}
// sync text nodes
if (type === 8 /* comment */ || type === 3 /* text */) {
if (to.nodeValue !== from.nodeValue) {
to.nodeValue = from.nodeValue
}
}
if (!ignoreValueOfActiveElement(to, ctx)) {
// sync input values
syncInputValue(from, to, ctx)
}
}
/**
* @param from {Element} element to sync the value from
* @param to {Element} element to sync the value to
* @param attributeName {String} the attribute name
* @param ctx the merge context
*/
function syncBooleanAttribute(from, to, attributeName, ctx) {
if (from[attributeName] !== to[attributeName]) {
let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx)
if (!ignoreUpdate) {
to[attributeName] = from[attributeName]
}
if (from[attributeName]) {
if (!ignoreUpdate) {
to.setAttribute(attributeName, from[attributeName])
}
} else {
if (!ignoreAttribute(attributeName, to, 'remove', ctx)) {
to.removeAttribute(attributeName)
}
}
}
}
/**
* NB: many bothans died to bring us information:
*
* https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
* https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
*
* @param from {Element} the element to sync the input value from
* @param to {Element} the element to sync the input value to
* @param ctx the merge context
*/
function syncInputValue(from, to, ctx) {
if (from instanceof HTMLInputElement && to instanceof HTMLInputElement && from.type !== 'file') {
let fromValue = from.value
let toValue = to.value
// sync boolean attributes
syncBooleanAttribute(from, to, 'checked', ctx)
syncBooleanAttribute(from, to, 'disabled', ctx)
if (!from.hasAttribute('value')) {
if (!ignoreAttribute('value', to, 'remove', ctx)) {
to.value = ''
to.removeAttribute('value')
}
} else if (fromValue !== toValue) {
if (!ignoreAttribute('value', to, 'update', ctx)) {
to.setAttribute('value', fromValue)
to.value = fromValue
}
}
} else if (from instanceof HTMLOptionElement) {
syncBooleanAttribute(from, to, 'selected', ctx)
} else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) {
let fromValue = from.value
let toValue = to.value
if (ignoreAttribute('value', to, 'update', ctx)) {
return
}
if (fromValue !== toValue) {
to.value = fromValue
}
if (to.firstChild && to.firstChild.nodeValue !== fromValue) {
to.firstChild.nodeValue = fromValue
}
}
}
//=============================================================================
// the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
//=============================================================================
function handleHeadElement(newHeadTag, currentHead, ctx) {
let added = []
let removed = []
let preserved = []
let nodesToAppend = []
let headMergeStyle = ctx.head.style
// put all new head elements into a Map, by their outerHTML
let srcToNewHeadNodes = new Map()
for (const newHeadChild of newHeadTag.children) {
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild)
}
// for each elt in the current head
for (const currentHeadElt of currentHead.children) {
// If the current head element is in the map
let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML)
let isReAppended = ctx.head.shouldReAppend(currentHeadElt)
let isPreserved = ctx.head.shouldPreserve(currentHeadElt)
if (inNewContent || isPreserved) {
if (isReAppended) {
// remove the current version and let the new version replace it and re-execute
removed.push(currentHeadElt)
} else {
// this element already exists and should not be re-appended, so remove it from
// the new content map, preserving it in the DOM
srcToNewHeadNodes.delete(currentHeadElt.outerHTML)
preserved.push(currentHeadElt)
}
} else {
if (headMergeStyle === 'append') {
// we are appending and this existing element is not new content
// so if and only if it is marked for re-append do we do anything
if (isReAppended) {
removed.push(currentHeadElt)
nodesToAppend.push(currentHeadElt)
}
} else {
// if this is a merge, we remove this content since it is not in the new head
if (ctx.head.shouldRemove(currentHeadElt) !== false) {
removed.push(currentHeadElt)
}
}
}
}
// Push the remaining new head elements in the Map into the
// nodes to append to the head tag
nodesToAppend.push(...srcToNewHeadNodes.values())
let promises = []
for (const newNode of nodesToAppend) {
let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild
if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
if (newElt.href || newElt.src) {
let resolve = null
let promise = new Promise(function (_resolve) {
resolve = _resolve
})
newElt.addEventListener('load', function () {
resolve()
})
promises.push(promise)
}
currentHead.appendChild(newElt)
ctx.callbacks.afterNodeAdded(newElt)
added.push(newElt)
}
}
// remove all removed elements, after we have appended the new elements to avoid
// additional network requests for things like style sheets
for (const removedElement of removed) {
if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
currentHead.removeChild(removedElement)
ctx.callbacks.afterNodeRemoved(removedElement)
}
}
ctx.head.afterHeadMorphed(currentHead, { added: added, kept: preserved, removed: removed })
return promises
}
function noOp() {}
/*
Deep merges the config object and the Idiomoroph.defaults object to
produce a final configuration object
*/
function mergeDefaults(config) {
let finalConfig = {}
// copy top level stuff into final config
Object.assign(finalConfig, defaults)
Object.assign(finalConfig, config)
// copy callbacks into final config (do this to deep merge the callbacks)
finalConfig.callbacks = {}
Object.assign(finalConfig.callbacks, defaults.callbacks)
Object.assign(finalConfig.callbacks, config.callbacks)
// copy head config into final config (do this to deep merge the head)
finalConfig.head = {}
Object.assign(finalConfig.head, defaults.head)
Object.assign(finalConfig.head, config.head)
return finalConfig
}
function createMorphContext(oldNode, newContent, config) {
config = mergeDefaults(config)
return {
target: oldNode,
newContent: newContent,
config: config,
morphStyle: config.morphStyle,
ignoreActive: config.ignoreActive,
ignoreActiveValue: config.ignoreActiveValue,
idMap: createIdMap(oldNode, newContent),
deadIds: new Set(),
callbacks: config.callbacks,
head: config.head
}
}
function isIdSetMatch(node1, node2, ctx) {
if (node1 == null || node2 == null) {
return false
}
if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) {
if (node1.id !== '' && node1.id === node2.id) {
return true
} else {
return getIdIntersectionCount(ctx, node1, node2) > 0
}
}
return false
}
function isSoftMatch(node1, node2) {
if (node1 == null || node2 == null) {
return false
}
return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName
}
function removeNodesBetween(startInclusive, endExclusive, ctx) {
while (startInclusive !== endExclusive) {
let tempNode = startInclusive
startInclusive = startInclusive.nextSibling
removeNode(tempNode, ctx)
}
removeIdsFromConsideration(ctx, endExclusive)
return endExclusive.nextSibling
}
//=============================================================================
// Scans forward from the insertionPoint in the old parent looking for a potential id match
// for the newChild. We stop if we find a potential id match for the new child OR
// if the number of potential id matches we are discarding is greater than the
// potential id matches for the new child
//=============================================================================
function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
// max id matches we are willing to discard in our search
let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent)
let potentialMatch = null
// only search forward if there is a possibility of an id match
if (newChildPotentialIdCount > 0) {
let potentialMatch = insertionPoint
// if there is a possibility of an id match, scan forward
// keep track of the potential id match count we are discarding (the
// newChildPotentialIdCount must be greater than this to make it likely
// worth it)
let otherMatchCount = 0
while (potentialMatch != null) {
// If we have an id match, return the current potential match
if (isIdSetMatch(newChild, potentialMatch, ctx)) {
return potentialMatch
}
// computer the other potential matches of this new content
otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent)
if (otherMatchCount > newChildPotentialIdCount) {
// if we have more potential id matches in _other_ content, we
// do not have a good candidate for an id match, so return null
return null
}
// advanced to the next old content child
potentialMatch = potentialMatch.nextSibling
}
}
return potentialMatch
}
//=============================================================================
// Scans forward from the insertionPoint in the old parent looking for a potential soft match
// for the newChild. We stop if we find a potential soft match for the new child OR
// if we find a potential id match in the old parents children OR if we find two
// potential soft matches for the next two pieces of new content
//=============================================================================
function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
let potentialSoftMatch = insertionPoint
let nextSibling = newChild.nextSibling
let siblingSoftMatchCount = 0
while (potentialSoftMatch != null) {
if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) {
// the current potential soft match has a potential id set match with the remaining new
// content so bail out of looking
return null
}
// if we have a soft match with the current node, return it
if (isSoftMatch(newChild, potentialSoftMatch)) {
return potentialSoftMatch
}
if (isSoftMatch(nextSibling, potentialSoftMatch)) {
// the next new node has a soft match with this node, so
// increment the count of future soft matches
siblingSoftMatchCount++
nextSibling = nextSibling.nextSibling
// If there are two future soft matches, bail to allow the siblings to soft match
// so that we don't consume future soft matches for the sake of the current node
if (siblingSoftMatchCount >= 2) {
return null
}
}
// advanced to the next old content child
potentialSoftMatch = potentialSoftMatch.nextSibling
}
return potentialSoftMatch
}
function parseContent(newContent) {
let parser = new DOMParser()
// remove svgs to avoid false-positive matches on head, etc.
let contentWithSvgsRemoved = newContent.replace(/