Recently Written · git

sbm-sync

Sync server for sbm bookmark files: one small Go program, plain files, AGPL

git clone https://github.com/equwal/sbm-sync

Log | Files | Refs


commit b3d3f70ca090c0d5d6d75515ceebb6922d116e76
equwal <truex@equwal.com>
2026-09-22 19:45:31 -0700

Bookmarks page: a live preview of the highlighted page

In a wide window, the bookmarks page shows the page of the highlighted
result in a frame on the right of the list, as the sbm add-on does. The
arrow keys in the search box and the mouse move the highlight, and
Enter opens the highlighted result. The frame loads a page 300 ms after
the highlight stops on it. An http page loads with https, because a
secure page cannot show an insecure frame. A search from the address
bar starts the preview at once; else it starts when the user types or
points at a result. A narrow window has no preview and loads no page.

The new script preview.js follows the results that search.js shows.
The frame is sandboxed (scripts, but no top navigation, popups or
forms) and sends no referrer. The browser loads the page: the server
does not fetch it. A page in the frame cannot take the focus from the
search box. The Content-Security-Policy allows https frames only.

 README.md    |  16 ++++++---
 bookmarks.go |  12 +++++++
 main.go      |   6 ++--
 pages.html   |  21 ++++++++++++
 preview.js   | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 search.js    |  14 ++++----
 web_test.go  |  42 +++++++++++++++++++++++
 7 files changed, 206 insertions(+), 13 deletions(-)
diff --git a/README.md b/README.md
index 9b9c855..6b282f2 100644
--- a/README.md
+++ b/README.md
@@ -44,10 +44,14 @@ browser, also on a phone. The home page then has the search box too. The
 page does what bm does:
 
 - Search as fzf does, with the search of the add-on. The results change
-  while you type, and Enter opens the first result (Ctrl+Enter: in a new
-  tab), as in the add-on. Pick a tag. Sort by date, URL, description or
+  while you type, and Enter opens the highlighted result (Ctrl+Enter: in a
+  new tab), as in the add-on. Pick a tag. Sort by date, URL, description or
   tag. Text that no bookmark matches opens as an address, or as a web
   search.
+- Live preview, in a wide window: the page of the highlighted result shows
+  on the right of the list while you search. The arrow keys and the mouse
+  move the highlight. An http page shows with https. Some sites do not let
+  other pages show them, so their preview stays empty.
 - Search from the address bar of the browser: type sbm, a space and your
   words. Pages link to an OpenSearch description, `/opensearch.xml`, and
   the bookmarks page tells the address to add:
@@ -61,9 +65,11 @@ page does what bm does:
 - Download the file.
 
 Each change goes through the same merge as a sync, so the changes that your
-devices made since the page opened stay. The only script is `search.js`,
-for the search while you type: it gets the results from the server. Without
-it, the search form works too.
+devices made since the page opened stay. The only scripts are `search.js`,
+for the search while you type, which gets the results from the server, and
+`preview.js`, for the live preview. Without them, the search form works too.
+The browser loads the page of the preview from its site, in a sandboxed
+frame without a referrer: the server does not fetch it.
 
 The pages obey the rules of sync: the email address must be confirmed, and
 with billing on, the account needs its trial or a subscription. The
diff --git a/bookmarks.go b/bookmarks.go
index ea867e6..51cc52d 100644
--- a/bookmarks.go
+++ b/bookmarks.go
@@ -40,6 +40,12 @@ var (
 //go:embed search.js
 var searchJS string
 
+// previewJS is the live preview of the list page: the page of the
+// highlighted bookmark in a frame.
+//
+//go:embed preview.js
+var previewJS string
+
 // sentence gives the text of an error as a sentence for a page.
 func sentence(err error) string {
 	return strings.ToUpper(err.Error()[:1]) + err.Error()[1:] + "."
@@ -748,6 +754,12 @@ func (s *server) script(w http.ResponseWriter, r *http.Request) {
 	io.WriteString(w, searchJS)
 }
 
+// previewScript gives the live preview of the list page.
+func (s *server) previewScript(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
+	io.WriteString(w, previewJS)
+}
+
 // openSearch describes the search of the bookmarks in the OpenSearch 1.1
 // format, so that a browser can search them from its address bar.
 func (s *server) openSearch(w http.ResponseWriter, r *http.Request) {
diff --git a/main.go b/main.go
index 261ec2d..f9e2768 100644
--- a/main.go
+++ b/main.go
@@ -179,12 +179,14 @@ func (s *server) routes() http.Handler {
 	m.HandleFunc("POST /bookmarks/import", s.importFile)
 	m.HandleFunc("GET /bookmarks.txt", s.download)
 	m.HandleFunc("GET /search.js", s.script)
+	m.HandleFunc("GET /preview.js", s.previewScript)
 	m.HandleFunc("GET /opensearch.xml", s.openSearch)
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		h := w.Header()
 		// Scripts only from this server: the live search of the bookmark
-		// pages gets its results with fetch.
-		h.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'unsafe-inline'; "+
+		// pages gets its results with fetch. Frames only with https pages:
+		// the live preview shows the page of a bookmark.
+		h.Set("Content-Security-Policy", "default-src 'none'; script-src 'self'; connect-src 'self'; frame-src https:; style-src 'unsafe-inline'; "+
 			"form-action 'self' https://checkout.stripe.com https://billing.stripe.com; "+
 			"frame-ancestors 'none'; base-uri 'none'")
 		h.Set("Referrer-Policy", "same-origin")
diff --git a/pages.html b/pages.html
index 14b3dab..e6af677 100644
--- a/pages.html
+++ b/pages.html
@@ -25,8 +25,18 @@ select { font: inherit; padding: .3em; margin: .2em 0 1em; }
 textarea { display: block; width: 100%; box-sizing: border-box; height: 60vh; margin: .2em 0 1em; font: 13px/1.4 monospace; }
 details { margin: 1em 0; } summary { cursor: pointer; }
 .bookmarks { list-style: none; padding: 0; } .bookmarks li { margin: .8em 0; overflow-wrap: anywhere; } .bookmarks small { color: #555; }
+.bookmarks li.sel { background: #e3eef3; box-shadow: 0 0 0 .3em #e3eef3; }
+/* The live preview needs a wide window. There it is on the right of the list. */
+#preview { display: none; }
+#preview p { margin: 0 0 .3em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+#preview iframe { flex: 1; width: 100%; box-sizing: border-box; border: 1px solid #ccc; background: #fff; }
+@media (min-width: 64em) {
+  body:has(#preview) { margin-left: 2em; }
+  #preview:not([hidden]) { display: flex; flex-direction: column; position: fixed; top: 1em; bottom: 1em; left: 41em; right: 1em; }
+}
 @media (prefers-color-scheme: dark) {
   body { background: #111; color: #ddd; } a { color: #4fb3d9; } .note, footer, .bookmarks small { color: #999; } .error { color: #f77; }
+  .bookmarks li.sel { background: #1e2d34; box-shadow: 0 0 0 .3em #1e2d34; } #preview iframe { border-color: #444; }
 }
 </style>
 </head>
@@ -150,6 +160,10 @@ their bookmark files.</p>
 </select></label>
 <button>Search</button>
 </form>
+<section id="preview" hidden>
+<p class="note">Type a search, or point at a bookmark, to see its page here.</p>
+<iframe title="Preview of the page" sandbox="allow-scripts allow-same-origin" referrerpolicy="no-referrer"></iframe>
+</section>
 <details{{if eq .Open "add"}} open{{end}}><summary>Add a bookmark</summary>
 <form method="post" action="/bookmarks/add">
 <label>URL <input name="url" value="{{.Form.URL}}" required></label>
@@ -187,6 +201,7 @@ This page shows the first {{len .Rows}}.{{end}}{{if or .Query .Tag}} <a href="/b
 {{with .Tags}}<h2>Tags</h2>
 <p>{{range .}}<a href="/bookmarks?tag={{.Name}}">{{.Name}}</a>&nbsp;({{.N}}) {{end}}</p>{{end}}
 <script src="/search.js" defer></script>
+<script src="/preview.js" defer></script>
 {{template "foot" .}}{{end}}
 
 {{define "edit"}}{{template "head" .}}
@@ -288,6 +303,12 @@ server deletes the account and all versions of your bookmark file at once.</p>
 <p>They keep your bookmarks on your device. They send them only to the sync
 server that you sign in to, and only after you sign in. Without an account,
 no data leaves your device.</p>
+<h2>The live preview</h2>
+<p>The bookmark page of this server, the app and the add-on show the page of
+the highlighted bookmark while you search. Your browser, or the app, loads
+that page from its own site, as a visit does, without a referrer. This
+server does not load it and does not learn which pages you preview. The app
+and the add-on can turn the preview off.</p>
 {{with .Contact}}<h2>Contact</h2>
 <p><a href="mailto:{{.}}">{{.}}</a></p>{{end}}
 {{template "foot" .}}{{end}}
diff --git a/preview.js b/preview.js
new file mode 100644
index 0000000..22cad2d
--- /dev/null
+++ b/preview.js
@@ -0,0 +1,108 @@
+// Live preview for the bookmark page of sbm Sync, as in the sbm add-on: the
+// page of the highlighted bookmark shows in a frame beside the results. The
+// arrow keys in the search box and the mouse move the highlight, and Enter
+// opens the highlighted bookmark (search.js). search.js changes the results;
+// this script follows the changes. The preview needs a wide window: in a
+// narrow one, the pane stays hidden and loads no page.
+"use strict";
+
+(() => {
+  const results = document.getElementById("results");
+  const pane = document.getElementById("preview");
+  const form = document.getElementById("search");
+  if (!results || !pane || !form) return;
+  const box = form.elements.q;
+  const frame = pane.querySelector("iframe");
+  const caption = pane.querySelector("p");
+  const wide = matchMedia("(min-width: 64em)");
+  // A page loads only after the highlight stays on it this long (ms), so
+  // that fast typing does not load a page for each letter.
+  const wait = 300;
+  // A search from the address bar of the browser starts the preview at once.
+  // Else it starts when the user types or points at a bookmark.
+  let started = box.value.trim() !== "";
+  let sel = 0; // the highlighted row
+  let timer = null;
+  let framed = null; // the address in the frame
+  let pointer = ""; // where the mouse was last
+
+  const rows = () => [...results.querySelectorAll("li")];
+
+  // address gives the address that the frame loads for a row, or null. An
+  // http page loads with https, because a secure page cannot show an
+  // insecure frame.
+  function address(li) {
+    const a = li && li.querySelector("a.open");
+    if (!a) return null;
+    const u = new URL(a.href);
+    if (u.protocol !== "http:" && u.protocol !== "https:") return null;
+    u.protocol = "https:";
+    return u.href;
+  }
+
+  function load(to) {
+    if (to === framed) return;
+    framed = to;
+    frame.src = to ?? "about:blank";
+  }
+
+  // show shows the page of row li, when the highlight stays on it.
+  function show(li) {
+    clearTimeout(timer);
+    if (!wide.matches) return;
+    const to = address(li);
+    caption.textContent = !li ? "No bookmark to preview." : to ?? "No preview: this bookmark is not a web page.";
+    if (to) timer = setTimeout(() => load(to), wait);
+    else load(null);
+  }
+
+  function highlight(i) {
+    const all = rows();
+    sel = Math.max(0, Math.min(all.length - 1, i));
+    all.forEach((li, j) => li.classList.toggle("sel", j === sel));
+    if (started) show(all[sel]);
+  }
+
+  // point: the user points at row i, with the mouse, the Tab key or the
+  // arrow keys.
+  function point(i) {
+    started = true;
+    highlight(i);
+  }
+
+  // New results: the highlight goes to the first row.
+  new MutationObserver(() => highlight(0)).observe(results, { childList: true });
+  box.addEventListener("input", () => {
+    started = true;
+  });
+  box.addEventListener("keydown", (e) => {
+    if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
+    e.preventDefault();
+    point(sel + (e.key === "ArrowDown" ? 1 : -1));
+    const li = rows()[sel];
+    if (li) li.scrollIntoView({ block: "nearest" });
+  });
+  results.addEventListener("mousemove", (e) => {
+    // The browser also sends mousemove when the page moves under a mouse
+    // that stays still. Only a move of the mouse moves the highlight.
+    const li = e.target.closest("li");
+    const at = e.screenX + "," + e.screenY;
+    if (!li || at === pointer) return;
+    pointer = at;
+    point(rows().indexOf(li));
+  });
+  results.addEventListener("focusin", (e) => {
+    const li = e.target.closest("li");
+    if (li) point(rows().indexOf(li));
+  });
+  wide.addEventListener("change", () => (wide.matches ? highlight(sel) : load(null)));
+  // A page in the frame can take the focus with a script, and then the
+  // typing goes into that page. The preview is only to look at, so the
+  // focus goes back to the search box at once. The mouse wheel and clicks
+  // on links still work in the frame.
+  window.addEventListener("blur", () => setTimeout(() => {
+    if (document.activeElement === frame) box.focus({ preventScroll: true });
+  }));
+  pane.hidden = false;
+  highlight(0);
+})();
diff --git a/search.js b/search.js
index d14f084..64da094 100644
--- a/search.js
+++ b/search.js
@@ -1,6 +1,7 @@
 // Live search for the bookmark pages of sbm Sync: the results change while
-// you type, and Enter opens the first result, as in the sbm add-on.
-// Ctrl+Enter opens it in a new tab. The server does the search: this script
+// you type, and Enter opens the first result, as in the sbm add-on. When
+// preview.js highlights a result, Enter opens that one. Ctrl+Enter opens it
+// in a new tab. The server does the search: this script
 // gets the page of the search and shows its results. Without this script the
 // form still works.
 "use strict";
@@ -57,9 +58,10 @@
       running = update();
     }
     await running;
-    const first = results.querySelector("a.open");
-    if (first === null) form.submit();
-    else if (e.ctrlKey || e.metaKey) window.open(first.href, "_blank", "noopener");
-    else location.assign(first.href);
+    const row = results.querySelector("li.sel");
+    const link = row ? row.querySelector("a.open") : results.querySelector("a.open");
+    if (link === null) form.submit();
+    else if (e.ctrlKey || e.metaKey) window.open(link.href, "_blank", "noopener");
+    else location.assign(link.href);
   });
 })();
diff --git a/web_test.go b/web_test.go
index 47da09f..b261efc 100644
--- a/web_test.go
+++ b/web_test.go
@@ -523,3 +523,45 @@ func TestLiveSearch(t *testing.T) {
 		t.Errorf("GET /search.js: %s %q, %d bytes", resp.Status, resp.Header.Get("Content-Type"), len(b))
 	}
 }
+
+func TestLivePreview(t *testing.T) {
+	e := newEnv(t, false)
+	c, _, _ := e.account("https://a.org\tA\t\n")
+	resp, err := c.Get(e.web.URL + "/bookmarks")
+	if err != nil {
+		t.Fatal(err)
+	}
+	b, _ := io.ReadAll(resp.Body)
+	resp.Body.Close()
+	// The preview shows pages of other sites in a frame, but only https
+	// pages: an http frame in an https page is mixed content. No site may
+	// show this page in a frame.
+	csp := resp.Header.Get("Content-Security-Policy")
+	if !strings.Contains(csp, "; frame-src https:;") || !strings.Contains(csp, "frame-ancestors 'none'") {
+		t.Errorf("Content-Security-Policy: %q", csp)
+	}
+	// The frame runs the scripts of the page, but the page cannot lead this
+	// page away, open windows or send forms, and it gets no referrer. The
+	// script shows the pane.
+	for _, want := range []string{`<section id="preview" hidden>`,
+		`<iframe title="Preview of the page" sandbox="allow-scripts allow-same-origin" referrerpolicy="no-referrer"></iframe>`,
+		`<script src="/preview.js" defer></script>`} {
+		if !strings.Contains(string(b), want) {
+			t.Errorf("the page has no %q", want)
+		}
+	}
+	resp, err = e.browser().Get(e.web.URL + "/preview.js")
+	if err != nil {
+		t.Fatal(err)
+	}
+	b, _ = io.ReadAll(resp.Body)
+	resp.Body.Close()
+	if resp.StatusCode != http.StatusOK || resp.Header.Get("Content-Type") != "text/javascript; charset=utf-8" || string(b) != previewJS {
+		t.Errorf("GET /preview.js: %s %q, %d bytes", resp.Status, resp.Header.Get("Content-Type"), len(b))
+	}
+	// The home page has the search, but no room for a preview.
+	if home := e.body(c, "/"); !strings.Contains(home, `id="search"`) ||
+		strings.Contains(home, `<section id="preview"`) || strings.Contains(home, "/preview.js") {
+		t.Error("the home page must have the search and no preview")
+	}
+}