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}
}
// when no header and markdown, header automatic
func Table(rows ...TableRow) Entry {
return Entry{typ: table, rows: rows}
}
@ -279,8 +280,8 @@ func Runoff(out io.Writer, d Document) error {
return renderRoff(out, d)
}
func Markdown(io.Writer, Document) error {
return nil
func Markdown(out io.Writer, d Document) error {
return renderMarkdown(out, d)
}
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
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?
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 tenth 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
.in 4
.ti 0
12.\~this is the twelveth item
12.\~this is the twelfth item
`
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("ten"), textfmt.Text("this is the tenth 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
.in 12
.ti 0
12.\~twelve:\~this is the twelveth item
12.\~twelve:\~this is the twelfth item
`
if b.String() != expect {

View File

@ -81,3 +81,12 @@ func columnWidths(rows [][]string) []int {
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)
}
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) {
var n []string
for _, di := range d {
@ -37,17 +48,6 @@ func ttyDefinitionNames(d []DefinitionItem) ([]string, error) {
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) {
if len(text.cat) > 0 {
for i, tc := range text.cat {
@ -63,6 +63,8 @@ func renderTTYText(w writer, text Txt) {
text.text = singleLine(text.text)
text.text = escapeTeletype(text.text)
text.link = singleLine(text.link)
text.link = escapeTeletype(text.link)
if text.link != "" {
if text.text != "" {
w.write(text.text)
@ -79,18 +81,6 @@ func renderTTYText(w writer, text Txt) {
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) {
w.write(timesn(" ", e.indent))
renderTTYText(w, e.text)

View File

@ -373,7 +373,7 @@ Entry explanations:
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())
}
})
@ -789,7 +789,7 @@ third 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 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
10. this is the tenth item
11. this is the eleventh item
12. this is the twelveth item
12. this is the twelfth item
`
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("ten"), textfmt.Text("this is the tenth 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
10. ten: this is the tenth 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 {

29
text.go
View File

@ -97,3 +97,32 @@ func textToString(t Txt) 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
}
type mdWriter struct {
w io.Writer
internal bool
err error
}
func (w *ttyWriter) write(a ...any) {
for _, ai := range a {
if w.err != nil {
@ -90,6 +96,35 @@ func (w *roffWriter) setErr(err error) {
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) {
lines := strings.Split(txt, "\n")
for i, l := range lines {