refactor: 项目结构重组,src/ 扁平化为根目录,提取 pkg/ 子包

- 模块名重命名 yunshu -> hub.gaomia.site/titor/YunShu
- Go 版本升级 1.21 -> 1.25
- src/ 目录删除,所有文件移至根目录
- 新增 pkg/mdprint/: Markdown AST 解析+ANSI 渲染
- 新增 pkg/style/: 终端颜色样式(8色 ANSI + 24位真彩色)
- 新增 pkg/termui/: 终端输入组件(交互式输入/密码/确认)
- 更新文档:AGENTS.md、architecture.md、changelog.md、taolun.md
- gitignore 通配符修复 yunshu.exe -> yunshu.exe*
This commit is contained in:
titor
2026-05-09 03:55:56 +08:00
parent 5f355a0d7c
commit d2b9b2c4bb
26 changed files with 1739 additions and 159 deletions

91
pkg/mdprint/inline.go Normal file
View File

@@ -0,0 +1,91 @@
package mdprint
func parseInline(text string) []Node {
var nodes []Node
runes := []rune(text)
i := 0
for i < len(runes) {
rem := string(runes[i:])
if len(rem) >= 2 && rem[:2] == "**" {
end := findDelim(runes, i+2, "**")
if end >= 0 {
inner := string(runes[i+2 : end])
nodes = append(nodes, Bold{Content: parseInline(inner)})
i = end + 2
continue
}
nodes = append(nodes, Text{Text: "**"})
i += 2
continue
}
if len(rem) >= 1 && rem[:1] == "*" {
end := findDelim(runes, i+1, "*")
if end >= 0 {
inner := string(runes[i+1 : end])
nodes = append(nodes, Italic{Content: parseInline(inner)})
i = end + 1
continue
}
nodes = append(nodes, Text{Text: "*"})
i++
continue
}
if len(rem) >= 1 && rem[:1] == "`" {
end := findDelim(runes, i+1, "`")
if end >= 0 {
nodes = append(nodes, Code{Text: string(runes[i+1 : end])})
i = end + 1
continue
}
nodes = append(nodes, Text{Text: "`"})
i++
continue
}
if len(rem) >= 1 && rem[:1] == "[" {
closeB := findDelim(runes, i+1, "]")
if closeB >= 0 && closeB+1 < len(runes) && runes[closeB+1] == '(' {
closeP := findDelim(runes, closeB+2, ")")
if closeP >= 0 {
linkText := string(runes[i+1 : closeB])
url := string(runes[closeB+2 : closeP])
nodes = append(nodes, Link{Content: parseInline(linkText), URL: url})
i = closeP + 1
continue
}
}
}
textStart := i
for i < len(runes) {
ch := string(runes[i])
if ch == "*" || ch == "`" || ch == "[" {
break
}
if ch == "\\" && i+1 < len(runes) {
i += 2
continue
}
i++
}
if i > textStart {
nodes = append(nodes, Text{Text: string(runes[textStart:i])})
}
}
return nodes
}
func findDelim(runes []rune, start int, delim string) int {
for j := start; j < len(runes); j++ {
rem := string(runes[j:])
if len(rem) >= len(delim) && rem[:len(delim)] == delim {
return j
}
}
return -1
}

52
pkg/mdprint/mdprint.go Normal file
View File

@@ -0,0 +1,52 @@
package mdprint
import "fmt"
// Node is the interface for all AST nodes.
type Node interface{ isNode() }
// ---- block-level ----
type Heading struct{ Level int; Content []Node }
type Paragraph struct{ Content []Node }
type CodeBlock struct{ Lang, Body string }
type Blockquote struct{ Children []Node }
type List struct{ Ordered bool; Items []ListItem }
type ListItem struct{ Checked *bool; Content []Node }
type Table struct{ Headers []string; Rows [][]string }
type ThematicBreak struct{}
// ---- inline-level ----
type Text struct{ Text string }
type Bold struct{ Content []Node }
type Italic struct{ Content []Node }
type Code struct{ Text string }
type Link struct{ Content []Node; URL string }
func (Heading) isNode() {}
func (Paragraph) isNode() {}
func (CodeBlock) isNode() {}
func (Blockquote) isNode() {}
func (List) isNode() {}
func (ListItem) isNode() {}
func (Table) isNode() {}
func (ThematicBreak) isNode() {}
func (Text) isNode() {}
func (Bold) isNode() {}
func (Italic) isNode() {}
func (Code) isNode() {}
func (Link) isNode() {}
// Print parses Markdown content and renders it to the terminal.
func Print(content string) {
blocks := parseBlocks(content)
for i, b := range blocks {
if i > 0 {
fmt.Println()
}
fmt.Print(renderNode(b))
}
fmt.Println()
}

308
pkg/mdprint/mdprint_test.go Normal file
View File

@@ -0,0 +1,308 @@
package mdprint
import (
"strings"
"testing"
)
func stripANSI(s string) string {
var b strings.Builder
i := 0
for i < len(s) {
if s[i] == '\033' && i+1 < len(s) && s[i+1] == '[' {
j := i + 2
for j < len(s) && s[j] != 'm' {
j++
}
if j < len(s) {
i = j + 1
continue
}
}
b.WriteByte(s[i])
i++
}
return b.String()
}
func TestParseInline_Text(t *testing.T) {
nodes := parseInline("hello world")
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
_, ok := nodes[0].(Text)
if !ok {
t.Fatalf("expected Text, got %T", nodes[0])
}
}
func TestParseInline_Bold(t *testing.T) {
nodes := parseInline("**bold**")
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
b, ok := nodes[0].(Bold)
if !ok {
t.Fatalf("expected Bold, got %T", nodes[0])
}
if len(b.Content) != 1 {
t.Fatalf("expected 1 child, got %d", len(b.Content))
}
txt, ok := b.Content[0].(Text)
if !ok {
t.Fatalf("expected Text in Bold, got %T", b.Content[0])
}
if txt.Text != "bold" {
t.Fatalf("expected 'bold', got '%s'", txt.Text)
}
}
func TestParseInline_Italic(t *testing.T) {
nodes := parseInline("*italic*")
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
_, ok := nodes[0].(Italic)
if !ok {
t.Fatalf("expected Italic, got %T", nodes[0])
}
}
func TestParseInline_Code(t *testing.T) {
nodes := parseInline("`code`")
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
c, ok := nodes[0].(Code)
if !ok {
t.Fatalf("expected Code, got %T", nodes[0])
}
if c.Text != "code" {
t.Fatalf("expected 'code', got '%s'", c.Text)
}
}
func TestParseInline_Link(t *testing.T) {
nodes := parseInline("[text](url)")
if len(nodes) != 1 {
t.Fatalf("expected 1 node, got %d", len(nodes))
}
link, ok := nodes[0].(Link)
if !ok {
t.Fatalf("expected Link, got %T", nodes[0])
}
if link.URL != "url" {
t.Fatalf("expected URL 'url', got '%s'", link.URL)
}
if len(link.Content) != 1 {
t.Fatalf("expected 1 child in link, got %d", len(link.Content))
}
txt, ok := link.Content[0].(Text)
if !ok {
t.Fatalf("expected Text in Link, got %T", link.Content[0])
}
if txt.Text != "text" {
t.Fatalf("expected 'text', got '%s'", txt.Text)
}
}
func TestParseInline_Mixed(t *testing.T) {
nodes := parseInline("a **b** c")
if len(nodes) != 3 {
t.Fatalf("expected 3 nodes, got %d: %#v", len(nodes), nodes)
}
t1, ok := nodes[0].(Text)
if !ok || t1.Text != "a " {
t.Fatalf("expected 'a ' Text, got %#v", nodes[0])
}
_, ok = nodes[1].(Bold)
if !ok {
t.Fatalf("expected Bold at index 1, got %T", nodes[1])
}
t2, ok := nodes[2].(Text)
if !ok || t2.Text != " c" {
t.Fatalf("expected ' c' Text, got %#v", nodes[2])
}
}
func TestParseBlocks_Heading(t *testing.T) {
blocks := parseBlocks("# Title")
if len(blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(blocks))
}
h, ok := blocks[0].(Heading)
if !ok {
t.Fatalf("expected Heading, got %T", blocks[0])
}
if h.Level != 1 {
t.Fatalf("expected level 1, got %d", h.Level)
}
if stripANSI(renderNode(h)) != "\n▪ Title\n" {
t.Fatalf("got '%s'", stripANSI(renderNode(h)))
}
}
func TestParseBlocks_CodeFence(t *testing.T) {
blocks := parseBlocks("```go\npackage main\n```")
if len(blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(blocks))
}
cb, ok := blocks[0].(CodeBlock)
if !ok {
t.Fatalf("expected CodeBlock, got %T", blocks[0])
}
if cb.Lang != "go" {
t.Fatalf("expected lang 'go', got '%s'", cb.Lang)
}
body := stripANSI(renderNode(cb))
if !strings.Contains(body, "package main") {
t.Fatalf("expected 'package main' in body")
}
}
func TestParseBlocks_Blockquote(t *testing.T) {
blocks := parseBlocks("> hello\n> world")
if len(blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(blocks))
}
_, ok := blocks[0].(Blockquote)
if !ok {
t.Fatalf("expected Blockquote, got %T", blocks[0])
}
}
func TestParseBlocks_UnorderedList(t *testing.T) {
blocks := parseBlocks("- a\n- b\n- c")
if len(blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(blocks))
}
l, ok := blocks[0].(List)
if !ok {
t.Fatalf("expected List, got %T", blocks[0])
}
if len(l.Items) != 3 {
t.Fatalf("expected 3 items, got %d", len(l.Items))
}
}
func TestParseBlocks_OrderedList(t *testing.T) {
blocks := parseBlocks("1. a\n2. b")
if len(blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(blocks))
}
l, ok := blocks[0].(List)
if !ok {
t.Fatalf("expected List, got %T", blocks[0])
}
if !l.Ordered {
t.Fatalf("expected ordered list")
}
}
func TestParseBlocks_Table(t *testing.T) {
blocks := parseBlocks("| H1 | H2 |\n|---|----|\n| A | B |")
if len(blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(blocks))
}
tbl, ok := blocks[0].(Table)
if !ok {
t.Fatalf("expected Table, got %T", blocks[0])
}
if len(tbl.Headers) != 2 {
t.Fatalf("expected 2 headers, got %d", len(tbl.Headers))
}
if len(tbl.Rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(tbl.Rows))
}
}
func TestParseBlocks_ThematicBreak(t *testing.T) {
blocks := parseBlocks("---")
if len(blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(blocks))
}
_, ok := blocks[0].(ThematicBreak)
if !ok {
t.Fatalf("expected ThematicBreak, got %T", blocks[0])
}
}
func TestParseBlocks_Paragraph(t *testing.T) {
blocks := parseBlocks("hello\nworld")
if len(blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(blocks))
}
_, ok := blocks[0].(Paragraph)
if !ok {
t.Fatalf("expected Paragraph, got %T", blocks[0])
}
}
func TestParseBlocks_MultiBlock(t *testing.T) {
content := "# Title\n\nhello **world**\n\n> quote"
blocks := parseBlocks(content)
if len(blocks) != 3 {
t.Fatalf("expected 3 blocks, got %d", len(blocks))
}
}
func TestRender_HeadingLevels(t *testing.T) {
for level := 1; level <= 6; level++ {
prefix := strings.Repeat("#", level)
blocks := parseBlocks(prefix + " test")
if len(blocks) != 1 {
t.Fatalf("heading level %d: expected 1 block", level)
}
h, ok := blocks[0].(Heading)
if !ok {
t.Fatalf("heading level %d: expected Heading", level)
}
if h.Level != level {
t.Fatalf("heading level %d: expected level=%d", level, level)
}
result := stripANSI(renderNode(h))
var expected string
switch level {
case 1:
expected = "\n▪ test\n"
case 2, 3:
expected = "\n▪ test"
case 4, 5:
expected = "\n▫ test"
case 6:
expected = "\ntest"
}
if result != expected {
t.Fatalf("heading level %d: got '%q', expected '%q'", level, result, expected)
}
}
}
func TestRender_BoldItalic(t *testing.T) {
nodes := parseInline("**bold** and *italic*")
r := stripANSI(renderInline(nodes))
if r != "bold and italic" {
t.Fatalf("got '%s'", r)
}
}
func TestRender_Link(t *testing.T) {
nodes := parseInline("[click](https://example.com)")
r := stripANSI(renderInline(nodes))
if !strings.Contains(r, "click") || !strings.Contains(r, "https://example.com") {
t.Fatalf("got '%s'", r)
}
}
func TestRender_FullDocument(t *testing.T) {
content := "# 天气报告\n\n今天**晴**,温度 *适中*。\n\n> 温馨提示:出门带伞\n\n- 温度25°C\n- 湿度40%\n\n代码`fmt.Println(\"hello\")`\n\n| 项目 | 值 |\n|------|----|\n| AQI | 42 |\n"
blocks := parseBlocks(content)
if len(blocks) == 0 {
t.Fatal("expected blocks")
}
_, ok := blocks[0].(Heading)
if !ok {
t.Fatal("first block should be Heading")
}
}

287
pkg/mdprint/parse.go Normal file
View File

@@ -0,0 +1,287 @@
package mdprint
import (
"strings"
)
func parseBlocks(content string) []Node {
lines := strings.Split(content, "\n")
var blocks []Node
i := 0
for i < len(lines) {
line := lines[i]
trim := strings.TrimSpace(line)
if trim == "" {
i++
continue
}
if level := headingLevel(trim); level > 0 {
rest := strings.TrimSpace(trim[level:])
blocks = append(blocks, Heading{Level: level, Content: parseInline(rest)})
i++
continue
}
if trim == "---" || trim == "***" || trim == "___" {
blocks = append(blocks, ThematicBreak{})
i++
continue
}
if isFence(trim) {
b, lang, consumed := parseFence(lines, i)
blocks = append(blocks, CodeBlock{Lang: lang, Body: b})
i += consumed
continue
}
if strings.HasPrefix(trim, ">") {
q, consumed := parseQuote(lines, i)
blocks = append(blocks, q)
i += consumed
continue
}
if mark, ordered, ok := listMarker(trim); ok {
items, consumed := parseListItems(lines, i, mark)
blocks = append(blocks, List{Ordered: ordered, Items: items})
i += consumed
continue
}
if strings.HasPrefix(trim, "|") && i+1 < len(lines) && isTableSep(strings.TrimSpace(lines[i+1])) {
t, consumed := parseTable(lines, i)
blocks = append(blocks, t)
i += consumed
continue
}
p, consumed := parseParagraph(lines, i)
blocks = append(blocks, p)
i += consumed
}
return blocks
}
func headingLevel(s string) int {
n := 0
for _, c := range s {
if c == '#' {
n++
} else {
break
}
}
if n > 0 && n <= 6 && len(s) > n && s[n] == ' ' {
return n
}
return 0
}
func isFence(s string) bool {
count := 0
for _, c := range s {
if c == '`' || c == '~' {
count++
} else {
break
}
}
return count >= 3
}
func fenceChar(s string) rune {
for _, c := range s {
return c
}
return '`'
}
func parseFence(lines []string, start int) (body, lang string, consumed int) {
first := strings.TrimSpace(lines[start])
ch := fenceChar(first)
lang = strings.TrimSpace(first[3:])
var buf strings.Builder
consumed = 1
for i := start + 1; i < len(lines); i++ {
consumed++
if strings.TrimSpace(lines[i]) == strings.Repeat(string(ch), 3) {
break
}
if buf.Len() > 0 {
buf.WriteString("\n")
}
buf.WriteString(lines[i])
}
body = buf.String()
return
}
func parseQuote(lines []string, start int) (Blockquote, int) {
var contents []Node
var buf []string
flush := func() {
if len(buf) > 0 {
text := strings.Join(buf, "\n")
contents = append(contents, Paragraph{Content: parseInline(text)})
buf = nil
}
}
i := start
for i < len(lines) {
trim := strings.TrimSpace(lines[i])
if !strings.HasPrefix(trim, ">") {
break
}
rest := trim[1:]
if strings.HasPrefix(rest, " ") {
rest = rest[1:]
}
if rest == "" {
flush()
} else {
buf = append(buf, rest)
}
i++
}
flush()
return Blockquote{Children: contents}, i - start
}
func listMarker(s string) (mark string, ordered bool, ok bool) {
trim := strings.TrimSpace(s)
if len(trim) >= 2 && (trim[0] == '-' || trim[0] == '*') && trim[1] == ' ' {
return string(trim[0]), false, true
}
if len(trim) >= 3 && trim[0] >= '1' && trim[0] <= '9' && trim[1] == '.' && trim[2] == ' ' {
return ".", true, true
}
return "", false, false
}
func parseListItems(lines []string, start int, mark string) ([]ListItem, int) {
var items []ListItem
consumed := 0
for i := start; i < len(lines); i++ {
trim := strings.TrimSpace(lines[i])
m, _, ok := listMarker(trim)
if !ok || m != mark {
break
}
consumed++
rest := strings.TrimSpace(trim[len(mark)+1:])
var checked *bool
if strings.HasPrefix(rest, "[ ] ") {
f := false
checked = &f
rest = rest[4:]
} else if strings.HasPrefix(rest, "[x] ") {
t := true
checked = &t
rest = rest[4:]
} else if strings.HasPrefix(rest, "[X] ") {
t := true
checked = &t
rest = rest[4:]
}
items = append(items, ListItem{
Checked: checked,
Content: parseInline(rest),
})
}
return items, consumed
}
func isTableSep(s string) bool {
if !strings.HasPrefix(s, "|") || !strings.HasSuffix(s, "|") {
return false
}
s = s[1 : len(s)-1]
cells := strings.Split(s, "|")
if len(cells) == 0 {
return false
}
for _, c := range cells {
c = strings.TrimSpace(c)
if c == "" {
return false
}
for _, ch := range c {
if ch != '-' && ch != ':' {
return false
}
}
}
return true
}
func parseTable(lines []string, start int) (Table, int) {
headerLine := strings.TrimSpace(lines[start])
headers := splitTableRow(headerLine)
consumed := 2
var rows [][]string
for i := start + 2; i < len(lines); i++ {
trim := strings.TrimSpace(lines[i])
if !strings.HasPrefix(trim, "|") {
break
}
rows = append(rows, splitTableRow(trim))
consumed++
}
return Table{Headers: headers, Rows: rows}, consumed
}
func splitTableRow(s string) []string {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "|") {
s = s[1:]
}
if strings.HasSuffix(s, "|") {
s = s[:len(s)-1]
}
cells := strings.Split(s, "|")
result := make([]string, len(cells))
for i, c := range cells {
result[i] = strings.TrimSpace(c)
}
return result
}
func parseParagraph(lines []string, start int) (Paragraph, int) {
var contentLines []string
consumed := 0
for i := start; i < len(lines); i++ {
trim := strings.TrimSpace(lines[i])
if trim == "" {
break
}
if headingLevel(trim) > 0 {
break
}
if trim == "---" || trim == "***" || trim == "___" {
break
}
if isFence(trim) {
break
}
if strings.HasPrefix(trim, ">") {
break
}
if _, _, ok := listMarker(trim); ok {
break
}
contentLines = append(contentLines, lines[i])
consumed++
}
text := strings.Join(contentLines, "\n")
return Paragraph{Content: parseInline(text)}, consumed
}

204
pkg/mdprint/render.go Normal file
View File

@@ -0,0 +1,204 @@
package mdprint
import (
"fmt"
"strings"
"hub.gaomia.site/titor/YunShu/pkg/style"
)
func renderNode(node Node) string {
switch n := node.(type) {
case Heading:
return renderHeading(n)
case Paragraph:
return renderParagraph(n)
case CodeBlock:
return renderCodeBlock(n)
case Blockquote:
return renderBlockquote(n)
case List:
return renderList(n)
case Table:
return renderTable(n)
case ThematicBreak:
return "\n" + style.Dim.Render("────────────────────────────") + "\n"
case Text:
return style.New().Render(n.Text)
case Bold:
return renderBold(n)
case Italic:
return renderItalic(n)
case Code:
return renderInlineCode(n)
case Link:
return renderLink(n)
default:
return fmt.Sprintf("%v", n)
}
}
func renderInline(nodes []Node) string {
var b strings.Builder
for _, child := range nodes {
b.WriteString(renderNode(child))
}
return b.String()
}
var headingConfig = []struct {
symbol string
style *style.Style
}{
{"▪ ", style.New().Bold().FgHex("#6B8E9B")},
{"▪ ", style.New().Bold().FgHex("#89A894")},
{"▪ ", style.New().Bold().FgHex("#A6C0B5")},
{"▫ ", style.New().Bold().FgHex("#C3B1BD")},
{"▫ ", style.New().Bold().FgHex("#7B8E8A")},
{"", style.New().Bold().Dim()},
}
func renderHeading(h Heading) string {
cfg := headingConfig[0]
if h.Level-1 < len(headingConfig) {
cfg = headingConfig[h.Level-1]
}
suffix := ""
if h.Level == 1 {
suffix = "\n"
}
return "\n" + cfg.style.Render(cfg.symbol+renderInline(h.Content)) + suffix
}
func renderParagraph(p Paragraph) string {
return renderInline(p.Content)
}
func renderCodeBlock(c CodeBlock) string {
var b strings.Builder
if c.Lang != "" {
b.WriteString(style.Dim.Render(c.Lang + " ") + "\n")
}
for _, line := range strings.Split(c.Body, "\n") {
b.WriteString(" " + style.New().Fg(style.ColorYellow).Render(line) + "\n")
}
return strings.TrimRight(b.String(), "\n")
}
func renderBlockquote(q Blockquote) string {
var b strings.Builder
for i, child := range q.Children {
if i > 0 {
b.WriteString("\n")
}
rendered := renderNode(child)
for _, line := range strings.Split(rendered, "\n") {
b.WriteString(style.Dim.Render("│ ") + line + "\n")
}
}
return strings.TrimRight(b.String(), "\n")
}
func renderList(l List) string {
var b strings.Builder
for i, item := range l.Items {
if i > 0 {
b.WriteString("\n")
}
prefix := "- "
if l.Ordered {
prefix = fmt.Sprintf("%d. ", i+1)
}
if item.Checked != nil {
check := " "
if *item.Checked {
check = "x"
}
prefix = fmt.Sprintf("- [%s] ", check)
}
b.WriteString(style.Dim.Render(prefix) + renderInline(item.Content))
}
return b.String()
}
func renderTable(t Table) string {
if len(t.Headers) == 0 {
return ""
}
var b strings.Builder
colWidths := make([]int, len(t.Headers))
for i, h := range t.Headers {
colWidths[i] = len(h)
}
for _, row := range t.Rows {
for i, cell := range row {
if i < len(colWidths) && len(cell) > colWidths[i] {
colWidths[i] = len(cell)
}
}
}
renderRow := func(cells []string, isHeader bool) {
b.WriteString("| ")
for i, cell := range cells {
if i > 0 {
b.WriteString(" | ")
}
if i < len(colWidths) {
cell = fmt.Sprintf("%-*s", colWidths[i], cell)
}
if isHeader {
b.WriteString(style.Bold.Render(cell))
} else {
b.WriteString(cell)
}
}
b.WriteString(" |")
}
renderRow(t.Headers, true)
sep := "| "
for i, w := range colWidths {
if i > 0 {
sep += " | "
}
sep += strings.Repeat("-", w)
}
sep += " |"
b.WriteString("\n" + style.Dim.Render(sep))
for _, row := range t.Rows {
b.WriteString("\n")
renderRow(row, false)
}
return b.String()
}
func renderBold(b Bold) string {
return style.New().Bold().Render(renderInline(b.Content))
}
func renderItalic(i Italic) string {
return style.New().Italic().Render(renderInline(i.Content))
}
func renderInlineCode(c Code) string {
return style.New().Fg(style.ColorYellow).Render("`" + c.Text + "`")
}
func renderLink(l Link) string {
text := renderInline(l.Content)
url := l.URL
if text == "" {
text = url
url = ""
}
styled := style.New().Underline().Fg(style.ColorBlue).Render(text)
if url != "" {
styled += style.Dim.Render(" (" + url + ")")
}
return styled
}