Files
profile/profile.d/disp.sh
T

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