diff --git a/README.md b/README.md index d0ed9be..1af9746 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ A bar-style prompt showing current time, execution time of the last command | `ku` | processes | Kill all processes owned by the given user name or ID | | `matrix` | rain | Console screensaver with Matrix-style digital rain (binary, kana, ascii charset) | | `mcd` | filefct | Create a directory and immediately move into it | +| `mdcat` | disp | Render Markdown files in terminal with colors, inline formatting, and framed code blocks | | `meteo` | info | Display weather forecast for the configured or given city | | `myextip` | net | Get information about your public IP address | | `get_pkgmgr` | packages | Detect the active package manager of the running distribution (`apt`, `dnf`, `yum`, `zypper`, `pacman`, `apk`, `portage`, `xbps`, `nix`) | diff --git a/profile.d/disp.sh b/profile.d/disp.sh index 66cc69c..7f91f8f 100644 --- a/profile.d/disp.sh +++ b/profile.d/disp.sh @@ -236,6 +236,478 @@ export -f disp # ------------------------------------------------------------------------------ +# ------------------------------------------------------------------------------ +# Render Markdown files with terminal formatting +# Usage: mdcat [file] +mdcat() +{ + _mdcat_style_inline() + { + local text="$1" + local bold_on="" italic_on="" code_on="" style_off="" + local link_text_on="" link_url_on="" + + if [[ -z $NO_COLOR ]]; then + bold_on=$'\e[1m' + italic_on=$'\e[3m' + code_on="${On_IBlack}${BIWhite}" + link_text_on=$'\e[4;96m' + link_url_on="$IBlack" + style_off="$RESETCOL" + fi + + # Apply inline transforms in a safe order: code, links, then emphasis. + # This prevents emphasis parsing inside code spans or link URLs. + while [[ "$text" =~ \[([^][]+)\]\(([^()]+)\) ]]; do + local match="${BASH_REMATCH[0]}" + local label="${BASH_REMATCH[1]}" + local url="${BASH_REMATCH[2]}" + local before="${text%%"$match"*}" + local after="${text#*"$match"}" + if [[ "$label" == "$url" ]]; then + if [[ -z $NO_COLOR ]]; then + text="${before}${link_text_on}${label}${style_off}${after}" + else + text="${before}${label}${after}" + fi + elif [[ -z $NO_COLOR ]]; then + text="${before}${link_text_on}${label}${style_off} ${link_url_on}(${url})${style_off}${after}" + else + text="${before}${label} (${url})${after}" + fi + done + + # Style bare URLs without re-matching the same URL forever. + # The prefix capture keeps progress monotonic and prevents freeze loops. + # Skip this transformation in NO_COLOR mode. + if [[ -z $NO_COLOR ]]; then + while [[ "$text" =~ (^|[[:space:]\(])(https?://[^[:space:]\)]+) ]]; do + local match="${BASH_REMATCH[0]}" + local pre="${BASH_REMATCH[1]}" + local url="${BASH_REMATCH[2]}" + local before="${text%%"$match"*}" + local after="${text#*"$match"}" + text="${before}${pre}${link_text_on}${url}${style_off}${after}" + done + fi + + while [[ "$text" =~ \`([^\`]+)\` ]]; do + local match="${BASH_REMATCH[0]}" + local val="${BASH_REMATCH[1]}" + local before="${text%%"$match"*}" + local after="${text#*"$match"}" + text="${before}${code_on}${val}${style_off}${after}" + done + + while [[ "$text" =~ \*\*([^*]+)\*\* ]]; do + local match="${BASH_REMATCH[0]}" + local val="${BASH_REMATCH[1]}" + local before="${text%%"$match"*}" + local after="${text#*"$match"}" + text="${before}${bold_on}${val}${style_off}${after}" + done + + while [[ "$text" =~ __([^_]+)__ ]]; do + local match="${BASH_REMATCH[0]}" + local val="${BASH_REMATCH[1]}" + local before="${text%%"$match"*}" + local after="${text#*"$match"}" + text="${before}${bold_on}${val}${style_off}${after}" + done + + while [[ "$text" =~ \*([^*]+)\* ]]; do + local match="${BASH_REMATCH[0]}" + local val="${BASH_REMATCH[1]}" + local before="${text%%"$match"*}" + local after="${text#*"$match"}" + text="${before}${italic_on}${val}${style_off}${after}" + done + + # Match _italic_ only when underscores are outside word-like identifiers. + while [[ "$text" =~ (^|[[:space:][:punct:]])_([^[:space:]_][^_]*[^[:space:]_])_([[:space:][:punct:]]|$) ]]; do + local match="${BASH_REMATCH[0]}" + local pre="${BASH_REMATCH[1]}" + local val="${BASH_REMATCH[2]}" + local post="${BASH_REMATCH[3]}" + local before="${text%%"$match"*}" + local after="${text#*"$match"}" + text="${before}${pre}${italic_on}${val}${style_off}${post}${after}" + done + + # Unescape Markdown punctuation escapes, such as \<, \>, \_, and \*. + while [[ "$text" =~ \\([[:punct:]]) ]]; do + local match="${BASH_REMATCH[0]}" + local val="${BASH_REMATCH[1]}" + local before="${text%%"$match"*}" + local after="${text#*"$match"}" + text="${before}${val}${after}" + done + + printf "%s\n" "$text" + } + + _mdcat_print_hr() + { + local cols="${COLUMNS:-}" + if [[ -z "$cols" || ! "$cols" =~ ^[0-9]+$ || "$cols" -lt 20 ]]; then + cols=$(tput cols 2>/dev/null) + fi + [[ -z "$cols" || ! "$cols" =~ ^[0-9]+$ || "$cols" -lt 20 ]] && cols=80 + + local hr + printf -v hr "%*s" "$cols" "" + hr="${hr// /-}" + if [[ -z $NO_COLOR ]]; then + printf "%b%s%b\n" "$IBlack" "$hr" "$RESETCOL" + else + printf "%s\n" "$hr" + fi + } + + _mdcat_print_code_block() + { + local lang="$1" + shift + local -a lines=("$@") + + local cols="${COLUMNS:-}" + if [[ -z "$cols" || ! "$cols" =~ ^[0-9]+$ || "$cols" -lt 20 ]]; then + cols=$(tput cols 2>/dev/null) + fi + [[ -z "$cols" || ! "$cols" =~ ^[0-9]+$ || "$cols" -lt 20 ]] && cols=80 + + local max_inner=$((cols - 4)) + (( max_inner < 16 )) && max_inner=16 + + local width=16 line + for line in "${lines[@]}"; do + local line_len=${#line} + (( line_len > width )) && width=$line_len + done + (( width > max_inner )) && width=$max_inner + + local border + printf -v border "+-%*s-+" "$width" "" + border="${border// /-}" + + local frame_on="" code_on="" off="" + if [[ -z $NO_COLOR ]]; then + frame_on="$IBlack" + code_on="$BIWhite" + off="$RESETCOL" + fi + + printf "%b%s%b\n" "$frame_on" "$border" "$off" + if [[ -n "$lang" ]]; then + local tag="language: $lang" + local wrapped_tag + wrapped_tag=$(printf "%s" "$tag" | fold -s -w "$width") + while IFS= read -r line || [[ -n "$line" ]]; do + printf "%b| %b%-*s%b |%b\n" "$frame_on" "$code_on" "$width" "$line" "$frame_on" "$off" + done <<< "$wrapped_tag" + printf "%b%s%b\n" "$frame_on" "$border" "$off" + fi + + if [[ ${#lines[@]} -eq 0 ]]; then + printf "%b| %b%-*s%b |%b\n" "$frame_on" "$code_on" "$width" "" "$frame_on" "$off" + else + for line in "${lines[@]}"; do + local wrapped + wrapped=$(printf "%s" "$line" | fold -s -w "$width") + while IFS= read -r wline || [[ -n "$wline" ]]; do + printf "%b| %b%-*s%b |%b\n" "$frame_on" "$code_on" "$width" "$wline" "$frame_on" "$off" + done <<< "$wrapped" + done + fi + printf "%b%s%b\n" "$frame_on" "$border" "$off" + } + + _mdcat_print_table() + { + local -a lines=("$@") + local -a table_rows=() + local -a col_widths=() + local i j ncols=0 + local sep=$'\x1f' + + _mdcat_parse_table_row() + { + local input="$1" + local line + line=$(printf '%s' "$input" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//') + line="${line#|}" + line="${line%|}" + + # Parse Markdown cells using only '|' separators and preserve spaces inside cells. + local -a cells=() + local cell rest="$line" + while :; do + if [[ "$rest" == *'|'* ]]; then + cell="${rest%%|*}" + rest="${rest#*|}" + else + cell="$rest" + rest="" + fi + cell=$(printf '%s' "$cell" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//') + cells+=("$cell") + [[ -z "$rest" ]] && break + done + + if [[ ${#cells[@]} -eq 0 ]]; then + printf '%s' "" + return + fi + + local joined="${cells[0]}" + for ((j=1; j<${#cells[@]}; ++j)); do + joined+="$sep${cells[j]}" + done + printf '%s' "$joined" + } + + # Parse header and data rows, skipping the Markdown separator row. + # Width is computed from visible text length with ANSI escapes stripped. + for ((i=0; i<${#lines[@]}; ++i)); do + (( i == 1 )) && continue + local parsed + parsed=$(_mdcat_parse_table_row "${lines[i]}") + table_rows+=("$parsed") + + local -a row=() + IFS="$sep" read -r -a row <<< "$parsed" + (( ncols < ${#row[@]} )) && ncols=${#row[@]} + for ((j=0; j<${#row[@]}; ++j)); do + local vis + vis=$(_mdcat_style_inline "${row[j]}") + vis=$(printf '%b' "$vis" | sed -E 's/\x1B\[[0-9;]*[mK]//g') + local cell_len=${#vis} + [[ -z "${col_widths[j]}" ]] && col_widths[j]=0 + (( col_widths[j] < cell_len )) && col_widths[j]=$cell_len + done + done + + # Ensure all width slots are initialized before drawing borders. + for ((j=0; j[[:space:]]?(.*)$ ]]; then + local quote="${BASH_REMATCH[2]}" + quote=$(_mdcat_style_inline "$quote") + if [[ -z $NO_COLOR ]]; then + printf "%s%b|%b %b\n" "${BASH_REMATCH[1]}" "$ICyan" "$RESETCOL" "$quote" + else + printf "%s| %b\n" "${BASH_REMATCH[1]}" "$quote" + fi + continue + fi + + if [[ "$line" =~ ^([[:space:]]*)[-+*][[:space:]]+(.*)$ ]]; then + local item="${BASH_REMATCH[2]}" + item=$(_mdcat_style_inline "$item") + if [[ -z $NO_COLOR ]]; then + printf "%s%b*%b %b\n" "${BASH_REMATCH[1]}" "$IGreen" "$RESETCOL" "$item" + else + printf "%s* %b\n" "${BASH_REMATCH[1]}" "$item" + fi + continue + fi + + if [[ "$line" =~ ^([[:space:]]*)([0-9]+)\.[[:space:]]+(.*)$ ]]; then + local nitem="${BASH_REMATCH[3]}" + nitem=$(_mdcat_style_inline "$nitem") + if [[ -z $NO_COLOR ]]; then + printf "%s%b%s.%b %b\n" "${BASH_REMATCH[1]}" "$IGreen" "${BASH_REMATCH[2]}" "$RESETCOL" "$nitem" + else + printf "%s%s. %b\n" "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "$nitem" + fi + continue + fi + + printf "%b\n" "$(_mdcat_style_inline "$line")" + done < "${input_file:-/dev/stdin}" + + if [[ $in_code -eq 1 ]]; then + _mdcat_print_code_block "$code_lang" "${code_lines[@]}" + fi +} +export -f mdcat +# ------------------------------------------------------------------------------ + + # Load disp section variables load_conf disp set_colors diff --git a/profile.d/help.sh b/profile.d/help.sh index 526d193..5c3dc34 100644 --- a/profile.d/help.sh +++ b/profile.d/help.sh @@ -79,6 +79,7 @@ help() printf "ku\t\tKill all processes owned by the given user name or ID\n" printf "matrix\t\tConsole screensaver with Matrix-style digital rain (binary, kana, ascii charset)\n" printf "mcd\t\tCreate a directory and immediately move into it\n" + printf "mdcat\t\tRender Markdown files in terminal with colors and code frames\n" printf "meteo\t\tDisplay weather forecast for the configured or given city\n" printf "myextip\t\tGet information about your public IP address\n" printf "pkgs\t\tSearch for a pattern in installed package names (dpkg/rpm, supports -i)\n" @@ -102,7 +103,7 @@ help() printf "utaz\t\tSmartly uncompress archives (zip, tar.gz/bz2/xz/lz, rar, arj, lha, ace, 7z, zst, cpio, cab, deb, rpm)\n" printf "ver\t\tDisplay the installed profile version\n\n" - printf "\nPlease use --help to obtain usage details.\n" + printf "\nPlease use --help or help to obtain usage details.\n" } export -f help # ------------------------------------------------------------------------------