diff --git a/assets/companion.js b/assets/companion.js
index 81e54bc..005f713 100644
--- a/assets/companion.js
+++ b/assets/companion.js
@@ -200,13 +200,20 @@
// leaves the status icon and reveal buttons invisible on mobile.
if (isMobileDevice()) return;
probeStatus().then(function () {
- updateFooterIcon();
- wireFileLinks();
- wireRevealButton();
- wireFileRevealButtons();
+ 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();
+ wireRevealButton();
+ wireFileRevealButtons();
+ }
+
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
diff --git a/assets/fitness/fitness.js b/assets/fitness/fitness.js
index da0cbdb..8b120e8 100644
--- a/assets/fitness/fitness.js
+++ b/assets/fitness/fitness.js
@@ -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';
});
+}
diff --git a/assets/hx.js b/assets/hx.js
index be1ac09..ae61c5b 100644
--- a/assets/hx.js
+++ b/assets/hx.js
@@ -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'));
+ });
})();
diff --git a/assets/layout.html b/assets/layout.html
index 656673f..22f736a 100644
--- a/assets/layout.html
+++ b/assets/layout.html
@@ -17,10 +17,14 @@
- {{if not .EditMode}}{{end}}
- {{block "headScripts" .}}{{end}}
+ {{if not .EditMode}}
+
+ {{end}}
-
+ {{/* 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. */}}
+
-
- {{if not .EditMode}}{{end}}
-
-
- {{block "content" .}}{{end}}
-
-
-
- {{if not .EditMode}}{{block "aside" .}}{{end}}{{end}}
-
+
{{template "shell" .}}
{{block "extras" .}}{{end}}
{{end}}
+
+{{/* The page-specific header actions; the partial swaps them out-of-band
+(the attribute is inert in a full page). */}}
+{{define "page-actions"}}
+
+
+
+ {{template "headerActions" .}}
+
{{end}}
+
+{{define "shell"}}
+{{if not .EditMode}}{{end}}
+
+
+ {{block "content" .}}{{end}}
+
+
+
+{{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}}
+{{template "page-actions" .}}
+{{template "shell" .}}{{end}}
diff --git a/assets/modal.js b/assets/modal.js
index 474384a..09cc1f9 100644
--- a/assets/modal.js
+++ b/assets/modal.js
@@ -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);
diff --git a/assets/page/contents.js b/assets/page/contents.js
index 2c48110..4ee1fe9 100644
--- a/assets/page/contents.js
+++ b/assets/page/contents.js
@@ -1,15 +1,21 @@
-document.addEventListener("DOMContentLoaded", function () {
- var aside = document.querySelector("aside.sidebar");
- var specials = document.querySelector(".actions-special");
- if (!aside || !aside.children.length || !specials) return;
+// 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;
- var item = document.createElement("button");
- item.type = "button";
- item.className = "btn btn-block";
- item.title = "Contents";
- item.textContent = "CONTENTS";
- item.addEventListener("click", function () {
- if (typeof openOverlay === "function") openOverlay(aside);
- });
- specials.appendChild(item);
-});
+ var item = document.createElement("button");
+ item.type = "button";
+ item.className = "btn btn-block";
+ item.title = "Contents";
+ item.textContent = "CONTENTS";
+ item.addEventListener("click", function () {
+ if (typeof openOverlay === "function") openOverlay(aside);
+ });
+ specials.appendChild(item);
+ }
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
+ else run();
+})();
diff --git a/assets/page/main.html b/assets/page/main.html
index fb76518..cad1dcd 100644
--- a/assets/page/main.html
+++ b/assets/page/main.html
@@ -1,5 +1,3 @@
-{{define "headScripts"}}{{end}}
-
{{define "headerActions"}}{{if .CanEdit}}
diff --git a/assets/page/toc.js b/assets/page/toc.js
index 91b4d4c..2a71468 100644
--- a/assets/page/toc.js
+++ b/assets/page/toc.js
@@ -1,32 +1,38 @@
-document.addEventListener("DOMContentLoaded", function () {
- var content = document.querySelector("main");
- if (!content) return;
+// 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;
- var headings = content.querySelectorAll("h2, h3, h4");
- if (headings.length < 2) return;
+ var headings = content.querySelectorAll("h2, h3, h4");
+ if (headings.length < 2) return;
- var nav = document.createElement("nav");
- nav.className = "toc panel";
+ var nav = document.createElement("nav");
+ nav.className = "toc panel";
- var header = document.createElement("div");
- header.className = "panel-header";
- header.textContent = "Contents";
- nav.appendChild(header);
+ var header = document.createElement("div");
+ header.className = "panel-header";
+ header.textContent = "Contents";
+ nav.appendChild(header);
- var list = document.createElement("ul");
- headings.forEach(function (h) {
- if (!h.id) return;
- var li = document.createElement("li");
- li.className = "toc-" + h.tagName.toLowerCase();
- var a = document.createElement("a");
- a.href = "#" + h.id;
- var clone = h.cloneNode(true);
- clone.querySelectorAll(".btn, .muted, .dropdown").forEach(function (el) { el.remove(); });
- a.textContent = clone.textContent.trim();
- li.appendChild(a);
- list.appendChild(li);
- });
- nav.appendChild(list);
+ var list = document.createElement("ul");
+ headings.forEach(function (h) {
+ if (!h.id) return;
+ var li = document.createElement("li");
+ li.className = "toc-" + h.tagName.toLowerCase();
+ var a = document.createElement("a");
+ a.href = "#" + h.id;
+ var clone = h.cloneNode(true);
+ clone.querySelectorAll(".btn, .muted, .dropdown").forEach(function (el) { el.remove(); });
+ a.textContent = clone.textContent.trim();
+ li.appendChild(a);
+ list.appendChild(li);
+ });
+ nav.appendChild(list);
- content.insertBefore(nav, content.firstChild);
-});
+ content.insertBefore(nav, content.firstChild);
+ }
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
+ else run();
+})();
diff --git a/assets/scroll-sync.js b/assets/scroll-sync.js
index b17326b..715142f 100644
--- a/assets/scroll-sync.js
+++ b/assets/scroll-sync.js
@@ -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 });
})();
diff --git a/assets/search/actions.js b/assets/search/actions.js
index 52685e3..d2ae985 100644
--- a/assets/search/actions.js
+++ b/assets/search/actions.js
@@ -52,19 +52,19 @@ function createSearchPage(name) {
});
}
+// 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, …
document.addEventListener('DOMContentLoaded', function () {
-
- var createLink = document.querySelector('[data-create-page]');
- if (createLink) {
- createLink.addEventListener('click', function (e) {
- 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, …
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') || '';
+ }
+});
diff --git a/assets/search/main.html b/assets/search/main.html
index f21f3e9..f6703ff 100644
--- a/assets/search/main.html
+++ b/assets/search/main.html
@@ -1,5 +1,3 @@
-{{define "headScripts"}}{{end}}
-
{{define "searchQuery"}}{{.Query}}{{end}}
{{define "content"}}
diff --git a/assets/style.css b/assets/style.css
index d8dda33..74cf670 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -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; }
diff --git a/dialogs.go b/dialogs.go
index 05a882c..1a06541 100644
--- a/dialogs.go
+++ b/dialogs.go
@@ -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.
diff --git a/e2e/playwright.config.js b/e2e/playwright.config.js
index dc79e09..ef6e3ef 100644
--- a/e2e/playwright.config.js
+++ b/e2e/playwright.config.js
@@ -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}/`,
diff --git a/e2e/tests/boost.spec.js b/e2e/tests/boost.spec.js
new file mode 100644
index 0000000..d53f103
--- /dev/null
+++ b/e2e/tests/boost.spec.js
@@ -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);
+});
diff --git a/main.go b/main.go
index 3ba6420..2a3bece 100644
--- a/main.go
+++ b/main.go
@@ -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)
}
}
diff --git a/search.go b/search.go
index ab5f400..7305da3 100644
--- a/search.go
+++ b/search.go
@@ -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)
}
}