From aa90a04f9111a5532692ccded56904c60b917585 Mon Sep 17 00:00:00 2001 From: luxick Date: Wed, 30 Sep 2026 19:29:08 +0200 Subject: [PATCH] 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 --- assets/companion.js | 15 +++- assets/fitness/fitness.js | 6 ++ assets/hx.js | 120 +++++++++++++++++++++++++++-- assets/layout.html | 100 ++++++++++++++---------- assets/modal.js | 8 +- assets/page/contents.js | 34 +++++---- assets/page/main.html | 2 - assets/page/toc.js | 60 ++++++++------- assets/scroll-sync.js | 15 ++-- assets/search/actions.js | 33 +++++--- assets/search/main.html | 2 - assets/style.css | 2 + dialogs.go | 12 +++ e2e/playwright.config.js | 10 ++- e2e/tests/boost.spec.js | 155 ++++++++++++++++++++++++++++++++++++++ main.go | 2 +- search.go | 2 +- 17 files changed, 461 insertions(+), 117 deletions(-) create mode 100644 e2e/tests/boost.spec.js 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}} -
-
- Request: {{.RenderMS}} ms - {{block "footerExtras" .}}{{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}} +
+
+ Request: {{.RenderMS}} ms + {{block "footerExtras" .}}{{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) } }