850 lines
30 KiB
Bash
850 lines
30 KiB
Bash
#!/usr/bin/env bash
|
|
# ------------------------------------------------------------------------------
|
|
# Copyright (c) 2013-2026 Geoffray Levasseur <fatalerrors@geoffray-levasseur.org>
|
|
# Protected by the BSD3 license. Please read bellow for details.
|
|
#
|
|
# * Redistribution and use in source and binary forms,
|
|
# * with or without modification, are permitted provided
|
|
# * that the following conditions are met:
|
|
# *
|
|
# * Redistributions of source code must retain the above
|
|
# * copyright notice, this list of conditions and the
|
|
# * following disclaimer.
|
|
# *
|
|
# * Redistributions in binary form must reproduce the above
|
|
# * copyright notice, this list of conditions and the following
|
|
# * disclaimer in the documentation and/or other materials
|
|
# * provided with the distribution.
|
|
# *
|
|
# * Neither the name of the copyright holder nor the names
|
|
# * of any other contributors may be used to endorse or
|
|
# * promote products derived from this software without
|
|
# * specific prior written permission.
|
|
# *
|
|
# * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
|
# * CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
|
|
# * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
|
|
# * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
# * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
|
# * CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
# * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
|
# * BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
# * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
# * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
|
# * OF SUCH DAMAGE.
|
|
# ------------------------------------------------------------------------------
|
|
|
|
# Common helpers (helpers/common.sh)
|
|
# shellcheck source=../helpers/common.sh
|
|
declare -F _profile_load_helpers >/dev/null || . "$(dirname -- "${BASH_SOURCE[0]}")/../helpers/common.sh"
|
|
|
|
load_conf disp
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Color definitions
|
|
set_colors()
|
|
{
|
|
# Standard 16 colors display declaration
|
|
export DEFAULTFG='\e[0;39m'
|
|
export DEFAULTBG='\e[0;49m'
|
|
export DEFAULTCOL="${DEFAULTBG}${DEFAULTFG}"
|
|
export RESETCOL='\e[0m'
|
|
|
|
# Regular Colors
|
|
export Black='\e[0;30m'
|
|
export Red='\e[0;31m'
|
|
export Green='\e[0;32m'
|
|
export Yellow='\e[0;33m'
|
|
export Blue='\e[0;34m'
|
|
export Purple='\e[0;35m'
|
|
export Cyan='\e[0;36m'
|
|
export White='\e[0;37m'
|
|
|
|
# Bold
|
|
export BBlack='\e[1;30m'
|
|
export BRed='\e[1;31m'
|
|
export BGreen='\e[1;32m'
|
|
export BYellow='\e[1;33m'
|
|
export BBlue='\e[1;34m'
|
|
export BPurple='\e[1;35m'
|
|
export BCyan='\e[1;36m'
|
|
export BWhite='\e[1;37m'
|
|
|
|
# Underline
|
|
export UBlack='\e[4;30m'
|
|
export URed='\e[4;31m'
|
|
export UGreen='\e[4;32m'
|
|
export UYellow='\e[4;33m'
|
|
export UBlue='\e[4;34m'
|
|
export UPurple='\e[4;35m'
|
|
export UCyan='\e[4;36m'
|
|
export UWhite='\e[4;37m'
|
|
|
|
# Background
|
|
export On_Black='\e[40m'
|
|
export On_Red='\e[41m'
|
|
export On_Green='\e[42m'
|
|
export On_Yellow='\e[43m'
|
|
export On_Blue='\e[44m'
|
|
export On_Purple='\e[45m'
|
|
export On_Cyan='\e[46m'
|
|
export On_White='\e[47m'
|
|
|
|
# High Intensity
|
|
export IBlack='\e[0;90m'
|
|
export IRed='\e[0;91m'
|
|
export IGreen='\e[0;92m'
|
|
export IYellow='\e[0;93m'
|
|
export IBlue='\e[0;94m'
|
|
export IPurple='\e[0;95m'
|
|
export ICyan='\e[0;96m'
|
|
export IWhite='\e[0;97m'
|
|
|
|
# Bold High Intensity
|
|
export BIBlack='\e[1;90m'
|
|
export BIRed='\e[1;91m'
|
|
export BIGreen='\e[1;92m'
|
|
export BIYellow='\e[1;93m'
|
|
export BIBlue='\e[1;94m'
|
|
export BIPurple='\e[1;95m'
|
|
export BICyan='\e[1;96m'
|
|
export BIWhite='\e[1;97m'
|
|
|
|
# High Intensity backgrounds
|
|
export On_IBlack='\e[0;100m'
|
|
export On_IRed='\e[0;101m'
|
|
export On_IGreen='\e[0;102m'
|
|
export On_IYellow='\e[0;103m'
|
|
export On_IBlue='\e[0;104m'
|
|
export On_IPurple='\e[0;105m'
|
|
export On_ICyan='\e[0;106m'
|
|
export On_IWhite='\e[0;107m'
|
|
}
|
|
export -f set_colors
|
|
# ------------------------------------------------------------------------------
|
|
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Display a message
|
|
# Usage: disp <type> <message>
|
|
# Types:
|
|
# I : info (green), stdout
|
|
# W : warning (yellow), stderr
|
|
# E : error (red), stderr
|
|
# D : debug (cyan), stderr, shown only when DEBUG is an integer > 1
|
|
# Without a recognised type (case-insensitive), the arguments are the message
|
|
# itself, printed unprefixed on stdout like an info.
|
|
#
|
|
# Environment:
|
|
# NO_COLOR non-empty: no colour or reset sequence is emitted
|
|
# QUIET=1 hides info and unprefixed messages (warnings/errors remain)
|
|
# COLUMNS terminal width used for wrapping (tput cols otherwise, 80 fallback)
|
|
#
|
|
# The message is wrapped to the terminal width on spaces, ANSI sequences count
|
|
# for no width, and continuation lines are aligned after the prefix. Backslash
|
|
# escapes (\t, \e, ...) are interpreted when printing (printf %b), but wrapping
|
|
# is computed before that, so a literal "\t" counts as two characters.
|
|
# A message hidden by QUIET/DEBUG still returns 0.
|
|
disp()
|
|
{
|
|
local _disp_print_wrapped
|
|
_disp_print_wrapped()
|
|
{
|
|
local prefix="$1"
|
|
local target_fd="$2"
|
|
shift 2
|
|
local message="$*"
|
|
|
|
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
|
|
|
|
# Match ANSI CSI sequences in real-ESC, literal \e and literal \033 forms
|
|
# so their on-screen width (zero) is excluded from all width calculations.
|
|
local ansi_re=$'\e\\[[0-9;]*[A-Za-z]'
|
|
ansi_re+='|\\e\[[0-9;]*[A-Za-z]'
|
|
ansi_re+='|\\033\[[0-9;]*[A-Za-z]'
|
|
|
|
# The hanging-indent width is the prefix's VISIBLE length (ANSI stripped),
|
|
# so callers no longer need to pass a separate plain-text length.
|
|
local _pv=$prefix
|
|
while [[ $_pv =~ $ansi_re ]]; do _pv=${_pv/"${BASH_REMATCH[0]}"/}; done
|
|
local indent_len=0
|
|
(( ${#_pv} > 0 )) && indent_len=$(( ${#_pv} + 1 ))
|
|
|
|
local width=$((cols - indent_len))
|
|
(( width < 10 )) && width=10
|
|
|
|
# ANSI-aware soft wrap (fold -s semantics): break at the last space that
|
|
# keeps a line within `width` VISIBLE columns, hard-breaking only when a
|
|
# run has no space. ANSI escapes are copied through at zero width so they
|
|
# no longer inflate the measured width and cause premature wrapping.
|
|
local wrapped="" line="" line_vis=0 last_space=-1
|
|
local _i=0 _n=${#message}
|
|
while (( _i < _n )); do
|
|
local _c=${message:_i:1}
|
|
# Only a real ESC or a literal backslash can start an ANSI sequence;
|
|
# testing this first avoids copying the message tail (and running
|
|
# the regex) for every ordinary character.
|
|
# shellcheck disable=SC1003 # a literal backslash
|
|
if [[ $_c == $'\e' || $_c == '\' ]]; then
|
|
local _sub=${message:_i}
|
|
if [[ $_sub =~ ^($ansi_re) ]]; then
|
|
line+=${BASH_REMATCH[0]}
|
|
_i=$(( _i + ${#BASH_REMATCH[0]} ))
|
|
continue
|
|
fi
|
|
fi
|
|
_i=$(( _i + 1 ))
|
|
line+=$_c
|
|
(( ++line_vis ))
|
|
[[ $_c == ' ' ]] && last_space=${#line}
|
|
if (( line_vis > width )); then
|
|
if (( last_space > 0 )); then
|
|
wrapped+=${line:0:last_space-1}$'\n'
|
|
line=${line:last_space}
|
|
local _st=$line
|
|
while [[ $_st =~ $ansi_re ]]; do _st=${_st/"${BASH_REMATCH[0]}"/}; done
|
|
line_vis=${#_st}
|
|
else
|
|
local _keep=${line: -1}
|
|
wrapped+=${line:0:${#line}-1}$'\n'
|
|
line=$_keep
|
|
line_vis=1
|
|
fi
|
|
last_space=-1
|
|
fi
|
|
done
|
|
wrapped+=$line
|
|
|
|
# RESETCOL closes any colour left open by the message. It is skipped
|
|
# under NO_COLOR so that no escape sequence at all is emitted.
|
|
local reset="${RESETCOL:-}"
|
|
[[ -n ${NO_COLOR:-} ]] && reset=""
|
|
|
|
# >&"$target_fd" sends the output to stdout (1) or stderr (2). The
|
|
# first line carries the prefix, continuation lines are indented to
|
|
# line up with the message body.
|
|
local first_line=1
|
|
local line
|
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
|
if (( first_line )); then
|
|
if [[ -n "$prefix" ]]; then
|
|
printf "%b\n" "${prefix} ${line}${reset}" >&"$target_fd"
|
|
else
|
|
printf "%b\n" "${line}${reset}" >&"$target_fd"
|
|
fi
|
|
first_line=0
|
|
else
|
|
printf "%*s%b\n" "$indent_len" "" "${line}${reset}" >&"$target_fd"
|
|
fi
|
|
done <<< "$wrapped"
|
|
}
|
|
|
|
# Handle NO_COLOR: disable colors if set
|
|
local color_enabled=1
|
|
[[ -n ${NO_COLOR:-} ]] && color_enabled=0
|
|
|
|
# Each branch ends with an "if" (never a bare "[[ ]] && cmd") so that a
|
|
# message hidden by QUIET/DEBUG still returns 0: a non-zero status would
|
|
# trip "set -e", ERR traps, and "disp ... && next" chains.
|
|
case ${1^^} in
|
|
"I")
|
|
if [[ $color_enabled -eq 1 ]]; then
|
|
local heads="[ ${IGreen}info${DEFAULTFG} ]"
|
|
else
|
|
local heads="[ info ]"
|
|
fi
|
|
shift
|
|
if [[ ${QUIET:-} != 1 ]]; then
|
|
_disp_print_wrapped "$heads" 1 "$*"
|
|
fi
|
|
;;
|
|
"W")
|
|
if [[ $color_enabled -eq 1 ]]; then
|
|
local heads="[ ${IYellow}Warning${DEFAULTFG} ]"
|
|
else
|
|
local heads="[ Warning ]"
|
|
fi
|
|
shift
|
|
_disp_print_wrapped "$heads" 2 "$*"
|
|
;;
|
|
"E")
|
|
if [[ $color_enabled -eq 1 ]]; then
|
|
local heads="[ ${IRed}ERROR${DEFAULTFG} ]"
|
|
else
|
|
local heads="[ ERROR ]"
|
|
fi
|
|
shift
|
|
_disp_print_wrapped "$heads" 2 "$*"
|
|
;;
|
|
"D")
|
|
if [[ $color_enabled -eq 1 ]]; then
|
|
local heads="[ ${ICyan}debug${DEFAULTFG} ]"
|
|
else
|
|
local heads="[ debug ]"
|
|
fi
|
|
shift
|
|
# Shown only when DEBUG is an integer greater than 1; sent to stderr
|
|
# like the other diagnostics, so it never pollutes captured output.
|
|
if [[ ${DEBUG:-} =~ ^[0-9]+$ && $DEBUG -gt 1 ]]; then
|
|
_disp_print_wrapped "$heads" 2 "$*"
|
|
fi
|
|
;;
|
|
* )
|
|
# No (known) type: the whole argument list is the message, as for info
|
|
if [[ ${QUIET:-} != 1 ]]; then
|
|
_disp_print_wrapped "" 1 "$*"
|
|
fi
|
|
;;
|
|
esac
|
|
}
|
|
export -f disp
|
|
# ------------------------------------------------------------------------------
|
|
|
|
|
|
# ------------------------------------------------------------------------------
|
|
# Render Markdown files with terminal formatting
|
|
# Usage: mdcat [file]
|
|
mdcat()
|
|
{
|
|
local _mdcat_style_inline
|
|
_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
|
|
|
|
# Inline transforms. Code spans and backslash-escaped punctuation
|
|
# (\<, \>, \_, \*, ...) are lifted out first and replaced by numbered
|
|
# placeholders (\x01N\x02 and \x03N\x04), so that nothing inside them is
|
|
# reinterpreted (links, emphasis); they are put back, code spans styled,
|
|
# at the very end. Then links, bare URLs and emphasis.
|
|
local -a spans=() escs=()
|
|
local ph_open=$'\x01' ph_close=$'\x02' esc_open=$'\x03' esc_close=$'\x04'
|
|
while [[ "$text" =~ \`([^\`]+)\` ]]; do
|
|
local match="${BASH_REMATCH[0]}"
|
|
local before="${text%%"$match"*}"
|
|
local after="${text#*"$match"}"
|
|
spans+=("${BASH_REMATCH[1]}")
|
|
text="${before}${ph_open}$(( ${#spans[@]} - 1 ))${ph_close}${after}"
|
|
done
|
|
while [[ "$text" =~ \\([[:punct:]]) ]]; do
|
|
local match="${BASH_REMATCH[0]}"
|
|
local before="${text%%"$match"*}"
|
|
local after="${text#*"$match"}"
|
|
escs+=("${BASH_REMATCH[1]}")
|
|
text="${before}${esc_open}$(( ${#escs[@]} - 1 ))${esc_close}${after}"
|
|
done
|
|
|
|
# Links: [label](url)
|
|
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}${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
|
|
|
|
# Put the styled code spans and the escaped characters back (see the
|
|
# placeholders above).
|
|
local idx
|
|
for idx in "${!spans[@]}"; do
|
|
text="${text/"${ph_open}${idx}${ph_close}"/"${code_on}${spans[idx]}${style_off}"}"
|
|
done
|
|
for idx in "${!escs[@]}"; do
|
|
text="${text/"${esc_open}${idx}${esc_close}"/"${escs[idx]}"}"
|
|
done
|
|
|
|
printf "%s\n" "$text"
|
|
}
|
|
|
|
local _mdcat_print_hr
|
|
_mdcat_print_hr()
|
|
{
|
|
local cols="${_mdcat_cols:-}"
|
|
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
|
|
}
|
|
|
|
local _mdcat_print_code_block
|
|
_mdcat_print_code_block()
|
|
{
|
|
local lang="$1"
|
|
shift
|
|
local -a lines=("$@")
|
|
|
|
local cols="${_mdcat_cols:-}"
|
|
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"
|
|
}
|
|
|
|
local _mdcat_print_table
|
|
# That function is a bit slow, we need to try to optimize it
|
|
_mdcat_print_table()
|
|
{
|
|
local _mdcat_parse_table_row
|
|
# Split one table row into cells, joined with $sep (set by the caller,
|
|
# reached through dynamic scoping). Cells are separated by '|' only; an
|
|
# escaped "\|" is not supported. Trimming uses parameter expansion
|
|
# (strip leading, then trailing, whitespace) to avoid forking sed.
|
|
_mdcat_parse_table_row()
|
|
{
|
|
local input="$1" j
|
|
local line="$input"
|
|
line="${line#"${line%%[![:space:]]*}"}"
|
|
line="${line%"${line##*[![: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="${cell#"${cell%%[![:space:]]*}"}"
|
|
cell="${cell%"${cell##*[![: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"
|
|
}
|
|
|
|
local -a lines=("$@")
|
|
local -a table_rows=()
|
|
local -a col_widths=()
|
|
local -A cell_styled=() cell_vislen=()
|
|
local i j ncols=0 orow=0
|
|
local sep=$'\x1f'
|
|
|
|
# Parse header and data rows, skipping the Markdown separator row.
|
|
# Each cell is styled exactly once here; the styled text and its visible
|
|
# width (ANSI stripped) are cached and reused when drawing, so the costly
|
|
# _mdcat_style_inline runs once per cell instead of twice.
|
|
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 styled visible key="$orow:$j"
|
|
styled=$(_mdcat_style_inline "${row[j]}")
|
|
# Visible width: expand the literal \e sequences, then strip
|
|
# every ANSI CSI sequence (no sed fork per cell).
|
|
printf -v visible '%b' "$styled"
|
|
while [[ $visible =~ $'\e'\[[0-9\;]*[A-Za-z] ]]; do
|
|
visible=${visible/"${BASH_REMATCH[0]}"/}
|
|
done
|
|
cell_styled["$key"]="$styled"
|
|
cell_vislen["$key"]=${#visible}
|
|
[[ -z "${col_widths[j]}" ]] && col_widths[j]=0
|
|
(( col_widths[j] < ${#visible} )) && col_widths[j]=${#visible}
|
|
done
|
|
orow=$((orow + 1))
|
|
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
|
|
|
|
# Colours are dropped under NO_COLOR, like everywhere else in mdcat.
|
|
local c_frame="$IBlack" c_head="$BBlue" c_off="$RESETCOL" c_cell="$RESETCOL"
|
|
if [[ -n ${NO_COLOR:-} ]]; then
|
|
c_frame="" c_head="" c_off="" c_cell=""
|
|
fi
|
|
|
|
# Draw top border: one run of dashes per column (cell width + 2 spaces)
|
|
local border="+" seg
|
|
for ((j=0; j<ncols; ++j)); do
|
|
printf -v seg '%*s' "$((col_widths[j] + 2))" ""
|
|
border+="${seg// /-}+"
|
|
done
|
|
printf "%b\n" "$c_frame$border$c_off"
|
|
|
|
# Print header row (cached styled cells)
|
|
printf "%b|" "$c_frame"
|
|
for ((j=0; j<ncols; ++j)); do
|
|
local hkey="0:$j"
|
|
local styled_cell="${cell_styled[$hkey]:-}"
|
|
local pad=$((col_widths[j] - ${cell_vislen[$hkey]:-0}))
|
|
(( pad < 0 )) && pad=0
|
|
printf " %b%b%*s%b |" "$c_head" "$styled_cell" "$pad" "" "$c_frame"
|
|
done
|
|
printf "%b\n" "$c_off"
|
|
|
|
# Header separator
|
|
printf "%b|" "$c_frame"
|
|
for ((j=0; j<ncols; ++j)); do
|
|
printf -v seg '%*s' "${col_widths[j]}" ""
|
|
printf " %s |" "${seg// /-}"
|
|
done
|
|
printf "%b\n" "$c_off"
|
|
|
|
# Print data rows (cached styled cells)
|
|
for ((i=1; i<${#table_rows[@]}; ++i)); do
|
|
printf "%b|" "$c_frame"
|
|
for ((j=0; j<ncols; ++j)); do
|
|
local key="$i:$j"
|
|
local styled_cell="${cell_styled[$key]:-}"
|
|
local pad=$((col_widths[j] - ${cell_vislen[$key]:-0}))
|
|
(( pad < 0 )) && pad=0
|
|
printf " %b%b%*s%b |" "$c_cell" "$styled_cell" "$pad" "" "$c_frame"
|
|
done
|
|
printf "%b\n" "$c_off"
|
|
done
|
|
|
|
# Draw bottom border
|
|
printf "%b\n" "$c_frame$border$c_off"
|
|
}
|
|
|
|
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
|
|
|
|
# getopt emits a shell-quoted, normalised argument list ("--" ends options)
|
|
local -a _pargs=()
|
|
_getopt_split _pargs "$PARSED"
|
|
set -- "${_pargs[@]}"
|
|
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=()
|
|
|
|
# Resolve the terminal width once for the whole render instead of shelling
|
|
# out to tput for every printed line.
|
|
local _mdcat_cols="${COLUMNS:-}"
|
|
if [[ -z "$_mdcat_cols" || ! "$_mdcat_cols" =~ ^[0-9]+$ || "$_mdcat_cols" -lt 20 ]]; then
|
|
_mdcat_cols=$(tput cols 2>/dev/null)
|
|
fi
|
|
[[ -z "$_mdcat_cols" || ! "$_mdcat_cols" =~ ^[0-9]+$ || "$_mdcat_cols" -lt 20 ]] && _mdcat_cols=80
|
|
|
|
# Single-line push-back buffer: table detection reads one line ahead, so a
|
|
# non-separator look-ahead line must be reprocessed rather than discarded.
|
|
local pending_line="" has_pending=0
|
|
while :; do
|
|
if [[ $has_pending -eq 1 ]]; then
|
|
raw="$pending_line"
|
|
has_pending=0
|
|
elif ! IFS= read -r raw; then
|
|
[[ -n "$raw" ]] || break
|
|
fi
|
|
line="${raw%$'\r'}"
|
|
|
|
# Table detection: line with |, next line with | and ---
|
|
# (never inside a fenced code block, where a table is just sample text)
|
|
if [[ $in_code -eq 0 && $in_table -eq 0 && "$line" =~ ^[[:space:]]*\|.*\|[[:space:]]*$ ]]; then
|
|
local next="" next_ok=1
|
|
IFS= read -r next || next_ok=0
|
|
# Accept: | --- | --- | or |:---|---:| etc.
|
|
if [[ "$next" =~ ^[[:space:]]*\|[[:space:]]*:?[-]+:?([[:space:]]*\|[[:space:]]*:?[ -]+:?)*\|[[:space:]]*$ ]]; then
|
|
in_table=1
|
|
table_lines=("$line" "$next")
|
|
continue
|
|
fi
|
|
# Not a separator: push the look-ahead line back so it is not lost.
|
|
if [[ $next_ok -eq 1 || -n "$next" ]]; then
|
|
pending_line="$next"
|
|
has_pending=1
|
|
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
|
|
local h_off="$RESETCOL"
|
|
[[ -n $NO_COLOR ]] && h_off=""
|
|
printf "%b%s%b\n" "$h_on" "$title" "$h_off"
|
|
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}"
|
|
|
|
# Flush any block still open at end-of-input.
|
|
if [[ $in_code -eq 1 ]]; then
|
|
_mdcat_print_code_block "$code_lang" "${code_lines[@]}"
|
|
fi
|
|
if [[ $in_table -eq 1 ]]; then
|
|
_mdcat_print_table "${table_lines[@]}"
|
|
fi
|
|
}
|
|
export -f mdcat
|
|
# ------------------------------------------------------------------------------
|
|
|
|
|
|
# Load color codes
|
|
set_colors
|
|
|
|
# EOF
|