Disable shellcheck in usplit
[bollux.git/.git] / bollux
diff --git a/bollux b/bollux
index 9055b5d..d58aac3 100755 (executable)
--- a/bollux
+++ b/bollux
@@ -2,14 +2,11 @@
 # bollux: a bash gemini client
 # Author: Case Duckworth
 # License: MIT
-# Version: 0.2.2
+# Version: 0.4.0
 
 # Program information
 PRGN="${0##*/}"
-VRSN=0.2.2
-# State
-REDIRECTS=0
-set -f
+VRSN=0.4.0
 
 bollux_usage() {
        cat <<END
@@ -27,54 +24,67 @@ parameters:
 END
 }
 
-run() {
-       log debug "$@"
+run() { # run COMMAND...
+       trap bollux_quit SIGINT
+       log debug "$*"
        "$@"
 }
 
-die() {
-       ec="$1"
+die() { # die EXIT_CODE MESSAGE
+       local ec="$1"
        shift
        log error "$*"
        exit "$ec"
 }
 
-# pure bash bible trim_string
-trim() {
+# builtin replacement for `sleep`
+# https://github.com/dylanaraps/pure-bash-bible#use-read-as-an-alternative-to-the-sleep-command
+sleep() { # sleep SECONDS
+       read -rt "$1" <> <(:) || :
+}
+
+# https://github.com/dylanaraps/pure-bash-bible/
+trim_string() { # trim_string STRING
        : "${1#"${1%%[![:space:]]*}"}"
        : "${_%"${_##*[![:space:]]}"}"
        printf '%s\n' "$_"
 }
 
-log() {
+log() { # log LEVEL MESSAGE
        [[ "$BOLLUX_LOGLEVEL" == QUIET ]] && return
+       local fmt
+
        case "$1" in
-       d* | D*) # debug
+       [dD]*) # debug
                [[ "$BOLLUX_LOGLEVEL" == DEBUG ]] || return
                fmt=34
                ;;
-       e* | E*) # error
+       [eE]*) # error
                fmt=31
                ;;
        *) fmt=1 ;;
        esac
        shift
-       printf >&2 '\e[%sm%s:\e[0m\t%s\n' "$fmt" "$PRGN" "$*"
+
+       printf >&2 '\e[%sm%s:%s:\e[0m\t%s\n' "$fmt" "$PRGN" "${FUNCNAME[1]}" "$*"
 }
 
 # main entry point
 bollux() {
-       run bollux_config
-       run bollux_args "$@"
-       run history_init
+       run bollux_config    # TODO: figure out better config method
+       run bollux_args "$@" # and argument parsing
+       run bollux_init
 
-       if [[ ! "${BOLLUX_URL:+isset}" ]]; then
+       if [[ ! "${BOLLUX_URL:+x}" ]]; then
                run prompt GO BOLLUX_URL
        fi
 
-       run blastoff "$BOLLUX_URL"
+       log d "BOLLUX_URL='$BOLLUX_URL'"
+
+       run blastoff -u "$BOLLUX_URL"
 }
 
+# process command-line arguments
 bollux_args() {
        while getopts :hvq OPT; do
                case "$OPT" in
@@ -94,6 +104,7 @@ bollux_args() {
        fi
 }
 
+# process config file and set variables
 bollux_config() {
        : "${BOLLUX_CONFIG:=${XDG_CONFIG_DIR:-$HOME/.config}/bollux/bollux.conf}"
 
@@ -105,17 +116,21 @@ bollux_config() {
        fi
 
        ## behavior
-       : "${BOLLUX_DOWNDIR:=.}"                   # where to save downloads
-       : "${BOLLUX_LOGLEVEL:=3}"                  # log level
-       : "${BOLLUX_MAXREDIR:=5}"                  # max redirects
-       : "${BOLLUX_PORT:=1965}"                   # port number
-       : "${BOLLUX_PROTO:=gemini}"                # default protocol
-       : "${BOLLUX_LESSKEY:=/tmp/bollux-lesskey}" # where to store binds
-       : "${BOLLUX_PAGESRC:=/tmp/bollux-src}"     # where to save the page source
-       : "${BOLLUX_URL:=}"                        # start url
+       : "${BOLLUX_TIMEOUT:=30}"                      # connection timeout
+       : "${BOLLUX_MAXREDIR:=5}"                      # max redirects
+       : "${BOLLUX_PORT:=1965}"                       # port number
+       : "${BOLLUX_PROTO:=gemini}"                    # default protocol
+       : "${BOLLUX_URL:=}"                            # start url
+       : "${BOLLUX_BYEMSG:=See You Space Cowboy ...}" # bye message
+       ## files
+       : "${BOLLUX_DATADIR:=${XDG_DATA_DIR:-$HOME/.local/share}/bollux}"
+       : "${BOLLUX_DOWNDIR:=.}"                       # where to save downloads
+       : "${BOLLUX_LESSKEY:=$BOLLUX_DATADIR/lesskey}" # where to store binds
+       : "${BOLLUX_PAGESRC:=$BOLLUX_DATADIR/pagesrc}" # where to save the source
+       BOLLUX_HISTFILE="$BOLLUX_DATADIR/history"      # where to save the history
        ## typesetting
-       : "${T_MARGIN:=4}"      # left and right margin
-       : "${T_WIDTH:=0}"       # width of the viewport -- 0 = get term width
+       : "${T_MARGIN:=4}" # left and right margin
+       : "${T_WIDTH:=0}"  # width of the viewport -- 0 = get term width
        # colors -- these will be wrapped in \e[ __ m
        C_RESET='\e[0m'         # reset
        : "${C_SIGIL:=35}"      # sigil (=>, #, ##, ###, *, ```)
@@ -126,130 +141,206 @@ bollux_config() {
        : "${C_HEADER2:=1}"     # header 2 formatting
        : "${C_HEADER3:=3}"     # header 3 formatting
        : "${C_LIST:=0}"        # list formatting
+       : "${C_QUOTE:=3}"       # quote formatting
        : "${C_PRE:=0}"         # preformatted text formatting
+       ## state
+       UC_BLANK=':?:'
 }
 
+# quit happily
 bollux_quit() {
-       log x "Thanks for flying $PRGN"
+       printf '\e[1m%s\e[0m:\t\e[3m%s\e[0m\n' "$PRGN" "$BOLLUX_BYEMSG"
        exit
 }
+# trap C-c
+trap bollux_quit SIGINT
 
-set_title() {
-       printf '\e]2;%s - bollux\007' "$*"
+# set the terminal title
+set_title() { # set_title STRING
+       printf '\e]2;%s\007' "$*"
 }
 
-prompt() {
-       prompt="$1"
+# prompt for input
+prompt() { # prompt [-u] PROMPT [READ_ARGS...]
+       local read_cmd=(read -e -r)
+       if [[ "$1" == "-u" ]]; then
+               read_cmd+=(-i "$BOLLUX_URL")
+               shift
+       fi
+       local prompt="$1"
        shift
-       read </dev/tty -e -r -p "$prompt> " "$@"
+       read_cmd+=(-p "$prompt> ")
+       "${read_cmd[@]}" </dev/tty "$@"
 }
 
-blastoff() { # load a url
-       local well_formed=true
+# load a URL
+blastoff() { # blastoff [-u] URL
+       local u
+
        if [[ "$1" == "-u" ]]; then
-               well_formed=false
-               shift
+               u="$(run uwellform "$2")"
+       else
+               u="$1"
        fi
-       URL="$1"
 
-       if $well_formed && [[ "$1" != "$BOLLUX_URL" ]]; then
-               URL="$(run transform_resource "$BOLLUX_URL" "$1")"
+       local -a url
+       run utransform url "$BOLLUX_URL" "$u"
+       if ! ucdef url[1]; then
+               run ucset url[1] "$BOLLUX_PROTO"
        fi
-       [[ "$URL" != *://* ]] && URL="$BOLLUX_PROTO://$URL"
-       URL="$(trim "$URL")"
 
-       server="${URL#*://}"
-       server="${server%%/*}"
+       {
+               if declare -Fp "${url[1]}_request" >/dev/null 2>&1; then
+                       run "${url[1]}_request" "$url"
+               else
+                       die 99 "No request handler for '${url[1]}'"
+               fi
+       } | run normalize | {
+               if declare -Fp "${url[1]}_response" >/dev/null 2>&1; then
+                       run "${url[1]}_response" "$url"
+               else
+                       log d "No response handler for '${url[1]}', passing thru"
+                       passthru
+               fi
+       }
+}
 
-       log d "URL='$URL' server='$server'"
+# URLS
+## https://tools.ietf.org/html/rfc3986
+uwellform() {
+       local u="$1"
 
-       run request_url "$server" "$BOLLUX_PORT" "$URL" |
-               run handle_response "$URL"
+       if [[ "$u" != *://* ]]; then
+               u="$BOLLUX_PROTO://$u"
+       fi
+
+       u="$(trim_string "$u")"
+
+       printf '%s\n' "$u"
 }
 
-transform_resource() { # transform_resource BASE_URL REFERENCE_URL
-       declare -A R B T # reference, base url, target
-       eval "$(run parse_url B "$1")"
-       eval "$(run parse_url R "$2")"
-       # A non-strict parser may ignore a scheme in the reference
-       # if it is identical to the base URI's scheme.
-       if ! "${STRICT:-true}" && [[ "${R[scheme]}" == "${B[scheme]}" ]]; then
-               unset "${R[scheme]}"
+usplit() { # usplit NAME:ARRAY URL:STRING
+       local re='^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?'
+       [[ $2 =~ $re ]] || return $?
+
+       # shellcheck disable=2034
+       local scheme="${BASH_REMATCH[2]}" \
+               authority="${BASH_REMATCH[4]}" \
+               path="${BASH_REMATCH[5]}" \
+               query="${BASH_REMATCH[7]}" \
+               fragment="${BASH_REMATCH[9]}"
+
+       # 0=url 1=scheme 2=authority 3=path 4=query 5=fragment
+       local i=1 c
+       for c in scheme authority path query fragment; do
+               if [[ "${!c}" || "$c" == path ]]; then
+                       printf -v "$1[$i]" '%s' "${!c}"
+               else
+                       # shellcheck disable=2059
+                       printf -v "$1[$i]" "$UC_BLANK"
+               fi
+               ((i += 1))
+       done
+       # shellcheck disable=2059
+       printf -v "$1[0]" "$(ujoin "$1")" # inefficient I'm sure
+}
+
+ujoin() { # ujoin NAME:ARRAY
+       local -n U="$1"
+
+       if ucdef U[1]; then
+               printf -v U[0] "%s:" "${U[1]}"
+       fi
+
+       if ucdef U[2]; then
+               printf -v U[0] "${U[0]}//%s" "${U[2]}"
+       fi
+
+       printf -v U[0] "${U[0]}%s" "${U[3]}"
+
+       if ucdef U[4]; then
+               printf -v U[0] "${U[0]}?%s" "${U[4]}"
        fi
 
-       # basically pseudo-code from spec ported to bash
-       if isdefined "R[scheme]"; then
-               T[scheme]="${R[scheme]}"
-               isdefined "R[authority]" && T[authority]="${R[authority]}"
-               isdefined R[path] &&
-                       T[path]="$(run remove_dot_segments "${R[path]}")"
-               isdefined "R[query]" && T[query]="${R[query]}"
+       if ucdef U[5]; then
+               printf -v U[0] "${U[0]}#%s" "${U[5]}"
+       fi
+
+       log d "${U[0]}"
+}
+
+ucdef() { [[ "${!1}" != "$UC_BLANK" ]]; } # ucdef NAME
+ucblank() { [[ -z "${!1}" ]]; }           # ucblank NAME
+ucset() {                                 # ucset NAME VALUE
+       run eval "${1}='$2'"
+       run ujoin "${1/\[*\]/}"
+}
+
+utransform() {   # utransform TARGET:ARRAY BASE:STRING REFERENCE:STRING
+       local -a B R    # base, reference
+       local -n T="$1" # target
+       usplit B "$2"
+       usplit R "$3"
+
+       # initialize T
+       for ((i = 1; i <= 5; i++)); do
+               T[$i]="$UC_BLANK"
+       done
+
+       # 0=url 1=scheme 2=authority 3=path 4=query 5=fragment
+       if ucdef R[1]; then
+               T[1]="${R[1]}"
+               if ucdef R[2]; then
+                       T[2]="${R[2]}"
+               fi
+               if ucdef R[3]; then
+                       T[3]="$(pundot "${R[3]}")"
+               fi
+               if ucdef R[4]; then
+                       T[4]="${R[4]}"
+               fi
        else
-               if isdefined "R[authority]"; then
-                       T[authority]="${R[authority]}"
-                       isdefined "R[authority]" &&
-                               T[path]="$(remove_dot_segments "${R[path]}")"
-                       isdefined R[query] && T[query]="${R[query]}"
+               if ucdef R[2]; then
+                       T[2]="${R[2]}"
+                       if ucdef R[2]; then
+                               T[3]="$(pundot "${R[3]}")"
+                       fi
+                       if ucdef R[4]; then
+                               T[4]="${R[4]}"
+                       fi
                else
-                       if isempty "R[path]"; then
-                               T[path]="${B[path]}"
-                               if isdefined R[query]; then
-                                       T[query]="${R[query]}"
+                       if ucblank R[3]; then
+                               T[3]="${B[3]}"
+                               if ucdef R[4]; then
+                                       T[4]="${R[4]}"
                                else
-                                       T[query]="${B[query]}"
+                                       T[4]="${B[4]}"
                                fi
                        else
-                               if [[ "${R[path]}" == /* ]]; then
-                                       T[path]="$(remove_dot_segments "${R[path]}")"
+                               if [[ "${R[3]}" == /* ]]; then
+                                       T[3]="$(pundot "${R[3]}")"
                                else
-                                       T[path]="$(merge_paths "B[authority]" "${B[path]}" "${R[path]}")"
-                                       T[path]="$(remove_dot_segments "${T[path]}")"
+                                       T[3]="$(pmerge B R)"
+                                       T[3]="$(pundot "${T[3]}")"
+                               fi
+                               if ucdef R[4]; then
+                                       T[4]="${R[4]}"
                                fi
-                               isdefined R[query] && T[query]="${R[query]}"
                        fi
-                       T[authority]="${B[authority]}"
+                       T[2]="${B[2]}"
                fi
-               T[scheme]="${B[scheme]}"
+               T[1]="${B[1]}"
        fi
-       isdefined R[fragment] && T[fragment]="${R[fragment]}"
-       # cf. 5.3 -- recomposition
-       local r=""
-       isdefined "T[scheme]" && r="$r${T[scheme]}:"
-       # remove the port from the authority
-       isdefined "T[authority]" && r="$r//${T[authority]%:*}"
-       r="$r${T[path]}"
-       isdefined T[query] && r="$r?${T[query]}"
-       isdefined T[fragment] && r="$r#${T[fragment]}"
-       printf '%s\n' "$r"
-}
-
-merge_paths() { # 5.2.3
-       # shellcheck disable=2034
-       B_authority="$1"
-       B_path="$2"
-       R_path="$3"
-       # if R_path is empty, get rid of // in B_path
-       if [[ -z "$R_path" ]]; then
-               printf '%s\n' "${B_path//\/\//\//}"
-               return
+       if ucdef R[5]; then
+               T[5]="${R[5]}"
        fi
 
-       if isdefined "B_authority" && isempty "B_path"; then
-               printf '/%s\n' "${R_path//\/\//\//}"
-       else
-               if [[ "$B_path" == */* ]]; then
-                       B_path="${B_path%/*}/"
-               else
-                       B_path=""
-               fi
-               printf '%s/%s\n' "${B_path%/}" "${R_path#/}"
-       fi
+       ujoin T
 }
 
-remove_dot_segments() { # 5.2.4
+pundot() { # pundot PATH:STRING
        local input="$1"
-       local output=
-       # ^/\.(/|$) - BASH_REMATCH[0]
+       local output
        while [[ "$input" ]]; do
                if [[ "$input" =~ ^\.\.?/ ]]; then
                        input="${input#${BASH_REMATCH[0]}}"
@@ -262,7 +353,7 @@ remove_dot_segments() { # 5.2.4
                elif [[ "$input" == . || "$input" == .. ]]; then
                        input=
                else
-                       [[ $input =~ ^(/?[^/]*)(/?.*)$ ]] || log debug NOMATCH
+                       [[ $input =~ ^(/?[^/]*)(/?.*)$ ]] || return 1
                        output="$output${BASH_REMATCH[1]}"
                        input="${BASH_REMATCH[2]}"
                fi
@@ -270,154 +361,359 @@ remove_dot_segments() { # 5.2.4
        printf '%s\n' "${output//\/\//\//}"
 }
 
-parse_url() { # eval "$(split_url NAME STRING)" => NAME[...]
-       local name="$1"
-       local string="$2"
-       # shopt -u extglob # TODO port re ^ to extglob syntax
-       local re='^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?'
-       [[ $string =~ $re ]] || return $?
-       # shopt -s extglob
-
-       local scheme="${BASH_REMATCH[2]}"
-       local authority="${BASH_REMATCH[4]}"
-       local path="${BASH_REMATCH[5]}"
-       local query="${BASH_REMATCH[7]}"
-       local fragment="${BASH_REMATCH[9]}"
-
-       for c in scheme authority query fragment; do
-               [[ "${!c}" ]] &&
-                       run printf '%s[%s]=%q\n' "$name" "$c" "${!c}"
+pmerge() {
+       local -n b="$1"
+       local -n r="$2"
+
+       if ucblank r[3]; then
+               printf '%s\n' "${b[3]//\/\//\//}"
+               return
+       fi
+
+       if ucdef b[2] && ucblank b[3]; then
+               printf '/%s\n' "${r[3]//\/\//\//}"
+       else
+               local bp=""
+               if [[ "${b[3]}" == */* ]]; then
+                       bp="${b[3]%/*}"
+               fi
+               printf '%s/%s\n' "${bp%/}" "${r[3]#/}"
+       fi
+}
+
+# https://github.com/dylanaraps/pure-bash-bible/
+uencode() { # uencode URL:STRING
+       local LC_ALL=C
+       for ((i = 0; i < ${#1}; i++)); do
+               : "${1:i:1}"
+               case "$_" in
+               [a-zA-Z0-9.~_-])
+                       printf '%s' "$_"
+                       ;;
+               *)
+                       printf '%%%02X' "'$_"
+                       ;;
+               esac
        done
-       # unclear if the path is always set even if empty but it looks that way
-       run printf '%s[path]=%q\n' "$name" "$path"
+       printf '\n'
 }
 
-# is a NAME defined ('set' in bash)?
-isdefined() { [[ "${!1+x}" ]]; } # isdefined NAME
-# is a NAME defined AND empty?
-isempty() { [[ ! "${!1-x}" ]]; } # isempty NAME
+# https://github.com/dylanaraps/pure-bash-bible/
+udecode() { # udecode URL:STRING
+       : "${1//+/ }"
+       printf '%b\n' "${_//%/\\x}"
+}
+
+# GEMINI
+# https://gemini.circumlunar.space/docs/specification.html
+gemini_request() { # gemini_request URL
+       local -a url
+       usplit url "$1"
 
-request_url() {
-       local server="$1"
-       local port="$2"
-       local url="$3"
+       # get rid of userinfo
+       ucset url[2] "${url[2]#*@}"
 
-       ssl_cmd=(openssl s_client -crlf -quiet -connect "$server:$port")
-       ssl_cmd+=(-servername "$server") # SNI
-       run "${ssl_cmd[@]}" <<<"$url" 2>/dev/null
+       local port
+       if [[ "${url[2]}" == *:* ]]; then
+               port="${url[2]#*:}"
+               ucset url[2] "${url[2]%:*}"
+       else
+               port=1965 # TODO variablize
+       fi
+
+       local ssl_cmd=(
+               openssl s_client
+               -crlf -quiet -connect "${url[2]}:$port"
+               -servername "${url[2]}"      # SNI
+               -no_ssl3 -no_tls1 -no_tls1_1 # disable old TLS/SSL versions
+       )
+
+       run "${ssl_cmd[@]}" <<<"$url"
 }
 
-handle_response() {
-       local URL="$1" code meta
+gemini_response() { # gemini_response URL
+       local url code meta
+       local title
+       url="$1"
 
-       while read -r -d $'\r' hdr; do
-               code="$(gawk '{print $1}' <<<"$hdr")"
-               meta="$(
-                       gawk '{for(i=2;i<=NF;i++)printf "%s ",$i;printf "\n"}' <<<"$hdr"
-               )"
+       # we need a loop here so it waits for the first line
+       while read -t "$BOLLUX_TIMEOUT" -r code meta ||
+               { (($? > 128)) && die 99 "Timeout."; }; do
                break
        done
 
-       log x "[$code] $meta"
+       log d "[$code] $meta"
 
        case "$code" in
-       1*)
+       1*) # input
                REDIRECTS=0
-               run history_append "$URL"
-               run prompt "$meta" QUERY
-               # shellcheck disable=2153
-               run blastoff "?$QUERY"
+               BOLLUX_URL="$url"
+               case "$code" in
+               10) run prompt "$meta" ;;
+               11) run prompt "$meta" -s ;; # password input
+               esac
+               run blastoff "?$(uencode "$REPLY")"
                ;;
-       2*)
+       2*) # OK
                REDIRECTS=0
-               run history_append "$URL"
-               run display "$meta"
+               BOLLUX_URL="$url"
+               # read ahead to find a title
+               local pretitle
+               while read -r; do
+                       pretitle="$pretitle$REPLY"$'\n'
+                       if [[ "$REPLY" =~ ^#[[:space:]]*(.*) ]]; then
+                               title="${BASH_REMATCH[1]}"
+                               break
+                       fi
+               done
+               run history_append "$url" "${title:-}"
+               # read the body out and pipe it to display
+               {
+                       printf '%s' "$pretitle"
+                       passthru
+               } | run display "$meta" "${title:-}"
                ;;
-       3*)
+       3*) # redirect
                ((REDIRECTS += 1))
                if ((REDIRECTS > BOLLUX_MAXREDIR)); then
                        die $((100 + code)) "Too many redirects!"
                fi
-               run blastoff "$meta"
+               BOLLUX_URL="$url"
+               run blastoff "$meta" # TODO: confirm redirect
                ;;
-       4*)
+       4*) # temporary error
                REDIRECTS=0
-               die "$((100 + code))" "$code"
+               die "$((100 + code))" "Temporary error [$code]: $meta"
                ;;
-       5*)
+       5*) # permanent error
                REDIRECTS=0
-               die "$((100 + code))" "$code"
+               die "$((100 + code))" "Permanent error [$code]: $meta"
                ;;
-       6*)
+       6*) # certificate error
                REDIRECTS=0
-               die "$((100 + code))" "$code"
+               log d "Not implemented: Client certificates"
+               # TODO: recheck the speck
+               die "$((100 + code))" "[$code] $meta"
                ;;
        *)
                [[ -z "${code-}" ]] && die 100 "Empty response code."
-               die "$((100 + code)) Unknown response code: $code."
+               die "$((100 + code))" "Unknown response code: $code."
                ;;
        esac
 }
 
-display() {
+# GOPHER
+# https://tools.ietf.org/html/rfc1436 protocol
+# https://tools.ietf.org/html/rfc4266 url
+gopher_request() { # gopher_request URL
+       local url server port type path
+       url="$1"
+       port=70
+
+       # RFC 4266
+       [[ "$url" =~ gopher://([^/?#:]*)(:([0-9]+))?(/((.))?(/?.*))?$ ]]
+       server="${BASH_REMATCH[1]}"
+       port="${BASH_REMATCH[3]:-70}"
+       type="${BASH_REMATCH[6]:-1}"
+       path="${BASH_REMATCH[7]}"
+
+       log d "URL='$url' SERVER='$server' TYPE='$type' PATH='$path'"
+
+       exec 9<>"/dev/tcp/$server/$port"
+       printf '%s\r\n' "$path" >&9
+       passthru <&9
+}
+
+gopher_response() { # gopher_response URL
+       local url pre type cur_server
+       pre=false
+       url="$1"
+       # RFC 4266
+       [[ "$url" =~ gopher://([^/?#:]*)(:([0-9]+))?(/((.))?(/?.*))?$ ]]
+       cur_server="${BASH_REMATCH[1]}"
+       type="${BASH_REMATCH[6]:-1}"
+
+       run history_append "$url" "" # gopher doesn't really have titles, huh
+
+       log d "TYPE='$type'"
+
+       case "$type" in
+       0) # text
+               run display text/plain
+               ;;
+       1) # menu
+               run gopher_convert | run display text/gemini
+               ;;
+       3) # failure
+               die 203 "GOPHER: failed"
+               ;;
+       7) # search
+               if [[ "$url" =~ $'\t' ]]; then
+                       run gopher_convert | run display text/gemini
+               else
+                       run prompt 'SEARCH'
+                       run blastoff "$url      $REPLY"
+               fi
+               ;;
+       *) # something else
+               run download "$url"
+               ;;
+       esac
+}
+
+# 'cat' but in pure bash
+passthru() {
+       while IFS= read -r; do
+               printf '%s\n' "$REPLY"
+       done
+}
+
+# convert gophermap to text/gemini (probably naive)
+gopher_convert() {
+       local type label path server port regex
+       # cf. https://github.com/jamestomasino/dotfiles-minimal/blob/master/bin/gophermap2gemini.awk
+       while IFS= read -r; do
+               printf -v regex '(.)([^\t]*)(\t([^\t]*)\t([^\t]*)\t([^\t]*))?'
+               if [[ "$REPLY" =~ $regex ]]; then
+                       type="${BASH_REMATCH[1]}"
+                       label="${BASH_REMATCH[2]}"
+                       path="${BASH_REMATCH[4]:-/}"
+                       server="${BASH_REMATCH[5]:-$cur_server}"
+                       port="${BASH_REMATCH[6]}"
+               else
+                       log e "CAN'T PARSE LINE"
+                       printf '%s\n' "$REPLY"
+                       continue
+               fi
+               case "$type" in
+               .) # end of file
+                       printf '.\n'
+                       break
+                       ;;
+               i) # label
+                       case "$label" in
+                       '#'* | '*'[[:space:]]*)
+                               if $pre; then
+                                       printf '%s\n' '```'
+                                       pre=false
+                               fi
+                               ;;
+                       *)
+                               if ! $pre; then
+                                       printf '%s\n' '```'
+                                       pre=true
+                               fi
+                               ;;
+                       esac
+                       printf '%s\n' "$label"
+                       ;;
+               h) # html link
+                       if $pre; then
+                               printf '%s\n' '```'
+                               pre=false
+                       fi
+                       printf '=> %s %s\n' "${path:4}" "$label"
+                       ;;
+               T) # telnet link
+                       if $pre; then
+                               printf '%s\n' '```'
+                               pre=false
+                       fi
+                       printf '=> telnet://%s:%s/%s%s %s\n' \
+                               "$server" "$port" "$type" "$path" "$label"
+                       ;;
+               *) # other type
+                       if $pre; then
+                               printf '%s\n' '```'
+                               pre=false
+                       fi
+                       printf '=> gopher://%s:%s/%s%s %s\n' \
+                               "$server" "$port" "$type" "$path" "$label"
+                       ;;
+               esac
+       done
+       if $pre; then
+               printf '%s\n' '```'
+       fi
+       # close the connection
+       exec 9<&-
+       exec 9>&-
+}
+
+# display the fetched content
+display() { # display METADATA [TITLE]
+       local -a less_cmd
+       local i mime charset
        # split header line
        local -a hdr
-       local i
-       IFS=$'\n' read -d "" -ra hdr <<<"${1//;/$'\n'}"
+       IFS=';' read -ra hdr <<<"$1"
+       # title is optional but nice looking
+       local title
+       if (($# == 2)); then
+               title="$2"
+       fi
 
-       mime="$(trim "${hdr[0],,}")"
+       mime="$(trim_string "${hdr[0],,}")"
        for ((i = 1; i <= "${#hdr[@]}"; i++)); do
-               h="$(trim "${hdr[$i]}")"
+               h="${hdr[$i]}"
                case "$h" in
-               charset=*) charset="${h#charset=}" ;;
+               *charset=*) charset="${h#*=}" ;;
                esac
        done
 
        [[ -z "$mime" ]] && mime="text/gemini"
-       if [[ -z "$charset" ]]; then
-               charset="utf-8"
-       fi
+       [[ -z "$charset" ]] && charset="utf-8"
 
        log debug "mime='$mime'; charset='$charset'"
 
        case "$mime" in
        text/*)
-               set_title "$BOLLUX_URL"
-               less_cmd=(less -R)
-               {
-                       [[ -r "$BOLLUX_LESSKEY" ]] || mklesskey "$BOLLUX_LESSKEY"
-               } && less_cmd+=(-k "$BOLLUX_LESSKEY")
+               set_title "$title${title:+ - }bollux"
+               # render ANSI color escapes and don't wrap pre-formatted blocks
+               less_cmd=(less -RS)
+               mklesskey "$BOLLUX_LESSKEY" && less_cmd+=(-k "$BOLLUX_LESSKEY")
+               local helpline="o:open, g/G:goto, [:back, ]:forward, r:refresh"
                less_cmd+=(
-                       -Pm'bollux$'
-                       -PM'o\:open, g\:goto, r\:refresh$'
-                       -M
+                       -Pm"$(less_prompt_escape "$BOLLUX_URL") - bollux$" # 'status'line
+                       -P="$(less_prompt_escape "$helpline")$"            # helpline
+                       -m                                                 # start with statusline
+                       +k                                                 # float content to the top
                )
 
-               submime="${mime#*/}"
-               if declare -F | grep -q "$submime"; then
-                       log d "typeset_$submime"
-                       {
-                               normalize_crlf |
-                                       iconv -f "${charset^^}" -t "UTF-8" - |
-                                       tee "$BOLLUX_PAGESRC" |
-                                       run "typeset_$submime" |
-                                       run "${less_cmd[@]}" && bollux_quit
-                       } || run handle_keypress "$?"
+               local typeset
+               local submime="${mime#*/}"
+               if declare -Fp "typeset_$submime" &>/dev/null; then
+                       typeset="typeset_$submime"
                else
-                       log "cat"
-                       {
-                               normalize_crlf |
-                                       iconv -f "${charset^^}" -t "UTF-8" - |
-                                       tee "$BOLLUX_PAGESRC" |
-                                       run "${less_cmd[@]}" && bollux_quit
-                       } || run handle_keypress "$?"
+                       typeset="passthru"
                fi
+
+               {
+                       run iconv -f "${charset^^}" -t "UTF-8" |
+                               run tee "$BOLLUX_PAGESRC" |
+                               run "$typeset" | #cat
+                               run "${less_cmd[@]}" && bollux_quit
+               } || run handle_keypress "$?"
                ;;
        *) run download "$BOLLUX_URL" ;;
        esac
 }
 
-mklesskey() {
+# escape strings for the less prompt
+less_prompt_escape() { # less_prompt_escape STRING
+       local i
+       for ((i = 0; i < ${#1}; i++)); do
+               : "${1:i:1}"
+               case "$_" in
+               [\?:\.%\\]) printf '\%s' "$_" ;;
+               *) printf '%s' "$_" ;;
+               esac
+       done
+       printf '\n'
+}
+
+# generate a lesskey(1) file for custom keybinds
+mklesskey() { # mklesskey FILENAME
        lesskey -o "$1" - <<-END
                #command
                o quit 0 # 48 open a link
@@ -425,19 +721,27 @@ mklesskey() {
                [ quit 2 # 50 back
                ] quit 3 # 51 forward
                r quit 4 # 52 re-request / download
+               G quit 5 # 53 goto a url (pre-filled)
                # other keybinds
-               \40 forw-screen-force
+               \\40 forw-screen-force
+               h left-scroll
+               l right-scroll
+               ? status   # 'status' will show a little help thing.
+               = noaction
        END
 }
 
-normalize_crlf() {
+# normalize files
+normalize() {
        shopt -s extglob
        while IFS= read -r; do
+               # normalize line endings
                printf '%s\n' "${REPLY//$'\r'?($'\n')/}"
        done
        shopt -u extglob
 }
 
+# typeset a text/gemini document
 typeset_gemini() {
        local pre=false
        local ln=0 # link number
@@ -447,7 +751,7 @@ typeset_gemini() {
                (
                        :
                        :
-               ) # XXX this doesn't work!?
+               ) # dumb formatting brought to you by shfmt
                log d "LINES=$LINES; COLUMNS=$COLUMNS"
                T_WIDTH=$COLUMNS
        fi
@@ -468,17 +772,16 @@ typeset_gemini() {
                        fi
                        continue
                        ;;
-               =\>*)
+               '=>'*)
                        : $((ln += 1))
                        gemini_link "$REPLY" $pre "$ln"
                        ;;
-               \#*) gemini_header "$REPLY" $pre ;;
-               \**)
-                       if [[ "$REPLY" =~ ^\*[[:space:]]+ ]]; then
-                               gemini_list "$REPLY" $pre
-                       else
-                               gemini_text "$REPLY" $pre
-                       fi
+               '#'*) gemini_header "$REPLY" $pre ;;
+               '*'[[:space:]]*)
+                       gemini_list "$REPLY" $pre
+                       ;;
+               '>'*)
+                       gemini_quote "$REPLY" $pre
                        ;;
                *) gemini_text "$REPLY" $pre ;;
                esac
@@ -487,7 +790,8 @@ typeset_gemini() {
 
 gemini_link() {
        local re="^(=>)[[:blank:]]*([^[:blank:]]+)[[:blank:]]*(.*)"
-       local s t a l # sigil, text, annotation(url), line
+       local s t a # sigil, text, annotation(url)
+       local ln="$3"
        if ! ${2-false} && [[ "$1" =~ $re ]]; then
                s="${BASH_REMATCH[1]}"
                a="${BASH_REMATCH[2]}"
@@ -498,11 +802,13 @@ gemini_link() {
                fi
 
                printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
-               printf -v l "\e[${C_LINK_NUMBER}m[%d]${C_RESET} \
-                       \e[${C_LINK_TITLE}m%s${C_RESET} \
-                       \e[${C_LINK_URL}m%s${C_RESET}\n" \
-                       "$3" "$t" "$a"
-               fold_line "$WIDTH" "$l"
+               printf "\e[${C_LINK_NUMBER}m[%d]${C_RESET} " "$ln"
+               fold_line -n -B "\e[${C_LINK_TITLE}m" -A "${C_RESET}" \
+                       -l "$((${#ln} + 3))" -m "${T_MARGIN}" \
+                       "$WIDTH" "$(trim_string "$t")"
+               fold_line -B " \e[${C_LINK_URL}m" -A "${C_RESET}" \
+                       -l "$((${#ln} + 3 + ${#t}))" -m "$((T_MARGIN + ${#ln} + 2))" \
+                       "$WIDTH" "$a"
        else
                gemini_pre "$1"
        fi
@@ -510,7 +816,7 @@ gemini_link() {
 
 gemini_header() {
        local re="^(#+)[[:blank:]]*(.*)"
-       local s t a l # sigil, text, annotation(lvl), line
+       local s t a # sigil, text, annotation(lvl)
        if ! ${2-false} && [[ "$1" =~ $re ]]; then
                s="${BASH_REMATCH[1]}"
                a="${#BASH_REMATCH[1]}"
@@ -519,8 +825,8 @@ gemini_header() {
                hdrfmt="$(eval echo "\$C_HEADER$a")"
 
                printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
-               printf -v l "\e[${hdrfmt}m%s${C_RESET}\n" "$t"
-               fold_line "$WIDTH" "$l"
+               fold_line -B "\e[${hdrfmt}m" -A "${C_RESET}" -m "${T_MARGIN}" \
+                       "$WIDTH" "$t"
        else
                gemini_pre "$1"
        fi
@@ -528,14 +834,29 @@ gemini_header() {
 
 gemini_list() {
        local re="^(\*)[[:blank:]]*(.*)"
-       local s t a l # sigil, text, annotation(n/a), line
+       local s t # sigil, text
        if ! ${2-false} && [[ "$1" =~ $re ]]; then
                s="${BASH_REMATCH[1]}"
                t="${BASH_REMATCH[2]}"
 
-               printf "\e[${C_SIGIL}m%${S_MARGIN}s " "$s"
-               printf -v l "\e[${C_LIST}m%s${C_RESET}\n" "$t"
-               fold_line "$WIDTH" "$l"
+               printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
+               fold_line -B "\e[${C_LIST}m" -A "${C_RESET}" -m "$T_MARGIN" \
+                       "$WIDTH" "$t"
+       else
+               gemini_pre "$1"
+       fi
+}
+
+gemini_quote() {
+       local re="^(>)[[:blank:]]*(.*)"
+       local s t # sigil, text
+       if ! ${2-false} && [[ "$1" =~ $re ]]; then
+               s="${BASH_REMATCH[1]}"
+               t="${BASH_REMATCH[2]}"
+
+               printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
+               fold_line -B "\e[${C_QUOTE}m" -A "${C_RESET}" -m "$T_MARGIN" \
+                       "$WIDTH" "$t"
        else
                gemini_pre "$1"
        fi
@@ -544,7 +865,8 @@ gemini_list() {
 gemini_text() {
        if ! ${2-false}; then
                printf "%${S_MARGIN}s " ' '
-               fold_line "$WIDTH" "$1"
+               fold_line -m "$T_MARGIN" \
+                       "$WIDTH" "$1"
        else
                gemini_pre "$1"
        fi
@@ -555,40 +877,77 @@ gemini_pre() {
        printf "\e[${C_PRE}m%s${C_RESET}\n" "$1"
 }
 
-fold_line() { # fold_line WIDTH TEXT
-       local width="$1"
-       local margin="${2%%[![:space:]]*}"
-       if [[ "$margin" ]]; then
-               margin="${#margin}"
-       else
-               margin="$T_MARGIN"
+# wrap lines on words to WIDTH
+fold_line() {
+       # fold_line [-n] [-m MARGIN] [-f MARGIN] [-l LENGTH] [-B BEFORE] [-A AFTER] WIDTH TEXT
+       local newline=true
+       local -i margin_all=0 margin_first=0 width ll=0 wl=0 wn=0
+       local before="" after=""
+       OPTIND=0
+       while getopts nm:f:l:B:A: OPT; do
+               case "$OPT" in
+               n) # -n = no trailing newline
+                       newline=false
+                       ;;
+               m) # -m MARGIN = margin for all lines
+                       margin_all="$OPTARG"
+                       ;;
+               f) # -f MARGIN = margin for first line
+                       margin_first="$OPTARG"
+                       ;;
+               l) # -l LENGTH = length of line before starting fold
+                       ll="$OPTARG"
+                       ;;
+               B) # -B BEFORE = text to insert before each line
+                       before="$OPTARG"
+                       ;;
+               A) # -A AFTER = text to insert after each line
+                       after="$OPTARG"
+                       ;;
+               *) return 1 ;;
+               esac
+       done
+       shift "$((OPTIND - 1))"
+       width="$1"
+       ll=$((ll % width))
+       #shellcheck disable=2086
+       set -- $2
+
+       local plain=""
+       if ((margin_first > 0 && ll == 0)); then
+               printf "%${margin_first}s" " "
+       fi
+       if [[ -n "$before" ]]; then
+               printf '%b' "$before"
        fi
-       local ll=0 wl plain
-       # shellcheck disable=2086
-       set -- $2 # TODO: is this the best way?
-
        for word; do
+               ((wn += 1))
+               shopt -s extglob
                plain="${word//$'\x1b'\[*([0-9;])m/}"
+               shopt -u extglob
                wl=$((${#plain} + 1))
                if (((ll + wl) >= width)); then
-                       printf "\n%${margin}s" ' '
+                       printf "${after:-}\n%${margin_all}s${before:-}" ' '
                        ll=$wl
                else
-                       ll=$((ll + wl))
+                       ((ll += wl))
                fi
-               printf '%s ' "$word"
+               printf '%s' "$word"
+               ((wn != $#)) && printf ' '
        done
-       printf '\n'
+       [[ -n "$after" ]] && printf '%b' "$after"
+       $newline && printf '\n'
 }
 
-handle_keypress() {
+# use the exit code from less (see mklesskey) to do things
+handle_keypress() { # handle_keypress CODE
        case "$1" in
        48) # o - open a link -- show a menu of links on the page
                run select_url "$BOLLUX_PAGESRC"
                ;;
        49) # g - goto a url -- input a new url
-               prompt GO URL
-               run blastoff -u "$URL"
+               prompt GO
+               run blastoff -u "$REPLY"
                ;;
        50) # [ - back in the history
                run history_back || {
@@ -605,37 +964,52 @@ handle_keypress() {
        52) # r - re-request the current resource
                run blastoff "$BOLLUX_URL"
                ;;
-       *) # 53-57 -- still available for binding
+       53) # G - goto a url (pre-filled with current)
+               run prompt -u GO
+               run blastoff -u "$REPLY"
+               ;;
+       *) # 54-57 -- still available for binding
+               die "$?" "less(1) error"
                ;;
        esac
 }
 
-select_url() {
+# select a URL from a text/gemini file
+select_url() { # select_url FILE
        run mapfile -t < <(extract_links <"$1")
+       if ((${#MAPFILE[@]} == 0)); then
+               log e "No links on this page!"
+               sleep 0.5
+               run blastoff "$BOLLUX_URL"
+       fi
+       PS3="OPEN> "
        select u in "${MAPFILE[@]}"; do
                case "$REPLY" in
                q) bollux_quit ;;
+               [^0-9]*) run blastoff -u "$REPLY" && break ;;
                esac
-               run blastoff "$(gawk '{print $1}' <<<"$u")" && break
+               run blastoff "${u%%[[:space:]]*}" && break
        done </dev/tty
 }
 
+# extract the links from a text/gemini file
 extract_links() {
-       gawk '
-       /^=>/ {
-               sub(/=>[[:space:]]*/,"")
-               if ($2) {
-                       rest=""
-                       for (i=2;i<=NF;i++) {
-                               rest=rest (rest?" ":"")$i
-                       }
-                       printf "%s (\033[34m%s\033[0m)\n", $1, rest
-               } else {
-                       printf "%s\n", $1
-               }
-       }'
+       local url alt
+       while read -r; do
+               if [[ "$REPLY" =~ ^=\>[[:space:]]*([^[:space:]]+)([[:space:]]+(.*))?$ ]]; then
+                       url="${BASH_REMATCH[1]}"
+                       alt="${BASH_REMATCH[3]}"
+
+                       if [[ "$alt" ]]; then
+                               printf '%s \e[34m(%s)\e[0m\n' "$url" "$alt"
+                       else
+                               printf '%s\n' "$url"
+                       fi
+               fi
+       done
 }
 
+# download $BOLLUX_URL
 download() {
        tn="$(mktemp)"
        log x "Downloading: '$BOLLUX_URL' => '$tn'..."
@@ -650,18 +1024,35 @@ download() {
        fi
 }
 
-history_init() {
+# initialize bollux
+bollux_init() {
+       # Trap cleanup
+       trap bollux_cleanup INT QUIT EXIT
+       # State
+       REDIRECTS=0
+       set -f
+       # History
        declare -a HISTORY # history is kept in an array
        HN=0               # position of history in the array
+       run mkdir -p "${BOLLUX_HISTFILE%/*}"
 }
 
-history_append() { # history_append URL
+# clean up on exit
+bollux_cleanup() {
+       # Stubbed in case of need in future
+       :
+}
+
+# append a URL to history
+history_append() { # history_append URL TITLE
        BOLLUX_URL="$1"
+       # date/time, url, title (best guess)
+       run printf '%(%FT%T)T\t%s\t%s\n' -1 "$1" "$2" >>"$BOLLUX_HISTFILE"
        HISTORY[$HN]="$BOLLUX_URL"
-       log d "HN=$HN HISTORY: ${HISTORY[*]}"
        ((HN += 1))
 }
 
+# move back in history (session)
 history_back() {
        log d "HN=$HN"
        ((HN -= 2))
@@ -670,8 +1061,10 @@ history_back() {
                log e "Beginning of history."
                return 1
        fi
-       blastoff "${HISTORY[$HN]}"
+       run blastoff "${HISTORY[$HN]}"
 }
+
+# move forward in history (session)
 history_forward() {
        log d "HN=$HN"
        if ((HN >= ${#HISTORY[@]})); then
@@ -679,11 +1072,9 @@ history_forward() {
                log e "End of history."
                return 1
        fi
-       blastoff "${HISTORY[$HN]}"
+       run blastoff "${HISTORY[$HN]}"
 }
 
 if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
        run bollux "$@"
-else
-       BOLLUX_LOGLEVEL=DEBUG
 fi