// Package markdown parses the markdown subset mopac-pdf typesets: headings, // paragraphs, lists (ordered/unordered, one nesting level), GFM pipe tables, // fenced code blocks, chart blocks, blockquotes, horizontal rules, images and // inline bold/italic/code/link. It is intentionally small: the input currency // of this stack is Redmine notes, Discourse posts and briefing output, all of // which are plain, readable markdown. package markdown import ( "strconv" "strings" ) // Block kinds emitted by Parse. type Kind int const ( KindParagraph Kind = iota KindHeading KindList KindTable KindCode KindChart KindQuote KindRule KindImage ) // Block is one top-level block of the document. type Block struct { Kind Kind Level int // heading level 1..6 Text string // paragraph / heading / quote text (raw inline) Ordered bool // list Items []string // list item text (raw inline) Header []string // table: column labels (raw inline) Rows [][]string Align []string // table: per-column "left"|"center"|"right" Language string // code fence info string Lines []string // code body lines (verbatim) Chart *Chart // chart block Alt string // image alt/caption Path string // image path } // Chart is a parsed ```chart fenced data block. Labels/Values are parallel // slices in input order. type Chart struct { Type string Title string Unit string Labels []string Values []float64 WidthPct int // embed width in percent of text column (default 78) OtherPairs map[string]string } // Parse converts markdown source into blocks. Front-matter must already be // stripped by the caller. Unknown constructs degrade to paragraphs. func Parse(src string) []Block { lines := strings.Split(strings.ReplaceAll(src, "\r\n", "\n"), "\n") var blocks []Block i := 0 for i < len(lines) { line := lines[i] trimmed := strings.TrimSpace(line) switch { case trimmed == "": i++ case strings.HasPrefix(trimmed, "```") || strings.HasPrefix(trimmed, "~~~"): block, next := parseFence(lines, i, trimmed) blocks = append(blocks, block) i = next case isHeading(trimmed): level := len(trimmed) - len(strings.TrimLeft(trimmed, "#")) text := strings.TrimSpace(strings.Trim(trimmed[level:], "#")) text = strings.TrimSpace(text) blocks = append(blocks, Block{Kind: KindHeading, Level: level, Text: text}) i++ case trimmed == "---" || trimmed == "***" || trimmed == "___": blocks = append(blocks, Block{Kind: KindRule}) i++ case isTableLine(trimmed) && i+1 < len(lines) && isTableSeparator(lines[i+1]): block, next := parseTable(lines, i) blocks = append(blocks, block) i = next case isListLine(trimmed): block, next := parseList(lines, i) blocks = append(blocks, block) i = next case strings.HasPrefix(trimmed, ">"): var parts []string for i < len(lines) { t := strings.TrimSpace(lines[i]) if !strings.HasPrefix(t, ">") { break } parts = append(parts, strings.TrimSpace(strings.TrimPrefix(t, ">"))) i++ } blocks = append(blocks, Block{Kind: KindQuote, Text: strings.Join(parts, " ")}) case isImageLine(trimmed): alt, path := parseImage(trimmed) blocks = append(blocks, Block{Kind: KindImage, Alt: alt, Path: path}) i++ default: var parts []string for i < len(lines) { t := strings.TrimSpace(lines[i]) if t == "" || isHeading(t) || isListLine(t) || isTableLine(t) || strings.HasPrefix(t, "```") || strings.HasPrefix(t, ">") || t == "---" || isImageLine(t) { break } parts = append(parts, t) i++ } blocks = append(blocks, Block{Kind: KindParagraph, Text: strings.Join(parts, " ")}) } } return blocks } func isHeading(t string) bool { if !strings.HasPrefix(t, "#") { return false } level := len(t) - len(strings.TrimLeft(t, "#")) return level >= 1 && level <= 6 && len(t) > level && t[level] == ' ' } func isListLine(t string) bool { if strings.HasPrefix(t, "- ") || strings.HasPrefix(t, "* ") || strings.HasPrefix(t, "+ ") { return true } label, rest, ok := strings.Cut(t, ". ") return ok && rest != "" && isOrderedLabel(label) } func isOrderedLabel(s string) bool { if s == "" { return false } for _, r := range s { if r < '0' || r > '9' { return false } } return true } func isTableLine(t string) bool { return strings.HasPrefix(t, "|") && strings.HasSuffix(t, "|") && strings.Count(t, "|") >= 2 } func isTableSeparator(t string) bool { t = strings.TrimSpace(t) if !isTableLine(t) { return false } cells := splitRow(t) for _, c := range cells { c = strings.TrimSpace(c) if c == "" { return false } if !strings.ContainsAny(c, "-") { return false } for _, r := range c { if r != '-' && r != ':' && r != ' ' { return false } } } return true } func isImageLine(t string) bool { return strings.HasPrefix(t, "![") && strings.Contains(t, "](") && strings.HasSuffix(t, ")") } func parseImage(t string) (alt, path string) { inner := strings.TrimSuffix(strings.TrimPrefix(t, "!["), ")") alt, path, _ = strings.Cut(inner, "](") return alt, path } func parseFence(lines []string, i int, fence string) (Block, int) { info := strings.TrimSpace(strings.Trim(fence, "`~ ")) var body []string j := i + 1 for j < len(lines) { t := strings.TrimSpace(lines[j]) if isClosingFence(t, fence) { break } body = append(body, lines[j]) j++ } if info == "chart" { if chart := parseChart(body); chart != nil { return Block{Kind: KindChart, Chart: chart}, j + 1 } } return Block{Kind: KindCode, Language: info, Lines: body}, j + 1 } func isClosingFence(t, fence string) bool { if t == fence { return true } marker := "`" if strings.HasPrefix(fence, "~") { marker = "~" } if strings.Trim(fence, marker) == "" { return strings.Trim(t, marker) == "" && len(t) >= len(fence) } // Opening fence carried an info string (e.g. ```chart): the closer is a // bare run of the marker at least as long as the opening run. openLen := len(fence) - len(strings.TrimLeft(fence, marker)) return strings.Trim(t, marker) == "" && len(t) >= openLen } func parseChart(body []string) *Chart { c := &Chart{Type: "bar", WidthPct: 78, OtherPairs: map[string]string{}} for _, raw := range body { line := strings.TrimSpace(raw) if line == "" || strings.HasPrefix(line, "#") { continue } key, value, ok := strings.Cut(line, ":") if !ok { continue } key = strings.TrimSpace(key) value = strings.TrimSpace(value) switch strings.ToLower(key) { case "type": c.Type = strings.ToLower(value) case "title": c.Title = value case "unit": c.Unit = value case "width": if n, err := strconv.Atoi(strings.TrimSuffix(value, "%")); err == nil && n > 10 && n <= 100 { c.WidthPct = n } default: if num, err := strconv.ParseFloat(value, 64); err == nil && key != "" { c.Labels = append(c.Labels, key) c.Values = append(c.Values, num) } else { c.OtherPairs[key] = value } } } if len(c.Labels) == 0 { return nil } return c } func parseList(lines []string, i int) (Block, int) { block := Block{Kind: KindList} first := strings.TrimSpace(lines[i]) _, rest, _ := strings.Cut(first, ". ") if strings.HasPrefix(first, "- ") || strings.HasPrefix(first, "* ") || strings.HasPrefix(first, "+ ") { rest = first[2:] } else { block.Ordered = true } block.Items = append(block.Items, rest) i++ for i < len(lines) { t := strings.TrimSpace(lines[i]) if t == "" { break } if !isListLine(t) { break } _, item, _ := strings.Cut(t, ". ") if strings.HasPrefix(t, "- ") || strings.HasPrefix(t, "* ") || strings.HasPrefix(t, "+ ") { item = t[2:] } block.Items = append(block.Items, item) i++ } return block, i } func splitRow(t string) []string { t = strings.TrimSpace(t) t = strings.TrimPrefix(t, "|") t = strings.TrimSuffix(t, "|") raw := strings.Split(t, "|") cells := make([]string, len(raw)) for i, c := range raw { cells[i] = strings.TrimSpace(c) } return cells } func parseTable(lines []string, i int) (Block, int) { header := splitRow(lines[i]) sep := splitRow(lines[i+1]) align := make([]string, len(sep)) for col, s := range sep { left := strings.HasPrefix(s, ":") right := strings.HasSuffix(s, ":") switch { case left && right: align[col] = "center" case right: align[col] = "right" default: align[col] = "left" } } block := Block{Kind: KindTable, Header: header, Align: align} j := i + 2 for j < len(lines) { t := strings.TrimSpace(lines[j]) if !isTableLine(t) { break } row := splitRow(t) for len(row) < len(header) { row = append(row, "") } row = row[:len(header)] block.Rows = append(block.Rows, row) j++ } return block, j }