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:
2026-09-30 19:29:08 +02:00
co-authored by Claude Opus 5.5
parent fa90b3baee
commit aa90a04f91
17 changed files with 461 additions and 117 deletions
+9 -2
View File
@@ -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') {
+6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+8 -2
View File
@@ -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();
})();
-2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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') || '';
}
});
-2
View File
@@ -1,5 +1,3 @@
{{define "headScripts"}}<script src="{{asset "search/actions.js"}}"></script>{{end}}
{{define "searchQuery"}}{{.Query}}{{end}}
{{define "content"}}
+2
View File
@@ -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
View File
@@ -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.
+7 -3
View File
@@ -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}/`,
+155
View File
@@ -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);
});
+1 -1
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
}
}