summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBigCoke233 <hi@guhub.cn>2026-01-17 00:00:27 +0800
committerBigCoke233 <hi@guhub.cn>2026-01-17 00:00:27 +0800
commit0f27b0a56d069d38998934df9b0c41b8a18c68d7 (patch)
tree610ab8b90f385c2ca6b7098a21d59fb9ca96c39b
parent2a7c4f829611ead8c10004c9a6b3668a4ed1d1c8 (diff)
perf: use instantpage.js to improve page speed
Former-commit-id: e52bd5a9c99c5e769f039e794572e028619ba53e
-rw-r--r--assets/js/instantpage.js452
-rw-r--r--layouts/_partials/scripts.html9
2 files changed, 461 insertions, 0 deletions
diff --git a/assets/js/instantpage.js b/assets/js/instantpage.js
new file mode 100644
index 00000000..60f1cb17
--- /dev/null
+++ b/assets/js/instantpage.js
@@ -0,0 +1,452 @@
+/*! instant.page v5.2.0 - (C) 2019-2025 Alexandre Dieulot - https://instant.page/license */
+
+let _chromiumMajorVersionInUserAgent = null
+ , _speculationRulesType
+ , _allowQueryString
+ , _allowExternalLinks
+ , _useWhitelist
+ , _delayOnHover = 65
+ , _lastTouchstartEvent
+ , _mouseoverTimer
+ , _preloadedList = new Set()
+
+init()
+
+function init() {
+ const supportChecksRelList = document.createElement('link').relList
+
+ const supportsPrefetch = supportChecksRelList.supports('prefetch')
+ if (!supportsPrefetch) {
+ return
+ }
+
+ const chromium100Check = 'throwIfAborted' in AbortSignal.prototype // Chromium 100+, Safari 15.4+, Firefox 97+
+ const firefox115AndSafari17_0Check = supportChecksRelList.supports('modulepreload') // Firefox 115+, Safari 17.0+, Chromium 66+
+ const safari15_4AndFirefox116Check = Intl.PluralRules && 'selectRange' in Intl.PluralRules.prototype // Safari 15.4+, Firefox 116+, Chromium 106+
+ const firefox115AndSafari15_4Check = firefox115AndSafari17_0Check || safari15_4AndFirefox116Check
+ const isBrowserSupported = chromium100Check && firefox115AndSafari15_4Check
+ if (!isBrowserSupported) {
+ return
+ }
+ // In order to lessen maintenance and unnoticed bugs we only support:
+ // - Chromium ⩾ 100 — UC Browser 14
+ // - Gecko as in Firefox ⩾ 115 — last version supported on Windows 7
+ // - WebKit as in Safari ⩾ 15.4 — last major WebKit version supported on iPhone 6s & 7
+ //
+ // WebKit doesn’t support prefetch anyway, but instant.page might
+ // eventually drop this requirement by providing an option for
+ // fetch()-based preloading.
+ //
+ // Additionally, instant.page should not cause JavaScript errors in:
+ // - Chromium ⩾ 61
+ // - Gecko as in Firefox ⩾ 60
+ // - WebKit as in Safari ⩾ 10.1 (iOS ⩾ 10.3 and macOS ⩾ 10.10)
+ // Browser engines older than that don’t support <script type=module>
+ // and thus don’t load instant.page at all.
+
+ const handleVaryAcceptHeader = 'instantVaryAccept' in document.body.dataset || 'Shopify' in window
+ // The `Vary: Accept` header when received in Chromium 79–109 makes prefetches
+ // unusable, as Chromium used to send a different `Accept` header.
+ // It’s applied on all Shopify sites by default, as Shopify is very popular
+ // and is the main source of this problem.
+ // `window.Shopify` only exists on “classic” Shopify sites. Those using
+ // Hydrogen (Remix SPA) aren’t concerned.
+
+ const chromiumUserAgentIndex = navigator.userAgent.indexOf('Chrome/')
+ if (chromiumUserAgentIndex > -1) {
+ _chromiumMajorVersionInUserAgent = parseInt(navigator.userAgent.substring(chromiumUserAgentIndex + 'Chrome/'.length))
+ }
+ // The user agent client hints API is a theoretically more reliable way to
+ // get Chromium’s version… but it’s not available in Samsung Internet 20.
+ // It also requires a secure context, which would make debugging harder,
+ // and is only available in recent Chromium versions.
+ // In practice, Chromium browsers never shy from announcing "Chrome" in
+ // their regular user agent string, as that maximizes their compatibility.
+
+ if (handleVaryAcceptHeader && _chromiumMajorVersionInUserAgent && _chromiumMajorVersionInUserAgent < 110) {
+ return
+ }
+
+ _speculationRulesType = 'none'
+ if (HTMLScriptElement.supports && HTMLScriptElement.supports('speculationrules')) {
+ const speculationRulesConfig = document.body.dataset.instantSpecrules
+ if (speculationRulesConfig == 'prerender') {
+ _speculationRulesType = 'prerender'
+ } else if (speculationRulesConfig != 'no') {
+ _speculationRulesType = 'prefetch'
+ }
+ }
+
+ const useMousedownShortcut = 'instantMousedownShortcut' in document.body.dataset
+ _allowQueryString = 'instantAllowQueryString' in document.body.dataset
+ _allowExternalLinks = 'instantAllowExternalLinks' in document.body.dataset
+ _useWhitelist = 'instantWhitelist' in document.body.dataset
+
+ let preloadOnMousedown = false
+ let preloadOnlyOnMousedown = false
+ let preloadWhenVisible = false
+ if ('instantIntensity' in document.body.dataset) {
+ const intensityParameter = document.body.dataset.instantIntensity
+
+ if (intensityParameter == 'mousedown' && !useMousedownShortcut) {
+ preloadOnMousedown = true
+ }
+
+ if (intensityParameter == 'mousedown-only' && !useMousedownShortcut) {
+ preloadOnMousedown = true
+ preloadOnlyOnMousedown = true
+ }
+
+ if (intensityParameter == 'viewport') {
+ const isOnSmallScreen = document.documentElement.clientWidth * document.documentElement.clientHeight < 450000
+ // Smartphones are the most likely to have a slow connection, and
+ // their small screen size limits the number of links (and thus
+ // server load).
+ //
+ // Foldable phones (being expensive as of 2023), tablets and PCs
+ // generally have a decent connection, and a big screen displaying
+ // more links that would put more load on the server.
+ //
+ // iPhone 14 Pro Max (want): 430×932 = 400 760
+ // Samsung Galaxy S22 Ultra with display size set to 80% (want):
+ // 450×965 = 434 250
+ // Small tablet (don’t want): 600×960 = 576 000
+ // Those number are virtual screen size, the viewport (used for
+ // the check above) will be smaller with the browser’s interface.
+
+ const isNavigatorConnectionSaveDataEnabled = navigator.connection && navigator.connection.saveData
+ const isNavigatorConnectionLike2g = navigator.connection && navigator.connection.effectiveType && navigator.connection.effectiveType.includes('2g')
+ const isNavigatorConnectionAdequate = !isNavigatorConnectionSaveDataEnabled && !isNavigatorConnectionLike2g
+
+ if (isOnSmallScreen && isNavigatorConnectionAdequate) {
+ preloadWhenVisible = true
+ }
+ }
+
+ if (intensityParameter == 'viewport-all') {
+ preloadWhenVisible = true
+ }
+
+ const intensityAsInteger = parseInt(intensityParameter)
+ if (!isNaN(intensityAsInteger)) {
+ _delayOnHover = intensityAsInteger
+ }
+ }
+
+ const eventListenersOptions = {
+ capture: true,
+ passive: true,
+ }
+
+ if (preloadOnlyOnMousedown) {
+ document.addEventListener('touchstart', touchstartEmptyListener, eventListenersOptions)
+ }
+ else {
+ document.addEventListener('touchstart', touchstartListener, eventListenersOptions)
+ }
+
+ if (!preloadOnMousedown) {
+ document.addEventListener('mouseover', mouseoverListener, eventListenersOptions)
+ }
+
+ if (preloadOnMousedown) {
+ document.addEventListener('mousedown', mousedownListener, eventListenersOptions)
+ }
+ if (useMousedownShortcut) {
+ document.addEventListener('mousedown', mousedownShortcutListener, eventListenersOptions)
+ }
+
+ if (preloadWhenVisible) {
+ let requestIdleCallbackOrFallback = window.requestIdleCallback
+ // Safari has no support as of 16.3: https://webkit.org/b/164193
+ if (!requestIdleCallbackOrFallback) {
+ requestIdleCallbackOrFallback = (callback) => {
+ callback()
+ // A smarter fallback like setTimeout is not used because devices that
+ // may eventually be eligible to a Safari version supporting prefetch
+ // will be very powerful.
+ // The weakest devices that could be eligible are the 2017 iPad and
+ // the 2016 MacBook.
+ }
+ }
+
+ requestIdleCallbackOrFallback(function observeIntersection() {
+ const intersectionObserver = new IntersectionObserver((entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ const anchorElement = entry.target
+ intersectionObserver.unobserve(anchorElement)
+ preload(anchorElement.href)
+ }
+ })
+ })
+
+ document.querySelectorAll('a').forEach((anchorElement) => {
+ if (isPreloadable(anchorElement)) {
+ intersectionObserver.observe(anchorElement)
+ }
+ })
+ }, {
+ timeout: 1500,
+ })
+ }
+}
+
+function touchstartListener(event) {
+ _lastTouchstartEvent = event
+
+ const anchorElement = event.target.closest('a')
+
+ if (!isPreloadable(anchorElement)) {
+ return
+ }
+
+ preload(anchorElement.href, 'high')
+}
+
+function touchstartEmptyListener(event) {
+ _lastTouchstartEvent = event
+}
+
+function mouseoverListener(event) {
+ if (isEventLikelyTriggeredByTouch(event)) {
+ // This avoids uselessly adding a mouseout event listener and setting a timer.
+ return
+ }
+
+ if (!('closest' in event.target)) {
+ return
+ // Without this check sometimes an error “event.target.closest is not a function” is thrown, for unknown reasons
+ // That error denotes that `event.target` isn’t undefined. My best guess is that it’s the Document.
+ //
+ // Details could be gleaned from throwing such an error:
+ //throw new TypeError(`instant.page non-element event target: timeStamp=${~~event.timeStamp}, type=${event.type}, typeof=${typeof event.target}, nodeType=${event.target.nodeType}, nodeName=${event.target.nodeName}, viewport=${innerWidth}x${innerHeight}, coords=${event.clientX}x${event.clientY}, scroll=${scrollX}x${scrollY}`)
+ }
+ const anchorElement = event.target.closest('a')
+
+ if (!isPreloadable(anchorElement)) {
+ return
+ }
+
+ anchorElement.addEventListener('mouseout', mouseoutListener, {passive: true})
+
+ _mouseoverTimer = setTimeout(() => {
+ preload(anchorElement.href, 'high')
+ _mouseoverTimer = null
+ }, _delayOnHover)
+}
+
+function mousedownListener(event) {
+ if (isEventLikelyTriggeredByTouch(event)) {
+ // When preloading only on mousedown, not touch, we need to stop there
+ // because touches send compatibility mouse events including mousedown.
+ //
+ // (When preloading on touchstart, instructions below this block would
+ // have no effect.)
+ return
+ }
+
+ const anchorElement = event.target.closest('a')
+
+ if (!isPreloadable(anchorElement)) {
+ return
+ }
+
+ preload(anchorElement.href, 'high')
+}
+
+function mouseoutListener(event) {
+ if (event.relatedTarget && event.target.closest('a') == event.relatedTarget.closest('a')) {
+ return
+ }
+
+ if (_mouseoverTimer) {
+ clearTimeout(_mouseoverTimer)
+ _mouseoverTimer = null
+ }
+}
+
+function mousedownShortcutListener(event) {
+ if (isEventLikelyTriggeredByTouch(event)) {
+ // Due to a high potential for complications with this mousedown shortcut
+ // combined with other parties’ JavaScript code, we don’t want it to run
+ // at all on touch devices, even though mousedown and click are triggered
+ // at almost the same time on touch.
+ return
+ }
+
+ const anchorElement = event.target.closest('a')
+
+ if (event.which > 1 || event.metaKey || event.ctrlKey) {
+ return
+ }
+
+ if (!anchorElement) {
+ return
+ }
+
+ anchorElement.addEventListener('click', function (event) {
+ if (event.detail == 1337) {
+ return
+ }
+
+ event.preventDefault()
+ }, {capture: true, passive: false, once: true})
+
+ const customEvent = new MouseEvent('click', {view: window, bubbles: true, cancelable: false, detail: 1337})
+ anchorElement.dispatchEvent(customEvent)
+}
+
+function isEventLikelyTriggeredByTouch(event) {
+ // Touch devices fire “mouseover” and “mousedown” (and other) events after
+ // a touch for compatibility reasons.
+ // This function checks if it’s likely that we’re dealing with such an event.
+
+ if (!_lastTouchstartEvent || !event) {
+ return false
+ }
+
+ if (event.target != _lastTouchstartEvent.target) {
+ return false
+ }
+
+ const now = event.timeStamp
+ // Chromium (tested Chrome 95 and 122 on Android) sometimes uses the same
+ // event.timeStamp value in touchstart, mouseover, and mousedown.
+ // Testable in test/extras/delay-not-considered-touch.html
+ // This is okay for our purpose: two equivalent timestamps will be less
+ // than the max duration, which means they’re related events.
+ // TODO: fill/find Chromium bug
+ const durationBetweenLastTouchstartAndNow = now - _lastTouchstartEvent.timeStamp
+
+ const MAX_DURATION_TO_BE_CONSIDERED_TRIGGERED_BY_TOUCHSTART = 2500
+ // How long after a touchstart event can a simulated mouseover/mousedown event fire?
+ // /test/extras/delay-not-considered-touch.html tries to answer that question.
+ // I saw up to 1450 ms on an overwhelmed Samsung Galaxy S2.
+ // On the other hand, how soon can an unrelated mouseover event happen after an unrelated touchstart?
+ // Meaning the user taps a link, then grabs their pointing device and clicks another/the same link.
+ // That scenario could occur if a user taps a link, thinks it hasn’t worked, and thus fall back to their pointing device.
+ // I do that in about 1200 ms on a Chromebook. In which case this function returns a false positive.
+ // False positives are okay, as this function is only used to decide to abort handling mouseover/mousedown/mousedownShortcut.
+ // False negatives could lead to unforeseen state, particularly in mousedownShortcutListener.
+
+ return durationBetweenLastTouchstartAndNow < MAX_DURATION_TO_BE_CONSIDERED_TRIGGERED_BY_TOUCHSTART
+
+ // TODO: Investigate if pointer events could be used.
+ // https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent/pointerType
+
+ // TODO: Investigate if InputDeviceCapabilities could be used to make it
+ // less hacky on Chromium browsers.
+ // https://developer.mozilla.org/en-US/docs/Web/API/InputDeviceCapabilities_API
+ // https://wicg.github.io/input-device-capabilities/
+ // Needs careful reading of the spec and tests (notably, what happens with a
+ // mouse connected to an Android or iOS smartphone?) to make sure it’s solid.
+ // Also need to judge if WebKit could implement it differently, as they
+ // don’t mind doing when a spec gives room to interpretation.
+ // It seems to work well on Chrome on ChromeOS.
+
+ // TODO: Consider using event screen position as another heuristic.
+}
+
+function isPreloadable(anchorElement) {
+ if (!anchorElement || !anchorElement.href) {
+ return
+ }
+
+ if (_useWhitelist && !('instant' in anchorElement.dataset)) {
+ return
+ }
+
+ if (anchorElement.origin != location.origin) {
+ let allowed = _allowExternalLinks || 'instant' in anchorElement.dataset
+ if (!allowed || !_chromiumMajorVersionInUserAgent) {
+ // Chromium-only: see comment on “restrictive prefetch” and “cross-site speculation rules prefetch”
+ return
+ }
+ }
+
+ if (!['http:', 'https:'].includes(anchorElement.protocol)) {
+ return
+ }
+
+ if (anchorElement.protocol == 'http:' && location.protocol == 'https:') {
+ return
+ }
+
+ if (!_allowQueryString && anchorElement.search && !('instant' in anchorElement.dataset)) {
+ return
+ }
+
+ if (anchorElement.hash && anchorElement.pathname + anchorElement.search == location.pathname + location.search) {
+ return
+ }
+
+ if ('noInstant' in anchorElement.dataset) {
+ return
+ }
+
+ return true
+}
+
+function preload(url, fetchPriority = 'auto') {
+ if (_preloadedList.has(url)) {
+ return
+ }
+
+ if (_speculationRulesType != 'none') {
+ preloadUsingSpeculationRules(url)
+ } else {
+ preloadUsingLinkElement(url, fetchPriority)
+ }
+
+ _preloadedList.add(url)
+}
+
+function preloadUsingSpeculationRules(url) {
+ const scriptElement = document.createElement('script')
+ scriptElement.type = 'speculationrules'
+
+ scriptElement.textContent = JSON.stringify({
+ [_speculationRulesType]: [{
+ source: 'list',
+ urls: [url]
+ }]
+ })
+
+ // When using speculation rules, cross-site prefetch is supported, but will
+ // only work if the user has no cookies for the destination site. The
+ // prefetch will not be sent, if the user does have such cookies.
+
+ document.head.appendChild(scriptElement)
+}
+
+function preloadUsingLinkElement(url, fetchPriority = 'auto') {
+ const linkElement = document.createElement('link')
+ linkElement.rel = 'prefetch'
+ linkElement.href = url
+
+ linkElement.fetchPriority = fetchPriority
+ // By default, a prefetch is loaded with a low priority.
+ // When there’s a fair chance that this prefetch is going to be used in the
+ // near term (= after a touch/mouse event), giving it a high priority helps
+ // make the page load faster in case there are other resources loading.
+ // Prioritizing it implicitly means deprioritizing every other resource
+ // that’s loading on the page. Due to HTML documents usually being much
+ // smaller than other resources (notably images and JavaScript), and
+ // prefetches happening once the initial page is sufficiently loaded,
+ // this theft of bandwidth should rarely be detrimental.
+
+ linkElement.as = 'document'
+ // as=document is Chromium-only and allows cross-origin prefetches to be
+ // usable for navigation. They call it “restrictive prefetch” and intend
+ // to remove it: https://crbug.com/1352371
+ //
+ // This document from the Chrome team dated 2022-08-10
+ // https://docs.google.com/document/d/1x232KJUIwIf-k08vpNfV85sVCRHkAxldfuIA5KOqi6M
+ // claims (I haven’t tested) that data- and battery-saver modes as well as
+ // the setting to disable preloading do not disable restrictive prefetch,
+ // unlike regular prefetch. That’s good for prefetching on a touch/mouse
+ // event, but might be bad when prefetching every link in the viewport.
+
+ document.head.appendChild(linkElement)
+}
diff --git a/layouts/_partials/scripts.html b/layouts/_partials/scripts.html
index b1e9aa2e..878ac01e 100644
--- a/layouts/_partials/scripts.html
+++ b/layouts/_partials/scripts.html
@@ -1,8 +1,17 @@
{{ with resources.Get "js/tooltip.js" }}
+<!-- tooltip by Eltrac -->
<script>{{ .Content | safeJS }}</script>
{{ end }}
+{{ if not .IsHome }}
{{ with resources.Get "js/inject-footnotes.js" }}
+<!-- inject footnotes by Eltrac -->
+<script>{{ .Content | safeJS }}</script>
+{{ end }}
+{{ end }}
+
+{{ with resources.Get "js/instantpage.js" }}
+<!-- instantpage.js -->
<script>{{ .Content | safeJS }}</script>
{{ end }}