Skip to content

Instantly share code, notes, and snippets.

@stuartc
Created June 26, 2025 12:06
Show Gist options
  • Save stuartc/93eee28d15b627eeba97bc1ff0c3fccc to your computer and use it in GitHub Desktop.
Save stuartc/93eee28d15b627eeba97bc1ff0c3fccc to your computer and use it in GitHub Desktop.

Revisions

  1. stuartc created this gist Jun 26, 2025.
    319 changes: 319 additions & 0 deletions md2pb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,319 @@
    #!/usr/bin/env bash

    # md2pb - Convert markdown to rich text format for Slack/Discord/etc
    #
    # DESCRIPTION:
    # Converts markdown to both plain text and HTML formats, then places both on the
    # macOS clipboard. This allows pasting rich formatted text into apps like Slack,
    # Discord, or any app that accepts HTML clipboard data.
    #
    # USAGE:
    # md2pb [FILE]
    # echo "**bold text**" | md2pb
    # md2pb README.md
    #
    # REQUIREMENTS:
    # - macOS (uses osascript and pbcopy)
    # - pandoc (brew install pandoc)
    #
    # EXAMPLES:
    # # From stdin
    # echo "# Header\n**Bold** and *italic* text" | md2pb
    #
    # # From file
    # md2pb my-notes.md
    #
    # # With llm tool
    # llm logs -n 1 -r | md2pb
    #
    # AUTHOR: Stuart Corbishley
    # DATE: 2025-06-26

    set -euo pipefail

    # Colors for output
    RED='\033[0;31m'
    GREEN='\033[0;32m'
    YELLOW='\033[1;33m'
    NC='\033[0m' # No Color

    # Function to print colored output
    print_error() {
    echo -e "${RED}❌ Error: $1${NC}" >&2
    }

    print_success() {
    echo -e "${GREEN}$1${NC}"
    }

    print_warning() {
    echo -e "${YELLOW}⚠️ Warning: $1${NC}"
    }

    print_info() {
    echo -e "$1"
    }

    # Check if we're on macOS
    check_macos() {
    if [[ "$OSTYPE" != "darwin"* ]]; then
    print_error "This script requires macOS (uses osascript and pbcopy)"
    print_info "Current OS: $OSTYPE"
    exit 1
    fi
    }

    # Check if pandoc is installed
    check_pandoc() {
    if ! command -v pandoc &>/dev/null; then
    print_error "pandoc is required but not installed"
    print_info ""
    print_info "Install with:"
    print_info " brew install pandoc"
    print_info ""
    print_info "Or visit: https://pandoc.org/installing.html"
    exit 1
    fi
    }

    # Show usage
    show_usage() {
    echo "Usage: md2pb [OPTIONS] [FILE]"
    echo ""
    echo "Convert markdown to rich text format for clipboard"
    echo ""
    echo "Options:"
    echo " --debug-html=FILE Save HTML output to FILE for debugging"
    echo " --debug-text=FILE Save plain text output to FILE for debugging"
    echo " --style=STYLE Formatting style: slack (default) or normal"
    echo " -h, --help Show this help message"
    echo ""
    echo "Arguments:"
    echo " FILE Markdown file to convert (optional, uses stdin if not provided)"
    echo ""
    echo "Examples:"
    echo " echo \"**bold text**\" | md2pb"
    echo " md2pb README.md"
    echo " md2pb --style=normal README.md"
    echo " md2pb --debug-html=output.html --debug-text=output.txt README.md"
    echo " llm logs -n 1 -r | md2pb"
    }

    # Main conversion function
    convert_markdown_to_clipboard() {
    local input_source="$1"
    local debug_html_file="$2"
    local debug_text_file="$3"
    local style="$4"

    local tmpdir=""
    local tmphtml=""
    local tmptext=""
    local tmpfilter=""

    # Cleanup function
    cleanup() {
    rm -rf "${tmpdir:-}"
    }
    trap cleanup EXIT

    # Create temporary directory and files
    tmpdir=$(mktemp -d /tmp/markdown-clipboard.XXXXXX)
    tmphtml="$tmpdir/output.html"
    tmptext="$tmpdir/output.txt"
    tmpfilter="$tmpdir/filter.lua"

    # Create lua filter for Slack formatting
    cat >"$tmpfilter" <<'EOF'
    function Header(elem)
    -- Convert headers to bold text for Slack/Discord
    local content = pandoc.utils.stringify(elem.content)
    return {
    pandoc.Para{pandoc.LineBreak()},
    pandoc.Para{pandoc.Strong{pandoc.Str(content)}}
    }
    end
    function Para(elem)
    return {
    pandoc.Para{pandoc.LineBreak()},
    elem
    }
    end
    EOF

    # Configure markdown format based on style
    local markdown_extensions=(
    "markdown"
    "+lists_without_preceding_blankline"
    "+strikeout"
    "+autolink_bare_uris"
    "+task_lists"
    "-smart"
    )
    local markdown_format
    markdown_format=$(
    IFS=""
    echo "${markdown_extensions[*]}"
    )

    local pandoc_html_args=()

    if [[ "$style" == "slack" ]]; then
    pandoc_html_args+=("--lua-filter=$tmpfilter")
    fi

    pandoc_html_args+=("-t" "html" "--standalone")

    # Convert to both formats
    if [[ "$input_source" == "stdin" ]]; then
    # Read from stdin
    local content
    content=$(cat)

    if [[ -z "$content" ]]; then
    print_error "No input provided"
    exit 1
    fi

    echo "$content" | pandoc -f "$markdown_format" -t plain >"$tmptext"
    echo "$content" | pandoc -f "$markdown_format" "${pandoc_html_args[@]}" >"$tmphtml"
    else
    # Read from file
    if [[ ! -f "$input_source" ]]; then
    print_error "File not found: $input_source"
    exit 1
    fi

    if [[ ! -r "$input_source" ]]; then
    print_error "Cannot read file: $input_source"
    exit 1
    fi

    pandoc -f "$markdown_format" -t plain "$input_source" >"$tmptext"
    pandoc -f "$markdown_format" "${pandoc_html_args[@]}" "$input_source" >"$tmphtml"
    fi

    # Set plain text version first (this is the key!)
    if ! pbcopy <"$tmptext"; then
    print_error "Failed to copy plain text to clipboard"
    exit 1
    fi

    # Add HTML format to existing clipboard
    if ! osascript \
    -e "set htmlData to (read POSIX file \"$tmphtml\" as «class HTML»)" \
    -e 'set currentClip to the clipboard as record' \
    -e 'set the clipboard to (currentClip & {«class HTML»:htmlData})' \
    2>/dev/null; then
    print_error "Failed to add HTML format to clipboard"
    exit 1
    fi

    # Save HTML to debug file if requested
    if [[ -n "$debug_html_file" ]]; then
    if cp "$tmphtml" "$debug_html_file"; then
    print_info "🐛 HTML debug output saved to: $debug_html_file"
    else
    print_warning "Failed to save HTML debug output to: $debug_html_file"
    fi
    fi

    # Save plain text to debug file if requested
    if [[ -n "$debug_text_file" ]]; then
    if cp "$tmptext" "$debug_text_file"; then
    print_info "🐛 Plain text debug output saved to: $debug_text_file"
    else
    print_warning "Failed to save plain text debug output to: $debug_text_file"
    fi
    fi

    # Show success message with preview
    local line_count word_count
    line_count=$(wc -l <"$tmptext" | tr -d ' ')
    word_count=$(wc -w <"$tmptext" | tr -d ' ')

    print_success "Markdown converted and copied to clipboard"
    print_info "📊 Stats: $line_count lines, $word_count words"
    print_info "📋 Ready to paste into Slack, Discord, or other rich text apps"
    }

    # Main script logic
    main() {
    local debug_html_file=""
    local debug_text_file=""
    local input_file=""
    local style="slack"

    # Parse command line arguments
    while [[ $# -gt 0 ]]; do
    case $1 in
    -h | --help | help)
    show_usage
    exit 0
    ;;
    --debug-html=*)
    debug_html_file="${1#*=}"
    if [[ -z "$debug_html_file" ]]; then
    print_error "--debug-html requires a filename"
    exit 1
    fi
    shift
    ;;
    --debug-text=*)
    debug_text_file="${1#*=}"
    if [[ -z "$debug_text_file" ]]; then
    print_error "--debug-text requires a filename"
    exit 1
    fi
    shift
    ;;
    --style=*)
    style="${1#*=}"
    if [[ "$style" != "slack" && "$style" != "normal" ]]; then
    print_error "Style must be 'slack' or 'normal', got: $style"
    exit 1
    fi
    shift
    ;;
    -*)
    print_error "Unknown option: $1"
    echo ""
    show_usage
    exit 1
    ;;
    *)
    if [[ -n "$input_file" ]]; then
    print_error "Too many input files specified"
    echo ""
    show_usage
    exit 1
    fi
    input_file="$1"
    shift
    ;;
    esac
    done

    # Check system requirements
    check_macos
    check_pandoc

    # Determine input source
    if [[ -z "$input_file" ]]; then
    # No input file, check if stdin has data
    if [[ -t 0 ]]; then
    print_error "No input provided"
    echo ""
    show_usage
    exit 1
    fi
    convert_markdown_to_clipboard "stdin" "$debug_html_file" "$debug_text_file" "$style"
    else
    # Input file specified
    convert_markdown_to_clipboard "$input_file" "$debug_html_file" "$debug_text_file" "$style"
    fi
    }

    # Run main function with all arguments
    main "$@"