Fix title extraction
[bollux.git/.git] / bollux
diff --git a/bollux b/bollux
index b319e9d..5e03da3 100755 (executable)
--- a/bollux
+++ b/bollux
 #!/usr/bin/env bash
-# bollux: a bash gemini client or whatever
-# Author: Case Duckworth <acdw@acdw.net>
+# bollux: a bash gemini client
+# Author: Case Duckworth
 # License: MIT
-# Version: -0.7
+# Version: 0.2.2
 
-# set -euo pipefail              # strict mode
+# Program information
+PRGN="${0##*/}"
+VRSN=0.2.2
+# State
+REDIRECTS=0
+set -f
 
-### constants ###
-PRGN="${0##*/}"                # program name
-DLDR="${BOLLUX_DOWNDIR:=.}"    # where to download
-LOGL="${BOLLUX_LOGLEVEL:=3}"   # log level
-MAXR="${BOLLUX_MAXREDIR:=5}"   # max redirects
-PORT="${BOLLUX_PORT:=1965}"    # port number
-PROT="${BOLLUX_PROTO:=gemini}" # protocol
-RDRS=0                         # redirects
-VRSN=-0.7                      # version number
-
-# shellcheck disable=2120
 bollux_usage() {
-       cat <<END_USAGE >&2
-       $PRGN ($VRSN): a bash gemini client
-       usage:
-               $PRGN [-h]
-               $PRGN [-L LVL] [URL]
-       options:
-               -h      show this help
-               -L LVL  set the loglevel to LVL.
-                       Default: $BOLLUX_LOGLEVEL
-                       The loglevel is between 0 and 5, with
-                       lower levels being more dire.
-       parameters:
-               URL     the URL to navigate view or download
-END_USAGE
-       exit "${1:-0}"
-}
-
-# LOGLEVELS:
-# 0 - application fatal error
-# 1 - application warning
-# 2 - response error
-# 3 - response logging
-# 4 - application logging
-# 5 - diagnostic
-
-### utility functions ###
-# a better echo
-put() { printf '%s\n' "$*"; }
-
-# conditionally log events to stderr
-# lower = more important
-log() { # log [LEVEL] [<] MESSAGE
+       cat <<END
+$PRGN (v. $VRSN): a bash gemini client
+usage:
+       $PRGN [-h]
+       $PRGN [-q] [-v] [URL]
+flags:
+       -h      show this help and exit
+       -q      be quiet: log no messages
+       -v      verbose: log more messages
+parameters:
+       URL     the URL to start in
+               If not provided, the user will be prompted.
+END
+}
+
+run() {
+       log debug "$@"
+       "$@"
+}
+
+die() {
+       ec="$1"
+       shift
+       log error "$*"
+       exit "$ec"
+}
+
+# pure bash bible trim_string
+trim() {
+       : "${1#"${1%%[![:space:]]*}"}"
+       : "${_%"${_##*[![:space:]]}"}"
+       printf '%s\n' "$_"
+}
+
+log() {
+       [[ "$BOLLUX_LOGLEVEL" == QUIET ]] && return
        case "$1" in
-       -)
-               lvl="-1"
-               shift
+       d* | D*) # debug
+               [[ "$BOLLUX_LOGLEVEL" == DEBUG ]] || return
+               fmt=34
                ;;
-       [0-5])
-               lvl="$1"
-               shift
+       e* | E*) # error
+               fmt=31
                ;;
-       *) lvl=4 ;;
+       *) fmt=1 ;;
        esac
+       shift
+       printf >&2 '\e[%sm%s:\e[0m\t%s\n' "$fmt" "$PRGN" "$*"
+}
 
-       output="$*"
-       if ((lvl < LOGL)); then
-               if (($# == 0)); then
-                       while IFS= read -r line; do
-                               output="$output${output:+$'\n'}$line"
-                       done
-               fi
-               printf '\e[34m%s\e[0m:\t%s\n' "$PRGN" "$output" >&2
+# main entry point
+bollux() {
+       run bollux_config
+       run bollux_args "$@"
+       run history_init
+
+       if [[ ! "${BOLLUX_URL:+isset}" ]]; then
+               run prompt GO BOLLUX_URL
        fi
+
+       run blastoff "$BOLLUX_URL"
 }
 
-# halt and catch fire
-die() { # die [EXIT-CODE] MESSAGE
-       case "$1" in
-       [0-9]*)
-               ec="$1"
-               shift
-               ;;
-       *) ec=1 ;;
-       esac
+bollux_args() {
+       while getopts :hvq OPT; do
+               case "$OPT" in
+               h)
+                       bollux_usage
+                       exit
+                       ;;
+               v) BOLLUX_LOGLEVEL=DEBUG ;;
+               q) BOLLUX_LOGLEVEL=QUIET ;;
+               :) die 1 "Option -$OPTARG requires an argument" ;;
+               *) die 1 "Unknown option: -$OPTARG" ;;
+               esac
+       done
+       shift $((OPTIND - 1))
+       if (($# == 1)); then
+               BOLLUX_URL="$1"
+       fi
+}
 
-       log 0 "$*"
-       exit "$ec"
+bollux_config() {
+       : "${BOLLUX_CONFIG:=${XDG_CONFIG_DIR:-$HOME/.config}/bollux/bollux.conf}"
+
+       if [ -f "$BOLLUX_CONFIG" ]; then
+               # shellcheck disable=1090
+               . "$BOLLUX_CONFIG"
+       else
+               log debug "Can't load config file '$BOLLUX_CONFIG'."
+       fi
+
+       ## behavior
+       : "${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
+       # colors -- these will be wrapped in \e[ __ m
+       C_RESET='\e[0m'         # reset
+       : "${C_SIGIL:=35}"      # sigil (=>, #, ##, ###, *, ```)
+       : "${C_LINK_NUMBER:=1}" # link number
+       : "${C_LINK_TITLE:=4}"  # link title
+       : "${C_LINK_URL:=36}"   # link URL
+       : "${C_HEADER1:=1;4}"   # header 1 formatting
+       : "${C_HEADER2:=1}"     # header 2 formatting
+       : "${C_HEADER3:=3}"     # header 3 formatting
+       : "${C_LIST:=0}"        # list formatting
+       : "${C_PRE:=0}"         # preformatted text formatting
+}
+
+bollux_quit() {
+       log x "$BOLLUX_BYEMSG"
+       exit
 }
 
-# ask the user for input
-ask() { # ask PROMPT [READ_OPT...]
+set_title() {
+       printf '\e]2;%s - bollux\007' "$*"
+}
+
+prompt() {
        prompt="$1"
        shift
        read </dev/tty -e -r -p "$prompt> " "$@"
 }
 
-# fail if something isn't installed
-require() { hash "$1" 2>/dev/null || die 127 "Requirement '$1' not found."; }
+blastoff() { # load a url
+       local well_formed=true
+       if [[ "$1" == "-u" ]]; then
+               well_formed=false
+               shift
+       fi
+       URL="$1"
 
-# trim a string
-trim() { sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//'; }
+       if $well_formed && [[ "$1" != "$BOLLUX_URL" ]]; then
+               URL="$(run transform_resource "$BOLLUX_URL" "$1")"
+       fi
+       [[ "$URL" != *://* ]] && URL="$BOLLUX_PROTO://$URL"
+       URL="$(trim "$URL")"
 
-# stubs for when things aren't implemented (fully)
-NOT_IMPLEMENTED() { die 200 "NOT IMPLEMENTED!!!"; }
-NOT_FULLY_IMPLEMENTED() { log 1 "NOT FULLY IMPLEMENTED!!!"; }
+       server="${URL#*://}"
+       server="${server%%/*}"
 
-### gemini ###
-# url functions
-# normalize a path from /../ /./ /
-normalize_path() { # normalize_path <<< PATH
-       gawk '{
-       if ($0 == "" || $0 ~ /^\/\/[^\/]/) {
-               return -1
-       }
-       split($0, path, /\//)
-       for (c in path) {
-               if (path[c] == "" || path[c] == ".") {
-                       continue
-               }
-               if (path[c] == "..") {
-                       sub(/[^\/]+$/, "", ret)
-                       continue
-               }
-               if (! ret || match(ret, /\/$/)) {
-                       slash = ""
-               } else {
-                       slash = "/"
-               }
-               ret = ret slash path[c]
-       }
-       print ret
-       }'
-}
-
-# split a url into the URL array
-split_url() {
-       gawk '{
-       if (match($0, /^[A-Za-z]+:/)) {
-               arr["scheme"] = substr($0, RSTART, RLENGTH)
-               $0 = substr($0, RLENGTH + 1)
-       }
-       if (match($0, /^\/\/[^\/?#]+?/) || (match($0, /^[^\/?#]+?/) && scheme)) {
-               arr["authority"] = substr($0, RSTART, RLENGTH)
-               $0 = substr($0, RLENGTH + 1)
-       }
-       if (match($0, /^\/?[^?#]+/)) {
-               arr["path"] = substr($0, RSTART, RLENGTH)
-               $0 = substr($0, RLENGTH + 1)
-       }
-       if (match($0, /^\?[^#]+/)) {
-               arr["query"] = substr($0, RSTART, RLENGTH)
-               $0 = substr($0, RLENGTH + 1)
-       }
-       if (match($0, /^#.*/)) {
-               arr["fragment"] = substr($0, RSTART, RLENGTH)
-               $0 = substr($0, RLENGTH + 1)
-       }
-       for (part in arr) {
-               printf "URL[\"%s\"]=\"%s\"\n", part, arr[part]
-       }
-       }'
-}
-
-# example.com => gemini://example.com/
-_address() { # _address URL
-       addr="$1"
-
-       [[ "$addr" != *://* ]] && addr="$PROT://$addr"
-       trim <<<"$addr"
-}
-
-# return only the server part from an address, with the port added
-# gemini://example.com/path/to/file => example.com:1965
-_server() {
-       serv="$(_address "$1")" # normalize first
-       serv="${serv#*://}"
-       serv="${serv%%/*}"
-       if [[ "$serv" != *:* ]]; then
-               serv="$serv:$PORT"
+       log d "URL='$URL' server='$server'"
+
+       run request_url "$server" "$BOLLUX_PORT" "$URL" |
+               run handle_response "$URL"
+}
+
+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]}"
        fi
-       trim <<<"$serv"
+
+       # 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]}"
+       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]}"
+               else
+                       if isempty "R[path]"; then
+                               T[path]="${B[path]}"
+                               if isdefined R[query]; then
+                                       T[query]="${R[query]}"
+                               else
+                                       T[query]="${B[query]}"
+                               fi
+                       else
+                               if [[ "${R[path]}" == /* ]]; then
+                                       T[path]="$(remove_dot_segments "${R[path]}")"
+                               else
+                                       T[path]="$(merge_paths "B[authority]" "${B[path]}" "${R[path]}")"
+                                       T[path]="$(remove_dot_segments "${T[path]}")"
+                               fi
+                               isdefined R[query] && T[query]="${R[query]}"
+                       fi
+                       T[authority]="${B[authority]}"
+               fi
+               T[scheme]="${B[scheme]}"
+       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"
 }
 
-# request a gemini page
-# by default, extract the server from the url
-request() { # request [-s SERVER] URL
-       case "$1" in
-       -s)
-               serv="$(_server "$2")"
-               addr="$(_address "$3")"
-               ;;
-       *)
-               serv="$(_server "$1")"
-               addr="$(_address "$1")"
-               ;;
-       esac
+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
+       fi
 
-       log 5 "serv: $serv"
-       log 5 "addr: $addr"
+       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
+}
 
-       sslcmd=(openssl s_client -crlf -ign_eof -quiet -connect "$serv")
-       # use SNI
-       sslcmd+=(-servername "${serv%:*}")
-       log "${sslcmd[@]}"
-       "${sslcmd[@]}" <<<"$addr" 2>/dev/null
+remove_dot_segments() { # 5.2.4
+       local input="$1"
+       local output=
+       # ^/\.(/|$) - BASH_REMATCH[0]
+       while [[ "$input" ]]; do
+               if [[ "$input" =~ ^\.\.?/ ]]; then
+                       input="${input#${BASH_REMATCH[0]}}"
+               elif [[ "$input" =~ ^/\.(/|$) ]]; then
+                       input="/${input#${BASH_REMATCH[0]}}"
+               elif [[ "$input" =~ ^/\.\.(/|$) ]]; then
+                       input="/${input#${BASH_REMATCH[0]}}"
+                       [[ "$output" =~ /?[^/]+$ ]]
+                       output="${output%${BASH_REMATCH[0]}}"
+               elif [[ "$input" == . || "$input" == .. ]]; then
+                       input=
+               else
+                       [[ $input =~ ^(/?[^/]*)(/?.*)$ ]] || log debug NOMATCH
+                       output="$output${BASH_REMATCH[1]}"
+                       input="${BASH_REMATCH[2]}"
+               fi
+       done
+       printf '%s\n' "${output//\/\//\//}"
 }
 
-# handle the response
-# cf. gemini://gemini.circumlunar.space/docs/spec-spec.txt
-handle() { # handle URL < RESPONSE
-       URL="$1"
-       while read -d $'\r' -r head; do
-               break # wait to read the first line
+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}"
+       done
+       # unclear if the path is always set even if empty but it looks that way
+       run printf '%s[path]=%q\n' "$name" "$path"
+}
+
+# is a NAME defined ('set' in bash)?
+isdefined() { [[ "${!1+x}" ]]; } # isdefined NAME
+# is a NAME defined AND empty?
+isempty() { [[ ! "${!1-x}" ]]; } # isempty NAME
+
+request_url() {
+       local server="$1"
+       local port="$2"
+       local url="$3"
+
+       # support for TLS v1.3 and v1.2
+       ssl_cmd=(openssl s_client -crlf -quiet -connect "$server:$port")
+       ssl_cmd+=(-servername "$server") # SNI
+       ssl_cmd_tls1_2=("${ssl_cmd[@]}" -tls1_2)
+       ssl_cmd_tls1_3=("${ssl_cmd[@]}" -tls1_3)
+
+       # always try to connect with TLS v1.3 first
+       run "${ssl_cmd_tls1_3[@]}" <<<"$url" 2>/dev/null || run "${ssl_cmd_tls1_2[@]}" <<<"$url" 2>/dev/null
+}
+
+handle_response() {
+       local URL="$1" code meta
+
+       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"
+               )"
+               break
        done
-       code="$(gawk '{print $1}' <<<"$head")"
-       meta="$(gawk '{for(i=2;i<=NF;i++)printf "%s ",$i;printf "\n"}' <<<"$head")"
 
-       log 5 "[$code]  $meta"
+       log x "[$code] $meta"
 
        case "$code" in
-       1*) # INPUT
-               log 3 "Input"
-               RDRS=0 # this is not a redirect
-               ask "$meta" QUERY
-               bollux "$URL?$QUERY"
+       1*)
+               REDIRECTS=0
+               run history_append "$URL" "$meta"
+               run prompt "$meta" QUERY
+               # shellcheck disable=2153
+               run blastoff "?$QUERY"
                ;;
-       2*) # SUCCESS
-               log 3 "Success"
-               RDRS=0 # this is not a redirect
-               case "$code" in
-               20) log 5 "- OK" ;;
-               21) log 5 "- End of client certificate session" ;;
-               *) log 2 "- Unknown response code: '$code'." ;;
-               esac
-               display "$meta"
+       2*)
+               REDIRECTS=0
+               # read ahead to find a title
+               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:-}"
+               {
+                       printf '%s' "$pretitle"
+                       while read -r; do
+                               printf '%s\n' "$REPLY"
+                       done
+               } | run display "$meta"
                ;;
-       3*) # REDIRECT
-               log 3 "Redirecting"
-               case "$code" in
-               30) log 5 "- Temporary" ;;
-               31) log 5 "- Permanent" ;;
-               *) log 2 "- Unknown response code: '$code'." ;;
-               esac
-               ((RDRS += 1))
-               ((RDRS > MAXR)) && die "$code" "Too many redirects!"
-               bollux "$meta"
+       3*)
+               ((REDIRECTS += 1))
+               if ((REDIRECTS > BOLLUX_MAXREDIR)); then
+                       die $((100 + code)) "Too many redirects!"
+               fi
+               run blastoff "$meta"
                ;;
-       4*) # TEMPORARY FAILURE
-               log 2 "Temporary failure"
-               RDRS=0 # this is not a redirect
-               case "$code" in
-               41) log 5 "- Server unavailable" ;;
-               42) log 5 "- CGI error" ;;
-               43) log 5 "- Proxy error" ;;
-               44) log 5 "- Rate limited" ;;
-               *) log 2 "- Unknown response code: '$code'." ;;
-               esac
-               exit "$code"
+       4*)
+               REDIRECTS=0
+               die "$((100 + code))" "$code"
                ;;
-       5*) # PERMANENT FAILURE
-               log 2 "Permanent failure"
-               RDRS=0 # this is not a redirect
-               case "$code" in
-               51) log 5 "- Not found" ;;
-               52) log 5 "- No longer available" ;;
-               53) log 5 "- Proxy request refused" ;;
-               59) log 5 "- Bad request" ;;
-               *) log 2 "- Unknown response code: '$code'." ;;
-               esac
-               exit "$code"
+       5*)
+               REDIRECTS=0
+               die "$((100 + code))" "$code"
                ;;
-       6*) # CLIENT CERT REQUIRED
-               log 2 "Client certificate required"
-               RDRS=0 # this is not a redirect
-               case "$code" in
-               61) log 5 "- Transient cert requested" ;;
-               62) log 5 "- Authorized cert required" ;;
-               63) log 5 "- Cert not accepted" ;;
-               64) log 5 "- Future cert rejected" ;;
-               65) log 5 "- Expired cert rejected" ;;
-               *) log 2 "- Unknown response code: '$code'." ;;
-               esac
-               exit "$code"
+       6*)
+               REDIRECTS=0
+               die "$((100 + code))" "$code"
                ;;
-       *) # ???
-               die "$code" "Unknown response code: '$code'."
+       *)
+               [[ -z "${code-}" ]] && die 100 "Empty response code."
+               die "$((100 + code)) Unknown response code: $code."
                ;;
        esac
 }
 
-# display the page
-display() { # display MIMETYPE < DOCUMENT
-       mimetype="$1"
-       case "$mimetype" in
+display() {
+       # split header line
+       local -a hdr
+       local i
+       IFS=$'\n' read -d "" -ra hdr <<<"${1//;/$'\n'}"
+
+       mime="$(trim "${hdr[0],,}")"
+       for ((i = 1; i <= "${#hdr[@]}"; i++)); do
+               h="$(trim "${hdr[$i]}")"
+               case "$h" in
+               charset=*) charset="${h#charset=}" ;;
+               esac
+       done
+
+       [[ -z "$mime" ]] && mime="text/gemini"
+       if [[ -z "$charset" ]]; then
+               charset="utf-8"
+       fi
+
+       log debug "mime='$mime'; charset='$charset'"
+
+       case "$mime" in
        text/*)
-               # normalize line endings to "\n"
-               # gawk 'BEGIN{RS=""}{gsub(/\r\n?/,"\n");print}'
-               cat
-               # TODO: use less with linking and stuff
-               # less -R -p'^=>' +g
-               # lesskey:
-               # l /=>\n # highlight links
-               # o pipe \n open_url # open the link on the top line
-               # u shell select_url % # shows a selection prompt for all urls (on screen? file?)
-               # Q exit 1  # for one of these, show a selection prompt for urls
-               # q exit 0  # for the other, just quit
-               ###
-               # also look into the prompt, the filename, and input preprocessor
-               # ($LESSOPEN, $LESSCLOSE)
-               ;;
-       *)
-               download "$URL"
+               set_title "$BOLLUX_URL"
+               less_cmd=(less -R)
+               {
+                       [[ -r "$BOLLUX_LESSKEY" ]] || mklesskey "$BOLLUX_LESSKEY"
+               } && less_cmd+=(-k "$BOLLUX_LESSKEY")
+               less_cmd+=(
+                       -Pm'bollux$'
+                       -PM'o\:open, g\:goto, r\:refresh$'
+                       -M
+               )
+
+               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 "$?"
+               else
+                       log "cat"
+                       {
+                               normalize_crlf |
+                                       iconv -f "${charset^^}" -t "UTF-8" |
+                                       tee "$BOLLUX_PAGESRC" |
+                                       run "${less_cmd[@]}" && bollux_quit
+                       } || run handle_keypress "$?"
+               fi
                ;;
+       *) run download "$BOLLUX_URL" ;;
        esac
 }
 
-download() { # download URL < FILE
-       tn="$(mktemp)"
-       dd status=progress >"$tn"
-       fn="$DLDR/${URL##*/}"
-       if [[ -f "$fn" ]]; then
-               log - "Saved '$tn'."
-       else
-               if mv "$tn" "$fn"; then
-                       log - "Saved '$fn'."
-               else
-                       log 0 "Error saving '$fn'."
-                       log - "Saved '$tn'."
+mklesskey() {
+       lesskey -o "$1" - <<-END
+               #command
+               o quit 0 # 48 open a link
+               g quit 1 # 49 goto a url
+               [ quit 2 # 50 back
+               ] quit 3 # 51 forward
+               r quit 4 # 52 re-request / download
+               # other keybinds
+               \40 forw-screen-force
+       END
+}
+
+normalize_crlf() {
+       shopt -s extglob
+       while IFS= read -r; do
+               printf '%s\n' "${REPLY//$'\r'?($'\n')/}"
+       done
+       shopt -u extglob
+}
+
+typeset_gemini() {
+       local pre=false
+       local ln=0 # link number
+
+       if ((T_WIDTH == 0)); then
+               shopt -s checkwinsize
+               (
+                       :
+                       :
+               ) # XXX this doesn't work!?
+               log d "LINES=$LINES; COLUMNS=$COLUMNS"
+               T_WIDTH=$COLUMNS
+       fi
+       WIDTH=$((T_WIDTH - T_MARGIN))
+       ((WIDTH < 0)) && WIDTH=80  # default if dumb
+       S_MARGIN=$((T_MARGIN - 1)) # spacing
+
+       log d "T_WIDTH=$T_WIDTH"
+       log d "WIDTH=$WIDTH"
+
+       while IFS= read -r; do
+               case "$REPLY" in
+               '```'*)
+                       if $pre; then
+                               pre=false
+                       else
+                               pre=true
+                       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_text "$REPLY" $pre ;;
+               esac
+       done
+}
+
+gemini_link() {
+       local re="^(=>)[[:blank:]]*([^[:blank:]]+)[[:blank:]]*(.*)"
+       local s t a l # sigil, text, annotation(url), line
+       if ! ${2-false} && [[ "$1" =~ $re ]]; then
+               s="${BASH_REMATCH[1]}"
+               a="${BASH_REMATCH[2]}"
+               t="${BASH_REMATCH[3]}"
+               if [[ -z "$t" ]]; then
+                       t="$a"
+                       a=
                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"
+       else
+               gemini_pre "$1"
        fi
 }
 
-### main entry point ###
-bollux() {
-       OPTIND=0
-       process_cmdline "$@"
-       shift $((OPTIND - 1))
+gemini_header() {
+       local re="^(#+)[[:blank:]]*(.*)"
+       local s t a l # sigil, text, annotation(lvl), line
+       if ! ${2-false} && [[ "$1" =~ $re ]]; then
+               s="${BASH_REMATCH[1]}"
+               a="${#BASH_REMATCH[1]}"
+               t="${BASH_REMATCH[2]}"
+               local hdrfmt
+               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"
+       else
+               gemini_pre "$1"
+       fi
+}
 
-       if (($# == 1)); then
-               URL="$1"
+gemini_list() {
+       local re="^(\*)[[:blank:]]*(.*)"
+       local s t a l # sigil, text, annotation(n/a), line
+       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"
+       else
+               gemini_pre "$1"
+       fi
+}
+
+gemini_text() {
+       if ! ${2-false}; then
+               printf "%${S_MARGIN}s " ' '
+               fold_line "$WIDTH" "$1"
        else
-               ask GO URL
+               gemini_pre "$1"
        fi
+}
 
-       log 5 "URL : $URL"
+gemini_pre() {
+       printf "\e[${C_SIGIL}m%${S_MARGIN}s " '```'
+       printf "\e[${C_PRE}m%s${C_RESET}\n" "$1"
+}
 
-       request "$URL" | handle "$URL"
+fold_line() { # fold_line WIDTH TEXT
+       local width="$1"
+       local margin="${2%%[![:space:]]*}"
+       if [[ "$margin" ]]; then
+               margin="${#margin}"
+       else
+               margin="$T_MARGIN"
+       fi
+       local ll=0 wl plain
+       # shellcheck disable=2086
+       set -- $2 # TODO: is this the best way?
+
+       for word; do
+               plain="${word//$'\x1b'\[*([0-9;])m/}"
+               wl=$((${#plain} + 1))
+               if (((ll + wl) >= width)); then
+                       printf "\n%${margin}s" ' '
+                       ll=$wl
+               else
+                       ll=$((ll + wl))
+               fi
+               printf '%s ' "$word"
+       done
+       printf '\n'
 }
 
-bollux_setup() {
-       mkfifo .resource
-       trap bollux_cleanup INT QUIT TERM EXIT
+handle_keypress() {
+       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"
+               ;;
+       50) # [ - back in the history
+               run history_back || {
+                       sleep 0.5
+                       run blastoff "$BOLLUX_URL"
+               }
+               ;;
+       51) # ] - forward in the history
+               run history_forward || {
+                       sleep 0.5
+                       run blastoff "$BOLLUX_URL"
+               }
+               ;;
+       52) # r - re-request the current resource
+               run blastoff "$BOLLUX_URL"
+               ;;
+       *) # 53-57 -- still available for binding
+               ;;
+       esac
 }
 
-bollux_cleanup() {
-       echo
-       rm -f .resource
+select_url() {
+       run mapfile -t < <(extract_links <"$1")
+       select u in "${MAPFILE[@]}"; do
+               case "$REPLY" in
+               q) bollux_quit ;;
+               esac
+               run blastoff "$(gawk '{print $1}' <<<"$u")" && break
+       done </dev/tty
+}
+
+extract_links() {
+       local url alt
+       while read -r line; do
+               if [[ "$line" =~ ^=\>[[: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() {
+       tn="$(mktemp)"
+       log x "Downloading: '$BOLLUX_URL' => '$tn'..."
+       dd status=progress >"$tn"
+       fn="$BOLLUX_DOWNDIR/${BOLLUX_URL##*/}"
+       if [[ -f "$fn" ]]; then
+               log x "Saved '$tn'."
+       elif mv "$tn" "$fn"; then
+               log x "Saved '$fn'."
+       else
+               log error "Error saving '$fn': downloaded to '$tn'."
+       fi
+}
+
+history_init() {
+       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 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"
+       ((HN += 1))
+}
+
+history_back() {
+       log d "HN=$HN"
+       ((HN -= 2))
+       if ((HN < 0)); then
+               HN=0
+               log e "Beginning of history."
+               return 1
+       fi
+       run blastoff "${HISTORY[$HN]}"
+}
+history_forward() {
+       log d "HN=$HN"
+       if ((HN >= ${#HISTORY[@]})); then
+               HN="${#HISTORY[@]}"
+               log e "End of history."
+               return 1
+       fi
+       run blastoff "${HISTORY[$HN]}"
 }
 
 if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
-       set -euo pipefail # strict mode
-       # requirements here -- so they're only checked once
-       require gawk
-       require dd
-       require mv
-       require openssl
-       require sed
-
-       bollux "$@"
-       echo
+       run bollux "$@"
+else
+       BOLLUX_LOGLEVEL=DEBUG
 fi