Created
February 23, 2026 18:25
-
-
Save huytd/8f4e218be27ff49b2e33f8fcc799e07b to your computer and use it in GitHub Desktop.
Markdown render using Bash script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # Usage: ./mdview filename.md OR cat file.md | ./mdview | |
| # --- Configuration --- | |
| MAX_CELL_WIDTH=${MAX_CELL_WIDTH:-50} | |
| # --- ANSI Colors --- | |
| ESC=$'\033' | |
| RESET="${ESC}[0m" | |
| BOLD="${ESC}[1m" | |
| ITALIC="${ESC}[3m" | |
| CODE_STYLE="${ESC}[48;5;236m${ESC}[38;5;189m" | |
| HEADER_1="${ESC}[1;38;5;39m" | |
| HEADER_2="${ESC}[1;38;5;75m" | |
| HEADER_3="${ESC}[1;38;5;111m" | |
| BULLET_COLOR="${ESC}[38;5;250m" | |
| in_code_block=0 | |
| table_buffer=() | |
| # Helper: Get the length of a string ignoring ANSI escape codes | |
| visible_len() { | |
| local vis=$(printf '%s' "$1" | sed -E "s/${ESC}\\[[0-9;]*[a-zA-Z]//g") | |
| echo "${#vis}" | |
| } | |
| # Render buffered table data with text wrapping | |
| flush_table() { | |
| if [[ ${#table_buffer[@]} -eq 0 ]]; then return; fi | |
| local col_widths=() | |
| local parsed_rows=() | |
| local num_cols=0 | |
| local r c cell i pad row | |
| # Pass 1: Parse rows and determine column widths | |
| for row in "${table_buffer[@]}"; do | |
| # Skip Markdown separator lines (e.g., |---|:---:|) | |
| if [[ "$row" =~ ^[[:space:]]*\|?[[:space:]]*:?-+[-:|[:space:]]*$ ]]; then continue; fi | |
| # Strip leading/trailing pipes | |
| row=$(printf '%s\n' "$row" | sed -E 's/^[[:space:]]*\||\|[[:space:]]*$//g') | |
| parsed_rows+=("$row") | |
| IFS='|' read -r -a cells <<< "${row} " | |
| c=0 | |
| for cell in "${cells[@]}"; do | |
| cell=$(printf '%s\n' "$cell" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') | |
| # Find the longest single word to ensure we don't chop URLs | |
| local max_word_len=0 | |
| local IFS_space=' ' | |
| read -ra words <<< "$cell" | |
| for w in "${words[@]}"; do | |
| local wlen=$(visible_len "$w") | |
| if (( wlen > max_word_len )); then max_word_len=$wlen; fi | |
| done | |
| local total_len=$(visible_len "$cell") | |
| local target_width=$total_len | |
| # Enforce the MAX_CELL_WIDTH limit, but respect unbroken long words | |
| if (( target_width > MAX_CELL_WIDTH )); then target_width=$MAX_CELL_WIDTH; fi | |
| if (( target_width < max_word_len )); then target_width=$max_word_len; fi | |
| if [[ -z "${col_widths[$c]}" ]] || (( target_width > col_widths[$c] )); then | |
| col_widths[$c]=$target_width | |
| fi | |
| ((c++)) | |
| done | |
| if (( c > num_cols )); then num_cols=$c; fi | |
| done | |
| # Helper: Draw horizontal table dividers | |
| draw_sep() { | |
| local left=$1 mid=$2 right=$3 | |
| printf "%s" "$left" | |
| for ((c=0; c<num_cols; c++)); do | |
| for ((i=0; i<col_widths[c]+2; i++)); do printf "─"; done | |
| if (( c < num_cols - 1 )); then printf "%s" "$mid"; fi | |
| done | |
| printf "%s\n" "$right" | |
| } | |
| # Pass 2: Word Wrap & Draw the table | |
| draw_sep "┌" "┬" "┐" | |
| r=0 | |
| for row in "${parsed_rows[@]}"; do | |
| IFS='|' read -r -a cells <<< "${row} " | |
| local max_subrows=1 | |
| local -a wrapped_cols=() | |
| # Wrap each cell into lines | |
| for ((c=0; c<num_cols; c++)); do | |
| local cell="${cells[$c]}" | |
| cell=$(printf '%s\n' "$cell" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') | |
| local width="${col_widths[$c]}" | |
| local -a current_cell_lines=() | |
| local current_line="" | |
| local current_len=0 | |
| local IFS_space=' ' | |
| read -ra words <<< "$cell" | |
| for w in "${words[@]}"; do | |
| local wlen=$(visible_len "$w") | |
| if (( current_len == 0 )); then | |
| current_line="$w" | |
| current_len=$wlen | |
| elif (( current_len + 1 + wlen > width )); then | |
| current_cell_lines+=("$current_line") | |
| current_line="$w" | |
| current_len=$wlen | |
| else | |
| current_line="$current_line $w" | |
| current_len=$((current_len + 1 + wlen)) | |
| fi | |
| done | |
| if [[ -n "$current_line" ]]; then | |
| current_cell_lines+=("$current_line") | |
| fi | |
| # Save the lines joined by a newline for easy extraction later | |
| local joined="" | |
| for ((i=0; i<${#current_cell_lines[@]}; i++)); do | |
| if (( i > 0 )); then joined+=$'\n'; fi | |
| joined+="${current_cell_lines[$i]}" | |
| done | |
| wrapped_cols[$c]="$joined" | |
| if (( ${#current_cell_lines[@]} > max_subrows )); then | |
| max_subrows=${#current_cell_lines[@]} | |
| fi | |
| done | |
| # Print the wrapped sub-rows line-by-line | |
| for ((sub=0; sub<max_subrows; sub++)); do | |
| printf "│" | |
| for ((c=0; c<num_cols; c++)); do | |
| local cell_lines_str="${wrapped_cols[$c]}" | |
| local line_val="" | |
| # Extract the specific line string for this sub-row | |
| local i=0 | |
| local old_IFS="$IFS" | |
| IFS=$'\n' | |
| for ln in $cell_lines_str; do | |
| if (( i == sub )); then | |
| line_val="$ln" | |
| break | |
| fi | |
| ((i++)) | |
| done | |
| IFS="$old_IFS" | |
| local vlen=$(visible_len "$line_val") | |
| local pad=$(( col_widths[c] - vlen )) | |
| if (( pad < 0 )); then pad=0; fi | |
| # RESET is called before padding spaces so backgrounds don't bleed out of the text | |
| printf " %s${RESET}" "$line_val" | |
| for ((i=0; i<pad; i++)); do printf " "; done | |
| printf " │" | |
| done | |
| printf "\n" | |
| done | |
| if (( r == 0 )); then | |
| draw_sep "├" "┼" "┤" | |
| fi | |
| ((r++)) | |
| done | |
| draw_sep "└" "┴" "┘" | |
| table_buffer=() | |
| } | |
| # --- Main Parsing Loop --- | |
| while IFS= read -r line || [[ -n "$line" ]]; do | |
| # Fenced code block toggle | |
| if [[ "$line" =~ ^\`\`\` ]]; then | |
| flush_table | |
| in_code_block=$((1 - in_code_block)) | |
| continue | |
| fi | |
| # Inside code block | |
| if (( in_code_block )); then | |
| printf "${CODE_STYLE}%s${RESET}\n" "$line" | |
| continue | |
| fi | |
| # Inline replacements (Bold, Italic, Code) via sed | |
| formatted=$(printf '%s' "$line" | sed -E \ | |
| -e "s/\`([^\`]+)\`/${CODE_STYLE} \1 ${RESET}/g" \ | |
| -e "s/\*\*([^*]+)\*\*/${BOLD}\1${RESET}/g" \ | |
| -e "s/__([^_]+)__/${BOLD}\1${RESET}/g" \ | |
| -e "s/\*([^*]+)\*/${ITALIC}\1${RESET}/g" \ | |
| -e "s/_([^_]+)_/${ITALIC}\1${RESET}/g") | |
| # Table detection (starts with optional spaces then a pipe) | |
| if [[ "$formatted" =~ ^[[:space:]]*\| ]]; then | |
| table_buffer+=("$formatted") | |
| continue | |
| else | |
| flush_table | |
| fi | |
| # Block-level rendering | |
| if [[ "$formatted" =~ ^'### ' ]]; then | |
| printf "${HEADER_3}%s${RESET}\n" "${formatted#\#\#\# }" | |
| elif [[ "$formatted" =~ ^'## ' ]]; then | |
| printf "${HEADER_2}%s${RESET}\n" "${formatted#\#\# }" | |
| elif [[ "$formatted" =~ ^'# ' ]]; then | |
| printf "${HEADER_1}%s${RESET}\n" "${formatted#\# }" | |
| elif [[ "$formatted" =~ ^'- ' ]]; then | |
| printf "${BULLET_COLOR}•${RESET} %s\n" "${formatted#- }" | |
| else | |
| printf "%s\n" "$formatted" | |
| fi | |
| done < "${1:-/dev/stdin}" | |
| flush_table |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment