Boost page navigation with htmx
Links and GET forms to wiki pages swap the next page into .shell instead of loading a new document: the server answers boosted requests and history restores with a partial (title, page actions out-of-band, shell content). The header stays, browsers without cross-document view transitions (Firefox) no longer flash between pages, and the swap runs as a same-document view transition. Files, the editor, internal endpoints and POST forms still navigate normally. Back/Forward refetch pages (no snapshots that could predate a move or delete); the tree rail keeps its scroll position and the content column returns to where it was. Mutation redirects swap their result in the same way. Page scripts that assumed a fresh document now also run after a swap (toc, contents, companion, scroll-sync, search actions) or register once (fitness). The e2e suite now also runs in Firefox. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+9
-2
@@ -200,11 +200,18 @@
|
||||
// leaves the status icon and reveal buttons invisible on mobile.
|
||||
if (isMobileDevice()) return;
|
||||
probeStatus().then(function () {
|
||||
wireFileLinks(); // delegated to document: once
|
||||
applyToPage();
|
||||
// Boosted navigation swaps in a new page without reloading this
|
||||
// script: re-apply to its footer icon and reveal buttons.
|
||||
document.addEventListener('page:swapped', applyToPage);
|
||||
});
|
||||
}
|
||||
|
||||
function applyToPage() {
|
||||
updateFooterIcon();
|
||||
wireFileLinks();
|
||||
wireRevealButton();
|
||||
wireFileRevealButtons();
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
// reloads the page with that chart's query parameter updated. Plain GET
|
||||
// navigation — each view is distinct and bookmarkable, so no
|
||||
// postReplace/history handling is needed.
|
||||
// Boosted navigation re-runs this script on every visit; the listeners
|
||||
// are delegated to document, so register them once.
|
||||
if (!window.fitnessWired) {
|
||||
window.fitnessWired = true;
|
||||
|
||||
document.addEventListener('change', function (e) {
|
||||
var url = new URL(window.location.href);
|
||||
var sel = e.target.closest('[data-fitness-range]');
|
||||
@@ -72,3 +77,4 @@ document.addEventListener('pointermove', function (e) {
|
||||
tip.style.left = left + 'px';
|
||||
tip.style.top = top + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
+115
-5
@@ -6,25 +6,135 @@
|
||||
// so settle in the same tick.
|
||||
htmx.config.defaultSettleDelay = 0;
|
||||
|
||||
function boosting() {
|
||||
return document.body.getAttribute('hx-boost') === 'true';
|
||||
}
|
||||
|
||||
// A server-sent HX-Redirect follows a mutation (move, delete, …): load the
|
||||
// target into the current history entry rather than pushing a new one, so
|
||||
// Back skips the stale pre-mutation page. replaceState + reload rather than
|
||||
// location.replace: a target differing only by #fragment would otherwise
|
||||
// not re-fetch.
|
||||
// Back skips the stale pre-mutation page. Boosted pages swap it in like a
|
||||
// navigation; elsewhere replaceState + reload (not location.replace: a
|
||||
// target differing only by #fragment would otherwise not re-fetch).
|
||||
htmx.location = {
|
||||
set href(target) {
|
||||
if (boosting()) {
|
||||
if (window.closeModal) closeModal();
|
||||
htmx.ajax('GET', target, {
|
||||
target: '.shell',
|
||||
swap: 'innerHTML transition:true',
|
||||
headers: { 'HX-Boosted': 'true' },
|
||||
replace: target
|
||||
});
|
||||
return;
|
||||
}
|
||||
window.history.replaceState(null, '', target);
|
||||
window.location.reload();
|
||||
},
|
||||
reload: function () { window.location.reload(); }
|
||||
};
|
||||
|
||||
// Failed requests answer with a plain-text reason; show it.
|
||||
// Failed requests answer with a plain-text reason; show it. A failed
|
||||
// boosted navigation falls back to loading the page normally instead.
|
||||
function fallBack(e) {
|
||||
if (!e.detail.boosted) return false;
|
||||
window.location.href = e.detail.pathInfo.finalRequestPath || e.detail.pathInfo.requestPath;
|
||||
return true;
|
||||
}
|
||||
document.addEventListener('htmx:responseError', function (e) {
|
||||
if (fallBack(e)) return;
|
||||
var xhr = e.detail.xhr;
|
||||
alert(xhr.responseText || ('Request failed (' + xhr.status + ')'));
|
||||
});
|
||||
document.addEventListener('htmx:sendError', function () {
|
||||
document.addEventListener('htmx:sendError', function (e) {
|
||||
if (fallBack(e)) return;
|
||||
alert('Network error');
|
||||
});
|
||||
|
||||
// --- Boosted navigation ---------------------------------------------
|
||||
// Outside the editor, links and GET forms to wiki pages load the next page
|
||||
// into .shell instead of replacing the document: the server answers with
|
||||
// the "partial" (title, page actions out-of-band, shell content). The
|
||||
// header stays put, nothing flashes in browsers without cross-document
|
||||
// view transitions, and the swap runs as a same-document view transition.
|
||||
// Back/Forward refetch the partial (hx-history="false" on .shell: no
|
||||
// snapshots that could show a page from before a move or delete).
|
||||
htmx.config.scrollIntoViewOnBoost = false;
|
||||
|
||||
// Only wiki pages are boosted: same origin, a slash-terminated path, no
|
||||
// query except a search (?q=). Files, the editor (?edit), internal
|
||||
// endpoints (/_…) and POST forms navigate normally.
|
||||
function isPageURL(url) {
|
||||
if (url.origin !== location.origin) return false;
|
||||
if (!url.pathname.endsWith('/') || url.pathname.indexOf('/_') === 0) return false;
|
||||
var ok = true;
|
||||
url.searchParams.forEach(function (v, key) { if (key !== 'q') ok = false; });
|
||||
return ok;
|
||||
}
|
||||
|
||||
// htmx decides per element whether it is boosted, right after this event.
|
||||
document.addEventListener('htmx:beforeProcessNode', function (e) {
|
||||
var elt = e.target;
|
||||
if (elt.hasAttribute('hx-boost')) return;
|
||||
var page = false;
|
||||
if (elt.tagName === 'A') {
|
||||
page = elt.hasAttribute('href') && !elt.hasAttribute('download') && isPageURL(new URL(elt.href));
|
||||
} else if (elt.tagName === 'FORM') {
|
||||
page = (elt.getAttribute('method') || 'get').toLowerCase() === 'get' && isPageURL(new URL(elt.action));
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (!page) elt.setAttribute('hx-boost', 'false');
|
||||
});
|
||||
|
||||
function isShell(elt) {
|
||||
return elt && elt.classList && elt.classList.contains('shell');
|
||||
}
|
||||
|
||||
// Scroll positions: the tree rail keeps its position across navigation;
|
||||
// the content column (.center) starts at the top, or where it was when
|
||||
// coming back via Back/Forward.
|
||||
var treeTop = 0;
|
||||
var restoring = false;
|
||||
var centerTops = {};
|
||||
function pageKey() {
|
||||
return location.pathname + location.search;
|
||||
}
|
||||
function rememberTree() {
|
||||
var tree = document.querySelector('.tree-scroll');
|
||||
treeTop = tree ? tree.scrollTop : 0;
|
||||
}
|
||||
document.addEventListener('scroll', function (e) {
|
||||
var t = e.target;
|
||||
if (t.classList && t.classList.contains('center')) centerTops[pageKey()] = t.scrollTop;
|
||||
}, { capture: true, passive: true });
|
||||
|
||||
document.addEventListener('htmx:beforeSwap', function (e) {
|
||||
if (e.detail.boosted) {
|
||||
e.detail.target = document.querySelector('.shell');
|
||||
e.detail.swapOverride = 'innerHTML transition:true';
|
||||
}
|
||||
if (isShell(e.detail.target)) rememberTree();
|
||||
});
|
||||
document.addEventListener('htmx:historyCacheMissLoad', function () {
|
||||
restoring = true;
|
||||
rememberTree();
|
||||
});
|
||||
|
||||
// After a new page is in .shell: restore scroll positions, then tell page
|
||||
// scripts (companion.js, …) to apply themselves to the new content.
|
||||
document.addEventListener('htmx:afterSettle', function (e) {
|
||||
if (!isShell(e.target)) return;
|
||||
var tree = document.querySelector('.tree-scroll');
|
||||
if (tree) {
|
||||
tree.scrollTop = treeTop;
|
||||
var active = tree.querySelector('.tree-row.is-active');
|
||||
if (active) active.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
if (restoring) {
|
||||
restoring = false;
|
||||
var center = document.querySelector('.center');
|
||||
if (center) center.scrollTop = centerTops[pageKey()] || 0;
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('page:swapped'));
|
||||
});
|
||||
})();
|
||||
|
||||
+43
-21
@@ -17,10 +17,14 @@
|
||||
<script src="{{asset "search-suggest.js"}}" defer></script>
|
||||
<script src="{{asset "tree-picker.js"}}"></script>
|
||||
<script src="{{asset "companion.js"}}" defer></script>
|
||||
{{if not .EditMode}}<script src="{{asset "overlay.js"}}" defer></script>{{end}}
|
||||
{{block "headScripts" .}}{{end}}
|
||||
{{if not .EditMode}}<script src="{{asset "overlay.js"}}" defer></script>
|
||||
<script src="{{asset "page/actions.js"}}"></script>
|
||||
<script src="{{asset "search/actions.js"}}"></script>{{end}}
|
||||
</head>
|
||||
<body>
|
||||
{{/* Outside the editor, links to pages are boosted: htmx swaps .shell
|
||||
(plus title and page actions, see "partial") instead of loading a new
|
||||
document. hx.js decides which links count as pages. */}}
|
||||
<body{{if not .EditMode}} hx-boost="true"{{end}}>
|
||||
<header>
|
||||
<nav class="breadcrumb row">
|
||||
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a>
|
||||
@@ -36,33 +40,50 @@
|
||||
<span class="dropdown actions">
|
||||
<button type="button" class="btn actions-toggle" data-action="actions-menu" aria-haspopup="true" aria-expanded="false" title="Actions">ACTIONS</button>
|
||||
<div class="dropdown-menu align-right actions-menu">
|
||||
<div class="actions-special">
|
||||
<button type="button" class="btn btn-block" title="Folders" onclick="openOverlay(document.querySelector('.tree-scroll'))">FOLDERS</button>
|
||||
</div>
|
||||
{{template "headerActions" . }}
|
||||
{{template "page-actions" .}}
|
||||
</div>
|
||||
</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</header>
|
||||
<div class="shell">
|
||||
{{if not .EditMode}}<aside class="tree-sidebar col">
|
||||
<div class="shell" hx-history-elt hx-history="false">{{template "shell" .}}</div>
|
||||
{{block "extras" .}}{{end}}
|
||||
<div id="modal-host"></div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{/* The page-specific header actions; the partial swaps them out-of-band
|
||||
(the attribute is inert in a full page). */}}
|
||||
{{define "page-actions"}}<div id="page-actions" class="page-actions" hx-swap-oob="true">
|
||||
<div class="actions-special">
|
||||
<button type="button" class="btn btn-block" title="Folders" onclick="openOverlay(document.querySelector('.tree-scroll'))">FOLDERS</button>
|
||||
</div>
|
||||
{{template "headerActions" .}}
|
||||
</div>{{end}}
|
||||
|
||||
{{define "shell"}}
|
||||
{{if not .EditMode}}<aside class="tree-sidebar col">
|
||||
<nav class="tree-scroll" aria-label="Folders">{{.Tree}}</nav>
|
||||
<script>
|
||||
// Keep the rail's scroll position across page loads (before
|
||||
// first paint, so it doesn't jump), then reveal the active row.
|
||||
// Full page loads: keep the rail's scroll position (before first
|
||||
// paint, so it doesn't jump), then reveal the active row. Boosted
|
||||
// navigation keeps it itself (hx.js); this script re-runs then and
|
||||
// bails out.
|
||||
(function () {
|
||||
if (document.readyState !== "loading") return;
|
||||
var el = document.currentScript.previousElementSibling;
|
||||
try { el.scrollTop = +sessionStorage.getItem("tree-scroll") || 0; } catch (e) {}
|
||||
var active = el.querySelector(".tree-row.is-active");
|
||||
if (active) active.scrollIntoView({ block: "nearest" });
|
||||
addEventListener("pagehide", function () {
|
||||
try { sessionStorage.setItem("tree-scroll", el.scrollTop); } catch (e) {}
|
||||
var cur = document.querySelector(".tree-scroll");
|
||||
try { sessionStorage.setItem("tree-scroll", cur ? cur.scrollTop : 0); } catch (e) {}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</aside>{{end}}
|
||||
<div class="center">
|
||||
</aside>{{end}}
|
||||
<div class="center">
|
||||
<main>
|
||||
{{block "content" .}}{{end}}
|
||||
</main>
|
||||
@@ -74,11 +95,12 @@
|
||||
<div class="dropdown-menu align-right open-up companion-flyout"></div>
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
{{if not .EditMode}}{{block "aside" .}}{{end}}{{end}}
|
||||
</div>
|
||||
{{block "extras" .}}{{end}}
|
||||
<div id="modal-host"></div>
|
||||
</body>
|
||||
</html>
|
||||
</div>
|
||||
{{if not .EditMode}}{{block "aside" .}}{{end}}{{end}}
|
||||
{{end}}
|
||||
|
||||
{{/* Response to boosted navigation and history restores (hx.js swaps it into
|
||||
.shell): the title, the page actions out-of-band, and the shell's content. */}}
|
||||
{{define "partial"}}<title>{{.Title}}</title>
|
||||
{{template "page-actions" .}}
|
||||
{{template "shell" .}}{{end}}
|
||||
|
||||
+6
-2
@@ -181,13 +181,17 @@
|
||||
// is open (e.g. the merge step replacing the move dialog); focus returns
|
||||
// to where it was before the first one opened.
|
||||
var hostPrevFocus = null;
|
||||
// History restores swap without event details; they never target the host.
|
||||
function isHost(e) {
|
||||
return !!(e.detail && e.detail.target && e.detail.target.id === 'modal-host');
|
||||
}
|
||||
document.addEventListener('htmx:beforeSwap', function (e) {
|
||||
if (e.detail.target.id !== 'modal-host') return;
|
||||
if (!isHost(e)) return;
|
||||
hostPrevFocus = current ? detach(true) : document.activeElement;
|
||||
});
|
||||
document.addEventListener('htmx:afterSwap', function (e) {
|
||||
if (!isHost(e)) return;
|
||||
var host = e.detail.target;
|
||||
if (host.id !== 'modal-host') return;
|
||||
var backdrop = host.querySelector('.modal-backdrop');
|
||||
if (!backdrop) return;
|
||||
show(backdrop, host, hostPrevFocus);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
// Runs on DOMContentLoaded for a full page load, and right away when this
|
||||
// script arrives with a boosted navigation (the document is loaded then).
|
||||
(function () {
|
||||
function run() {
|
||||
var aside = document.querySelector("aside.sidebar");
|
||||
var specials = document.querySelector(".actions-special");
|
||||
if (!aside || !aside.children.length || !specials) return;
|
||||
@@ -12,4 +15,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
if (typeof openOverlay === "function") openOverlay(aside);
|
||||
});
|
||||
specials.appendChild(item);
|
||||
});
|
||||
}
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
|
||||
else run();
|
||||
})();
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
{{define "headScripts"}}<script src="{{asset "page/actions.js"}}"></script>{{end}}
|
||||
|
||||
{{define "headerActions"}}{{if .CanEdit}}
|
||||
<button class="btn btn-block" onclick="newPage()" title="New page (N)"><span class="btn-icon">{{icon "new"}}</span><span class="btn-label">NEW</span></button>
|
||||
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager"><span class="btn-icon">{{icon "reveal"}}</span><span class="btn-label">REVEAL</span></button>
|
||||
|
||||
+8
-2
@@ -1,4 +1,7 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
// Runs on DOMContentLoaded for a full page load, and right away when this
|
||||
// script arrives with a boosted navigation (the document is loaded then).
|
||||
(function () {
|
||||
function run() {
|
||||
var content = document.querySelector("main");
|
||||
if (!content) return;
|
||||
|
||||
@@ -29,4 +32,7 @@ document.addEventListener("DOMContentLoaded", function () {
|
||||
nav.appendChild(list);
|
||||
|
||||
content.insertBefore(nav, content.firstChild);
|
||||
});
|
||||
}
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
|
||||
else run();
|
||||
})();
|
||||
|
||||
+10
-5
@@ -45,17 +45,22 @@
|
||||
}
|
||||
|
||||
// Reading mode: keep the latest scroll fraction recorded (throttled to one
|
||||
// write per frame) so it is ready the moment the editor opens.
|
||||
// write per frame) so it is ready the moment the editor opens. Boosted
|
||||
// navigation replaces .center and changes the page without a reload, so
|
||||
// listen on document and resolve both when writing.
|
||||
var pending = false;
|
||||
center.addEventListener('scroll', function () {
|
||||
if (pending) return;
|
||||
document.addEventListener('scroll', function (e) {
|
||||
if (pending || !e.target.classList || !e.target.classList.contains('center')) return;
|
||||
pending = true;
|
||||
requestAnimationFrame(function () {
|
||||
pending = false;
|
||||
center = document.querySelector('.center');
|
||||
if (!center) return;
|
||||
var m = maxScroll();
|
||||
try {
|
||||
sessionStorage.setItem(key, JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() }));
|
||||
sessionStorage.setItem('scrollsync:' + location.pathname,
|
||||
JSON.stringify({ f: m > 0 ? center.scrollTop / m : 0, t: Date.now() }));
|
||||
} catch (e) {}
|
||||
});
|
||||
}, { passive: true });
|
||||
}, { capture: true, passive: true });
|
||||
})();
|
||||
|
||||
+19
-10
@@ -52,19 +52,19 @@ function createSearchPage(name) {
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
|
||||
var createLink = document.querySelector('[data-create-page]');
|
||||
if (createLink) {
|
||||
createLink.addEventListener('click', function (e) {
|
||||
// The "create page" link on results pages; delegated, since boosted
|
||||
// navigation swaps the results in without a new DOMContentLoaded.
|
||||
document.addEventListener('click', function (e) {
|
||||
var createLink = e.target.closest && e.target.closest('[data-create-page]');
|
||||
if (!createLink) return;
|
||||
e.preventDefault();
|
||||
createSearchPage(createLink.getAttribute('data-create-page'));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Focus the search input on results pages so Tab steps directly into the
|
||||
// first match — the input sits immediately before the results in DOM
|
||||
// order, so the natural tab sequence is input → first result → next, …
|
||||
// Focus the search input on results pages so Tab steps directly into the
|
||||
// first match — the input sits immediately before the results in DOM order,
|
||||
// so the natural tab sequence is input → first result → next, …
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var input = document.querySelector('.search-input');
|
||||
if (input && input.value) {
|
||||
input.focus();
|
||||
@@ -72,3 +72,12 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||
try { input.setSelectionRange(end, end); } catch (e) {}
|
||||
}
|
||||
});
|
||||
|
||||
// The header (and its search box) survives boosted navigation: show the
|
||||
// current page's query, as a full load would.
|
||||
document.addEventListener('page:swapped', function () {
|
||||
var input = document.querySelector('.search-input');
|
||||
if (input && document.activeElement !== input) {
|
||||
input.value = new URLSearchParams(location.search).get('q') || '';
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
{{define "headScripts"}}<script src="{{asset "search/actions.js"}}"></script>{{end}}
|
||||
|
||||
{{define "searchQuery"}}{{.Query}}{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
|
||||
@@ -425,6 +425,8 @@ main > h2 {
|
||||
.dropdown-menu.scrollable { max-height: 23rem; overflow-y: auto; }
|
||||
|
||||
.actions-special > .btn {text-align: left;}
|
||||
/* Wrapper for the out-of-band swapped page actions; lays out as if absent. */
|
||||
.page-actions { display: contents; }
|
||||
/* Header actions carry an icon and a label: the mobile dropdown shows the
|
||||
label, the inline desktop bar (see responsive block) the icon. */
|
||||
.btn-icon { display: none; }
|
||||
|
||||
+12
@@ -15,6 +15,18 @@ func isHX(r *http.Request) bool {
|
||||
return r.Header.Get("HX-Request") == "true"
|
||||
}
|
||||
|
||||
// layoutTemplate names the template that renders a layout page for r: the
|
||||
// whole document, or for boosted navigation and htmx history restores only
|
||||
// the "partial" that hx.js swaps into the open page. The editor is never
|
||||
// boosted.
|
||||
func layoutTemplate(w http.ResponseWriter, r *http.Request, editMode bool) string {
|
||||
w.Header().Set("Vary", "HX-Boosted, HX-History-Restore-Request")
|
||||
if !editMode && (r.Header.Get("HX-Boosted") == "true" || r.Header.Get("HX-History-Restore-Request") == "true") {
|
||||
return "partial"
|
||||
}
|
||||
return "layout"
|
||||
}
|
||||
|
||||
// redirectAfter sends the client to target once a mutation succeeded. htmx
|
||||
// requests get HX-Redirect (assets/hx.js turns it into a history replace, so
|
||||
// Back skips the pre-mutation page); plain form posts get a 303.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const port = process.env.E2E_PORT || "8765";
|
||||
|
||||
@@ -11,11 +11,15 @@ export default defineConfig({
|
||||
reporter: "list",
|
||||
use: {
|
||||
baseURL: `http://127.0.0.1:${port}`,
|
||||
// System Chrome; no Playwright browser download needed.
|
||||
channel: "chrome",
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [
|
||||
// The system Chrome; no browser download needed.
|
||||
{ name: "chrome", use: { channel: "chrome" } },
|
||||
// Playwright's Firefox build: npx playwright install firefox
|
||||
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||||
],
|
||||
webServer: {
|
||||
command: "node server.mjs",
|
||||
url: `http://127.0.0.1:${port}/`,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// Boosted navigation: outside the editor, page links swap the next page into
|
||||
// the open document instead of loading a new one. A marker on window survives
|
||||
// a boosted navigation and disappears with a full page load.
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const wiki = join(dirname(fileURLToPath(import.meta.url)), "..", ".tmp", "wiki");
|
||||
|
||||
test.beforeEach(({ page }) => {
|
||||
page.on("pageerror", (err) => {
|
||||
// Chrome aborts the cross-document view transition (style.css) when the
|
||||
// next document can't take part, e.g. a plain-text file, and reports it
|
||||
// as an unhandled rejection that no page script can catch.
|
||||
if (err.message.includes("ViewTransition opt-in disabled")) return;
|
||||
throw err;
|
||||
});
|
||||
});
|
||||
|
||||
function randomWord(n) {
|
||||
return Array.from({ length: n }, () => String.fromCharCode(97 + Math.floor(Math.random() * 26))).join("");
|
||||
}
|
||||
|
||||
function sandbox(pages) {
|
||||
const root = "t" + randomWord(7);
|
||||
for (const [rel, body] of Object.entries(pages)) {
|
||||
const dir = join(wiki, root, rel);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
if (body !== null) writeFileSync(join(dir, "index.md"), body);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
const sidebar = (page) => page.locator("aside.tree-sidebar");
|
||||
const link = (scope, name) => scope.getByRole("link", { name, exact: true });
|
||||
const mark = (page) => page.evaluate(() => (window.__marker = 1));
|
||||
const marked = (page) => page.evaluate(() => window.__marker === 1);
|
||||
const long = (title) => `# ${title}\n\n` + Array.from({ length: 40 }, (_, i) => `## Part ${i}\n\ntext\n`).join("\n");
|
||||
|
||||
test("sidebar navigation swaps the page in without a reload", async ({ page }) => {
|
||||
const root = sandbox({
|
||||
"": "# Parent\n",
|
||||
Child: "# Child\n\n## First\n\none\n\n## Second\n\n- [ ] boosted task\n",
|
||||
});
|
||||
await page.goto(`/${root}/`);
|
||||
await mark(page);
|
||||
await link(sidebar(page), "Child").click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/Child/$`));
|
||||
await expect(page.locator("main h1")).toContainText("Child");
|
||||
await expect(page).toHaveTitle("Child");
|
||||
expect(await marked(page)).toBe(true);
|
||||
await expect(sidebar(page).locator(".tree-row.is-active")).toHaveText("Child");
|
||||
// Page scripts ran for the new content: TOC and section edit links.
|
||||
await expect(page.locator("nav.toc")).toContainText("Second");
|
||||
await expect(page.locator("main h2", { hasText: "First" }).getByRole("link", { name: "[edit]" })).toBeVisible();
|
||||
// htmx attributes in the new content work: the task toggles.
|
||||
await page.locator("main input.task-checkbox").check();
|
||||
await expect.poll(() => readFileSync(join(wiki, root, "Child", "index.md"), "utf8")).toContain("- [x] boosted task");
|
||||
});
|
||||
|
||||
test("Back and Forward restore pages and scroll without a reload", async ({ page }) => {
|
||||
const root = sandbox({ "": long("Parent"), Child: long("Child") });
|
||||
await page.goto(`/${root}/`);
|
||||
await mark(page);
|
||||
const center = page.locator(".center");
|
||||
await center.evaluate((el) => (el.scrollTop = 600));
|
||||
await link(sidebar(page), "Child").click();
|
||||
await expect(page.locator("main h1")).toContainText("Child");
|
||||
// A new page starts at the top.
|
||||
expect(await center.evaluate((el) => el.scrollTop)).toBe(0);
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
||||
await expect(page.locator("main h1")).toContainText("Parent");
|
||||
await expect(page).toHaveTitle("Parent");
|
||||
await expect.poll(() => center.evaluate((el) => el.scrollTop)).toBeGreaterThan(500);
|
||||
await page.goForward();
|
||||
await expect(page.locator("main h1")).toContainText("Child");
|
||||
expect(await marked(page)).toBe(true);
|
||||
});
|
||||
|
||||
test("Back to the first, fully loaded page works", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Parent\n", Child: "# Child\n" });
|
||||
await page.goto(`/${root}/Child/`);
|
||||
await mark(page);
|
||||
await link(sidebar(page), root).click();
|
||||
await expect(page.locator("main h1")).toContainText("Parent");
|
||||
await page.goBack();
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/Child/$`));
|
||||
await expect(page.locator("main h1")).toContainText("Child");
|
||||
expect(await marked(page)).toBe(true);
|
||||
});
|
||||
|
||||
test("files and the editor load as full pages", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Parent\n" });
|
||||
writeFileSync(join(wiki, root, "note.txt"), "plain file");
|
||||
await page.goto(`/${root}/`);
|
||||
await mark(page);
|
||||
await page.locator("main").getByRole("link", { name: "note.txt" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/note.txt$`));
|
||||
await expect(page.locator("body")).toContainText("plain file");
|
||||
expect(await marked(page)).toBe(false);
|
||||
|
||||
await page.goto(`/${root}/`);
|
||||
await mark(page);
|
||||
await page.locator("header").getByTitle("Edit page (E)").click();
|
||||
await expect(page).toHaveURL(/\?edit/);
|
||||
await expect(page.locator(".cm-content")).toBeVisible();
|
||||
expect(await marked(page)).toBe(false);
|
||||
});
|
||||
|
||||
test("header actions follow the page", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Parent\n" });
|
||||
await page.goto("/");
|
||||
await mark(page);
|
||||
const move = page.locator("header").getByTitle("Move page (M)");
|
||||
await expect(move).toHaveCount(0);
|
||||
await link(sidebar(page), root).click();
|
||||
await expect(page.locator("main h1")).toContainText("Parent");
|
||||
await expect(move).toBeVisible();
|
||||
await page.goBack();
|
||||
await expect(page.locator("main h1")).toContainText("Home");
|
||||
await expect(move).toHaveCount(0);
|
||||
expect(await marked(page)).toBe(true);
|
||||
});
|
||||
|
||||
test("search results and the search box follow boosted navigation", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Parent\n", Child: "# Child\n" });
|
||||
expect((await page.request.post("/_reindex")).status()).toBe(204);
|
||||
await page.goto(`/${root}/`);
|
||||
await mark(page);
|
||||
const input = page.locator("header .search-input");
|
||||
await input.fill(root);
|
||||
await input.press("Enter");
|
||||
await expect(page).toHaveURL(new RegExp(`/\?q=${root}$`));
|
||||
await expect(page).toHaveTitle(`Search: ${root}`);
|
||||
await expect(page.locator("main .search-card").first()).toBeVisible();
|
||||
await link(sidebar(page), root).click();
|
||||
await expect(page.locator("main h1")).toContainText("Parent");
|
||||
await expect(input).toHaveValue("");
|
||||
expect(await marked(page)).toBe(true);
|
||||
});
|
||||
|
||||
test("a mutation swaps in its result without a reload", async ({ page }) => {
|
||||
const root = sandbox({ "": "# Parent\n", Gone: "# Gone\n" });
|
||||
await page.goto(`/${root}/Gone/`);
|
||||
await mark(page);
|
||||
await page.locator("header").getByTitle("Delete page").click();
|
||||
await page.locator(".modal").getByRole("button", { name: "DELETE" }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/${root}/$`));
|
||||
await expect(page.locator("main h1")).toContainText("Parent");
|
||||
await expect(page.locator(".modal")).toHaveCount(0);
|
||||
await expect(link(sidebar(page), "Gone")).toHaveCount(0);
|
||||
expect(await marked(page)).toBe(true);
|
||||
});
|
||||
@@ -420,7 +420,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
||||
t = editTmpl
|
||||
}
|
||||
data.renderTimer = renderTimer{requestStart(r)}
|
||||
if err := t.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
if err := t.ExecuteTemplate(w, layoutTemplate(w, r, editMode), data); err != nil {
|
||||
log.Printf("template error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ func (h *handler) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
data.renderTimer = renderTimer{requestStart(r)}
|
||||
if err := searchTmpl.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
if err := searchTmpl.ExecuteTemplate(w, layoutTemplate(w, r, false), data); err != nil {
|
||||
log.Printf("search template error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user