1
0

render markdown entries

This commit is contained in:
Arpad Ryszka 2025-10-28 00:47:41 +01:00
parent 3b31267823
commit b78959867f
10 changed files with 2050 additions and 36 deletions

5
lib.go
View File

@ -197,6 +197,7 @@ func Row(cells ...TableCell) TableRow {
return TableRow{cells: cells} return TableRow{cells: cells}
} }
// when no header and markdown, header automatic
func Table(rows ...TableRow) Entry { func Table(rows ...TableRow) Entry {
return Entry{typ: table, rows: rows} return Entry{typ: table, rows: rows}
} }
@ -279,8 +280,8 @@ func Runoff(out io.Writer, d Document) error {
return renderRoff(out, d) return renderRoff(out, d)
} }
func Markdown(io.Writer, Document) error { func Markdown(out io.Writer, d Document) error {
return nil return renderMarkdown(out, d)
} }
func HTML(io.Writer, Document) error { func HTML(io.Writer, Document) error {

460
markdown.go Normal file
View File

@ -0,0 +1,460 @@
package textfmt
import (
"bytes"
"errors"
"fmt"
"io"
"slices"
"strings"
)
func escapeMarkdown(s string, additional ...rune) string {
var (
rr []rune
isNumberOnNewLine bool
isLinkOpen, isLinkClosed, isLinkLabel bool
)
isNewLine := true
r := []rune(s)
for _, ri := range r {
switch ri {
case '\\', '`', '*', '_', '[', ']', '#', '<', '>':
rr = append(rr, '\\', ri)
default:
switch {
case isNewLine:
switch ri {
case '+', '-':
rr = append(rr, '\\', ri)
default:
rr = append(rr, ri)
}
case isNumberOnNewLine:
switch ri {
case '.':
rr = append(rr, '\\', ri)
default:
rr = append(rr, ri)
}
case isLinkClosed:
switch ri {
case '(':
rr = append(rr, '\\', ri)
default:
rr = append(rr, ri)
}
case isLinkLabel:
switch ri {
case ')':
rr = append(rr, '\\', ri)
default:
rr = append(rr, ri)
}
default:
if slices.Contains(additional, ri) {
rr = append(rr, '\\', ri)
} else {
rr = append(rr, ri)
}
}
}
isNumberOnNewLine = (isNewLine || isNumberOnNewLine) && ri >= '0' && ri <= '9'
isNewLine = ri == '\n'
isLinkOpen = !isLinkLabel && ri == '[' || isLinkOpen && ri != ']'
isLinkClosed = isLinkOpen && ri == ']'
isLinkLabel = isLinkClosed && ri == '(' || isLinkLabel && ri != ')'
}
return string(rr)
}
func mdTextToString(text Txt) (string, error) {
var b bytes.Buffer
w := mdWriter{w: &b, internal: true}
renderMDText(&w, text)
if w.err != nil {
return "", w.err
}
return b.String(), nil
}
func mdCellTexts(rows []TableRow) ([][]string, error) {
var texts [][]string
for _, row := range rows {
var rowTexts []string
for _, cell := range row.cells {
txt, err := mdTextToString(cell.text)
if err != nil {
return nil, err
}
rowTexts = append(rowTexts, txt)
}
texts = append(texts, rowTexts)
}
return texts, nil
}
func mdEnsureHeaderTexts(h []string) []string {
var hh []string
for _, t := range h {
if strings.TrimSpace(t) == "" {
t = "\\-"
}
hh = append(hh, t)
}
return hh
}
func renderMDText(w writer, text Txt) {
if len(text.cat) > 0 {
for i, tc := range text.cat {
if i > 0 {
w.write(" ")
}
renderMDText(w, tc)
}
return
}
text.text = singleLine(text.text)
text.text = escapeMarkdown(text.text)
text.link = singleLine(text.link)
text.link = escapeMarkdown(text.link)
if text.bold {
w.write("**")
}
if text.italic {
w.write("_")
}
defer func() {
if text.italic {
w.write("_")
}
if text.bold {
w.write("**")
}
}()
if text.link != "" {
if text.text != "" {
w.write("[")
w.write(text.text)
w.write("](")
w.write(text.link)
w.write(")")
return
}
w.write(text.link)
return
}
w.write(text.text)
}
func renderMDTitle(w writer, e Entry) {
hashes := e.titleLevel + 1
if hashes > 6 {
hashes = 6
}
w.write(timesn("#", hashes), " ")
renderMDText(w, e.text)
}
func renderMDParagraphIndent(w writer, e Entry) {
txt, err := mdTextToString(e.text)
if err != nil {
w.setErr(err)
}
indentFirst := e.indent + e.indentFirst
if e.wrapWidth > 0 {
txt = wrap(txt, e.wrapWidth, indentFirst, e.indent)
}
writeLines(w, txt, indentFirst, e.indent)
}
func renderMDParagraph(w writer, e Entry) {
e.indent = 0
e.indentFirst = 0
renderMDParagraphIndent(w, e)
}
func renderMDList(w writer, e Entry) {
e.indent = 2
e.indentFirst = -2
if e.wrapWidth > 2 {
e.wrapWidth -= 2
}
for i, item := range e.items {
if i > 0 {
w.write("\n")
}
w.write("- ")
p := itemToParagraph(e, item.text)
renderMDParagraphIndent(w, p)
}
}
func renderMDNumberedList(w writer, e Entry) {
maxDigits := numDigits(len(e.items))
e.indent = maxDigits + 2
e.indentFirst = 0 - maxDigits - 2
if e.wrapWidth > maxDigits+2 {
e.wrapWidth -= maxDigits + 2
}
for i, item := range e.items {
if i > 0 {
w.write("\n")
}
w.write(padRight(fmt.Sprintf("%d.", i+1), maxDigits+2))
p := itemToParagraph(e, item.text)
renderMDParagraphIndent(w, p)
}
}
func renderMDDefinitions(w writer, e Entry) {
for _, d := range e.definitions {
e.items = append(
e.items,
Item(Cat(Text(fmt.Sprintf("%s:", d.name.text)), d.value)),
)
}
renderMDList(w, e)
}
func renderMDNumberedDefinitions(w writer, e Entry) {
for _, d := range e.definitions {
e.items = append(
e.items,
Item(Cat(Text(fmt.Sprintf("%s:", d.name.text)), d.value)),
)
}
renderMDNumberedList(w, e)
}
func renderMDTable(w writer, e Entry) {
e.rows = normalizeTable(e.rows)
e.rows = ensureHeader(e.rows)
if len(e.rows) == 0 || len(e.rows[0].cells) == 0 {
return
}
headerTexts, err := mdCellTexts(e.rows[:1])
if err != nil {
w.setErr(err)
return
}
cellTexts, err := mdCellTexts(e.rows[1:])
if err != nil {
w.setErr(err)
return
}
headerTexts[0] = mdEnsureHeaderTexts(headerTexts[0])
columns := columnWidths(headerTexts)
cellColumns := columnWidths(cellTexts)
if len(cellColumns) > 0 {
for i := range columns {
if cellColumns[i] > columns[i] {
columns[i] = cellColumns[i]
}
}
}
w.write("|")
for i, h := range headerTexts[0] {
w.write(" ", padRight(h, columns[i]))
w.write(" |")
}
w.write("\n|")
for _, c := range columns {
w.write(timesn("-", c+1))
w.write("-|")
}
for _, row := range cellTexts {
w.write("\n|")
for i, cell := range row {
w.write(" ", padRight(cell, columns[i]))
w.write(" |")
}
}
}
func renderMDCode(w writer, e Entry) {
w.write("```\n")
w.write(e.text.text)
w.write("\n```")
}
func renderMDMultiple(w writer, s SyntaxItem) {
s.topLevel = false
s.multiple = false
renderMDSyntaxItem(w, s)
w.write("...")
}
func renderMDRequired(w writer, s SyntaxItem) {
s.delimited = true
s.topLevel = false
s.required = false
w.write("<")
renderMDSyntaxItem(w, s)
w.write(">")
}
func renderMDOptional(w writer, s SyntaxItem) {
s.delimited = true
s.topLevel = false
s.optional = false
w.write("[")
renderMDSyntaxItem(w, s)
w.write("]")
}
func renderMDSequence(w writer, s SyntaxItem) {
if !s.delimited && !s.topLevel {
w.write("(")
}
for i, item := range s.sequence {
if i > 0 {
w.write(" ")
}
item.delimited = false
renderMDSyntaxItem(w, item)
}
if !s.delimited && !s.topLevel {
w.write(")")
}
}
func renderMDChoice(w writer, s SyntaxItem) {
if !s.delimited && !s.topLevel {
w.write("(")
}
for i, item := range s.choice {
if i > 0 {
separator := "|"
if s.topLevel {
separator = "\n"
}
w.write(separator)
}
item.delimited = false
renderMDSyntaxItem(w, item)
}
if !s.delimited && !s.topLevel {
w.write(")")
}
}
func renderMDSymbol(w writer, s SyntaxItem) {
w.write(escapeTeletype(s.symbol))
}
func renderMDSyntaxItem(w writer, s SyntaxItem) {
switch {
// foo...
case s.multiple:
renderMDMultiple(w, s)
// <foo>
case s.required:
renderMDRequired(w, s)
// [foo]
case s.optional:
renderMDOptional(w, s)
// foo bar baz or (foo bar baz)
case len(s.sequence) > 0:
renderMDSequence(w, s)
// foo|bar|baz or (foo|bar|baz)
case len(s.choice) > 0:
renderMDChoice(w, s)
// foo
default:
renderMDSymbol(w, s)
}
}
func renderMDSyntax(w writer, e Entry) {
s := e.syntax
s.topLevel = true
w.write("```\n")
renderMDSyntaxItem(w, s)
w.write("\n```")
}
func renderMarkdown(out io.Writer, d Document) error {
w := mdWriter{w: out}
for i, e := range d.entries {
if i > 0 {
w.write("\n\n")
}
switch e.typ {
case invalid:
return errors.New("invalid entry")
case title:
renderMDTitle(&w, e)
case paragraph:
renderMDParagraph(&w, e)
case list:
renderMDList(&w, e)
case numberedList:
renderMDNumberedList(&w, e)
case definitions:
renderMDDefinitions(&w, e)
case numberedDefinitions:
renderMDNumberedDefinitions(&w, e)
case table:
renderMDTable(&w, e)
case code:
renderMDCode(&w, e)
case syntax:
renderMDSyntax(&w, e)
}
}
if len(d.entries) > 0 {
w.write("\n")
}
return w.err
}

1490
markdown_test.go Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
indentation for syntax in tty and roff indentation for syntax in tty and roff
verify if empty document doesn't print even an empty line. E.g. empty table, empty paragraph. What should those
print?
does the table need the non-breaking space for the filling in roff? does the table need the non-breaking space for the filling in roff?
indentation for syntax may not require non-break spaces indentation for syntax may not require non-break spaces
test empty cat
show top level choice on separate lines in the same block

View File

@ -1034,7 +1034,7 @@ This is a paragraph.
textfmt.Item(textfmt.Text("this is the nineth item")), textfmt.Item(textfmt.Text("this is the nineth item")),
textfmt.Item(textfmt.Text("this is the tenth item")), textfmt.Item(textfmt.Text("this is the tenth item")),
textfmt.Item(textfmt.Text("this is the eleventh item")), textfmt.Item(textfmt.Text("this is the eleventh item")),
textfmt.Item(textfmt.Text("this is the twelveth item")), textfmt.Item(textfmt.Text("this is the twelfth item")),
), ),
) )
@ -1089,7 +1089,7 @@ This is a paragraph.
.br .br
.in 4 .in 4
.ti 0 .ti 0
12.\~this is the twelveth item 12.\~this is the twelfth item
` `
if b.String() != expect { if b.String() != expect {
@ -1399,7 +1399,7 @@ This is a paragraph.
textfmt.Definition(textfmt.Text("nine"), textfmt.Text("this is the nineth item")), textfmt.Definition(textfmt.Text("nine"), textfmt.Text("this is the nineth item")),
textfmt.Definition(textfmt.Text("ten"), textfmt.Text("this is the tenth item")), textfmt.Definition(textfmt.Text("ten"), textfmt.Text("this is the tenth item")),
textfmt.Definition(textfmt.Text("eleven"), textfmt.Text("this is the eleventh item")), textfmt.Definition(textfmt.Text("eleven"), textfmt.Text("this is the eleventh item")),
textfmt.Definition(textfmt.Text("twelve"), textfmt.Text("this is the twelveth item")), textfmt.Definition(textfmt.Text("twelve"), textfmt.Text("this is the twelfth item")),
), ),
) )
@ -1454,7 +1454,7 @@ This is a paragraph.
.br .br
.in 12 .in 12
.ti 0 .ti 0
12.\~twelve:\~this is the twelveth item 12.\~twelve:\~this is the twelfth item
` `
if b.String() != expect { if b.String() != expect {

View File

@ -81,3 +81,12 @@ func columnWidths(rows [][]string) []int {
return widths return widths
} }
func ensureHeader(rows []TableRow) []TableRow {
if len(rows) == 0 || len(rows[0].cells) == 0 || rows[0].header {
return rows
}
h := []TableRow{{header: true, cells: make([]TableCell, len(rows[0].cells))}}
return append(h, rows...)
}

View File

@ -23,6 +23,17 @@ func escapeTeletype(s string) string {
return string(r) return string(r)
} }
func ttyTextToString(text Txt) (string, error) {
var b bytes.Buffer
w := ttyWriter{w: &b, internal: true}
renderTTYText(&w, text)
if w.err != nil {
return "", w.err
}
return b.String(), nil
}
func ttyDefinitionNames(d []DefinitionItem) ([]string, error) { func ttyDefinitionNames(d []DefinitionItem) ([]string, error) {
var n []string var n []string
for _, di := range d { for _, di := range d {
@ -37,17 +48,6 @@ func ttyDefinitionNames(d []DefinitionItem) ([]string, error) {
return n, nil return n, nil
} }
func ttyTextToString(text Txt) (string, error) {
var b bytes.Buffer
w := ttyWriter{w: &b, internal: true}
renderTTYText(&w, text)
if w.err != nil {
return "", w.err
}
return b.String(), nil
}
func renderTTYText(w writer, text Txt) { func renderTTYText(w writer, text Txt) {
if len(text.cat) > 0 { if len(text.cat) > 0 {
for i, tc := range text.cat { for i, tc := range text.cat {
@ -63,6 +63,8 @@ func renderTTYText(w writer, text Txt) {
text.text = singleLine(text.text) text.text = singleLine(text.text)
text.text = escapeTeletype(text.text) text.text = escapeTeletype(text.text)
text.link = singleLine(text.link)
text.link = escapeTeletype(text.link)
if text.link != "" { if text.link != "" {
if text.text != "" { if text.text != "" {
w.write(text.text) w.write(text.text)
@ -79,18 +81,6 @@ func renderTTYText(w writer, text Txt) {
w.write(text.text) w.write(text.text)
} }
func itemToParagraph(list Entry, itemText Txt, prefix string) Entry {
p := Entry{
typ: paragraph,
wrapWidth: list.wrapWidth,
indent: list.indent + len([]rune(prefix)) + 1,
indentFirst: list.indentFirst - len([]rune(prefix)) - 1,
}
p.text.cat = []Txt{Text(prefix), itemText}
return p
}
func renderTTYTitle(w writer, e Entry) { func renderTTYTitle(w writer, e Entry) {
w.write(timesn(" ", e.indent)) w.write(timesn(" ", e.indent))
renderTTYText(w, e.text) renderTTYText(w, e.text)

View File

@ -373,7 +373,7 @@ Entry explanations:
t.Fatal(err) t.Fatal(err)
} }
if b.String() != "a link (https://sqrndfst.org\n/foo)\n" { if b.String() != "a link (https://sqrndfst.org /foo)\n" {
t.Fatal(b.String()) t.Fatal(b.String())
} }
}) })
@ -789,7 +789,7 @@ third item
textfmt.Item(textfmt.Text("this is the nineth item")), textfmt.Item(textfmt.Text("this is the nineth item")),
textfmt.Item(textfmt.Text("this is the tenth item")), textfmt.Item(textfmt.Text("this is the tenth item")),
textfmt.Item(textfmt.Text("this is the eleventh item")), textfmt.Item(textfmt.Text("this is the eleventh item")),
textfmt.Item(textfmt.Text("this is the twelveth item")), textfmt.Item(textfmt.Text("this is the twelfth item")),
), ),
) )
@ -809,7 +809,7 @@ third item
9. this is the nineth item 9. this is the nineth item
10. this is the tenth item 10. this is the tenth item
11. this is the eleventh item 11. this is the eleventh item
12. this is the twelveth item 12. this is the twelfth item
` `
if b.String() != expect { if b.String() != expect {
@ -1137,7 +1137,7 @@ third item
textfmt.Definition(textfmt.Text("nine"), textfmt.Text("this is the nineth item")), textfmt.Definition(textfmt.Text("nine"), textfmt.Text("this is the nineth item")),
textfmt.Definition(textfmt.Text("ten"), textfmt.Text("this is the tenth item")), textfmt.Definition(textfmt.Text("ten"), textfmt.Text("this is the tenth item")),
textfmt.Definition(textfmt.Text("eleven"), textfmt.Text("this is the eleventh item")), textfmt.Definition(textfmt.Text("eleven"), textfmt.Text("this is the eleventh item")),
textfmt.Definition(textfmt.Text("twelve"), textfmt.Text("this is the twelveth item")), textfmt.Definition(textfmt.Text("twelve"), textfmt.Text("this is the twelfth item")),
), ),
) )
@ -1157,7 +1157,7 @@ third item
9. nine: this is the nineth item 9. nine: this is the nineth item
10. ten: this is the tenth item 10. ten: this is the tenth item
11. eleven: this is the eleventh item 11. eleven: this is the eleventh item
12. twelve: this is the twelveth item 12. twelve: this is the twelfth item
` `
if b.String() != expect { if b.String() != expect {

29
text.go
View File

@ -97,3 +97,32 @@ func textToString(t Txt) string {
return singleLine(b.String()) return singleLine(b.String())
} }
func itemToParagraph(list Entry, itemText Txt, prefix ...string) Entry {
p := Entry{
typ: paragraph,
wrapWidth: list.wrapWidth,
indent: list.indent,
indentFirst: list.indentFirst,
}
if len(prefix) == 0 {
p.text = itemText
return p
}
var prefixLength int
for _, p := range prefix {
prefixLength += len([]rune(p)) + 1
}
var prefixText []Txt
for _, p := range prefix {
prefixText = append(prefixText, Text(p))
}
p.indent += prefixLength
p.indentFirst -= prefixLength
p.text.cat = append(prefixText, itemText)
return p
}

View File

@ -24,6 +24,12 @@ type roffWriter struct {
err error err error
} }
type mdWriter struct {
w io.Writer
internal bool
err error
}
func (w *ttyWriter) write(a ...any) { func (w *ttyWriter) write(a ...any) {
for _, ai := range a { for _, ai := range a {
if w.err != nil { if w.err != nil {
@ -90,6 +96,35 @@ func (w *roffWriter) setErr(err error) {
w.err = err w.err = err
} }
func (w *mdWriter) write(a ...any) {
for _, ai := range a {
if w.err != nil {
return
}
s := fmt.Sprint(ai)
r := []rune(s)
if !w.internal {
for i := range r {
if r[i] == '\u00a0' {
r[i] = ' '
}
}
}
s = string(r)
_, w.err = w.w.Write([]byte(s))
}
}
func (w *mdWriter) error() error {
return w.err
}
func (w *mdWriter) setErr(err error) {
w.err = err
}
func writeLines(w writer, txt string, indentFirst, indentRest int) { func writeLines(w writer, txt string, indentFirst, indentRest int) {
lines := strings.Split(txt, "\n") lines := strings.Split(txt, "\n")
for i, l := range lines { for i, l := range lines {