#!/bin/sh # tldr client by Ray Lee, http://github.com/raylee/tldr # tldr-sh-client version: 2021-12-18 spec: v1.5 # The following is written in a mildly functional style. It makes the code # marginally slower, but more easily traceable, debuggable, and composable. # And for me, at least, easier to reason about. # In scripts "$@" often means 'all the arguments'. Below, it's also used # for function indirection, executing the first parameter ($1) of $@ as a # command, $2 onward as parameters, and wrapping it with other behaviors. # Q: Why implement tldr in a plain POSIX shell script at all? It's nice to have # a low-dependency way to see the pages, independent of local architecture. For # some of us, POSIX sh is everywhere. # silent executes its arguments as a command with args, silently. silent() { >/dev/null 2>&1 "$@" } set -uf main() { check_requirements # Set up the local platform as the default for pages. preferred_platform "$(local_platform)" # Some people like running this without saving any state. cache true if [ "${TLDR_CACHE:-}" = no ]; then cache false debug log Disabling cache fi Cmd='' bool listing while [ $# -gt 0 ]; do case $1 in -h|--help) usage > /dev/stderr exit 0 ;; -l|--list) listing true ;; -L|--language) preferred_language "$2" shift ;; -n|--no-cache) cache false ;; -p|--platform) preferred_platform "$2" shift ;; -u|--update) update_tldr_cache exit 0 ;; -v|--version) version_info exit 0 ;; -*) panic "Unrecognized option '$1'" ;; *) # By the tldr client spec, the first argument # that does not start with a dash signals the # start of the command name. Spaces are # converted to dashes, and uppercase to lower. Cmd=$(printf %s "$*" | tr 'A-Z ' 'a-z-') break ;; esac shift done # Download a copy of the pages locally if we don't have them already. if ! exists "$(page_cache)/index.json"; then update_tldr_cache fi if listing; then list_pages exit 0 fi if [ -z "$Cmd" ]; then # If stdin is a pipeline or a file, display it as a page. if ! interactive; then display_tldr "$(cat)" else usage > /dev/stderr fi exit 0 fi tldr=$(get_page_md "$Cmd" 2>/dev/null) if [ -z "$tldr" ]; then panic "tldr page for command $Cmd not found" fi printf %s "$(display_tldr "$tldr")" echo # Freshen our local copy of the pages if they're out of date. if ! recent "$(page_cache)/index.json"; then update_tldr_cache fi } # panic exits the script with a final message to stderr. panic() { >/dev/stderr printf "%s\n" "$@" exit 1 } # have tests the availability of command $1 have() { silent command -v "$1" } # Check to see if we have the required external tools. check_requirements() { # if sed is available we assume the smaller utils such as tr, cut, # sort, uniq, grep... are as well. have sed || panic 'tldr requires sed installed and available' have unzip || panic 'tldr requires unzip installed and available' have curl || have wget || panic "tldr requires curl or wget installed and available" } # constants and read-only values base_url() { echo https://raw.githubusercontent.com/tldr-pages/tldr/main/pages } upstream_pages() { echo https://raw.githubusercontent.com/tldr-pages/tldr-pages.github.io/main/assets/tldr.zip } # cache_dir returns the base path for caching tldr data files. cache_dir() { if [ -n "${XDG_CACHE_HOME:-}" ]; then echo "${XDG_CACHE_HOME}/tldr" return fi if [ -n "${HOME:-}" ]; then if [ -d "$HOME/.cache" ]; then echo "$HOME/.cache/tldr" else echo "$HOME/.tldr" fi return fi echo "/tmp" } # page_cache returns the base path for tldr markdown pages. page_cache() { echo "$(cache_dir)/page-source" } # cache_days is how long to wait before refreshing our local copy. cache_days() { echo 14 } version_info() { grep '^# tldr-sh-client version: ' "$0" | cut -c3- echo "Check https://github.com/raylee/tldr-sh-client for updates." } # exists tests whether a file exists. exists() { silent test -e "$1" } # recent tests whether "$1" exists and is more recent than $(cache_days) old. recent() { exists "$(find "$1" -mtime "-$(cache_days)" 2>/dev/null)" } # basename acts like the command of the same name. basename() { fn=${1##*/} if [ $# -eq 2 ]; then fn=${fn%"$2"} fi printf "%s\n" "$fn" } # extension returns the portion of the filename after the last ., if any. extension() { echo "${1##*.}" } # lang_cc returns the ${language}_${country_code} portion of a locale. # example: lang_cc en_DK.utf8 => en_DK lang_cc() { for arg; do echo "${arg%.*}" done } Get() { if have curl; then curl -sfL "$1" else wget -O - "$1" fi } # Calling `cache true` enables the cache, any other parameter disables it. # Calling `cache` with no parameters changes the success code to true or false. cache() { case $# in 0) [ "${cache_bool:-}" = true ] ;; 1) cache_bool=$1 ;; esac } get_page_md() { if cache; then cat "$(page_path "$1")" else Get "$(base_url)/{$(local_platform),common}/$1.md" fi } # Update the local cached copy of the tldr source pages. update_tldr_cache() { if cache; then mkdir -p "$(page_cache)" "$(cache_dir)/local" Get "$(upstream_pages)" > "$(cache_dir)/tldr.zip" silent unzip -o -d "$(page_cache)" -u "$(cache_dir)/tldr.zip" fi } # # list the languages used in the current tldr zip archive. # tldr_languages() { # for path in "$(page_cache)"/pages.*; do # extension "$path" # done # } # Call preferred_platform with no arguments to get the current value, or pass # values to set. preferred_platform() { case $# in 0) echo "$preferred_platform_name" ;; *) preferred_platform_name="$*" ;; esac } # platform_order returns a list of search paths, with one (harmless) duplicate. platform_order() { echo local "$(preferred_platform)" common linux osx windows sunos android } # Call preferred_language with no arguments to get the current value, or pass # values to set. preferred_language() { case $# in 0) echo "${preferred_language_code:-}" ;; *) preferred_language_code="$*" ;; esac } # i18n_paths returns the prioritized list of language page paths to search. i18n_paths() { if [ -n "$(preferred_language)" ]; then echo pages."$(preferred_language)" # by the spec, if a preferred language is set, English is no # longer a fallback. return fi # if $LANG is unset or empty, English is the fallback. if [ -z "${LANG:-}" ]; then debug log i18n: LANGless echo pages return fi # else the languages are ordered by $LANGUAGE, then $LANG, then English. for i in $(split : "${LANGUAGE:-}") $LANG; do case $i in C|POSIX) continue ;; esac i=$(lang_cc "$i") echo "pages.$i" debug log i18n: pages."$i" done echo pages } # split separates the arguments based on a single split character in $1. split() { IFS="$1" shift # shellcheck disable=2048 for x in $*; do echo "$x" done } # page_path takes a (normalized) page name in $1, and localizes it for language # and platform. Platform pages are given preference over languages per the tldr # client spec. The path to the first page found in that order is returned. page_path() { if exists "$(cache_dir)/local/$1.md"; then echo "$(cache_dir)/local/$1.md" return fi for p in $(platform_order); do for l in $(i18n_paths); do if exists "$(page_cache)/$l/$p/$1.md"; then echo "$(page_cache)/$l/$p/$1.md" return fi done done } # term emits termcodes for style names passed as arguments. term() { for arg; do # shellcheck disable=2086 case $arg in reset) tput sgr0 || tput me ;; bold) tput bold || tput md ;; underline) tput smul || tput us ;; italic) tput sitm || tput ZH || printf "\033[3m" ;; eitalic) tput ritm || tput ZH || printf "\033[23m" ;; default) tput op ;; black) tput setaf 0 || tput AF 0 ;; red) tput setaf 1 || tput AF 1 ;; green) tput setaf 2 || tput AF 2 ;; yellow) tput setaf 3 || tput AF 3 ;; blue) tput setaf 4 || tput AF 4 ;; magenta) tput setaf 5 || tput AF 5 ;; cyan) tput setaf 6 || tput AF 6 ;; white) tput setaf 7 || tput AF 7 ;; onblue) tput setab 4 || tput AB 4 ;; ongrey) tput setab 7 || tput AB 7 ;; # custom styling if set in environment variable sheader) term ${TLDR_HEADER:-red} ;; squote) term ${TLDR_QUOTE:-italic} ;; sdescription) term ${TLDR_DESCRIPTION:-reset} ;; scode) term ${TLDR_CODE:-bold} ;; sparam) term ${TLDR_PARAM:-italic} ;; eparam) term ${TLDR_EPARAM:-eitalic} ;; esac done 2>/dev/null } # heading formats its arguments as a heading. heading() { printf "%s\n" "$(term sheader)$*$(term reset)" } # quotation formats its arguments as a quote. quotation() { echo "$(term squote)$*$(term reset)" } # list_item formats its arguments as a list item. list_item() { echo "$(term sdescription)$*$(term reset)" } # style_params styles each {{...}} span as parameters. style_params() { printf %s "$*" | sed "s/{{/$(term sparam)/g;s/}}/$(term eparam)/g" } # style_code styles all single-backtick blocks as code. style_code() { # Surround everything between two backticks with styling. printf "%s\n" "$*" \ | sed "s/\`\([^\`]*\)\`/ $(term scode)\1$(term reset)/g" } # text styles its arguments as plain text. text() { echo "$*" } # contains tests whether $1 occurs inside $2. contains() { # shellcheck disable=2295 test "${2#*$1}" != "$2" } # whitespace tests whether all its arguments are just whitespace. whitespace() { case "$*" in *[![:blank:]]*) false ;; *) true ;; esac } # display_tldr is a rudimentary-level recognition of tldr's CommonMark, plus # {{ }} which surround values in an example that users may edit. display_tldr() { last_token='' md="$1" md=$(style_params "$md") md=$(style_code "$md") # Read one line at a time, don't strip whitespace ('IFS='), and process # the last line even if it doesn't have a newline at the end. printf %s "$md" | while IFS= read -r line || [ -n "$line" ]; do # omit empty lines after list items if whitespace "$line" && [ "$last_token" = "list_item" ]; then continue fi case "$line" in \#*) heading "${line#??}" last_token="heading" ;; \>*) quotation "${line#??}" last_token="quotation" ;; -*) list_item "$line" last_token="list_item" ;; \`*) code "$line" last_token="code" ;; *) printf '%s\n' "$line" last_token="text" ;; esac done } # Convert the local platform name to tldr's version local_platform() { case $(uname -s) in Darwin) echo "osx" ;; Linux) echo "linux" ;; SunOS) echo "sunos" ;; CYGWIN* | MINGW32* | MSYS*) echo "windows" ;; *) echo "common" ;; # android? esac } no_warn() { 2>/dev/null "$@" } # list_pages lists pages for the current platform to stdout. Special platform # 'all' shows the name of every page, per the spec. list_pages() { { no_warn find "$(cache_dir)"/local -name '*.md' case $(preferred_platform) in all) find "$(page_cache)" -name '*.md' ;; *) find "$(page_cache)" -name '*.md' \ -path '*/'"$(preferred_platform)"'/*' ;; esac } \ | sed -e 's:.*/::' -e 's:.md$::' \ | sort \ | uniq } # usage shows the help text. usage() { cat < [options] -l, --list show all available pages -L, --language [code] override language detection, set preferred language -p, --platform [name] show page from specific platform -u, --update update cached copies of tldr page files -h, --help this help overview -v, --version show version information -n, --no-cache display pages directly from GitHub (watch ratelimits) Show the tldr page for command. The client caches a copy of the tldr pages under $(cache_dir) Local pages matching $(cache_dir)/local/*.md will be also be used and override provided pages. Cached pages will be refreshed after $(cache_days) days. Examples: Show an overivew of unzip: tldr unzip Show commands for all platforms: tldr -l -p all If you have fzf installed, try: tldr -l -p all | fzf --preview 'tldr {}' Show the Russian page for tar: tldr -L ru tar List pages in the Android section: tldr -p android -l EOF } # trace is used for debugging function paths. eg: trace display_tldr "$tldr" trace() { set -x "$@" set +x } # profile runs its continuation with profiling turned on (only works in bash). # eg `profile main "$@"` profile() { PS4=' $(linestamp) ' trace "$@" } # linestamp gives the current time and function caller formatted for a gutter. # For use in bash, not sh. linestamp() { # shellcheck disable=3028 disable=3054 printf "%0.3f %-20s |" "$EPOCHREALTIME" "${FUNCNAME[1]}" } # debug only executes the continuation if the environment variable # tldrDebug exists. debug() { [ -n "${tldrDebug+x}" ] && "$@" } # italic executes the continuation with italics on, eg: `italic cat ...`. italic() { term italic "$@" term reset } # If debugging is enabled, log prints its parameters to stderr on a line. log() { debug italic printf >/dev/stderr '%s\n' "$*" } # interactive tests whether our stdin is an interactive tty or a file/pipe. interactive() { # test if file descriptor 0 (stdin) is a tty [ -t 0 ] } # bool defines a boolean setter / getter initialized to false. # Usage: # bool name # name [true|false] # if name; then... if ! name; then... name && ... bool() { _bool_var_name=_bool_$1 # Yeah, I'm judging me too. I'm trying this on for size, to see if # localized complexity helps simplify the rest. # Anyway, this creates a new global function using $1 as its name, # acting per the description of `bool` above. eval " $1() { case \$# in 0) \$$_bool_var_name ;; 1) [ \"\$1\" = true ] && $_bool_var_name=true || $_bool_var_name=false ;; esac } $_bool_var_name=false " } main "$@"