fix(markdown): fix nested lists and simplify the renderer

This commit is contained in:
ayn2op 2026-10-16 18:52:15 -04:00
parent f8141b8f8d
commit b9e310e7fa
2 changed files with 91 additions and 109 deletions

View file

@ -17,9 +17,6 @@ import (
type Renderer struct {
cfg *config.Config
listIx *int
listNested int
}
const codeBlockIndent = " "
@ -29,12 +26,10 @@ func NewRenderer(cfg *config.Config) *Renderer {
}
func (r *Renderer) RenderText(source []byte, node ast.Node, base tview.Style) richtext.Text {
r.listIx = nil
r.listNested = 0
builder := new(richtext.Builder)
styleStack := []tview.Style{base}
linkDepth := 0
listDepth := 0
currentStyle := func() tview.Style {
return styleStack[len(styleStack)-1]
@ -50,94 +45,66 @@ func (r *Renderer) RenderText(source []byte, node ast.Node, base tview.Style) ri
theme := r.cfg.Theme.MessagesList
_ = ast.Walk(node, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
switch node := node.(type) {
case *ast.Document:
// noop
case *ast.Heading:
if entering {
builder.Write(strings.Repeat("#", node.Level)+" ", currentStyle())
} else {
if !entering {
switch node := node.(type) {
case *ast.Heading:
builder.NewLine()
}
case *ast.Text:
if entering {
builder.Write(string(node.Value.Bytes(source)), currentStyle())
switch {
case node.HardLineBreak():
builder.NewLine()
builder.NewLine()
case node.SoftLineBreak():
case *ast.ListItem:
// A list that the item ends with already ended the line.
if _, ok := node.LastChild().(*ast.List); !ok {
builder.NewLine()
}
case *ast.Link:
linkDepth--
popStyle()
case *ast.List:
listDepth--
case *md.Inline:
if !r.shouldMask(node) {
popStyle()
}
}
return ast.WalkContinue, nil
}
switch node := node.(type) {
case *ast.Heading:
builder.Write(strings.Repeat("#", node.Level)+" ", currentStyle())
case *ast.Text:
builder.Write(string(node.Value.Bytes(source)), currentStyle())
switch {
case node.HardLineBreak():
builder.NewLine()
builder.NewLine()
case node.SoftLineBreak():
builder.NewLine()
}
case *ast.CodeBlock:
if entering {
builder.NewLine()
r.renderCodeBlock(builder, source, node, currentStyle())
}
builder.NewLine()
r.renderCodeBlock(builder, source, node, currentStyle())
case *ast.AutoLink:
if entering {
url := string(node.Destination.Bytes(source))
style := tview.MergeStyle(currentStyle(), theme.URLStyle.Style).Url(url)
builder.Write(url, style)
}
url := string(node.Destination.Bytes(source))
style := tview.MergeStyle(currentStyle(), theme.URLStyle.Style).Url(url)
builder.Write(url, style)
case *ast.Link:
if entering {
url := string(node.Destination.Bytes(source))
linkDepth++
pushStyle(tview.MergeStyle(currentStyle(), theme.URLStyle.Style).Url(url))
} else {
if linkDepth > 0 {
linkDepth--
}
popStyle()
}
url := string(node.Destination.Bytes(source))
linkDepth++
pushStyle(tview.MergeStyle(currentStyle(), theme.URLStyle.Style).Url(url))
case *ast.List:
if node.IsOrdered() {
start := node.Start
r.listIx = &start
} else {
r.listIx = nil
}
if entering {
builder.NewLine()
r.listNested++
} else {
r.listNested--
}
builder.NewLine()
listDepth++
case *ast.ListItem:
if entering {
builder.Write(strings.Repeat(" ", r.listNested-1), currentStyle())
if r.listIx != nil {
builder.Write(strconv.Itoa(*r.listIx)+". ", currentStyle())
*r.listIx++
} else {
builder.Write("- ", currentStyle())
}
} else {
builder.NewLine()
}
builder.Write(strings.Repeat(" ", listDepth-1)+listMarker(node), currentStyle())
case *md.Inline:
if r.cfg.Markdown.MaskSpoilers && node.Attr&md.AttrSpoiler != 0 {
if entering {
builder.Write("[spoiler]", currentStyle())
}
if r.shouldMask(node) {
builder.Write("[spoiler]", currentStyle())
return ast.WalkSkipChildren, nil
}
if entering {
pushStyle(applyInlineAttr(currentStyle(), node.Attr, linkDepth > 0))
} else {
popStyle()
}
pushStyle(applyInlineAttr(currentStyle(), node.Attr, linkDepth > 0))
case *md.Mention:
if entering {
builder.Write(mentionText(node), tview.MergeStyle(currentStyle(), theme.MentionStyle.Style))
}
builder.Write(mentionText(node), tview.MergeStyle(currentStyle(), theme.MentionStyle.Style))
case *md.Emoji:
if entering {
builder.Write(":"+node.Name+":", tview.MergeStyle(currentStyle(), theme.EmojiStyle.Style))
}
builder.Write(":"+node.Name+":", tview.MergeStyle(currentStyle(), theme.EmojiStyle.Style))
}
return ast.WalkContinue, nil
})
@ -214,6 +181,25 @@ func (r *Renderer) renderCodeBlock(builder *richtext.Builder, source []byte, nod
}
}
// shouldMask reports whether node is a spoiler shown as "[spoiler]" in place of what it hides.
func (r *Renderer) shouldMask(node *md.Inline) bool {
return r.cfg.Markdown.MaskSpoilers && node.Attr&md.AttrSpoiler != 0
}
// listMarker returns what goes before item: its number in an ordered list, counted from the list's start, or a dash.
func listMarker(item *ast.ListItem) string {
list := item.Parent().(*ast.List)
if !list.IsOrdered() {
return "- "
}
number := list.Start
for sibling := item.PreviousSibling(); sibling != nil; sibling = sibling.PreviousSibling() {
number++
}
return strconv.Itoa(number) + ". "
}
func applyChromaStyle(base tview.Style, entry chroma.StyleEntry) tview.Style {
style := base
if entry.Colour.IsSet() {
@ -224,23 +210,16 @@ func applyChromaStyle(base tview.Style, entry chroma.StyleEntry) tview.Style {
))
}
// Token background colors are not applied, so code blocks keep the user's terminal/chat background.
switch entry.Bold {
case chroma.Yes:
style = style.Bold(true)
case chroma.No:
style = style.Bold(false)
// An attribute chroma passes on is left as it is.
if entry.Bold != chroma.Pass {
style = style.Bold(entry.Bold == chroma.Yes)
}
switch entry.Italic {
case chroma.Yes:
style = style.Italic(true)
case chroma.No:
style = style.Italic(false)
if entry.Italic != chroma.Pass {
style = style.Italic(entry.Italic == chroma.Yes)
}
switch entry.Underline {
case chroma.Yes:
style = style.Underline(true)
case chroma.No:
style = style.Underline(false)
if entry.Underline != chroma.Pass {
style = style.Underline(entry.Underline == chroma.Yes)
}
return style
}

View file

@ -10,27 +10,30 @@ import (
)
func TestRendererRenderText(t *testing.T) {
source := []byte("before ||secret|| after")
node := md.Parse(source)
for _, test := range []struct {
name string
mask bool
want string
name string
source string
mask bool
want string
}{
{"spoilers_masked", true, "before [spoiler] after"},
{"spoilers_visible", false, "before secret after"},
{"spoilers_masked", "before ||secret|| after", true, "before [spoiler] after"},
{"spoilers_visible", "before ||secret|| after", false, "before secret after"},
{"ordered_list_after_nested", "1. a\n2. b\n - x\n - y\n3. c", false, "\n1. a\n2. b\n - x\n - y\n3. c"},
{"ordered_list_after_nested_ordered", "1. a\n 1. x\n 2. y\n2. b", false, "\n1. a\n 1. x\n 2. y\n2. b"},
{"list_nested_twice", "- a\n - b\n - c\n- d", false, "\n- a\n - b\n - c\n- d"},
{"ordered_list_start", "3. a\n4. b", false, "\n3. a\n4. b"},
} {
t.Run(test.name, func(t *testing.T) {
rendered := NewRenderer(&config.Config{Markdown: config.MarkdownConfig{MaskSpoilers: test.mask}}).RenderText(source, node, tcell.StyleDefault)
var got strings.Builder
for _, line := range rendered {
source := []byte(test.source)
rendered := NewRenderer(&config.Config{Markdown: config.MarkdownConfig{MaskSpoilers: test.mask}}).RenderText(source, md.Parse(source), tcell.StyleDefault)
lines := make([]string, len(rendered))
for i, line := range rendered {
for _, segment := range line {
got.WriteString(segment.Text)
lines[i] += segment.Text
}
}
if got.String() != test.want {
t.Fatalf("mask=%t: got %q, want %q", test.mask, got.String(), test.want)
if got := strings.Join(lines, "\n"); got != test.want {
t.Fatalf("got %q, want %q", got, test.want)
}
})
}