2 # bollux: a bash gemini client
3 # Author: Case Duckworth
17 $PRGN (v. $VRSN): a bash gemini client
22 -h show this help and exit
23 -q be quiet: log no messages
24 -v verbose: log more messages
26 URL the URL to start in
27 If not provided, the user will be prompted.
43 # pure bash bible trim_string
45 : "${1#"${1%%[![:space:]]*}"}"
46 : "${_%"${_##*[![:space:]]}"}"
51 [[ "$BOLLUX_LOGLEVEL" == QUIET ]] && return
54 [[ "$BOLLUX_LOGLEVEL" == DEBUG ]] || return
63 printf >&2 '\e[%sm%s:\e[0m\t%s\n' "$fmt" "$PRGN" "$*"
71 if [[ ! "${BOLLUX_URL:+isset}" ]]; then
72 run prompt GO BOLLUX_URL
75 run blastoff "$BOLLUX_URL"
79 while getopts :hvq OPT; do
85 v) BOLLUX_LOGLEVEL=DEBUG ;;
86 q) BOLLUX_LOGLEVEL=QUIET ;;
87 :) die 1 "Option -$OPTARG requires an argument" ;;
88 *) die 1 "Unknown option: -$OPTARG" ;;
98 : "${BOLLUX_CONFIG:=${XDG_CONFIG_DIR:-$HOME/.config}/bollux/config}"
100 if [ -f "$BOLLUX_CONFIG" ]; then
101 # shellcheck disable=1090
104 log debug "Can't load config file '$BOLLUX_CONFIG'."
108 : "${BOLLUX_DOWNDIR:=.}" # where to save downloads
109 : "${BOLLUX_LOGLEVEL:=3}" # log level
110 : "${BOLLUX_MAXREDIR:=5}" # max redirects
111 : "${BOLLUX_PORT:=1965}" # port number
112 : "${BOLLUX_PROTO:=gemini}" # default protocol
113 : "${BOLLUX_LESSKEY:=/tmp/bollux-lesskey}" # where to store binds
114 : "${BOLLUX_PAGESRC:=/tmp/bollux-src}" # where to save the page source
115 : "${BOLLUX_URL:=}" # start url
117 : "${T_MARGIN:=4}" # left and right margin
118 : "${T_WIDTH:=0}" # width of the viewport -- 0 = get term width
119 # colors -- these will be wrapped in \e[ __ m
120 C_RESET='\e[0m' # reset
121 : "${C_SIGIL:=35}" # sigil (=>, #, ##, ###, *, ```)
122 : "${C_LINK_NUMBER:=1}" # link number
123 : "${C_LINK_TITLE:=4}" # link title
124 : "${C_LINK_URL:=36}" # link URL
125 : "${C_HEADER1:=1;4}" # header 1 formatting
126 : "${C_HEADER2:=1}" # header 2 formatting
127 : "${C_HEADER3:=3}" # header 3 formatting
128 : "${C_LIST:=0}" # list formatting
129 : "${C_PRE:=0}" # preformatted text formatting
135 read </dev/tty -e -r -p "$prompt> " "$@"
138 blastoff() { # load a url
139 local well_formed=true
140 if [[ "$1" == "-u" ]]; then
146 if $well_formed && [[ "$1" != "$BOLLUX_URL" ]]; then
147 URL="$(run transform_resource "$BOLLUX_URL" "$1")"
149 [[ "$URL" != *://* ]] && URL="$BOLLUX_PROTO://$URL"
153 server="${server%%/*}"
155 log d "URL='$URL' server='$server'"
157 run request_url "$server" "$BOLLUX_PORT" "$URL" |
158 run handle_response "$URL"
161 transform_resource() { # transform_resource BASE_URL REFERENCE_URL
162 declare -A R B T # reference, base url, target
163 eval "$(run parse_url B "$1")"
164 eval "$(run parse_url R "$2")"
165 # A non-strict parser may ignore a scheme in the reference
166 # if it is identical to the base URI's scheme.
167 if ! "${STRICT:-true}" && [[ "${R[scheme]}" == "${B[scheme]}" ]]; then
171 # basically pseudo-code from spec ported to bash
172 if isdefined "R[scheme]"; then
173 T[scheme]="${R[scheme]}"
174 isdefined "R[authority]" && T[authority]="${R[authority]}"
176 T[path]="$(run remove_dot_segments "${R[path]}")"
177 isdefined "R[query]" && T[query]="${R[query]}"
179 if isdefined "R[authority]"; then
180 T[authority]="${R[authority]}"
181 isdefined "R[authority]" &&
182 T[path]="$(remove_dot_segments "${R[path]}")"
183 isdefined R[query] && T[query]="${R[query]}"
185 if isempty "R[path]"; then
187 if isdefined R[query]; then
188 T[query]="${R[query]}"
190 T[query]="${B[query]}"
193 if [[ "${R[path]}" == /* ]]; then
194 T[path]="$(remove_dot_segments "${R[path]}")"
196 T[path]="$(merge_paths "B[authority]" "${B[path]}" "${R[path]}")"
197 T[path]="$(remove_dot_segments "${T[path]}")"
199 isdefined R[query] && T[query]="${R[query]}"
201 T[authority]="${B[authority]}"
203 T[scheme]="${B[scheme]}"
205 isdefined R[fragment] && T[fragment]="${R[fragment]}"
206 # cf. 5.3 -- recomposition
208 isdefined "T[scheme]" && r="$r${T[scheme]}:"
209 # remove the port from the authority
210 isdefined "T[authority]" && r="$r//${T[authority]%:*}"
212 isdefined T[query] && r="$r?${T[query]}"
213 isdefined T[fragment] && r="$r#${T[fragment]}"
217 merge_paths() { # 5.2.3
218 # shellcheck disable=2034
222 # if R_path is empty, get rid of // in B_path
223 if [[ -z "$R_path" ]]; then
224 printf '%s\n' "${B_path//\/\//\//}"
228 if isdefined "B_authority" && isempty "B_path"; then
229 printf '/%s\n' "${R_path//\/\//\//}"
231 if [[ "$B_path" == */* ]]; then
232 B_path="${B_path%/*}/"
236 printf '%s/%s\n' "${B_path%/}" "${R_path#/}"
240 remove_dot_segments() { # 5.2.4
243 # ^/\.(/|$) - BASH_REMATCH[0]
244 while [[ "$input" ]]; do
245 if [[ "$input" =~ ^\.\.?/ ]]; then
246 input="${input#${BASH_REMATCH[0]}}"
247 elif [[ "$input" =~ ^/\.(/|$) ]]; then
248 input="/${input#${BASH_REMATCH[0]}}"
249 elif [[ "$input" =~ ^/\.\.(/|$) ]]; then
250 input="/${input#${BASH_REMATCH[0]}}"
251 [[ "$output" =~ /?[^/]+$ ]]
252 output="${output%${BASH_REMATCH[0]}}"
253 elif [[ "$input" == . || "$input" == .. ]]; then
256 [[ $input =~ ^(/?[^/]*)(/?.*)$ ]] || echo NOMATCH >&2
257 output="$output${BASH_REMATCH[1]}"
258 input="${BASH_REMATCH[2]}"
261 printf '%s\n' "${output//\/\//\//}"
264 parse_url() { # eval "$(split_url NAME STRING)" => NAME[...]
267 # shopt -u extglob # TODO port re ^ to extglob syntax
268 local re='^(([^:/?#]+):)?(//([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?'
269 [[ $string =~ $re ]] || return $?
272 local scheme="${BASH_REMATCH[2]}"
273 local authority="${BASH_REMATCH[4]}"
274 local path="${BASH_REMATCH[5]}"
275 local query="${BASH_REMATCH[7]}"
276 local fragment="${BASH_REMATCH[9]}"
278 for c in scheme authority query fragment; do
280 run printf '%s[%s]=%q\n' "$name" "$c" "${!c}"
282 # unclear if the path is always set even if empty but it looks that way
283 run printf '%s[path]=%q\n' "$name" "$path"
286 # is a NAME defined ('set' in bash)?
287 isdefined() { [[ "${!1+x}" ]]; } # isdefined NAME
288 # is a NAME defined AND empty?
289 isempty() { [[ ! "${!1-x}" ]]; } # isempty NAME
296 ssl_cmd=(openssl s_client -crlf -quiet -connect "$server:$port")
297 ssl_cmd+=(-servername "$server") # SNI
298 run "${ssl_cmd[@]}" <<<"$url" 2>/dev/null
302 local url="$1" code meta
304 while read -r -d $'\r' hdr; do
305 code="$(gawk '{print $1}' <<<"$hdr")"
307 gawk '{for(i=2;i<=NF;i++)printf "%s ",$i;printf "\n"}' <<<"$hdr"
312 log x "[$code] $meta"
318 run prompt "$meta" QUERY
319 # shellcheck disable=2153
320 run blastoff "?$QUERY"
329 if ((REDIRECTS > BOLLUX_MAXREDIR)); then
330 die $((100 + code)) "Too many redirects!"
337 die "$((100 + code))" "$code"
341 die "$((100 + code))" "$code"
345 die "$((100 + code))" "$code"
348 [[ -z "${code-}" ]] && die 100 "Empty response code."
349 die "$((100 + code)) Unknown response code: $code."
361 log d "$mime $charset"
363 *) mime="$(trim "$1")" ;;
366 [[ -z "$mime" ]] && mime="text/gemini"
367 if [[ -z "$charset" ]]; then
370 charset="${charset#charset=}"
373 log debug "mime=$mime; charset=$charset"
379 [[ -r "$BOLLUX_LESSKEY" ]] || mklesskey "$BOLLUX_LESSKEY"
380 } && less_cmd+=(-k "$BOLLUX_LESSKEY")
383 -PM'o\:open, g\:goto, r\:refresh$'
388 if declare -F | grep -q "$submime"; then
389 log d "typeset_$submime"
392 tee "$BOLLUX_PAGESRC" |
393 run "typeset_$submime" |
395 } || run handle_keypress "$?"
400 tee "$BOLLUX_PAGESRC" |
402 } || run handle_keypress "$?"
405 *) run download "$BOLLUX_URL" ;;
410 lesskey -o "$1" - <<-END
412 o quit 0 # 48 open a link
413 g quit 1 # 49 goto a url
415 ] quit 3 # 51 forward
416 r quit 4 # 52 re-request / download
421 while IFS= read -r; do
422 printf '%s\n' "${REPLY//$'\r'?($'\n')/}"
428 local ln=0 # link number
430 if ((T_WIDTH == 0)); then
431 shopt -s checkwinsize
435 ) # XXX this doesn't work!?
436 log d "LINES=$LINES; COLUMNS=$COLUMNS"
439 WIDTH=$((T_WIDTH - T_MARGIN))
440 ((WIDTH < 0)) && WIDTH=80 # default if dumb
441 S_MARGIN=$((T_MARGIN - 1)) # spacing
443 log d "T_WIDTH=$T_WIDTH"
446 while IFS= read -r; do
458 gemini_link "$REPLY" $pre "$ln"
460 \#*) gemini_header "$REPLY" $pre ;;
462 if [[ "$REPLY" =~ ^\*[[:space:]]+ ]]; then
463 gemini_list "$REPLY" $pre
465 gemini_text "$REPLY" $pre
468 *) gemini_text "$REPLY" $pre ;;
474 local re="^(=>)[[:blank:]]*([^[:blank:]]+)[[:blank:]]*(.*)"
475 local s t a l # sigil, text, annotation(url), line
476 if ! ${2-false} && [[ "$1" =~ $re ]]; then
477 s="${BASH_REMATCH[1]}"
478 a="${BASH_REMATCH[2]}"
479 t="${BASH_REMATCH[3]}"
480 if [[ -z "$t" ]]; then
485 printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
486 printf -v l "\e[${C_LINK_NUMBER}m[%d]${C_RESET} \
487 \e[${C_LINK_TITLE}m%s${C_RESET} \
488 \e[${C_LINK_URL}m%s${C_RESET}\n" \
490 fold_line "$WIDTH" "$l"
497 local re="^(#+)[[:blank:]]*(.*)"
498 local s t a l # sigil, text, annotation(lvl), line
499 if ! ${2-false} && [[ "$1" =~ $re ]]; then
500 s="${BASH_REMATCH[1]}"
501 a="${#BASH_REMATCH[1]}"
502 t="${BASH_REMATCH[2]}"
504 hdrfmt="$(eval echo "\$C_HEADER$a")"
506 printf "\e[${C_SIGIL}m%${S_MARGIN}s ${C_RESET}" "$s"
507 printf -v l "\e[${hdrfmt}m%s${C_RESET}\n" "$t"
508 fold_line "$WIDTH" "$l"
515 local re="^(\*)[[:blank:]]*(.*)"
516 local s t a l # sigil, text, annotation(n/a), line
517 if ! ${2-false} && [[ "$1" =~ $re ]]; then
518 s="${BASH_REMATCH[1]}"
519 t="${BASH_REMATCH[2]}"
521 printf "\e[${C_SIGIL}m%${S_MARGIN}s " "$s"
522 printf -v l "\e[${C_LIST}m%s${C_RESET}\n" "$t"
523 fold_line "$WIDTH" "$l"
530 if ! ${2-false}; then
531 printf "%${S_MARGIN}s " ' '
532 fold_line "$WIDTH" "$1"
539 printf "\e[${C_SIGIL}m%${S_MARGIN}s " '```'
540 printf "\e[${C_PRE}m%s${C_RESET}\n" "$1"
543 fold_line() { # fold_line WIDTH TEXT
545 local margin="${2%%[![:space:]]*}"
546 if [[ "$margin" ]]; then
552 # shellcheck disable=2086
553 set -- $2 # TODO: is this the best way?
556 plain="${word//$'\x1b'\[*([0-9;])m/}"
557 wl=$((${#plain} + 1))
558 if (((ll + wl) >= width)); then
559 printf "\n%${margin}s" ' '
571 48) # o - open a link -- show a menu of links on the page
572 run select_url "$BOLLUX_PAGESRC"
574 49) # g - goto a url -- input a new url
576 run blastoff -u "$URL"
578 50) # [ - back in the history
581 51) # ] - forward in the history
584 52) # r - re-request the current resource
585 run blastoff "$BOLLUX_URL"
587 *) # 53-57 -- still available for binding
593 run mapfile -t < <(extract_links <"$1")
594 select u in "${MAPFILE[@]}"; do
595 run blastoff "$(gawk '{print $1}' <<<"$u")" && break
602 sub(/=>[[:space:]]*/,"")
605 for (i=2;i<=NF;i++) {
606 rest=rest (rest?" ":"")$i
608 printf "%s (\033[34m%s\033[0m)\n", $1, rest
617 log x "Downloading: '$BOLLUX_URL' => '$tn'..."
618 dd status=progress >"$tn"
619 fn="$BOLLUX_DOWNDIR/${BOLLUX_URL##*/}"
620 if [[ -f "$fn" ]]; then
622 elif mv "$tn" "$fn"; then
625 log error "Error saving '$fn': downloaded to '$tn'."
629 history_back() { log error "Not implemented."; }
630 history_forward() { log error "Not implemented."; }
632 if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
635 BOLLUX_LOGLEVEL=DEBUG