/ bollux
bollux
   1  #!/usr/bin/env bash
   2  # bollux: a bash gemini client
   3  # Author: Case Duckworth
   4  # License: MIT
   5  # Version: 0.4.0
   6  
   7  # Program information
   8  PRGN="${0##*/}"
   9  VRSN=0.4.0
  10  
  11  bollux_usage() {
  12  	cat <<END
  13  $PRGN (v. $VRSN): a bash gemini client
  14  usage:
  15  	$PRGN [-h]
  16  	$PRGN [-q] [-v] [URL]
  17  flags:
  18  	-h	show this help and exit
  19  	-q	be quiet: log no messages
  20  	-v	verbose: log more messages
  21  parameters:
  22  	URL	the URL to start in
  23  		If not provided, the user will be prompted.
  24  END
  25  }
  26  
  27  run() { # run COMMAND...
  28  	log debug "$*"
  29  	"$@"
  30  }
  31  
  32  die() { # die EXIT_CODE MESSAGE
  33  	local ec="$1"
  34  	shift
  35  	log error "$*"
  36  	exit "$ec"
  37  }
  38  
  39  # builtin replacement for `sleep`
  40  # https://github.com/dylanaraps/pure-bash-bible#use-read-as-an-alternative-to-the-sleep-command
  41  sleep() { # sleep SECONDS
  42  	read -rt "$1" <> <(:) || :
  43  }
  44  
  45  # https://github.com/dylanaraps/pure-bash-bible/
  46  trim_string() { # trim_string STRING
  47  	: "${1#"${1%%[![:space:]]*}"}"
  48  	: "${_%"${_##*[![:space:]]}"}"
  49  	printf '%s\n' "$_"
  50  }
  51  
  52  log() { # log LEVEL MESSAGE
  53  	[[ "$BOLLUX_LOGLEVEL" == QUIET ]] && return
  54  	local fmt
  55  
  56  	case "$1" in
  57  	[dD]*) # debug
  58  		[[ "$BOLLUX_LOGLEVEL" == DEBUG ]] || return
  59  		fmt=34
  60  		;;
  61  	[eE]*) # error
  62  		fmt=31
  63  		;;
  64  	*) fmt=1 ;;
  65  	esac
  66  	shift
  67  
  68  	printf >&2 '\e[%sm%s:%s:\e[0m\t%s\n' "$fmt" "$PRGN" "${FUNCNAME[1]}" "$*"
  69  }
  70  
  71  # main entry point
  72  bollux() {
  73  	run bollux_config    # TODO: figure out better config method
  74  	run bollux_args "$@" # and argument parsing
  75  	run bollux_init
  76  
  77  	if [[ ! "${BOLLUX_URL:+x}" ]]; then
  78  		run prompt GO BOLLUX_URL
  79  	fi
  80  
  81  	log d "BOLLUX_URL='$BOLLUX_URL'"
  82  
  83  	run blastoff -u "$BOLLUX_URL"
  84  }
  85  
  86  # process command-line arguments
  87  bollux_args() {
  88  	while getopts :hvq OPT; do
  89  		case "$OPT" in
  90  		h)
  91  			bollux_usage
  92  			exit
  93  			;;
  94  		v) BOLLUX_LOGLEVEL=DEBUG ;;
  95  		q) BOLLUX_LOGLEVEL=QUIET ;;
  96  		:) die 1 "Option -$OPTARG requires an argument" ;;
  97  		*) die 1 "Unknown option: -$OPTARG" ;;
  98  		esac
  99  	done
 100  	shift $((OPTIND - 1))
 101  	if (($# == 1)); then
 102  		BOLLUX_URL="$1"
 103  	fi
 104  }
 105  
 106  # process config file and set variables
 107  bollux_config() {
 108  	: "${BOLLUX_CONFIG:=${XDG_CONFIG_DIR:-$HOME/.config}/bollux/bollux.conf}"
 109  
 110  	if [ -f "$BOLLUX_CONFIG" ]; then
 111  		# shellcheck disable=1090
 112  		. "$BOLLUX_CONFIG"
 113  	else
 114  		log debug "Can't load config file '$BOLLUX_CONFIG'."
 115  	fi
 116  
 117  	## behavior
 118  	: "${BOLLUX_TIMEOUT:=30}"                      # connection timeout
 119  	: "${BOLLUX_MAXREDIR:=5}"                      # max redirects
 120  	: "${BOLLUX_PORT:=1965}"                       # port number
 121  	: "${BOLLUX_PROTO:=gemini}"                    # default protocol
 122  	: "${BOLLUX_URL:=}"                            # start url
 123  	: "${BOLLUX_BYEMSG:=See You Space Cowboy ...}" # bye message
 124  	## files
 125  	: "${BOLLUX_DATADIR:=${XDG_DATA_DIR:-$HOME/.local/share}/bollux}"
 126  	: "${BOLLUX_DOWNDIR:=.}"                       # where to save downloads
 127  	: "${BOLLUX_LESSKEY:=$BOLLUX_DATADIR/lesskey}" # where to store binds
 128  	: "${BOLLUX_PAGESRC:=$BOLLUX_DATADIR/pagesrc}" # where to save the source
 129  	BOLLUX_HISTFILE="$BOLLUX_DATADIR/history"      # where to save the history
 130  	## typesetting
 131  	: "${T_MARGIN:=4}"      # left and right margin
 132  	: "${T_WIDTH:=0}"       # width of the viewport -- 0 = get term width
 133  	# colors -- these will be wrapped in \e[ __ m
 134  	C_RESET='\e[0m'         # reset
 135  	: "${C_SIGIL:=35}"      # sigil (=>, #, ##, ###, *, ```)
 136  	: "${C_LINK_NUMBER:=1}" # link number
 137  	: "${C_LINK_TITLE:=4}"  # link title
 138  	: "${C_LINK_URL:=36}"   # link URL
 139  	: "${C_HEADER1:=1;4}"   # header 1 formatting
 140  	: "${C_HEADER2:=1}"     # header 2 formatting
 141  	: "${C_HEADER3:=3}"     # header 3 formatting
 142  	: "${C_LIST:=0}"        # list formatting
 143  	: "${C_QUOTE:=3}"       # quote formatting
 144  	: "${C_PRE:=0}"         # preformatted text formatting
 145  	## state
 146  	UC_BLANK=':?:'
 147  }
 148  
 149  # quit happily
 150  bollux_quit() {
 151  	printf '\e[1m%s\e[0m:\t\e[3m%s\e[0m\n' "$PRGN" "$BOLLUX_BYEMSG"
 152  	exit
 153  }
 154  
 155  # set the terminal title
 156  set_title() { # set_title STRING
 157  	printf '\e]2;%s\007' "$*"
 158  }
 159  
 160  # prompt for input
 161  prompt() { # prompt [-u] PROMPT [READ_ARGS...]
 162  	local read_cmd=(read -e -r)
 163  	if [[ "$1" == "-u" ]]; then
 164  		read_cmd+=(-i "$BOLLUX_URL")
 165  		shift
 166  	fi
 167  	local prompt="$1"
 168  	shift
 169  	read_cmd+=(-p "$prompt> ")
 170  	"${read_cmd[@]}" </dev/tty "$@"
 171  }
 172  
 173  # load a URL
 174  blastoff() { # blastoff [-u] URL
 175  	local u
 176  
 177  	if [[ "$1" == "-u" ]]; then
 178  		u="$(run uwellform "$2")"
 179  	else
 180  		u="$1"
 181  	fi
 182  
 183  	local -a url
 184  	run utransform url "$BOLLUX_URL" "$u"
 185  	if ! ucdef url[1]; then
 186  		run ucset url[1] "$BOLLUX_PROTO"
 187  	fi
 188  
 189  	{
 190  		if declare -Fp "${url[1]}_request" >/dev/null 2>&1; then
 191  			run "${url[1]}_request" "$url"
 192  		else
 193  			die 99 "No request handler for '${url[1]}'"
 194  		fi
 195  	} | run normalize | {
 196  		if declare -Fp "${url[1]}_response" >/dev/null 2>&1; then
 197  			run "${url[1]}_response" "$url"
 198  		else
 199  			log d "No response handler for '${url[1]}', passing thru"
 200  			passthru
 201  		fi
 202  	}
 203  }
 204  
 205  # URLS
 206  ## https://tools.ietf.org/html/rfc3986
 207  uwellform() {
 208  	local u="$1"
 209  	
 210  	if [[ "$u" != *://* ]]; then
 211  		u="$BOLLUX_PROTO://$u"
 212  	fi
 213  
 214  	u="$(trim_string "$u")"
 215  
 216  	printf '%s\n' "$u"
 217  }
 218  
 219  usplit() { # usplit NAME:ARRAY URL:STRING
 220  	local re='^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?'
 221  	[[ $2 =~ $re ]] || return $?
 222  
 223  	local scheme="${BASH_REMATCH[2]}"
 224  	local authority="${BASH_REMATCH[4]}"
 225  	local path="${BASH_REMATCH[5]}"
 226  	local query="${BASH_REMATCH[7]}"
 227  	local fragment="${BASH_REMATCH[9]}"
 228  
 229  	# 0=url 1=scheme 2=authority 3=path 4=query 5=fragment
 230  	local i=1 c
 231  	for c in scheme authority path query fragment; do
 232  		if [[ "${!c}" || "$c" == path ]]; then
 233  			printf -v "$1[$i]" '%s' "${!c}"
 234  		else
 235  			printf -v "$1[$i]" "$UC_BLANK"
 236  		fi
 237  		((i+=1))
 238  	done
 239  	printf -v "$1[0]" "$(ujoin "$1")" # inefficient I'm sure
 240  }
 241  
 242  ujoin() { # ujoin NAME:ARRAY
 243  	local -n U="$1"
 244  
 245  	if ucdef U[1]; then
 246  		printf -v U[0] "%s:" "${U[1]}"
 247  	fi
 248  
 249  	if ucdef U[2]; then
 250  		printf -v U[0] "${U[0]}//%s" "${U[2]}"
 251  	fi
 252  
 253  	printf -v U[0] "${U[0]}%s" "${U[3]}"
 254  	
 255  	if ucdef U[4]; then
 256  		printf -v U[0] "${U[0]}?%s" "${U[4]}"
 257  	fi
 258  
 259  	if ucdef U[5]; then
 260  		printf -v U[0] "${U[0]}#%s" "${U[5]}"
 261  	fi
 262  
 263  	log d "${U[0]}"
 264  }
 265  
 266  ucdef() { [[ "${!1}" != "$UC_BLANK" ]]; } # ucdef NAME
 267  ucblank() { [[ -z "${!1}" ]]; }           # ucblank NAME
 268  ucset() { # ucset NAME VALUE
 269  	run eval "${1}='$2'"
 270  	run ujoin "${1/\[*\]}"
 271  }
 272  
 273  utransform() { # utransform TARGET:ARRAY BASE:STRING REFERENCE:STRING
 274  	local -a B R # base, reference
 275  	local -n T="$1" # target
 276  	usplit B "$2"
 277  	usplit R "$3"
 278  
 279  	# initialize T
 280  	for ((i=1;i<=5;i++)); do
 281  		T[$i]="$UC_BLANK"
 282  	done
 283  
 284  	# 0=url 1=scheme 2=authority 3=path 4=query 5=fragment
 285  	if ucdef R[1]; then
 286  		T[1]="${R[1]}"
 287  		if ucdef R[2]; then
 288  			T[2]="${R[2]}"
 289  		fi
 290  		if ucdef R[3]; then
 291  			T[3]="$(pundot "${R[3]}")"
 292  		fi
 293  		if ucdef R[4]; then
 294  			T[4]="${R[4]}"
 295  		fi
 296  	else
 297  		if ucdef R[2]; then
 298  			T[2]="${R[2]}"
 299  			if ucdef R[2]; then
 300  				T[3]="$(pundot "${R[3]}")"
 301  			fi
 302  			if ucdef R[4]; then
 303  				T[4]="${R[4]}"
 304  			fi
 305  		else
 306  			if ucblank R[3]; then
 307  				T[3]="${B[3]}"
 308  				if ucdef R[4]; then
 309  					T[4]="${R[4]}"
 310  				else
 311  					T[4]="${B[4]}"
 312  				fi
 313  			else
 314  				if [[ "${R[3]}" == /* ]]; then
 315  					T[3]="$(pundot "${R[3]}")"
 316  				else
 317  					T[3]="$(pmerge B R)"
 318  					T[3]="$(pundot "${T[3]}")"
 319  				fi
 320  				if ucdef R[4]; then
 321  					T[4]="${R[4]}"
 322  				fi
 323  			fi
 324  			T[2]="${B[2]}"
 325  		fi
 326  		T[1]="${B[1]}"
 327  	fi
 328  	if ucdef R[5]; then
 329  		T[5]="${R[5]}"
 330  	fi
 331  
 332  	ujoin T
 333  }
 334  
 335  pundot() { # pundot PATH:STRING
 336  	local input="$1"
 337  	local output
 338  	while [[ "$input" ]]; do
 339  		if [[ "$input" =~ ^\.\.?/ ]]; then
 340  			input="${input#${BASH_REMATCH[0]}}"
 341  		elif [[ "$input" =~ ^/\.(/|$) ]]; then
 342  			input="/${input#${BASH_REMATCH[0]}}"
 343  		elif [[ "$input" =~ ^/\.\.(/|$) ]]; then
 344  			input="/${input#${BASH_REMATCH[0]}}"
 345  			[[ "$output" =~ /?[^/]+$ ]]
 346  			output="${output%${BASH_REMATCH[0]}}"
 347  		elif [[ "$input" == . || "$input" == .. ]]; then
 348  			input=
 349  		else
 350  			[[ $input =~ ^(/?[^/]*)(/?.*)$ ]] || return 1
 351  			output="$output${BASH_REMATCH[1]}"
 352  			input="${BASH_REMATCH[2]}"
 353  		fi
 354  	done
 355  	printf '%s\n' "${output//\/\//\//}"
 356  }
 357  
 358  pmerge() {
 359  	local -n b="$1"
 360  	local -n r="$2"
 361  
 362  	if ucblank r[3]; then
 363  		printf '%s\n' "${b[3]//\/\//\//}"
 364  		return
 365  	fi
 366  
 367  	if ucdef b[2] && ucblank b[3]; then
 368  		printf '/%s\n' "${r[3]//\/\//\//}"
 369  	else
 370  		local bp=""
 371  		if [[ "${b[3]}" == */* ]]; then
 372  			bp="${b[3]%/*}"
 373  		fi
 374  		printf '%s/%s\n' "${bp%/}" "${r[3]#/}"
 375  	fi
 376  }
 377  
 378  # https://github.com/dylanaraps/pure-bash-bible/
 379  uencode() { # uencode URL:STRING
 380  	local LC_ALL=C
 381  	for ((i = 0; i < ${#1}; i++)); do
 382  		: "${1:i:1}"
 383  		case "$_" in
 384  		[a-zA-Z0-9.~_-])
 385  			printf '%s' "$_"
 386  			;;
 387  		*)
 388  			printf '%%%02X' "'$_"
 389  			;;
 390  		esac
 391  	done
 392  	printf '\n'
 393  }
 394  
 395  # https://github.com/dylanaraps/pure-bash-bible/
 396  udecode() { # udecode URL:STRING 
 397  	: "${1//+/ }"
 398  	printf '%b\n' "${_//%/\\x}"
 399  }
 400  
 401  # GEMINI
 402  # https://gemini.circumlunar.space/docs/specification.html
 403  gemini_request() { # gemini_request URL
 404  	local -a url
 405  	usplit url "$1"
 406  
 407  	# get rid of userinfo
 408  	ucset url[2] "${url[2]#*@}"
 409  
 410  	local port
 411  	if [[ "${url[2]}" == *:* ]]; then
 412  		port="${url[2]#*:}"
 413  		ucset url[2] "${url[2]%:*}"
 414  	else
 415  		port=1965 # TODO variablize
 416  	fi
 417  
 418  	local ssl_cmd=(
 419  		openssl s_client 
 420  			-crlf -quiet -connect "${url[2]}:$port"
 421  			-servername "${url[2]}" # SNI
 422  			-no_ssl3 -no_tls1 -no_tls1_1 # disable old TLS/SSL versions
 423  	)
 424  
 425  	run "${ssl_cmd[@]}" <<<"$url"
 426  }
 427  
 428  gemini_response() { # gemini_response URL
 429  	local url code meta
 430  	local title
 431  	url="$1"
 432  
 433  	# we need a loop here so it waits for the first line
 434  	while read -t "$BOLLUX_TIMEOUT" -r code meta ||
 435  		{ (($? > 128)) && die 99 "Timeout."; }; do
 436  		break
 437  	done
 438  
 439  	log d "[$code] $meta"
 440  
 441  	case "$code" in
 442  	1*) # input
 443  		REDIRECTS=0
 444  		BOLLUX_URL="$url"
 445  		case "$code" in
 446  		10) run prompt "$meta" ;;
 447  		11) run prompt "$meta" -s ;; # password input
 448  		esac
 449  		run blastoff "?$(uencode "$REPLY")"
 450  		;;
 451  	2*) # OK
 452  		REDIRECTS=0
 453  		BOLLUX_URL="$url"
 454  		# read ahead to find a title
 455  		local pretitle
 456  		while read -r; do
 457  			pretitle="$pretitle$REPLY"$'\n'
 458  			if [[ "$REPLY" =~ ^#[[:space:]]*(.*) ]]; then
 459  				title="${BASH_REMATCH[1]}"
 460  				break
 461  			fi
 462  		done
 463  		run history_append "$url" "${title:-}"
 464  		# read the body out and pipe it to display
 465  		{
 466  			printf '%s' "$pretitle"
 467  			passthru
 468  		} | run display "$meta" "${title:-}"
 469  		;;
 470  	3*) # redirect
 471  		((REDIRECTS += 1))
 472  		if ((REDIRECTS > BOLLUX_MAXREDIR)); then
 473  			die $((100 + code)) "Too many redirects!"
 474  		fi
 475  		BOLLUX_URL="$url"
 476  		run blastoff "$meta" # TODO: confirm redirect
 477  		;;
 478  	4*) # temporary error
 479  		REDIRECTS=0
 480  		die "$((100 + code))" "Temporary error [$code]: $meta"
 481  		;;
 482  	5*) # permanent error
 483  		REDIRECTS=0
 484  		die "$((100 + code))" "Permanent error [$code]: $meta"
 485  		;;
 486  	6*) # certificate error
 487  		REDIRECTS=0
 488  		log d "Not implemented: Client certificates"
 489  		# TODO: recheck the speck
 490  		die "$((100 + code))" "[$code] $meta"
 491  		;;
 492  	*)
 493  		[[ -z "${code-}" ]] && die 100 "Empty response code."
 494  		die "$((100 + code))" "Unknown response code: $code."
 495  		;;
 496  	esac
 497  }
 498  
 499  # GOPHER
 500  # https://tools.ietf.org/html/rfc1436 protocol
 501  # https://tools.ietf.org/html/rfc4266 url
 502  gopher_request() { # gopher_request URL
 503  	local url server port type path
 504  	url="$1"
 505  	port=70
 506  
 507  	# RFC 4266
 508  	[[ "$url" =~ gopher://([^/?#:]*)(:([0-9]+))?(/((.))?(/?.*))?$ ]]
 509  	server="${BASH_REMATCH[1]}"
 510  	port="${BASH_REMATCH[3]:-70}"
 511  	type="${BASH_REMATCH[6]:-1}"
 512  	path="${BASH_REMATCH[7]}"
 513  
 514  	log d "URL='$url' SERVER='$server' TYPE='$type' PATH='$path'"
 515  
 516  	exec 9<>"/dev/tcp/$server/$port"
 517  	printf '%s\r\n' "$path" >&9
 518  	passthru <&9
 519  }
 520  
 521  gopher_response() { # gopher_response URL
 522  	local url pre type cur_server
 523  	pre=false
 524  	url="$1"
 525  	# RFC 4266
 526  	[[ "$url" =~ gopher://([^/?#:]*)(:([0-9]+))?(/((.))?(/?.*))?$ ]]
 527  	cur_server="${BASH_REMATCH[1]}"
 528  	type="${BASH_REMATCH[6]:-1}"
 529  
 530  	run history_append "$url" "" # gopher doesn't really have titles, huh
 531  
 532  	log d "TYPE='$type'"
 533  
 534  	case "$type" in
 535  	0) # text
 536  		run display text/plain
 537  		;;
 538  	1) # menu
 539  		run gopher_convert | run display text/gemini
 540  		;;
 541  	3) # failure
 542  		die 203 "GOPHER: failed"
 543  		;;
 544  	7) # search
 545  		if [[ "$url" =~ $'\t' ]]; then
 546  			run gopher_convert | run display text/gemini
 547  		else
 548  			run prompt 'SEARCH'
 549  			run blastoff "$url	$REPLY"
 550  		fi
 551  		;;
 552  	*) # something else
 553  		run download "$url"
 554  		;;
 555  	esac
 556  }
 557  
 558  # 'cat' but in pure bash
 559  passthru() {
 560  	while IFS= read -r; do
 561  		printf '%s\n' "$REPLY"
 562  	done
 563  }
 564  
 565  # convert gophermap to text/gemini (probably naive)
 566  gopher_convert() {
 567  	local type label path server port regex
 568  	# cf. https://github.com/jamestomasino/dotfiles-minimal/blob/master/bin/gophermap2gemini.awk
 569  	while IFS= read -r; do
 570  		printf -v regex '(.)([^\t]*)(\t([^\t]*)\t([^\t]*)\t([^\t]*))?'
 571  		if [[ "$REPLY" =~ $regex ]]; then
 572  			type="${BASH_REMATCH[1]}"
 573  			label="${BASH_REMATCH[2]}"
 574  			path="${BASH_REMATCH[4]:-/}"
 575  			server="${BASH_REMATCH[5]:-$cur_server}"
 576  			port="${BASH_REMATCH[6]}"
 577  		else
 578  			log e "CAN'T PARSE LINE"
 579  			printf '%s\n' "$REPLY"
 580  			continue
 581  		fi
 582  		case "$type" in
 583  		.) # end of file
 584  			printf '.\n'
 585  			break
 586  			;;
 587  		i) # label
 588  			case "$label" in
 589  			'#'* | '*'[[:space:]]*)
 590  				if $pre; then
 591  					printf '%s\n' '```'
 592  					pre=false
 593  				fi
 594  				;;
 595  			*)
 596  				if ! $pre; then
 597  					printf '%s\n' '```'
 598  					pre=true
 599  				fi
 600  				;;
 601  			esac
 602  			printf '%s\n' "$label"
 603  			;;
 604  		h) # html link
 605  			if $pre; then
 606  				printf '%s\n' '```'
 607  				pre=false
 608  			fi
 609  			printf '=> %s %s\n' "${path:4}" "$label"
 610  			;;
 611  		T) # telnet link
 612  			if $pre; then
 613  				printf '%s\n' '```'
 614  				pre=false
 615  			fi
 616  			printf '=> telnet://%s:%s/%s%s %s\n' \
 617  				"$server" "$port" "$type" "$path" "$label"
 618  			;;
 619  		*) # other type
 620  			if $pre; then
 621  				printf '%s\n' '```'
 622  				pre=false
 623  			fi
 624  			printf '=> gopher://%s:%s/%s%s %s\n' \
 625  				"$server" "$port" "$type" "$path" "$label"
 626  			;;
 627  		esac
 628  	done
 629  	if $pre; then
 630  		printf '%s\n' '```'
 631  	fi
 632  	# close the connection
 633  	exec 9<&-
 634  	exec 9>&-
 635  }
 636  
 637  # display the fetched content
 638  display() { # display METADATA [TITLE]
 639  	local -a less_cmd
 640  	local i mime charset
 641  	# split header line
 642  	local -a hdr
 643  	IFS=';' read -ra hdr <<<"$1"
 644  	# title is optional but nice looking
 645  	local title
 646  	if (($# == 2)); then
 647  		title="$2"
 648  	fi
 649  
 650  	mime="$(trim_string "${hdr[0],,}")"
 651  	for ((i = 1; i <= "${#hdr[@]}"; i++)); do
 652  		h="${hdr[$i]}"
 653  		case "$h" in
 654  		*charset=*) charset="${h#*=}" ;;
 655  		esac
 656  	done
 657  
 658  	[[ -z "$mime" ]] && mime="text/gemini"
 659  	[[ -z "$charset" ]] && charset="utf-8"
 660  
 661  	log debug "mime='$mime'; charset='$charset'"
 662  
 663  	case "$mime" in
 664  	text/*)
 665  		set_title "$title${title:+ - }bollux"
 666  		less_cmd=(less -R) # render ANSI color escapes
 667  		mklesskey "$BOLLUX_LESSKEY" && less_cmd+=(-k "$BOLLUX_LESSKEY")
 668  		local helpline="o:open, g/G:goto, [:back, ]:forward, r:refresh"
 669  		less_cmd+=(
 670  			-Pm"$(less_prompt_escape "$BOLLUX_URL") - bollux$" # 'status'line
 671  			-P="$(less_prompt_escape "$helpline")$" # helpline
 672  			-m # start with statusline
 673  			+k # float content to the top
 674  		)
 675  
 676  		local typeset
 677  		local submime="${mime#*/}"
 678  		if declare -Fp "typeset_$submime" &>/dev/null; then
 679  			typeset="typeset_$submime"
 680  		else
 681  			typeset="passthru"
 682  		fi
 683  
 684  		{
 685  			run iconv -f "${charset^^}" -t "UTF-8" |
 686  				run tee "$BOLLUX_PAGESRC" |
 687  				run "$typeset" | #cat
 688  				run "${less_cmd[@]}" && bollux_quit
 689  		} || run handle_keypress "$?"
 690  		;;
 691  	*) run download "$BOLLUX_URL" ;;
 692  	esac
 693  }
 694  
 695  # escape strings for the less prompt
 696  less_prompt_escape() { # less_prompt_escape STRING
 697  	local i
 698  	for ((i = 0; i < ${#1}; i++)); do
 699  		: "${1:i:1}"
 700  		case "$_" in
 701  		[\?:\.%\\]) printf '\%s' "$_" ;;
 702  		*) printf '%s' "$_" ;;
 703  		esac
 704  	done
 705  	printf '\n'
 706  }
 707  
 708  # generate a lesskey(1) file for custom keybinds
 709  mklesskey() { # mklesskey FILENAME
 710  	lesskey -o "$1" - <<-END
 711  		#command
 712  		o quit 0 # 48 open a link
 713  		g quit 1 # 49 goto a url
 714  		[ quit 2 # 50 back
 715  		] quit 3 # 51 forward
 716  		r quit 4 # 52 re-request / download
 717  		G quit 5 # 53 goto a url (pre-filled)
 718  		# other keybinds
 719  		\\40 forw-screen-force
 720  		h left-scroll
 721  		l right-scroll
 722  		? status   # 'status' will show a little help thing.
 723  		= noaction
 724  	END
 725  }
 726  
 727  # normalize files
 728  normalize() {
 729  	shopt -s extglob
 730  	while IFS= read -r; do
 731  		# normalize line endings
 732  		printf '%s\n' "${REPLY//$'\r'?($'\n')/}"
 733  	done
 734  	shopt -u extglob
 735  }
 736  
 737  # typeset a text/gemini document
 738  typeset_gemini() {
 739  	local pre=false
 740  	local ln=0 # link number
 741  
 742  	if ((T_WIDTH == 0)); then
 743  		shopt -s checkwinsize
 744  		(
 745  			:
 746  			:
 747  		) # dumb formatting brought to you by shfmt
 748  		log d "LINES=$LINES; COLUMNS=$COLUMNS"
 749  		T_WIDTH=$COLUMNS
 750  	fi
 751  	WIDTH=$((T_WIDTH - T_MARGIN))
 752  	((WIDTH < 0)) && WIDTH=80  # default if dumb
 753  	S_MARGIN=$((T_MARGIN - 1)) # spacing
 754  
 755  	log d "T_WIDTH=$T_WIDTH"
 756  	log d "WIDTH=$WIDTH"
 757  
 758  	while IFS= read -r; do
 759  		case "$REPLY" in
 760  		'```'*)
 761  			if $pre; then
 762  				pre=false
 763  			else
 764  				pre=true
 765  			fi
 766  			continue
 767  			;;
 768  		'=>'*)
 769  			: $((ln += 1))
 770  			gemini_link "$REPLY" $pre "$ln"
 771  			;;
 772  		'#'*) gemini_header "$REPLY" $pre ;;
 773  		'*'[[:space:]]*)
 774  			gemini_list "$REPLY" $pre
 775  			;;
 776  		'>'*)
 777  			gemini_quote "$REPLY" $pre
 778  			;;
 779  		*) gemini_text "$REPLY" $pre ;;
 780  		esac
 781  	done
 782  }
 783  
 784  gemini_link() {
 785  	local re="^(=>)[[:blank:]]*([^[:blank:]]+)[[:blank:]]*(.*)"
 786  	local s t a # sigil, text, annotation(url)
 787  	local ln="$3"
 788  	if ! ${2-false} && [[ "$1" =~ $re ]]; then
 789  		s="${BASH_REMATCH[1]}"
 790  		a="${BASH_REMATCH[2]}"
 791  		t="${BASH_REMATCH[3]}"
 792  		if [[ -z "$t" ]]; then
 793  			t="$a"
 794  			a=
 795  		fi
 796  
 797  		printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
 798  		printf "\e[${C_LINK_NUMBER}m[%d]${C_RESET} " "$ln"
 799  		fold_line -n -B "\e[${C_LINK_TITLE}m" -A "${C_RESET}" \
 800  			-l "$((${#ln} + 3))" -m "${T_MARGIN}" \
 801  			"$WIDTH" "$(trim_string "$t")"
 802  		fold_line -B " \e[${C_LINK_URL}m" -A "${C_RESET}" \
 803  			-l "$((${#ln} + 3 + ${#t}))" -m "$((T_MARGIN + ${#ln} + 2))" \
 804  			"$WIDTH" "$a"
 805  	else
 806  		gemini_pre "$1"
 807  	fi
 808  }
 809  
 810  gemini_header() {
 811  	local re="^(#+)[[:blank:]]*(.*)"
 812  	local s t a # sigil, text, annotation(lvl)
 813  	if ! ${2-false} && [[ "$1" =~ $re ]]; then
 814  		s="${BASH_REMATCH[1]}"
 815  		a="${#BASH_REMATCH[1]}"
 816  		t="${BASH_REMATCH[2]}"
 817  		local hdrfmt
 818  		hdrfmt="$(eval echo "\$C_HEADER$a")"
 819  
 820  		printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
 821  		fold_line -B "\e[${hdrfmt}m" -A "${C_RESET}" -m "${T_MARGIN}" \
 822  			"$WIDTH" "$t"
 823  	else
 824  		gemini_pre "$1"
 825  	fi
 826  }
 827  
 828  gemini_list() {
 829  	local re="^(\*)[[:blank:]]*(.*)"
 830  	local s t # sigil, text
 831  	if ! ${2-false} && [[ "$1" =~ $re ]]; then
 832  		s="${BASH_REMATCH[1]}"
 833  		t="${BASH_REMATCH[2]}"
 834  
 835  		printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
 836  		fold_line -B "\e[${C_LIST}m" -A "${C_RESET}" -m "$T_MARGIN" \
 837  			"$WIDTH" "$t"
 838  	else
 839  		gemini_pre "$1"
 840  	fi
 841  }
 842  
 843  gemini_quote() {
 844  	local re="^(>)[[:blank:]]*(.*)"
 845  	local s t # sigil, text
 846  	if ! ${2-false} && [[ "$1" =~ $re ]]; then
 847  		s="${BASH_REMATCH[1]}"
 848  		t="${BASH_REMATCH[2]}"
 849  
 850  		printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
 851  		fold_line -B "\e[${C_QUOTE}m" -A "${C_RESET}" -m "$T_MARGIN" \
 852  			"$WIDTH" "$t"
 853  	else
 854  		gemini_pre "$1"
 855  	fi
 856  }
 857  
 858  gemini_text() {
 859  	if ! ${2-false}; then
 860  		printf "%${S_MARGIN}s " ' '
 861  		fold_line -m "$T_MARGIN" \
 862  			"$WIDTH" "$1"
 863  	else
 864  		gemini_pre "$1"
 865  	fi
 866  }
 867  
 868  gemini_pre() {
 869  	printf "\e[${C_SIGIL}m%${S_MARGIN}s " '```'
 870  	printf "\e[${C_PRE}m%s${C_RESET}\n" "$1"
 871  }
 872  
 873  # wrap lines on words to WIDTH
 874  fold_line() {
 875  	# fold_line [-n] [-m MARGIN] [-f MARGIN] [-l LENGTH] [-B BEFORE] [-A AFTER] WIDTH TEXT
 876  	local newline=true
 877  	local -i margin_all=0 margin_first=0 width ll=0 wl=0 wn=0
 878  	local before="" after=""
 879  	OPTIND=0
 880  	while getopts nm:f:l:B:A: OPT; do
 881  		case "$OPT" in
 882  		n) # -n = no trailing newline
 883  			newline=false
 884  			;;
 885  		m) # -m MARGIN = margin for all lines
 886  			margin_all="$OPTARG"
 887  			;;
 888  		f) # -f MARGIN = margin for first line
 889  			margin_first="$OPTARG"
 890  			;;
 891  		l) # -l LENGTH = length of line before starting fold
 892  			ll="$OPTARG"
 893  			;;
 894  		B) # -B BEFORE = text to insert before each line
 895  			before="$OPTARG"
 896  			;;
 897  		A) # -A AFTER = text to insert after each line
 898  			after="$OPTARG"
 899  			;;
 900  		*) return 1 ;;
 901  		esac
 902  	done
 903  	shift "$((OPTIND - 1))"
 904  	width="$1"
 905  	ll=$((ll % width))
 906  	#shellcheck disable=2086
 907  	set -- $2
 908  
 909  	local plain=""
 910  	if ((margin_first > 0 && ll == 0)); then
 911  		printf "%${margin_first}s" " "
 912  	fi
 913  	if [[ -n "$before" ]]; then
 914  		printf '%b' "$before"
 915  	fi
 916  	for word; do
 917  		((wn += 1))
 918  		shopt -s extglob
 919  		plain="${word//$'\x1b'\[*([0-9;])m/}"
 920  		shopt -u extglob
 921  		wl=$((${#plain} + 1))
 922  		if (((ll + wl) >= width)); then
 923  			printf "${after:-}\n%${margin_all}s${before:-}" ' '
 924  			ll=$wl
 925  		else
 926  			((ll += wl))
 927  		fi
 928  		printf '%s' "$word"
 929  		((wn != $#)) && printf ' '
 930  	done
 931  	[[ -n "$after" ]] && printf '%b' "$after"
 932  	$newline && printf '\n'
 933  }
 934  
 935  # use the exit code from less (see mklesskey) to do things
 936  handle_keypress() { # handle_keypress CODE
 937  	case "$1" in
 938  	48) # o - open a link -- show a menu of links on the page
 939  		run select_url "$BOLLUX_PAGESRC"
 940  		;;
 941  	49) # g - goto a url -- input a new url
 942  		prompt GO
 943  		run blastoff -u "$REPLY"
 944  		;;
 945  	50) # [ - back in the history
 946  		run history_back || {
 947  			sleep 0.5
 948  			run blastoff "$BOLLUX_URL"
 949  		}
 950  		;;
 951  	51) # ] - forward in the history
 952  		run history_forward || {
 953  			sleep 0.5
 954  			run blastoff "$BOLLUX_URL"
 955  		}
 956  		;;
 957  	52) # r - re-request the current resource
 958  		run blastoff "$BOLLUX_URL"
 959  		;;
 960  	53) # G - goto a url (pre-filled with current)
 961  		run prompt -u GO
 962  		run blastoff -u "$REPLY"
 963  		;;
 964  	*) # 54-57 -- still available for binding
 965  		die "$?" "less(1) error"
 966  		;;
 967  	esac
 968  }
 969  
 970  # select a URL from a text/gemini file
 971  select_url() { # select_url FILE
 972  	run mapfile -t < <(extract_links <"$1")
 973  	if ((${#MAPFILE[@]} == 0)); then
 974  		log e "No links on this page!"
 975  		sleep 0.5
 976  		run blastoff "$BOLLUX_URL"
 977  	fi
 978  	PS3="OPEN> "
 979  	select u in "${MAPFILE[@]}"; do
 980  		case "$REPLY" in
 981  		q) bollux_quit ;;
 982  		[^0-9]*) run blastoff -u "$REPLY" && break ;;
 983  		esac
 984  		run blastoff "${u%%[[:space:]]*}" && break
 985  	done </dev/tty
 986  }
 987  
 988  # extract the links from a text/gemini file
 989  extract_links() {
 990  	local url alt
 991  	while read -r; do
 992  		if [[ "$REPLY" =~ ^=\>[[:space:]]*([^[:space:]]+)([[:space:]]+(.*))?$ ]]; then
 993  			url="${BASH_REMATCH[1]}"
 994  			alt="${BASH_REMATCH[3]}"
 995  
 996  			if [[ "$alt" ]]; then
 997  				printf '%s \e[34m(%s)\e[0m\n' "$url" "$alt"
 998  			else
 999  				printf '%s\n' "$url"
1000  			fi
1001  		fi
1002  	done
1003  }
1004  
1005  # download $BOLLUX_URL
1006  download() {
1007  	tn="$(mktemp)"
1008  	log x "Downloading: '$BOLLUX_URL' => '$tn'..."
1009  	dd status=progress >"$tn"
1010  	fn="$BOLLUX_DOWNDIR/${BOLLUX_URL##*/}"
1011  	if [[ -f "$fn" ]]; then
1012  		log x "Saved '$tn'."
1013  	elif mv "$tn" "$fn"; then
1014  		log x "Saved '$fn'."
1015  	else
1016  		log error "Error saving '$fn': downloaded to '$tn'."
1017  	fi
1018  }
1019  
1020  # initialize bollux
1021  bollux_init() {
1022  	# Trap cleanup
1023  	trap bollux_cleanup INT QUIT EXIT
1024  	# State
1025  	REDIRECTS=0
1026  	set -f
1027  	# History
1028  	declare -a HISTORY # history is kept in an array
1029  	HN=0               # position of history in the array
1030  	run mkdir -p "${BOLLUX_HISTFILE%/*}"
1031  }
1032  
1033  # clean up on exit
1034  bollux_cleanup() {
1035  	# Stubbed in case of need in future
1036  	:
1037  }
1038  
1039  # append a URL to history
1040  history_append() { # history_append URL TITLE
1041  	BOLLUX_URL="$1"
1042  	# date/time, url, title (best guess)
1043  	run printf '%(%FT%T)T\t%s\t%s\n' -1 "$1" "$2" >>"$BOLLUX_HISTFILE"
1044  	HISTORY[$HN]="$BOLLUX_URL"
1045  	((HN += 1))
1046  }
1047  
1048  # move back in history (session)
1049  history_back() {
1050  	log d "HN=$HN"
1051  	((HN -= 2))
1052  	if ((HN < 0)); then
1053  		HN=0
1054  		log e "Beginning of history."
1055  		return 1
1056  	fi
1057  	run blastoff "${HISTORY[$HN]}"
1058  }
1059  
1060  # move forward in history (session)
1061  history_forward() {
1062  	log d "HN=$HN"
1063  	if ((HN >= ${#HISTORY[@]})); then
1064  		HN="${#HISTORY[@]}"
1065  		log e "End of history."
1066  		return 1
1067  	fi
1068  	run blastoff "${HISTORY[$HN]}"
1069  }
1070  
1071  if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
1072  	run bollux "$@"
1073  fi