added experimental mdcat
This commit is contained in:
@@ -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<ncols; ++j)); do
|
||||
[[ -z "${col_widths[j]}" ]] && col_widths[j]=0
|
||||
done
|
||||
|
||||
# Draw top border
|
||||
local border="+"
|
||||
for ((j=0; j<ncols; ++j)); do
|
||||
local w=$((col_widths[j]+2))
|
||||
border+="$(printf '%*s' "$w" "" | tr ' ' '-')+"
|
||||
done
|
||||
printf "%b\n" "$IBlack$border$RESETCOL"
|
||||
|
||||
# Print header row
|
||||
local -a header=()
|
||||
IFS="$sep" read -r -a header <<< "${table_rows[0]}"
|
||||
printf "%b|" "$IBlack"
|
||||
for ((j=0; j<ncols; ++j)); do
|
||||
local raw_cell="${header[j]:-}"
|
||||
local styled_cell
|
||||
styled_cell=$(_mdcat_style_inline "$raw_cell")
|
||||
local visible
|
||||
visible=$(printf '%b' "$styled_cell" | sed -E 's/\x1B\[[0-9;]*[mK]//g')
|
||||
local pad=$((col_widths[j] - ${#visible}))
|
||||
(( pad < 0 )) && pad=0
|
||||
printf " %b%b%*s%b |" "$BBlue" "$styled_cell" "$pad" "" "$IBlack"
|
||||
done
|
||||
printf "%b\n" "$RESETCOL"
|
||||
|
||||
# Header separator
|
||||
printf "%b|" "$IBlack"
|
||||
for ((j=0; j<ncols; ++j)); do
|
||||
printf " %s |" "$(printf '%*s' "${col_widths[j]}" "" | tr ' ' '-')"
|
||||
done
|
||||
printf "%b\n" "$RESETCOL"
|
||||
|
||||
# Print data rows
|
||||
for ((i=1; i<${#table_rows[@]}; ++i)); do
|
||||
local -a row=()
|
||||
IFS="$sep" read -r -a row <<< "${table_rows[i]}"
|
||||
printf "%b|" "$IBlack"
|
||||
for ((j=0; j<ncols; ++j)); do
|
||||
local raw_cell="${row[j]:-}"
|
||||
local styled_cell
|
||||
styled_cell=$(_mdcat_style_inline "$raw_cell")
|
||||
local visible
|
||||
visible=$(printf '%b' "$styled_cell" | sed -E 's/\x1B\[[0-9;]*[mK]//g')
|
||||
local pad=$((col_widths[j] - ${#visible}))
|
||||
(( pad < 0 )) && pad=0
|
||||
printf " %b%b%*s%b |" "$RESETCOL" "$styled_cell" "$pad" "" "$IBlack"
|
||||
done
|
||||
printf "%b\n" "$RESETCOL"
|
||||
done
|
||||
|
||||
# Draw bottom border
|
||||
printf "%b\n" "$IBlack$border$RESETCOL"
|
||||
}
|
||||
|
||||
local PARSED
|
||||
PARSED=$(getopt -o h --long help -n 'mdcat' -- "$@")
|
||||
# shellcheck disable=SC2181 # getopt return code is checked immediately after
|
||||
if [[ $? -ne 0 ]]; then
|
||||
disp E "Invalid options, use \"mdcat --help\" to display usage."
|
||||
return 1
|
||||
fi
|
||||
|
||||
eval set -- "$PARSED"
|
||||
while true; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
printf "mdcat: Render a Markdown file with terminal formatting.\n"
|
||||
printf "Usage: mdcat [file]\n"
|
||||
printf "If no file is provided, mdcat reads from standard input.\n"
|
||||
return 0
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
break
|
||||
;;
|
||||
*)
|
||||
disp E "Invalid option, use \"mdcat --help\" to display options list"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ $# -gt 1 ]]; then
|
||||
disp E "Too many arguments. Usage: mdcat [file]"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local input_file=""
|
||||
if [[ $# -eq 1 ]]; then
|
||||
input_file="$1"
|
||||
if [[ ! -f "$input_file" ]]; then
|
||||
disp E "File not found: $input_file"
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -r "$input_file" ]]; then
|
||||
disp E "File is not readable: $input_file"
|
||||
return 1
|
||||
fi
|
||||
elif [[ -t 0 ]]; then
|
||||
disp E "No input provided. Usage: mdcat [file] or: cat file.md | mdcat"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local in_code=0 code_lang="" raw line
|
||||
local -a code_lines=()
|
||||
local in_table=0
|
||||
local -a table_lines=()
|
||||
while IFS= read -r raw || [[ -n "$raw" ]]; do
|
||||
line="${raw%$'\r'}"
|
||||
|
||||
# Table detection: line with |, next line with | and ---
|
||||
if [[ $in_table -eq 0 && "$line" =~ ^[[:space:]]*\|.*\|[[:space:]]*$ ]]; then
|
||||
local next
|
||||
IFS= read -r next || true
|
||||
# Accept: | --- | --- | or |:---|---:| etc.
|
||||
if [[ "$next" =~ ^[[:space:]]*\|[[:space:]]*:?[-]+:?([[:space:]]*\|[[:space:]]*:?[ -]+:?)*\|[[:space:]]*$ ]]; then
|
||||
in_table=1
|
||||
table_lines=("$line" "$next")
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
if [[ $in_table -eq 1 ]]; then
|
||||
# Accept table row if it starts and ends with |
|
||||
if [[ "$line" =~ ^[[:space:]]*\|.*\|[[:space:]]*$ && ! "$line" =~ ^[[:space:]]*\|[[:space:]]*:?[-]+:?([[:space:]]*\|[[:space:]]*:?[ -]+:?)*\|[[:space:]]*$ ]]; then
|
||||
table_lines+=("$line")
|
||||
continue
|
||||
else
|
||||
_mdcat_print_table "${table_lines[@]}"
|
||||
in_table=0
|
||||
table_lines=()
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $in_code -eq 1 ]]; then
|
||||
if [[ "$line" =~ ^\`\`\` ]]; then
|
||||
_mdcat_print_code_block "$code_lang" "${code_lines[@]}"
|
||||
in_code=0
|
||||
code_lang=""
|
||||
code_lines=()
|
||||
else
|
||||
code_lines+=("$line")
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$line" =~ ^\`\`\`[[:space:]]*([^[:space:]]*) ]]; then
|
||||
in_code=1
|
||||
code_lang="${BASH_REMATCH[1]}"
|
||||
code_lines=()
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$line" =~ ^(#{1,6})[[:space:]]+(.*)$ ]]; then
|
||||
local lvl=${#BASH_REMATCH[1]}
|
||||
local title="${BASH_REMATCH[2]}"
|
||||
local h_on=""
|
||||
if [[ -z $NO_COLOR ]]; then
|
||||
case "$lvl" in
|
||||
1) h_on="$BBlue" ;;
|
||||
2) h_on="$BCyan" ;;
|
||||
3) h_on="$BGreen" ;;
|
||||
*) h_on="$BIWhite" ;;
|
||||
esac
|
||||
fi
|
||||
printf "%b%s%b\n" "$h_on" "$title" "$RESETCOL"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$line" =~ ^[[:space:]]*([\-*_])[[:space:]]*\1[[:space:]]*\1[\-*_[:space:]]*$ ]]; then
|
||||
_mdcat_print_hr
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$line" =~ ^([[:space:]]*)\>[[: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
|
||||
|
||||
Reference in New Issue
Block a user