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:
91
pkg/mdprint/inline.go
Normal file
91
pkg/mdprint/inline.go
Normal 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
52
pkg/mdprint/mdprint.go
Normal 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
308
pkg/mdprint/mdprint_test.go
Normal 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
287
pkg/mdprint/parse.go
Normal 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
204
pkg/mdprint/render.go
Normal 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
|
||||
}
|
||||
132
pkg/style/style.go
Normal file
132
pkg/style/style.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package style
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var noColor = os.Getenv("NO_COLOR") != "" || os.Getenv("TERM") == "dumb"
|
||||
|
||||
type Style struct {
|
||||
codes []string
|
||||
}
|
||||
|
||||
func New() *Style {
|
||||
return &Style{}
|
||||
}
|
||||
|
||||
func (s *Style) clone() *Style {
|
||||
c := &Style{codes: make([]string, len(s.codes))}
|
||||
copy(c.codes, s.codes)
|
||||
return c
|
||||
}
|
||||
|
||||
func (s *Style) Bold() *Style {
|
||||
c := s.clone()
|
||||
c.codes = append(c.codes, "1")
|
||||
return c
|
||||
}
|
||||
|
||||
func (s *Style) Dim() *Style {
|
||||
c := s.clone()
|
||||
c.codes = append(c.codes, "2")
|
||||
return c
|
||||
}
|
||||
|
||||
func (s *Style) Italic() *Style {
|
||||
c := s.clone()
|
||||
c.codes = append(c.codes, "3")
|
||||
return c
|
||||
}
|
||||
|
||||
func (s *Style) Underline() *Style {
|
||||
c := s.clone()
|
||||
c.codes = append(c.codes, "4")
|
||||
return c
|
||||
}
|
||||
|
||||
func (s *Style) Fg(c Color) *Style {
|
||||
n := s.clone()
|
||||
n.codes = append(n.codes, fmt.Sprintf("%d", c))
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *Style) Bg(c Color) *Style {
|
||||
n := s.clone()
|
||||
n.codes = append(n.codes, fmt.Sprintf("%d", c+10))
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *Style) FgHex(hex string) *Style {
|
||||
n := s.clone()
|
||||
r, g, b := hexToRGB(hex)
|
||||
n.codes = append(n.codes, fmt.Sprintf("38;2;%d;%d;%d", r, g, b))
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *Style) BgHex(hex string) *Style {
|
||||
n := s.clone()
|
||||
r, g, b := hexToRGB(hex)
|
||||
n.codes = append(n.codes, fmt.Sprintf("48;2;%d;%d;%d", r, g, b))
|
||||
return n
|
||||
}
|
||||
|
||||
func hexToRGB(hex string) (r, g, b int) {
|
||||
hex = strings.TrimPrefix(hex, "#")
|
||||
if len(hex) != 6 {
|
||||
return 255, 255, 255
|
||||
}
|
||||
r = parseHex(hex[0:2])
|
||||
g = parseHex(hex[2:4])
|
||||
b = parseHex(hex[4:6])
|
||||
return
|
||||
}
|
||||
|
||||
func parseHex(s string) int {
|
||||
v := 0
|
||||
for _, c := range s {
|
||||
v *= 16
|
||||
switch {
|
||||
case c >= '0' && c <= '9':
|
||||
v += int(c - '0')
|
||||
case c >= 'a' && c <= 'f':
|
||||
v += int(c - 'a' + 10)
|
||||
case c >= 'A' && c <= 'F':
|
||||
v += int(c - 'A' + 10)
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Style) Render(text string) string {
|
||||
if noColor || len(s.codes) == 0 {
|
||||
return text
|
||||
}
|
||||
return "\033[" + strings.Join(s.codes, ";") + "m" + text + "\033[0m"
|
||||
}
|
||||
|
||||
type Color int
|
||||
|
||||
const (
|
||||
ColorBlack Color = 30
|
||||
ColorRed Color = 31
|
||||
ColorGreen Color = 32
|
||||
ColorYellow Color = 33
|
||||
ColorBlue Color = 34
|
||||
ColorMagenta Color = 35
|
||||
ColorCyan Color = 36
|
||||
ColorWhite Color = 37
|
||||
)
|
||||
|
||||
var (
|
||||
Red = New().Fg(ColorRed)
|
||||
Green = New().Fg(ColorGreen)
|
||||
Yellow = New().Fg(ColorYellow)
|
||||
Cyan = New().Fg(ColorCyan)
|
||||
Blue = New().Fg(ColorBlue)
|
||||
Magenta = New().Fg(ColorMagenta)
|
||||
White = New().Fg(ColorWhite)
|
||||
Bold = New().Bold()
|
||||
Dim = New().Dim()
|
||||
)
|
||||
1
pkg/termui/completer.go
Normal file
1
pkg/termui/completer.go
Normal file
@@ -0,0 +1 @@
|
||||
package termui
|
||||
189
pkg/termui/input.go
Normal file
189
pkg/termui/input.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package termui
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"hub.gaomia.site/titor/YunShu/pkg/style"
|
||||
)
|
||||
|
||||
const (
|
||||
stdInputHandle = ^uintptr(9)
|
||||
stdOutputHandle = ^uintptr(10)
|
||||
enableProcessed = 0x0001
|
||||
enableLineInput = 0x0002
|
||||
enableEchoInput = 0x0004
|
||||
enableVtProcessing = 0x0004
|
||||
)
|
||||
|
||||
var (
|
||||
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
procGetStdHandle = kernel32.NewProc("GetStdHandle")
|
||||
procGetConsoleMode = kernel32.NewProc("GetConsoleMode")
|
||||
procSetConsoleMode = kernel32.NewProc("SetConsoleMode")
|
||||
)
|
||||
|
||||
func ensureLineMode() {
|
||||
h, _, _ := procGetStdHandle.Call(stdInputHandle)
|
||||
if h == 0 || h == uintptr(syscall.InvalidHandle) {
|
||||
return
|
||||
}
|
||||
var mode uint32
|
||||
ret, _, _ := procGetConsoleMode.Call(h, uintptr(unsafe.Pointer(&mode)))
|
||||
if ret == 0 {
|
||||
return
|
||||
}
|
||||
need := uint32(enableProcessed | enableLineInput | enableEchoInput)
|
||||
if mode&need != need {
|
||||
procSetConsoleMode.Call(h, uintptr(need))
|
||||
}
|
||||
}
|
||||
|
||||
type InputConfig struct {
|
||||
Label string
|
||||
Help string
|
||||
Default string
|
||||
Required bool
|
||||
Validator Validator
|
||||
}
|
||||
|
||||
type InputOption func(*InputConfig)
|
||||
|
||||
func WithDefault(v string) InputOption {
|
||||
return func(c *InputConfig) { c.Default = v }
|
||||
}
|
||||
|
||||
func WithHelp(v string) InputOption {
|
||||
return func(c *InputConfig) { c.Help = v }
|
||||
}
|
||||
|
||||
func WithRequired(v bool) InputOption {
|
||||
return func(c *InputConfig) { c.Required = v }
|
||||
}
|
||||
|
||||
func WithValidator(v Validator) InputOption {
|
||||
return func(c *InputConfig) { c.Validator = v }
|
||||
}
|
||||
|
||||
func applyOpts(opts []InputOption) InputConfig {
|
||||
c := InputConfig{}
|
||||
for _, o := range opts {
|
||||
o(&c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func printLabel(label string, required bool) {
|
||||
s := style.New().Bold().Render(label)
|
||||
if required {
|
||||
s += "(必填)"
|
||||
}
|
||||
fmt.Println(s)
|
||||
}
|
||||
|
||||
func ReadLine() string {
|
||||
ensureLineMode()
|
||||
r := bufio.NewReader(os.Stdin)
|
||||
s, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(s, "\r\n")
|
||||
}
|
||||
|
||||
func TextInput(label string, opts ...InputOption) string {
|
||||
cfg := applyOpts(opts)
|
||||
printLabel(label, cfg.Required)
|
||||
|
||||
for {
|
||||
fmt.Print(style.Cyan.Render("? "))
|
||||
|
||||
line := ReadLine()
|
||||
|
||||
if line == "" {
|
||||
line = cfg.Default
|
||||
}
|
||||
|
||||
var err error
|
||||
if cfg.Required && line == "" {
|
||||
err = fmt.Errorf("不能为空")
|
||||
} else if cfg.Validator != nil {
|
||||
err = cfg.Validator(line)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Println(style.Red.Render("⚠ " + err.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Println(style.Green.Render("✔ " + line))
|
||||
return line
|
||||
}
|
||||
}
|
||||
|
||||
func PasswordInput(label string, opts ...InputOption) string {
|
||||
cfg := applyOpts(opts)
|
||||
printLabel(label, cfg.Required)
|
||||
|
||||
for {
|
||||
fmt.Print(style.Cyan.Render("? "))
|
||||
|
||||
line := ReadLine()
|
||||
|
||||
fmt.Print("\033[A\r\033[K")
|
||||
|
||||
if line == "" {
|
||||
line = cfg.Default
|
||||
}
|
||||
|
||||
var err error
|
||||
if cfg.Required && line == "" {
|
||||
err = fmt.Errorf("不能为空")
|
||||
} else if cfg.Validator != nil {
|
||||
err = cfg.Validator(line)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Println(style.Red.Render("⚠ " + err.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
masked := strings.Repeat("*", len(line))
|
||||
fmt.Println(style.Green.Render("✔ " + masked))
|
||||
return line
|
||||
}
|
||||
}
|
||||
|
||||
func Confirm(label string, defaultValue bool) bool {
|
||||
hint := "Y/n"
|
||||
if !defaultValue {
|
||||
hint = "y/N"
|
||||
}
|
||||
|
||||
fmt.Print(style.Cyan.Render("?"), label, "(", hint, ")")
|
||||
|
||||
line := ReadLine()
|
||||
line = strings.ToLower(line)
|
||||
|
||||
fmt.Print("\033[A\r\033[K")
|
||||
|
||||
ok := defaultValue
|
||||
if line == "y" || line == "yes" {
|
||||
ok = true
|
||||
} else if line == "n" || line == "no" {
|
||||
ok = false
|
||||
} else if line != "" {
|
||||
ok = false
|
||||
}
|
||||
|
||||
if ok {
|
||||
fmt.Println(style.Green.Render("✔ " + label))
|
||||
} else {
|
||||
fmt.Println(style.Red.Render("✘ " + label))
|
||||
}
|
||||
return ok
|
||||
}
|
||||
67
pkg/termui/validate.go
Normal file
67
pkg/termui/validate.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package termui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Validator func(string) error
|
||||
|
||||
var (
|
||||
NonEmpty Validator = func(v string) error {
|
||||
if v == "" {
|
||||
return fmt.Errorf("不能为空")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
IsURL Validator = func(v string) error {
|
||||
if !strings.HasPrefix(v, "http://") && !strings.HasPrefix(v, "https://") {
|
||||
return fmt.Errorf("必须以 http:// 或 https:// 开头")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
)
|
||||
|
||||
func MaxLength(n int) Validator {
|
||||
return func(v string) error {
|
||||
if len(v) > n {
|
||||
return fmt.Errorf("不能超过 %d 个字符", n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func MinLength(n int) Validator {
|
||||
return func(v string) error {
|
||||
if len(v) < n {
|
||||
return fmt.Errorf("至少需要 %d 个字符", n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func And(vv ...Validator) Validator {
|
||||
return func(v string) error {
|
||||
for _, fn := range vv {
|
||||
if err := fn(v); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func Or(vv ...Validator) Validator {
|
||||
return func(v string) error {
|
||||
var lastErr error
|
||||
for _, fn := range vv {
|
||||
if err := fn(v); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user