Recently Written · git

sbm

dmenu bookmarks. Instantly fuzzy search thousands of bookmarks and plumb them. LOOKING FOR SUCKLESS SOFTWARE EDITION? GO TO sbm-suckless INSTEAD

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

Log | Files | Refs


commit 577676dddee56219928f0ee7af6885546820992f
Spenser Truex <truex@equwal.com>
2026-09-22 20:11:19 -0700

bm-page: the text of the page in the fzf preview

The new tool bm-page prints a web page as plain text: its title, then
its main part (the <main> element, when the page has one), without
scripts, styles, comments and svg. It fetches the page with curl (5
second limit, at most 512k) and removes control characters, so that a
page cannot send escape sequences to the terminal. SBM_FETCH=0 keeps it
off the network, as for bm-title.

The fzf preview of bm now shows the bookmark in full, then the text of
its page when bm-page is installed. The preview is on the right, half
of the width, because a page needs more room than five lines. A short
sleep before the fetch keeps a fast scroll from fetching each page on
the way.

make install, the Windows installer and the package files include
bm-page.

 Makefile                                           |   2 +-
 README                                             |  16 +-
 bm                                                 |   9 +-
 bm-page                                            | 212 +++++++++++++++++++++
 config.mk                                          |   3 +-
 contrib/windows/sbm.nsi                            |   4 +-
 packaging/README.md                                |   4 +-
 packaging/chocolatey/tools/chocolateyInstall.ps1   |   2 +-
 packaging/chocolatey/tools/chocolateyUninstall.ps1 |   2 +-
 packaging/freebsd/deskutils/sbm/pkg-plist          |   1 +
 packaging/rpm/sbm.spec                             |   1 +
 packaging/scoop/sbm.json                           |   3 +-
 test/run.sh                                        |  79 +++++++-
 13 files changed, 318 insertions(+), 20 deletions(-)
diff --git a/Makefile b/Makefile
old mode 100644
new mode 100755
index 7ae6985..d1d4764
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
 include config.mk
 
-SCRIPTS = bm bm-migrate bm-import bm-check bm-html bm-title bm-commit bm-watch bm-sync
+SCRIPTS = bm bm-migrate bm-import bm-check bm-html bm-title bm-page bm-commit bm-watch bm-sync
 TESTS = test/run.sh test/fakemenu test/fakefzf
 
 all:
diff --git a/README b/README
old mode 100644
new mode 100755
index d39c3f9..f218327
--- a/README
+++ b/README
@@ -22,6 +22,7 @@ other Unix tools, and you install only the ones you use:
     bm-html     bookmark lines           -> a searchable web page
     bm-migrate  the old sbm file format  -> bookmark lines
     bm-title    a URL                    -> the page's title
+    bm-page     a URL                    -> the page as text
     bm-commit   commit the bookmark file to git
     bm-watch    browser bookmarks        -> bm, now and after each change
     bm-sync     the bookmark file        <-> your other devices
@@ -38,8 +39,8 @@ date +%s.
 
 == Requirements ==
 dmenu (or fzf), one of xclip, xsel or wl-clipboard, sh, awk, make.
-curl for bm-title, bm-check and bm-sync, jq for bm-import of Chromium files,
-git for bm-commit.
+curl for bm-title, bm-page, bm-check and bm-sync, jq for bm-import of
+Chromium files, git for bm-commit.
 
 == Install ==
 Edit TOOLS in config.mk to choose what gets installed, then
@@ -98,7 +99,10 @@ Closing the second menu goes back to the list, closing the list leaves.
 [add] at the top of the list adds a bookmark.
 
 With fzf (no display, or SBM_MENU=fzf) it is one screen: the bookmark under
-the cursor is previewed live, and keys do the rest.
+the cursor is previewed live, and keys do the rest. With bm-page installed,
+the preview also shows the text of the page: its title, then its main part.
+bm-page fetches the page with curl (5 second limit, at most 512k; SBM_FETCH=0
+turns it off).
 
     ENTER   open            ctrl-a  add            ctrl-s  next sort order
     ctrl-y  copy            ctrl-d  delete (asks)
@@ -275,9 +279,9 @@ version 0.11 from 2016, and it has no --preview and no --expect. The wrapper
 SBM_OPEN, SBM_COPY, SBM_PASTE and SBM_FZF_OPTS for Cygwin. Settings in
 ~/.bash_profile win.
 
-For bm-title, bm-check and bm-import, install the curl and jq packages of
-Cygwin. The Windows builds of curl and jq do not understand Cygwin paths,
-such as /dev/null. To remove sbm, use Apps in the Windows settings. The
+For bm-title, bm-page, bm-check and bm-import, install the curl and jq
+packages of Cygwin. The Windows builds of curl and jq do not understand
+Cygwin paths, such as /dev/null. To remove sbm, use Apps in the Windows settings. The
 uninstaller keeps your bookmarks. contrib/windows/build-installer.sh makes
 the installer with NSIS.
 
diff --git a/bm b/bm
index af6345d..873d658 100755
--- a/bm
+++ b/bm
@@ -235,8 +235,11 @@ is_bookmark () {
 }
 
 # For fzf a fifth field is added, and it is all that is listed. The first
-# four feed the preview, which follows the cursor.
-PREVIEW="printf '%s\n%s\ntags: %s\nused: %s\n' {1} {2} {3} {4}"
+# four feed the preview, which follows the cursor: the bookmark in full, then
+# the text of its page when bm-page is installed. fzf stops the preview when
+# the cursor moves on, so the short sleep keeps a fast scroll from fetching
+# each page on the way. A sleep that knows no fractions fails and skips it.
+PREVIEW="printf '%s\n%s\ntags: %s\nused: %s\n\n' {1} {2} {3} {4}; sleep 0.3 2>/dev/null; bm-page {1} 2>/dev/null"
 fzfrows () {
     awk -F'\t' 'NF {
         desc = $2
@@ -248,7 +251,7 @@ fzfrows () {
 # withpreview <fzf command and options>: for the output of fzfrows.
 withpreview () {
     "$@" --delimiter="$TAB" --with-nth=5.. \
-         --preview-window=down:5:wrap --preview="$PREVIEW"
+         --preview-window=right:50%:wrap --preview="$PREVIEW"
 }
 
 # choose <prompt>: a bookmark's URL, or whatever else the user typed.
diff --git a/bm-page b/bm-page
new file mode 100755
index 0000000..41d73ce
--- /dev/null
+++ b/bm-page
@@ -0,0 +1,212 @@
+#!/bin/sh
+
+# bm-page: print a web page as plain text: its title, a blank line, then its
+# text. When it is installed, the fzf list of bm shows the page of the
+# bookmark under the cursor in the preview, live. SBM_FETCH=0 keeps it off
+# the network.
+#
+#     bm-page https://suckless.org/ | less
+#
+# Only http and https pages are fetched. curl stops after 5 seconds, and dd
+# passes at most 512k of the page. Scripts, styles and comments are left
+# out. Control characters are removed, so that a page cannot send escape
+# sequences to the terminal.
+
+if [ $# -ne 1 ]; then
+    printf 'usage: bm-page <url>\n' >&2
+    exit 2
+fi
+[ "${SBM_FETCH:-1}" != 0 ] || exit 0
+case $(printf '%s' "$1" | tr '[:upper:]' '[:lower:]') in
+    http://*|https://*) ;;
+    *) exit 0 ;;
+esac
+
+# The first awk reads the page as one record (RS is a control character),
+# then splits it at each "<". Each part starts with a tag and ends with the
+# text after it, so one pass through the parts reads the page in linear
+# time. The second awk tidies the white space.
+curl -sL --max-time 5 -A 'Mozilla/5.0' -- "$1" 2>/dev/null \
+    | dd bs=1024 count=512 2>/dev/null | LC_ALL=C awk '
+    function utf8(c) {
+        if (c == 160) return " "
+        if (c < 128) return sprintf("%c", c)
+        if (c < 2048) return sprintf("%c%c", 192 + int(c / 64), 128 + c % 64)
+        if (c < 65536)
+            return sprintf("%c%c%c", 224 + int(c / 4096), 128 + int(c / 64) % 64, 128 + c % 64)
+        if (c < 1114112)
+            return sprintf("%c%c%c%c", 240 + int(c / 262144), 128 + int(c / 4096) % 64,
+                           128 + int(c / 64) % 64, 128 + c % 64)
+        return ""
+    }
+    function entity(name,    n, i) {
+        if (name ~ /^#[0-9]+$/) return utf8(substr(name, 2) + 0)
+        if (name ~ /^#[xX][0-9A-Fa-f]+$/) {
+            n = 0
+            for (i = 3; i <= length(name); i++)
+                n = n * 16 + index("0123456789abcdef", tolower(substr(name, i, 1))) - 1
+            return utf8(n)
+        }
+        if (name in named) return named[name]
+        return "&" name ";"
+    }
+    function decode(s,    out, i, j, name) {
+        out = ""
+        while ((i = index(s, "&")) > 0) {
+            out = out substr(s, 1, i - 1)
+            s = substr(s, i + 1)
+            j = index(s, ";")
+            name = substr(s, 1, j - 1)
+            if (j > 1 && j <= 12 && name ~ /^(#[0-9]+|#[xX][0-9A-Fa-f]+|[A-Za-z][A-Za-z0-9]*)$/) {
+                out = out entity(name)
+                s = substr(s, j + 1)
+            } else {
+                out = out "&"
+            }
+        }
+        return out s
+    }
+    # text: the text of the page, or of its title. As in a browser, a line
+    # break in the text is a space. The line breaks come from the tags: brk
+    # tells how many are waiting for the next text.
+    function text(s) {
+        if (intitle) {
+            title = title " " s
+            return
+        }
+        s = decode(s)
+        gsub(/[ \t\r\n]+/, " ", s)
+        if (s == "" || s == " ") {
+            if (s == " " && !brk) printf " "
+            return
+        }
+        if (brk) {
+            printf "%s", brk == 2 ? "\n\n" : "\n"
+            brk = 0
+            sub(/^ /, "", s)
+        }
+        printf "%s", s
+    }
+    BEGIN {
+        RS = "\001"
+        named["amp"] = "&"; named["lt"] = "<"; named["gt"] = ">"; named["quot"] = "\""
+        named["apos"] = "\047"; named["nbsp"] = " "
+        named["ndash"] = utf8(8211); named["mdash"] = utf8(8212); named["hellip"] = utf8(8230)
+        named["lsquo"] = utf8(8216); named["rsquo"] = utf8(8217)
+        named["ldquo"] = utf8(8220); named["rdquo"] = utf8(8221)
+        named["laquo"] = utf8(171); named["raquo"] = utf8(187); named["middot"] = utf8(183)
+        named["bull"] = utf8(8226); named["copy"] = utf8(169); named["reg"] = utf8(174)
+        named["trade"] = utf8(8482); named["deg"] = utf8(176); named["times"] = utf8(215)
+        named["euro"] = utf8(8364)
+        # Tags that start a line, and tags that start a paragraph.
+        split("br div li tr dt dd header footer nav main section article aside " \
+              "figure figcaption form details summary address hr", b, " ")
+        for (i in b) line[b[i]] = 1
+        split("p h1 h2 h3 h4 h5 h6 ul ol dl table blockquote pre", b, " ")
+        for (i in b) para[b[i]] = 1
+        # Tags whose content is no text of the page.
+        split("script style noscript template svg iframe object", b, " ")
+        for (i in b) hidden[b[i]] = 1
+    }
+    { page = page $0 }
+    END {
+        if (substr(page, 1, 5) == "%PDF-") {
+            print "(a PDF document)"
+            exit
+        }
+        n = split(page, part, "<")
+        text(part[1])
+        for (i = 2; i <= n; i++) {
+            p = part[i]
+            if (comment || substr(p, 1, 3) == "!--") {
+                k = index(p, "-->")
+                comment = !k
+                if (k && skip == "") text(substr(p, k + 3))
+                continue
+            }
+            k = index(p, ">")
+            if (!k) {
+                # A "<" in the text, or the end of a page that dd cut off.
+                if (skip == "" && i < n) text("<" p)
+                continue
+            }
+            tag = tolower(substr(p, 1, k - 1))
+            closing = substr(tag, 1, 1) == "/"
+            name = tag
+            sub(/^\//, "", name)
+            sub(/[^a-z0-9].*/, "", name)
+            rest = substr(p, k + 1)
+            if (skip != "") {
+                if (closing && name == skip) {
+                    skip = ""
+                    text(rest)
+                }
+                continue
+            }
+            if (name in hidden && !closing && substr(tag, length(tag)) != "/") {
+                skip = name
+                continue
+            }
+            if (name == "title" && !done) {
+                if (closing) {
+                    intitle = 0
+                    done = 1
+                    t = decode(title)
+                    gsub(/[ \t\r\n]+/, " ", t)
+                    printf "\n\001T%s\n", t
+                    brk = 0
+                } else {
+                    intitle = 1
+                }
+            } else if (name == "main") {
+                # Marks for the second awk: the main part of the page.
+                printf "\n\001%s\n", closing ? ">" : "<"
+                brk = 0
+            } else if (name in para) {
+                brk = 2
+            } else if (name in line) {
+                if (!brk) brk = 1
+            } else if (name == "td" || name == "th") {
+                text(" ")
+            }
+            text(rest)
+        }
+        print ""
+    }' | LC_ALL=C awk '
+    {
+        gsub(/\302\240/, " ")
+        gsub(/\302[\200-\237]/, "")
+        gsub(/[ \t\r]+/, " ")
+        sub(/^ /, "")
+        sub(/ $/, "")
+    }
+    /^\001T/ {
+        if (title == "") {
+            title = substr($0, 3)
+            sub(/^ /, "", title)
+        }
+        next
+    }
+    /^\001</ { if (!opened) { opened = 1; from = n + 1 }; next }
+    /^\001>/ { if (opened && !closed) { closed = 1; to = n }; next }
+    { line[++n] = $0 }
+    # The title, then the main part of the page when the page marks one, so
+    # that menus do not fill the preview. At most one blank line in a row.
+    END {
+        if (!opened) from = 1
+        if (!closed) to = n
+        if (title != "") {
+            print title
+            seen = blank = 1
+        }
+        for (i = from; i <= to; i++) {
+            if (line[i] == "") {
+                blank = seen
+                continue
+            }
+            if (blank) print ""
+            print line[i]
+            seen = 1
+            blank = 0
+        }
+    }' | tr -d '\000-\010\013-\037\177'
diff --git a/config.mk b/config.mk
old mode 100644
new mode 100755
index 063267d..cf0eeab
--- a/config.mk
+++ b/config.mk
@@ -10,7 +10,8 @@ MANPREFIX=${PREFIX}/share/man
 #   bm-check    report dead links (curl)
 #   bm-html     the bookmarks as a searchable web page
 #   bm-title    lets bm --add suggest the page title as description (curl)
+#   bm-page     shows the text of the page in the fzf preview of bm (curl)
 #   bm-commit   lets bm keep the file's history in git
 #   bm-watch    brings new browser bookmarks into bm (bm-import, jq)
 #   bm-sync     keeps the file the same on all your devices (curl)
-TOOLS = bm bm-migrate bm-import bm-check bm-html bm-title bm-commit bm-watch bm-sync
+TOOLS = bm bm-migrate bm-import bm-check bm-html bm-title bm-page bm-commit bm-watch bm-sync
diff --git a/contrib/windows/sbm.nsi b/contrib/windows/sbm.nsi
old mode 100644
new mode 100755
index 3546a0c..81614df
--- a/contrib/windows/sbm.nsi
+++ b/contrib/windows/sbm.nsi
@@ -83,6 +83,7 @@ Section
   File "${SRC}\bin\bm-html"
   File "${SRC}\bin\bm-import"
   File "${SRC}\bin\bm-migrate"
+  File "${SRC}\bin\bm-page"
   File "${SRC}\bin\bm-sync"
   File "${SRC}\bin\bm-title"
   File "${SRC}\bin\bm-watch"
@@ -100,7 +101,7 @@ Section
 
   ; Cygwin does not see an execute permission on files that a Windows
   ; program makes. Cygwin chmod sets it.
-  nsExec::ExecToLog '"$Cygwin\bin\chmod.exe" 755 /usr/local/bin/bm /usr/local/bin/bm-check /usr/local/bin/bm-commit /usr/local/bin/bm-html /usr/local/bin/bm-import /usr/local/bin/bm-migrate /usr/local/bin/bm-sync /usr/local/bin/bm-title /usr/local/bin/bm-watch /usr/local/bin/fzf /usr/local/libexec/sbm/fzf.exe'
+  nsExec::ExecToLog '"$Cygwin\bin\chmod.exe" 755 /usr/local/bin/bm /usr/local/bin/bm-check /usr/local/bin/bm-commit /usr/local/bin/bm-html /usr/local/bin/bm-import /usr/local/bin/bm-migrate /usr/local/bin/bm-page /usr/local/bin/bm-sync /usr/local/bin/bm-title /usr/local/bin/bm-watch /usr/local/bin/fzf /usr/local/libexec/sbm/fzf.exe'
   Pop $0
   ${If} $0 != 0
     Abort "chmod failed with exit status $0"
@@ -163,6 +164,7 @@ Section Uninstall
   Delete "$Cygwin\usr\local\bin\bm-html"
   Delete "$Cygwin\usr\local\bin\bm-import"
   Delete "$Cygwin\usr\local\bin\bm-migrate"
+  Delete "$Cygwin\usr\local\bin\bm-page"
   Delete "$Cygwin\usr\local\bin\bm-sync"
   Delete "$Cygwin\usr\local\bin\bm-title"
   Delete "$Cygwin\usr\local\bin\bm-watch"
diff --git a/packaging/README.md b/packaging/README.md
old mode 100644
new mode 100755
index 13385a6..653f57c
--- a/packaging/README.md
+++ b/packaging/README.md
@@ -24,7 +24,7 @@ mentions a sync client.
 
 `make install` copies the programs in `TOOLS` to `${DESTDIR}${PREFIX}/bin`:
 
-    bm  bm-migrate  bm-import  bm-check  bm-html  bm-title  bm-commit  bm-watch
+    bm  bm-migrate  bm-import  bm-check  bm-html  bm-title  bm-page  bm-commit  bm-watch
 
 Nothing is compiled. There are no man pages. Every package calls
 `make DESTDIR=... PREFIX=... install` rather than copying the scripts itself,
@@ -43,7 +43,7 @@ format is in use, and the user copies them to `~/.local/share/sbm/`.
 | Required | dmenu **or** fzf | the menu |
 | Required | xclip **or** xsel **or** wl-clipboard | `bm --copy` |
 | Recommended | xdg-utils | opening URLs (`xdg-open`) |
-| Optional | curl | `bm-title`, `bm-check` |
+| Optional | curl | `bm-title`, `bm-page`, `bm-check` |
 | Optional | jq | `bm-import` of Chromium JSON files |
 | Optional | git | `bm-commit` |
 
diff --git a/packaging/chocolatey/tools/chocolateyInstall.ps1 b/packaging/chocolatey/tools/chocolateyInstall.ps1
old mode 100644
new mode 100755
index e6a62ca..14996fa
--- a/packaging/chocolatey/tools/chocolateyInstall.ps1
+++ b/packaging/chocolatey/tools/chocolateyInstall.ps1
@@ -6,7 +6,7 @@ $toolsDir    = Split-Path -Parent $MyInvocation.MyCommand.Definition
 $stage       = Join-Path $toolsDir 'staging'
 
 # What Makefile's TOOLS installs.
-$scripts = @('bm', 'bm-migrate', 'bm-import', 'bm-check', 'bm-html', 'bm-title', 'bm-commit', 'bm-watch', 'bm-sync')
+$scripts = @('bm', 'bm-migrate', 'bm-import', 'bm-check', 'bm-html', 'bm-title', 'bm-page', 'bm-commit', 'bm-watch', 'bm-sync')
 
 # ---------------------------------------------------------------------------
 # Fetch and unpack.
diff --git a/packaging/chocolatey/tools/chocolateyUninstall.ps1 b/packaging/chocolatey/tools/chocolateyUninstall.ps1
old mode 100644
new mode 100755
index a5f567e..f055096
--- a/packaging/chocolatey/tools/chocolateyUninstall.ps1
+++ b/packaging/chocolatey/tools/chocolateyUninstall.ps1
@@ -2,7 +2,7 @@ $ErrorActionPreference = 'Stop'
 
 $toolsDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
 
-$scripts = @('bm', 'bm-migrate', 'bm-import', 'bm-check', 'bm-html', 'bm-title', 'bm-commit', 'bm-watch', 'bm-sync')
+$scripts = @('bm', 'bm-migrate', 'bm-import', 'bm-check', 'bm-html', 'bm-title', 'bm-page', 'bm-commit', 'bm-watch', 'bm-sync')
 
 foreach ($s in $scripts) {
   $cmdPath = Join-Path $toolsDir "$s.cmd"
diff --git a/packaging/freebsd/deskutils/sbm/pkg-plist b/packaging/freebsd/deskutils/sbm/pkg-plist
old mode 100644
new mode 100755
index 5447749..ccd8d22
--- a/packaging/freebsd/deskutils/sbm/pkg-plist
+++ b/packaging/freebsd/deskutils/sbm/pkg-plist
@@ -5,6 +5,7 @@ bin/bm-html
 bin/bm-import
 bin/bm-migrate
 bin/bm-title
+bin/bm-page
 bin/bm-watch
 %%PORTDOCS%%%%DOCSDIR%%/README
 %%PORTDOCS%%%%DOCSDIR%%/TODO
diff --git a/packaging/rpm/sbm.spec b/packaging/rpm/sbm.spec
old mode 100644
new mode 100755
index 39359b6..2bd2059
--- a/packaging/rpm/sbm.spec
+++ b/packaging/rpm/sbm.spec
@@ -81,6 +81,7 @@ make check
 %{_bindir}/bm-import
 %{_bindir}/bm-migrate
 %{_bindir}/bm-title
+%{_bindir}/bm-page
 %{_bindir}/bm-watch
 
 %changelog
diff --git a/packaging/scoop/sbm.json b/packaging/scoop/sbm.json
old mode 100644
new mode 100755
index a846f65..0c1f364
--- a/packaging/scoop/sbm.json
+++ b/packaging/scoop/sbm.json
@@ -37,7 +37,7 @@
         "  'if not defined SBM_SH echo sbm: no POSIX sh found. Install Git: scoop install git 1>&2',",
         "  'if not defined SBM_SH exit /b 127'",
         ")",
-        "foreach ($t in 'bm','bm-migrate','bm-import','bm-check','bm-html','bm-title','bm-commit','bm-watch') {",
+        "foreach ($t in 'bm','bm-migrate','bm-import','bm-check','bm-html','bm-title','bm-page','bm-commit','bm-watch') {",
         "  ($lines + ('\"%SBM_SH%\" \"%~dp0' + $t + '\" %*')) | Set-Content -LiteralPath \"$dir\\$t.cmd\" -Encoding ASCII",
         "}"
     ],
@@ -48,6 +48,7 @@
         "bm-check.cmd",
         "bm-html.cmd",
         "bm-title.cmd",
+        "bm-page.cmd",
         "bm-commit.cmd",
         "bm-watch.cmd"
     ],
diff --git a/test/run.sh b/test/run.sh
index 083a1f1..18cb118 100755
--- a/test/run.sh
+++ b/test/run.sh
@@ -16,6 +16,7 @@ IMPORT="${SBM_SH:-sh} $top/bm-import"
 CHECK="${SBM_SH:-sh} $top/bm-check"
 HTML="${SBM_SH:-sh} $top/bm-html"
 TITLE="${SBM_SH:-sh} $top/bm-title"
+PAGE="${SBM_SH:-sh} $top/bm-page"
 TAB=$(printf '\t')
 
 work=${TMPDIR:-/tmp}/sbmtest.$$
@@ -311,7 +312,7 @@ for gone in '--import x' --check --migrate '-s x'; do
 done
 
 bad=
-for script in bm bm-migrate bm-import bm-check bm-html bm-title bm-commit bm-watch; do
+for script in bm bm-migrate bm-import bm-check bm-html bm-title bm-page bm-commit bm-watch; do
     [ "$(sed -n 1p "$top/$script")" = '#!/bin/sh' ] || bad="$bad $script"
 done
 eq 'every script asks for /bin/sh' "$bad" ''
@@ -463,6 +464,78 @@ eq 'a missing tag file is created from the first new tag' \
     "$(cat "$USERTAGS")|$(cut -f3 "$BOOKMARKS")" 'first | |first'
 cp "$here/../usertags" "$USERTAGS"
 
+# ---- bm-page, and the page in the fzf preview ----
+
+mkdir "$work/page"
+cat > "$work/page/curl" <<'FAKE'
+#!/bin/sh
+printf 'curl %s\n' "$*" >> "$SBM_TEST_CURLLOG"
+for arg in "$@"; do url=$arg; done
+case $url in
+    *rich*)
+        printf '<!doctype html><html><head><TITLE lang=en>\n A &amp; B &#8212; &#x263A; </Title>\n'
+        printf '<style>p { color: red } a < b</style><script>if (a < b) document.write("<p>no</p>")</script></head>\n'
+        printf '<body><!-- a < comment -->\n<h1>Head</h1><p>One\n   two <a href="x">link</a>&nbsp;three.</p>\n'
+        printf '<ul><li>first</li><li>second</li></ul><svg><title>no</title><text>no</text></svg>\n'
+        printf '<noscript>no</noscript><p>5 &lt; 6 &bogus; &#65;</p></body></html>\n'
+        ;;
+    *ctrl*)
+        printf '<p>esc:\033[31mred\033[0m bel:\007 c1:\302\233x end</p>\n'
+        ;;
+    *main*)
+        printf '<title>T</title><nav>Menu one<br>Menu two</nav><main><h1>Article</h1><p>Body</p></main><footer>Foot</footer>\n'
+        ;;
+    *pdf*)
+        printf '%%PDF-1.7\n1 0 obj\n'
+        ;;
+    *big*)
+        # More than 512k before the text.
+        awk 'BEGIN { for (i = 0; i < 540000; i++) printf "x"; print "<p>too far</p>" }'
+        ;;
+esac
+FAKE
+chmod +x "$work/page/curl"
+
+# page <url>: bm-page with the stand-in curl.
+page () {
+    PATH="$work/page:$PATH" SBM_FETCH=1 $PAGE "$@"
+}
+nl='
+'
+
+eq 'bm-page prints the title, then the text without scripts, styles, comments and svg' \
+    "$(page https://rich.example/)" \
+    "A & B — ☺${nl}${nl}Head${nl}${nl}One two link three.${nl}${nl}first${nl}second${nl}${nl}5 < 6 &bogus; A"
+out=$(page https://ctrl.example/)
+eq 'bm-page removes control characters, so that a page cannot drive the terminal' \
+    "$out|$(printf '%s' "$out" | LC_ALL=C tr -d '\040-\176' | wc -c | tr -d ' ')" 'esc:[31mred[0m bel: c1:x end|0'
+eq 'bm-page shows only the main part of a page that marks one' \
+    "$(page https://main.example/)" "T${nl}${nl}Article${nl}${nl}Body"
+eq 'bm-page names a PDF document' "$(page https://pdf.example/)" '(a PDF document)'
+eq 'bm-page reads at most 512k of a page' "$(page https://big.example/ | grep -c 'too far')" '0'
+: > "$SBM_TEST_CURLLOG"
+eq 'bm-page with SBM_FETCH=0 prints nothing' \
+    "$(PATH="$work/page:$PATH" SBM_FETCH=0 $PAGE https://rich.example/)" ''
+eq 'bm-page fetches only http and https pages' \
+    "$(page ftp://rich.example/)$(page 'javascript:alert(1)')$(page rich.example)" ''
+eq 'bm-page with SBM_FETCH=0, or for another scheme, never runs curl' "$(cat "$SBM_TEST_CURLLOG")" ''
+eq 'bm-page takes an HTTPS address in capitals' "$(page HTTPS://main.example/ | sed -n 1p)" 'T'
+$PAGE 2>/dev/null
+eq 'bm-page wants exactly one url' "$?" '2'
+$PAGE https://a.example https://b.example 2>/dev/null
+eq 'bm-page wants exactly one url, not two' "$?" '2'
+
+# The preview command of the fzf list, as fzf runs it: each placeholder
+# becomes its field in single quotes.
+preview=$(sed -n 's/^PREVIEW="\(.*\)"$/\1/p' "$top/bm" |
+    sed "s|{1}|'https://main.example/'|g; s|{2}|'Main page'|; s|{3}|'code'|; s|{4}|'3'|")
+eq 'the fzf preview shows the bookmark, then the text of its page' \
+    "$(PATH="$top:$work/page:$PATH" SBM_FETCH=1 ${SBM_SH:-sh} -c "$preview")" \
+    "https://main.example/${nl}Main page${nl}tags: code${nl}used: 3${nl}${nl}T${nl}${nl}Article${nl}${nl}Body"
+eq 'the fzf preview shows the bookmark without bm-page' \
+    "$(PATH="$work/page:/usr/bin:/bin" SBM_FETCH=1 ${SBM_SH:-sh} -c "$preview" 2>&1)" \
+    "https://main.example/${nl}Main page${nl}tags: code${nl}used: 3"
+
 # ---- bm-import and bm --merge ----
 
 reset
@@ -726,13 +799,13 @@ eq 'browse: text that matches nothing is searched for on the web' \
 answers 'ctrl-s:' ''
 fz </dev/null
 eq 'browse: the preview follows the cursor' \
-    "$(grep -c -e "--preview=printf '%s.n%s.ntags: %s.nused: %s.n' {1} {2} {3} {4}" "$SBM_TEST_LOG")" '2'
+    "$(grep -c -e "--preview=printf '%s.n%s.ntags: %s.nused: %s.n.n' {1} {2} {3} {4}; sleep 0.3 2>/dev/null; bm-page {1} 2>/dev/null" "$SBM_TEST_LOG")" '2'
 
 : > "$SBM_TEST_LOG"
 answers 'Mid'
 fz -c </dev/null
 eq 'fzf: rows are listed by their display column, with a live preview' \
-    "$(grep -c -e "--with-nth=5.. --preview-window=down:5:wrap --preview=printf '%s" "$SBM_TEST_LOG")" '1'
+    "$(grep -c -e "--with-nth=5.. --preview-window=right:50%:wrap --preview=printf '%s" "$SBM_TEST_LOG")" '1'
 : > "$SBM_TEST_LOG"
 answers 'Mid'
 SBM_FZF_OPTS='--height=100%' fz -c </dev/null