Files
pad/internal/cli/format_markdown.go
T
David Barkhausen f900b0aefb fix(cli): address review on markdown list output
All three requested changes from @xarmian's review of #1070, plus both nits.

1. Escape backslashes before pipes in escapeMarkdownCell. A title containing
   "\|" became "\|", which GFM reads as an escaped backslash followed by a LIVE
   pipe, so the row still gained a column. Backslash-first turns it into "\\|".
   Confirmed the bug with a failing test before fixing it.

2. Sanitize the group headings. Extracted SanitizeMarkdownText (SGR strip +
   newline collapse) and ran the collection icon and name through it, so a
   newline in a collection name can no longer inject a second "## " heading.
   Sanitizing happens per part, before joining, because it trims and would
   otherwise eat the separating space. Pipes are deliberately not escaped
   outside a table.

3. Tightened the --format help to the precise enumeration:
   "markdown on: item list/starred, collection list, item show, project changelog"
   per option (a) on #898.

Nits:
- `item starred --format markdown` on an empty result now says "No starred
  items." rather than the shared renderer's "No items found."; the empty check
  moved above the format branch so both paths agree.
- Added format_markdown_routing_test.go: three end-to-end tests driving
  `item list` and `collection list` through cobra against an httptest server,
  asserting the markdown branch is actually reached and that the table and
  markdown paths don't leak into each other. Proven by disabling the markdown
  branch and watching the test fail. Follows the item_open_test.go pattern, with
  USERPROFILE set alongside HOME since os.UserHomeDir reads USERPROFILE on
  Windows — worth noting, as tests that set only HOME are why part of the
  credential-store suite fails there.

Gates: go build ./... PASS; go vet PASS; gofmt clean; golangci-lint 0 issues;
all markdown tests PASS. Both touched packages show the same 6+2 pre-existing
Windows failures as clean main under an identical sandboxed run.
2026-08-10 13:39:06 -04:00

163 lines
5.1 KiB
Go

package cli
import (
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/PerpetualSoftware/pad/internal/models"
)
// Markdown list rendering (#898).
//
// The `--format markdown` branch of the list commands writes a GitHub-flavored
// markdown table. Two rules separate it from the table renderer:
//
// 1. No ANSI. Markdown output is destined for a file, a PR body, or an agent's
// context — never a terminal — so the colorized helpers (ColorizedStatus,
// PriorityColor, Dim) are deliberately NOT reused here. Raw field values go
// in and the reader's renderer does the styling.
// 2. Every cell is escaped. An unescaped `|` in a title silently adds a column
// and corrupts the row; a newline ends the row early.
//
// Widths are not computed — markdown renderers lay the table out themselves, so
// the width-aware column math in renderItemTable has no counterpart here.
// SanitizeMarkdownText makes an arbitrary string safe to interpolate into
// markdown OUTSIDE a table cell — a heading, say. ANSI escapes are stripped and
// newlines collapse to spaces, so a value carrying a line break can't inject
// document structure. Pipes are left alone: they're only special inside a table.
func SanitizeMarkdownText(s string) string {
s = sgrPattern.ReplaceAllString(s, "")
s = strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ").Replace(s)
return strings.TrimSpace(s)
}
// escapeMarkdownCell makes an arbitrary string safe INSIDE a table cell: it
// sanitizes as above, then escapes backslashes and pipes.
//
// Order matters. Escaping the pipe alone is not enough: a title containing a
// backslash immediately before a pipe ("…\|…") would become "…\\|…", which GFM
// reads as an escaped backslash followed by a LIVE pipe, so the row still gains
// a column. Escaping backslashes FIRST turns it into "…\\\|…" — an escaped
// backslash then an escaped pipe — which renders back to the literal text.
// Doing it the other way round would double-escape the backslashes we just
// introduced. (Caught by @xarmian reviewing PR #1070.)
func escapeMarkdownCell(s string) string {
s = SanitizeMarkdownText(s)
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, "|", `\|`)
return s
}
// writeMarkdownRow writes one pipe-delimited row. Cells are expected to be
// pre-escaped.
func writeMarkdownRow(w io.Writer, cells ...string) {
fmt.Fprintf(w, "| %s |\n", strings.Join(cells, " | "))
}
// writeMarkdownSeparator writes the header/body separator for n columns.
func writeMarkdownSeparator(w io.Writer, n int) {
cells := make([]string, n)
for i := range cells {
cells[i] = "---"
}
writeMarkdownRow(w, cells...)
}
// PrintItemMarkdown prints items as a markdown table on stdout.
func PrintItemMarkdown(items []models.Item) {
RenderItemMarkdown(os.Stdout, items)
}
// RenderItemMarkdown is the writer-taking core of PrintItemMarkdown. Columns
// mirror the table renderer's — Ref · Title · Status · Priority · Collection ·
// Updated — so switching format changes the styling, not the information.
// Exported because cmd/pad composes it per collection group.
func RenderItemMarkdown(w io.Writer, items []models.Item) {
if len(items) == 0 {
fmt.Fprintln(w, "No items found.")
return
}
writeMarkdownRow(w, "Ref", "Title", "Status", "Priority", "Collection", "Updated")
writeMarkdownSeparator(w, 6)
for _, item := range items {
// The ref is the item's handle for every other command, so fall back to
// the slug rather than emitting an unusable empty first cell.
ref := ItemRef(item)
if ref == "" {
ref = item.Slug
}
if item.Pinned {
// The table marks pins with a yellow "*"; colour is unavailable
// here, so the marker has to be a glyph.
ref = "📌 " + ref
}
title := item.Title
if item.DeletedAt != nil {
title += " (archived)"
}
status, priority := itemStatusPriority(item.Fields)
if status == "" {
status = "—"
}
if priority == "" {
priority = "—"
}
collection := item.CollectionName
if item.CollectionIcon != "" {
collection = item.CollectionIcon + " " + collection
}
writeMarkdownRow(w,
escapeMarkdownCell(ref),
escapeMarkdownCell(title),
escapeMarkdownCell(status),
escapeMarkdownCell(priority),
escapeMarkdownCell(collection),
escapeMarkdownCell(RelativeTime(item.UpdatedAt)),
)
}
}
// PrintCollectionMarkdown prints collections as a markdown table on stdout.
func PrintCollectionMarkdown(collections []models.Collection) {
RenderCollectionMarkdown(os.Stdout, collections)
}
// RenderCollectionMarkdown is the writer-taking core of
// PrintCollectionMarkdown. Columns mirror PrintCollectionTable's.
func RenderCollectionMarkdown(w io.Writer, collections []models.Collection) {
if len(collections) == 0 {
fmt.Fprintln(w, "No collections found.")
return
}
writeMarkdownRow(w, "Name", "Slug", "Items", "Default")
writeMarkdownSeparator(w, 4)
for _, col := range collections {
name := col.Name
if col.Icon != "" {
name = col.Icon + " " + name
}
def := ""
if col.IsDefault {
def = "yes"
}
writeMarkdownRow(w,
escapeMarkdownCell(name),
escapeMarkdownCell(col.Slug),
strconv.Itoa(col.ItemCount),
def,
)
}
}