- 模块名重命名 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*
288 lines
5.6 KiB
Go
288 lines
5.6 KiB
Go
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
|
|
}
|