From a20d29767c7239e1733080728f4c99ac99eb82cc Mon Sep 17 00:00:00 2001 From: Konstantinos Artopoulos Date: Thu, 7 Aug 2025 18:06:15 +0300 Subject: [PATCH 01/11] refactor(markdown): make structure a proper AST instead of a linked list --- internal/markdown/node.go | 101 ++++++++++++++++++------- internal/markdown/node_test.go | 81 ++++++++++++++++++++ internal/markdown/parser.go | 11 +-- internal/markdown/parser_test.go | 16 ++-- internal/markdown/renderer.go | 122 +++++++++++++++++-------------- 5 files changed, 241 insertions(+), 90 deletions(-) create mode 100644 internal/markdown/node_test.go diff --git a/internal/markdown/node.go b/internal/markdown/node.go index 0b11b63..f6854de 100644 --- a/internal/markdown/node.go +++ b/internal/markdown/node.go @@ -11,49 +11,56 @@ const ( NodeKindGlamour NodeKind = iota NodeKindImage NodeKindCodeBlock + NodeKindGrid + NodeKindGridColumn ) type Node interface { fmt.Stringer Kind() NodeKind - Next() Node - SetNext(Node) + Children() []Node + AddChild(Node) } func Dump(n Node) string { var b strings.Builder + dumpNode(n, 0, &b) + return b.String() +} + +func dumpNode(n Node, indent int, b *strings.Builder) { + if n == nil { + return + } - indent := 0 - for n != nil { - b.WriteString(strings.ReplaceAll(n.String(), "\n", "\\n")) + b.WriteString(strings.ReplaceAll(n.String(), "\n", "\\n")) - n = n.Next() - if n != nil { + for _, c := range n.Children() { + if c != nil { b.WriteString("\n" + strings.Repeat(" ", indent) + "└-") + indent++ } - indent++ + dumpNode(c, indent, b) } - - return b.String() } type GlamourNode struct { Text string - next Node + children []Node } func (n GlamourNode) Kind() NodeKind { return NodeKindGlamour } -func (n GlamourNode) Next() Node { - return n.next +func (n GlamourNode) Children() []Node { + return n.children } -func (n *GlamourNode) SetNext(node Node) { - n.next = node +func (n *GlamourNode) AddChild(node Node) { + n.children = append(n.children, node) } func (n GlamourNode) String() string { @@ -66,19 +73,19 @@ type ImageNode struct { Width int Height int - next Node + children []Node } func (n ImageNode) Kind() NodeKind { return NodeKindImage } -func (n ImageNode) Next() Node { - return n.next +func (n ImageNode) Children() []Node { + return n.children } -func (n *ImageNode) SetNext(node Node) { - n.next = node +func (n *ImageNode) AddChild(node Node) { + n.children = append(n.children, node) } func (n ImageNode) String() string { @@ -103,19 +110,19 @@ type CodeBlockNode struct { StartLine int Code string - next Node + children []Node } func (n CodeBlockNode) Kind() NodeKind { return NodeKindCodeBlock } -func (n CodeBlockNode) Next() Node { - return n.next +func (n CodeBlockNode) Children() []Node { + return n.children } -func (n *CodeBlockNode) SetNext(node Node) { - n.next = node +func (n *CodeBlockNode) AddChild(node Node) { + n.children = append(n.children, node) } func (n CodeBlockNode) String() string { @@ -128,3 +135,47 @@ func (n CodeBlockNode) String() string { n.Code, ) } + +type GridNode struct { + ColumnCount int + + children []Node +} + +func (n GridNode) Kind() NodeKind { + return NodeKindGrid +} + +func (n GridNode) Children() []Node { + return n.children +} + +func (n *GridNode) AddChild(node Node) { + n.children = append(n.children, node) +} + +func (n GridNode) String() string { + return fmt.Sprintf(`GridNode(ColumnCount: %d)`, n.ColumnCount) +} + +type GridColumnNode struct { + Span int + + children []Node +} + +func (n GridColumnNode) Kind() NodeKind { + return NodeKindGridColumn +} + +func (n GridColumnNode) Children() []Node { + return n.children +} + +func (n *GridColumnNode) AddChild(node Node) { + n.children = append(n.children, node) +} + +func (n GridColumnNode) String() string { + return fmt.Sprintf(`GridColumnNode(Span: %d)`, n.Span) +} diff --git a/internal/markdown/node_test.go b/internal/markdown/node_test.go new file mode 100644 index 0000000..820c4d4 --- /dev/null +++ b/internal/markdown/node_test.go @@ -0,0 +1,81 @@ +package markdown + +import "testing" + +func TestDump(t *testing.T) { + tests := []struct { + name string + root Node + want string + }{ + { + name: "simple", + root: &GlamourNode{ + Text: "test", + children: []Node{ + &ImageNode{ + Label: "img", + Path: "./image.png", + Width: 100, + Height: 50, + children: []Node{&GlamourNode{ + Text: "test2", + }}, + }, + }}, + want: `GlamourNode(Text: "test") +└-ImageNode(Label: "img", Path: "./image.png", Width: 100, Height: 50) + └-GlamourNode(Text: "test2")`, + }, + { + name: "grid", + root: &GlamourNode{ + Text: "test", + children: []Node{ + &GridNode{ + ColumnCount: 3, + children: []Node{ + &GridColumnNode{ + Span: 1, + children: []Node{ + &GlamourNode{ + Text: "Col1", + children: []Node{&GlamourNode{Text: "Col1Nest"}}, + }, + &GlamourNode{Text: "Col1a"}, + }, + }, + &GridColumnNode{ + Span: 2, + children: []Node{ + &GlamourNode{Text: "Col2"}, + &GlamourNode{Text: "Col2a"}, + }, + }, + &GlamourNode{ + Text: "test2", + }, + }, + }}}, + want: `GlamourNode(Text: "test") +└-GridNode(ColumnNum: 3) + |-GridColumnNode(Span: 1) + | |-GlamourNode(Text: "Col1") + | | └-GlamourNode(Text: "Col1Nest") + | └-GlamourNode(Text: "Col1a") + |-GridColumnNode(Span: 2) + | |-GlamourNode(Text: "Col2") + | └-GlamourNode(Text: "Col2a") + └-GlamourNode(Text: "test2")`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Dump(tt.root) + if got != tt.want { + t.Errorf("Dump() got:\n%s\nwant:\n%s", got, tt.want) + } + }) + } + +} diff --git a/internal/markdown/parser.go b/internal/markdown/parser.go index ad6d322..ae7eef6 100644 --- a/internal/markdown/parser.go +++ b/internal/markdown/parser.go @@ -88,8 +88,9 @@ func (p MarkdownParser) Parse(in []byte) Node { root = &GlamourNode{Text: chunk.String()} curr = root } else { - curr.SetNext(&GlamourNode{Text: chunk.String()}) - curr = curr.Next() + node := &GlamourNode{Text: chunk.String()} + curr.AddChild(node) + curr = node } } @@ -99,8 +100,8 @@ func (p MarkdownParser) Parse(in []byte) Node { root = n curr = root } else { - curr.SetNext(n) - curr = curr.Next() + curr.AddChild(n) + curr = n } parsed = true @@ -114,7 +115,7 @@ func (p MarkdownParser) Parse(in []byte) Node { if root == nil { root = &GlamourNode{Text: chunk.String()} } else { - curr.SetNext(&GlamourNode{Text: chunk.String()}) + curr.AddChild(&GlamourNode{Text: chunk.String()}) } } diff --git a/internal/markdown/parser_test.go b/internal/markdown/parser_test.go index e58cf66..b986cd5 100644 --- a/internal/markdown/parser_test.go +++ b/internal/markdown/parser_test.go @@ -18,10 +18,10 @@ func TestMarkdownParser_Parse(t *testing.T) { in: []byte("# This is a string\n![alt text](./image.png)"), want: &GlamourNode{ Text: "# This is a string\n", - next: &ImageNode{ + children: []Node{&ImageNode{ Label: "alt text", Path: "./image.png", - }, + }}, }, }, { @@ -29,11 +29,13 @@ func TestMarkdownParser_Parse(t *testing.T) { in: []byte("# This is a string\n![alt text](./image.png)\n> Some other string"), want: &GlamourNode{ Text: "# This is a string\n", - next: &ImageNode{ - Label: "alt text", - Path: "./image.png", - next: &GlamourNode{ - Text: "\n> Some other string", + children: []Node{ + &ImageNode{ + Label: "alt text", + Path: "./image.png", + children: []Node{&GlamourNode{ + Text: "\n> Some other string", + }}, }, }, }, diff --git a/internal/markdown/renderer.go b/internal/markdown/renderer.go index 98d9e03..6649725 100644 --- a/internal/markdown/renderer.go +++ b/internal/markdown/renderer.go @@ -67,75 +67,91 @@ func (r *Renderer) RenderBytes(in []byte, animating bool) (string, error) { b.WriteString("\x1b_Ga=d\x1b\\") } - for n := r.parser.Parse(in); n != nil; n = n.Next() { - switch n.Kind() { - case NodeKindGlamour: - n := n.(*GlamourNode) - out, err := r.tr.Render(n.Text) - if err != nil { - return "", err - } - b.WriteString(out) + if err := r.renderNode(r.parser.Parse(in), animating, &b); err != nil { + return "", err + } - case NodeKindImage: - n := n.(*ImageNode) + return b.String(), nil +} - limg, err := r.options.imgBackend.Render(n.Path, n.Width, n.Height, true) - if err != nil { - b.WriteString(fmt.Sprintf("[Error rendering image: %s]", n.Label)) - continue - } +func (r *Renderer) renderNode(n Node, animating bool, b *strings.Builder) error { + if n == nil { + return nil + } - if r.options.imgBackend.SymbolsOnly() { - b.WriteString(limg) - continue - } + switch n.Kind() { + case NodeKindGlamour: + n := n.(*GlamourNode) + out, err := r.tr.Render(n.Text) + if err != nil { + return err + } + b.WriteString(out) - himg, err := r.options.imgBackend.Render(n.Path, n.Width, n.Height, false) - if err != nil { - b.WriteString(fmt.Sprintf("[Error rendering image: %s]", n.Label)) - continue - } + case NodeKindImage: + n := n.(*ImageNode) - if !animating { - b.WriteString(ansi.SaveCursor) - b.WriteString(limg) - b.WriteString(ansi.RestoreCursor) - b.WriteString(himg) - } else { - b.WriteString(limg) - } + limg, err := r.options.imgBackend.Render(n.Path, n.Width, n.Height, true) + if err != nil { + fmt.Fprintf(b, "[Error rendering image: %s]", n.Label) + break + } - case NodeKindCodeBlock: - n := n.(*CodeBlockNode) + if r.options.imgBackend.SymbolsOnly() { + b.WriteString(limg) + break + } + + himg, err := r.options.imgBackend.Render(n.Path, n.Width, n.Height, false) + if err != nil { + fmt.Fprintf(b, "[Error rendering image: %s]", n.Label) + break + } + + if !animating { + b.WriteString(ansi.SaveCursor) + b.WriteString(limg) + b.WriteString(ansi.RestoreCursor) + b.WriteString(himg) + } else { + b.WriteString(limg) + } - lines := strings.Split(n.Code, "\n") + case NodeKindCodeBlock: + n := n.(*CodeBlockNode) - var renderedContent string - if n.Language != "" { - lexer := lexers.Get(n.Language) - if lexer == nil { - lexer = lexers.Fallback - } - lexer = chroma.Coalesce(lexer) - style := config.GetChromaStyle(r.options.theme) + lines := strings.Split(n.Code, "\n") - renderedContent = r.renderHighlightedCode(n.Code, lines, n, lexer, style) - } else { - renderedContent = r.renderPlainCode(lines, n) + var renderedContent string + if n.Language != "" { + lexer := lexers.Get(n.Language) + if lexer == nil { + lexer = lexers.Fallback } + lexer = chroma.Coalesce(lexer) + style := config.GetChromaStyle(r.options.theme) - // Apply consistent styling - codeStyle := lipgloss.NewStyle().Width(78) + renderedContent = r.renderHighlightedCode(n.Code, lines, n, lexer, style) + } else { + renderedContent = r.renderPlainCode(lines, n) + } + + // Apply consistent styling + codeStyle := lipgloss.NewStyle().Width(78) - b.WriteString(codeStyle.Render(renderedContent)) + b.WriteString(codeStyle.Render(renderedContent)) - default: - return "", fmt.Errorf("invalid node kind: %d", n.Kind()) + default: + return fmt.Errorf("invalid node kind: %d", n.Kind()) + } + + for _, c := range n.Children() { + if err := r.renderNode(c, animating, b); err != nil { + return err } } - return b.String(), nil + return nil } func WithImageBackend(backend string) RendererOption { From d3d5edeb65c7a09556ee3f3596892750e26cb219 Mon Sep 17 00:00:00 2001 From: Konstantinos Artopoulos Date: Thu, 14 Aug 2025 12:42:29 +0300 Subject: [PATCH 02/11] feat(markdown): propertly render the tree with Dump --- internal/markdown/node.go | 22 +++++++++++++++------- internal/markdown/node_test.go | 2 +- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/internal/markdown/node.go b/internal/markdown/node.go index f6854de..428c169 100644 --- a/internal/markdown/node.go +++ b/internal/markdown/node.go @@ -25,23 +25,31 @@ type Node interface { func Dump(n Node) string { var b strings.Builder - dumpNode(n, 0, &b) + dumpNode(n, "", &b) return b.String() } -func dumpNode(n Node, indent int, b *strings.Builder) { +func dumpNode(n Node, prefix string, b *strings.Builder) { if n == nil { return } b.WriteString(strings.ReplaceAll(n.String(), "\n", "\\n")) - for _, c := range n.Children() { - if c != nil { - b.WriteString("\n" + strings.Repeat(" ", indent) + "└-") - indent++ + for i, c := range n.Children() { + if c == nil { + continue + } + + b.WriteString("\n" + prefix) + + if i == len(n.Children())-1 { + b.WriteString("└-") + dumpNode(c, prefix+" ", b) + } else { + b.WriteString("|-") + dumpNode(c, prefix+"| ", b) } - dumpNode(c, indent, b) } } diff --git a/internal/markdown/node_test.go b/internal/markdown/node_test.go index 820c4d4..d65c32f 100644 --- a/internal/markdown/node_test.go +++ b/internal/markdown/node_test.go @@ -58,7 +58,7 @@ func TestDump(t *testing.T) { }, }}}, want: `GlamourNode(Text: "test") -└-GridNode(ColumnNum: 3) +└-GridNode(ColumnCount: 3) |-GridColumnNode(Span: 1) | |-GlamourNode(Text: "Col1") | | └-GlamourNode(Text: "Col1Nest") From 2aaf8ffd0a046bbe28c25a408540c1d2ff8687b0 Mon Sep 17 00:00:00 2001 From: Konstantinos Artopoulos Date: Fri, 15 Aug 2025 00:20:05 +0300 Subject: [PATCH 03/11] feat(markdown): create a MarkdownRootNode and propertly make nodes be on the same level on the tree --- internal/markdown/node.go | 25 ++++++++++++++- internal/markdown/node_test.go | 55 +++++++++++++++----------------- internal/markdown/parser.go | 38 ++++++---------------- internal/markdown/parser_test.go | 36 ++++++++++----------- internal/markdown/renderer.go | 3 ++ 5 files changed, 79 insertions(+), 78 deletions(-) diff --git a/internal/markdown/node.go b/internal/markdown/node.go index 428c169..da49249 100644 --- a/internal/markdown/node.go +++ b/internal/markdown/node.go @@ -8,7 +8,8 @@ import ( type NodeKind uint8 const ( - NodeKindGlamour NodeKind = iota + NodeKindMarkdownRoot NodeKind = iota + NodeKindGlamour NodeKindImage NodeKindCodeBlock NodeKindGrid @@ -53,6 +54,26 @@ func dumpNode(n Node, prefix string, b *strings.Builder) { } } +type MarkdownRootNode struct { + children []Node +} + +func (n MarkdownRootNode) Kind() NodeKind { + return NodeKindMarkdownRoot +} + +func (n MarkdownRootNode) Children() []Node { + return n.children +} + +func (n *MarkdownRootNode) AddChild(node Node) { + n.children = append(n.children, node) +} + +func (n MarkdownRootNode) String() string { + return "MarkdownRootNode()" +} + type GlamourNode struct { Text string @@ -148,6 +169,7 @@ type GridNode struct { ColumnCount int children []Node + closing bool } func (n GridNode) Kind() NodeKind { @@ -170,6 +192,7 @@ type GridColumnNode struct { Span int children []Node + closing bool } func (n GridColumnNode) Kind() NodeKind { diff --git a/internal/markdown/node_test.go b/internal/markdown/node_test.go index d65c32f..517ac9c 100644 --- a/internal/markdown/node_test.go +++ b/internal/markdown/node_test.go @@ -10,38 +10,35 @@ func TestDump(t *testing.T) { }{ { name: "simple", - root: &GlamourNode{ - Text: "test", + root: &MarkdownRootNode{ children: []Node{ + &GlamourNode{Text: "test"}, &ImageNode{ Label: "img", Path: "./image.png", Width: 100, Height: 50, - children: []Node{&GlamourNode{ - Text: "test2", - }}, }, - }}, - want: `GlamourNode(Text: "test") -└-ImageNode(Label: "img", Path: "./image.png", Width: 100, Height: 50) - └-GlamourNode(Text: "test2")`, + &GlamourNode{Text: "test2"}, + }, + }, + want: `MarkdownRootNode() +|-GlamourNode(Text: "test") +|-ImageNode(Label: "img", Path: "./image.png", Width: 100, Height: 50) +└-GlamourNode(Text: "test2")`, }, { name: "grid", - root: &GlamourNode{ - Text: "test", + root: &MarkdownRootNode{ children: []Node{ + &GlamourNode{Text: "test"}, &GridNode{ ColumnCount: 3, children: []Node{ &GridColumnNode{ Span: 1, children: []Node{ - &GlamourNode{ - Text: "Col1", - children: []Node{&GlamourNode{Text: "Col1Nest"}}, - }, + &GlamourNode{Text: "Col1"}, &GlamourNode{Text: "Col1a"}, }, }, @@ -52,21 +49,21 @@ func TestDump(t *testing.T) { &GlamourNode{Text: "Col2a"}, }, }, - &GlamourNode{ - Text: "test2", - }, }, - }}}, - want: `GlamourNode(Text: "test") -└-GridNode(ColumnCount: 3) - |-GridColumnNode(Span: 1) - | |-GlamourNode(Text: "Col1") - | | └-GlamourNode(Text: "Col1Nest") - | └-GlamourNode(Text: "Col1a") - |-GridColumnNode(Span: 2) - | |-GlamourNode(Text: "Col2") - | └-GlamourNode(Text: "Col2a") - └-GlamourNode(Text: "test2")`, + }, + &GlamourNode{Text: "test2"}, + }, + }, + want: `MarkdownRootNode() +|-GlamourNode(Text: "test") +|-GridNode(ColumnCount: 3) +| |-GridColumnNode(Span: 1) +| | |-GlamourNode(Text: "Col1") +| | └-GlamourNode(Text: "Col1a") +| └-GridColumnNode(Span: 2) +| |-GlamourNode(Text: "Col2") +| └-GlamourNode(Text: "Col2a") +└-GlamourNode(Text: "test2")`, }, } for _, tt := range tests { diff --git a/internal/markdown/parser.go b/internal/markdown/parser.go index ae7eef6..9ecfd2c 100644 --- a/internal/markdown/parser.go +++ b/internal/markdown/parser.go @@ -45,19 +45,18 @@ func (p *MarkdownParser) Register(parser PrioritizedValue[Parser]) { } } -// Parse processes the input byte-by-byte and constructs a [Node] list, +// Parse processes the input byte-by-byte and constructs a [Node] tree, // starting from a root [Node]. For each byte, it checks for registered Parsers // triggered by that byte and attempts to parse using them. If no parser succeeds, // the byte is added to a buffer. Buffered text is eventually wrapped in a // [GlamourNode], the default [Node] type. func (p MarkdownParser) Parse(in []byte) Node { r := bytes.NewReader(in) + return p.parse(r, &MarkdownRootNode{}) +} - var ( - root Node - curr Node - chunk bytes.Buffer - ) +func (p MarkdownParser) parse(r *bytes.Reader, root Node) Node { + var chunk bytes.Buffer for { b, err := r.ReadByte() @@ -84,26 +83,11 @@ func (p MarkdownParser) Parse(in []byte) Node { } if chunk.String() != "" { - if root == nil { - root = &GlamourNode{Text: chunk.String()} - curr = root - } else { - node := &GlamourNode{Text: chunk.String()} - curr.AddChild(node) - curr = node - } - } - - chunk.Reset() - - if root == nil { - root = n - curr = root - } else { - curr.AddChild(n) - curr = n + root.AddChild(&GlamourNode{Text: chunk.String()}) + chunk.Reset() } + root.AddChild(n) parsed = true } if !parsed { @@ -112,11 +96,7 @@ func (p MarkdownParser) Parse(in []byte) Node { } if chunk.String() != "" { - if root == nil { - root = &GlamourNode{Text: chunk.String()} - } else { - curr.AddChild(&GlamourNode{Text: chunk.String()}) - } + root.AddChild(&GlamourNode{Text: chunk.String()}) } return root diff --git a/internal/markdown/parser_test.go b/internal/markdown/parser_test.go index b986cd5..6a2770a 100644 --- a/internal/markdown/parser_test.go +++ b/internal/markdown/parser_test.go @@ -11,41 +11,39 @@ func TestMarkdownParser_Parse(t *testing.T) { { name: "Basic single node", in: []byte("# This is a string"), - want: &GlamourNode{Text: "# This is a string"}, + want: &MarkdownRootNode{children: []Node{ + &GlamourNode{Text: "# This is a string"}, + }}, }, { name: "Text followed by image", in: []byte("# This is a string\n![alt text](./image.png)"), - want: &GlamourNode{ - Text: "# This is a string\n", - children: []Node{&ImageNode{ - Label: "alt text", - Path: "./image.png", - }}, + want: &MarkdownRootNode{ + children: []Node{ + &GlamourNode{Text: "# This is a string\n"}, + &ImageNode{Label: "alt text", Path: "./image.png"}, + }, }, }, { name: "Image in between text", in: []byte("# This is a string\n![alt text](./image.png)\n> Some other string"), - want: &GlamourNode{ - Text: "# This is a string\n", + want: &MarkdownRootNode{ children: []Node{ - &ImageNode{ - Label: "alt text", - Path: "./image.png", - children: []Node{&GlamourNode{ - Text: "\n> Some other string", - }}, - }, + &GlamourNode{Text: "# This is a string\n"}, + &ImageNode{Label: "alt text", Path: "./image.png"}, + &GlamourNode{Text: "\n> Some other string"}, }, }, }, { name: "Try parse invalid image node", in: []byte("# This is a string\n![not_an_image\n> Some other string"), - want: &GlamourNode{ - Text: "# This is a string\n![not_an_image\n> Some other string", - }, + want: &MarkdownRootNode{children: []Node{ + &GlamourNode{ + Text: "# This is a string\n![not_an_image\n> Some other string", + }, + }}, }, } for _, tt := range tests { diff --git a/internal/markdown/renderer.go b/internal/markdown/renderer.go index 6649725..bf5f79a 100644 --- a/internal/markdown/renderer.go +++ b/internal/markdown/renderer.go @@ -80,6 +80,9 @@ func (r *Renderer) renderNode(n Node, animating bool, b *strings.Builder) error } switch n.Kind() { + case NodeKindMarkdownRoot: + break + case NodeKindGlamour: n := n.(*GlamourNode) out, err := r.tr.Render(n.Text) From b64222fc9f3e2f3ac180845a3e1e5725f1fd22b5 Mon Sep 17 00:00:00 2001 From: Konstantinos Artopoulos Date: Sun, 17 Aug 2025 13:25:25 +0300 Subject: [PATCH 04/11] refactor(markdown): remove "Node" suffix from node `String()` representations --- internal/markdown/node.go | 14 ++++++-------- internal/markdown/node_test.go | 28 ++++++++++++++-------------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/internal/markdown/node.go b/internal/markdown/node.go index da49249..aaa86b3 100644 --- a/internal/markdown/node.go +++ b/internal/markdown/node.go @@ -71,7 +71,7 @@ func (n *MarkdownRootNode) AddChild(node Node) { } func (n MarkdownRootNode) String() string { - return "MarkdownRootNode()" + return "MarkdownRoot()" } type GlamourNode struct { @@ -93,7 +93,7 @@ func (n *GlamourNode) AddChild(node Node) { } func (n GlamourNode) String() string { - return fmt.Sprintf(`GlamourNode(Text: "%s")`, n.Text) + return fmt.Sprintf(`Glamour(Text: "%s")`, n.Text) } type ImageNode struct { @@ -119,7 +119,7 @@ func (n *ImageNode) AddChild(node Node) { func (n ImageNode) String() string { return fmt.Sprintf( - `ImageNode(Label: "%s", Path: "%s", Width: %d, Height: %d)`, + `Image(Label: "%s", Path: "%s", Width: %d, Height: %d)`, n.Label, n.Path, n.Width, @@ -156,7 +156,7 @@ func (n *CodeBlockNode) AddChild(node Node) { func (n CodeBlockNode) String() string { return fmt.Sprintf( - `CodeBlockNode(Language: %s, Ranges: %v, ShowLineNumbers: %t, StartLine: %d, Code: %s)`, + `CodeBlock(Language: %s, Ranges: %v, ShowLineNumbers: %t, StartLine: %d, Code: %s)`, n.Language, n.Ranges, n.ShowLineNumbers, @@ -169,7 +169,6 @@ type GridNode struct { ColumnCount int children []Node - closing bool } func (n GridNode) Kind() NodeKind { @@ -185,14 +184,13 @@ func (n *GridNode) AddChild(node Node) { } func (n GridNode) String() string { - return fmt.Sprintf(`GridNode(ColumnCount: %d)`, n.ColumnCount) + return fmt.Sprintf(`Grid(ColumnCount: %d)`, n.ColumnCount) } type GridColumnNode struct { Span int children []Node - closing bool } func (n GridColumnNode) Kind() NodeKind { @@ -208,5 +206,5 @@ func (n *GridColumnNode) AddChild(node Node) { } func (n GridColumnNode) String() string { - return fmt.Sprintf(`GridColumnNode(Span: %d)`, n.Span) + return fmt.Sprintf(`GridColumn(Span: %d)`, n.Span) } diff --git a/internal/markdown/node_test.go b/internal/markdown/node_test.go index 517ac9c..1da9e9d 100644 --- a/internal/markdown/node_test.go +++ b/internal/markdown/node_test.go @@ -22,10 +22,10 @@ func TestDump(t *testing.T) { &GlamourNode{Text: "test2"}, }, }, - want: `MarkdownRootNode() -|-GlamourNode(Text: "test") -|-ImageNode(Label: "img", Path: "./image.png", Width: 100, Height: 50) -└-GlamourNode(Text: "test2")`, + want: `MarkdownRoot() +|-Glamour(Text: "test") +|-Image(Label: "img", Path: "./image.png", Width: 100, Height: 50) +└-Glamour(Text: "test2")`, }, { name: "grid", @@ -54,16 +54,16 @@ func TestDump(t *testing.T) { &GlamourNode{Text: "test2"}, }, }, - want: `MarkdownRootNode() -|-GlamourNode(Text: "test") -|-GridNode(ColumnCount: 3) -| |-GridColumnNode(Span: 1) -| | |-GlamourNode(Text: "Col1") -| | └-GlamourNode(Text: "Col1a") -| └-GridColumnNode(Span: 2) -| |-GlamourNode(Text: "Col2") -| └-GlamourNode(Text: "Col2a") -└-GlamourNode(Text: "test2")`, + want: `MarkdownRoot() +|-Glamour(Text: "test") +|-Grid(ColumnCount: 3) +| |-GridColumn(Span: 1) +| | |-Glamour(Text: "Col1") +| | └-Glamour(Text: "Col1a") +| └-GridColumn(Span: 2) +| |-Glamour(Text: "Col2") +| └-Glamour(Text: "Col2a") +└-Glamour(Text: "test2")`, }, } for _, tt := range tests { From 863d6c5a63b8a074b972cd28d513167f983c08d2 Mon Sep 17 00:00:00 2001 From: Konstantinos Artopoulos Date: Mon, 18 Aug 2025 12:34:54 +0300 Subject: [PATCH 05/11] feat(markdown): provide a reference of MarkdownParser to parsers to enable recursive parsing --- internal/markdown/codeblock_parser.go | 2 +- internal/markdown/codeblock_parser_test.go | 2 +- internal/markdown/image_parser.go | 2 +- internal/markdown/image_parser_test.go | 2 +- internal/markdown/parser.go | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/markdown/codeblock_parser.go b/internal/markdown/codeblock_parser.go index a96cbe4..7fbebe3 100644 --- a/internal/markdown/codeblock_parser.go +++ b/internal/markdown/codeblock_parser.go @@ -33,7 +33,7 @@ func (p CodeBlockParser) Trigger() []byte { // return 0; // } // ``` -func (p *CodeBlockParser) Parse(r *bytes.Reader) Node { +func (p *CodeBlockParser) Parse(r *bytes.Reader, _ *MarkdownParser) Node { for range 2 { if b, err := r.ReadByte(); err != nil || b != '`' { slog.Warn("failed to parse codeblock node") diff --git a/internal/markdown/codeblock_parser_test.go b/internal/markdown/codeblock_parser_test.go index 270b4bc..f911717 100644 --- a/internal/markdown/codeblock_parser_test.go +++ b/internal/markdown/codeblock_parser_test.go @@ -60,7 +60,7 @@ func TestCodeblockParser_Parse(t *testing.T) { t.Run(tt.name, func(t *testing.T) { p := NewCodeBlockParser() - got := p.Parse(bytes.NewReader(tt.in)) + got := p.Parse(bytes.NewReader(tt.in), nil) if Dump(got) != Dump(tt.want) { t.Errorf("Parse() got:\n%s\nwant:\n%s", Dump(got), Dump(tt.want)) } diff --git a/internal/markdown/image_parser.go b/internal/markdown/image_parser.go index fbeee4e..40f03cb 100644 --- a/internal/markdown/image_parser.go +++ b/internal/markdown/image_parser.go @@ -21,7 +21,7 @@ func (p ImageParser) Trigger() []byte { // Parse attempts to extract an [ImageNode] from the input, matching the markdown // image syntax: [alt text|widthxheight](image-path) where width and height // values are optional. -func (p ImageParser) Parse(r *bytes.Reader) Node { +func (p ImageParser) Parse(r *bytes.Reader, _ *MarkdownParser) Node { var altText bytes.Buffer b, err := r.ReadByte() diff --git a/internal/markdown/image_parser_test.go b/internal/markdown/image_parser_test.go index 34fe609..b9191f3 100644 --- a/internal/markdown/image_parser_test.go +++ b/internal/markdown/image_parser_test.go @@ -49,7 +49,7 @@ func TestImageParser_Parse(t *testing.T) { t.Run(tt.name, func(t *testing.T) { p := NewImageParser() - got := p.Parse(bytes.NewReader(tt.in)) + got := p.Parse(bytes.NewReader(tt.in), nil) if Dump(got) != Dump(tt.want) { t.Errorf("Parse() got:\n%s\nwant:\n%s", Dump(got), Dump(tt.want)) } diff --git a/internal/markdown/parser.go b/internal/markdown/parser.go index 9ecfd2c..2f33e48 100644 --- a/internal/markdown/parser.go +++ b/internal/markdown/parser.go @@ -18,7 +18,7 @@ type Parser interface { // Parse attempts to parse a [Node] from the reader. It should return nil if // parsing fails, allowing [MarkdownParser.Parse] to try the next parser. // Note: The trigger byte has already been consumed before calling Parse. - Parse(r *bytes.Reader) Node + Parse(r *bytes.Reader, m *MarkdownParser) Node } // MarkdownParser is an extensible parser that converts a markdown string into @@ -76,7 +76,7 @@ func (p MarkdownParser) parse(r *bytes.Reader, root Node) Node { parsed := false for _, parser := range parsers { markedPos, _ := r.Seek(0, io.SeekCurrent) - n := parser.Value.Parse(r) + n := parser.Value.Parse(r, &p) if n == nil { _, _ = r.Seek(markedPos, io.SeekStart) continue From eee05b7d3db425a65958c6ff01d304b1bd24cbff Mon Sep 17 00:00:00 2001 From: Konstantinos Artopoulos Date: Thu, 21 Aug 2025 18:47:49 +0300 Subject: [PATCH 06/11] feat(markdown): seperate parsing of a single node to allow reuse from other parsers --- internal/markdown/parser.go | 52 ++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/internal/markdown/parser.go b/internal/markdown/parser.go index 2f33e48..5dc77ac 100644 --- a/internal/markdown/parser.go +++ b/internal/markdown/parser.go @@ -46,18 +46,15 @@ func (p *MarkdownParser) Register(parser PrioritizedValue[Parser]) { } // Parse processes the input byte-by-byte and constructs a [Node] tree, -// starting from a root [Node]. For each byte, it checks for registered Parsers +// starting from a [MarkdownRootNode]. For each byte, it checks for registered Parsers // triggered by that byte and attempts to parse using them. If no parser succeeds, // the byte is added to a buffer. Buffered text is eventually wrapped in a // [GlamourNode], the default [Node] type. func (p MarkdownParser) Parse(in []byte) Node { r := bytes.NewReader(in) - return p.parse(r, &MarkdownRootNode{}) -} + root := &MarkdownRootNode{} -func (p MarkdownParser) parse(r *bytes.Reader, root Node) Node { var chunk bytes.Buffer - for { b, err := r.ReadByte() if err != nil { @@ -67,32 +64,17 @@ func (p MarkdownParser) parse(r *bytes.Reader, root Node) Node { slog.Error("failed advancing reader", slog.Any("error", err)) } - parsers, ok := p.registry[b] - if !ok { + n := p.parseNode(r, b) + if n == nil { chunk.WriteByte(b) continue } - parsed := false - for _, parser := range parsers { - markedPos, _ := r.Seek(0, io.SeekCurrent) - n := parser.Value.Parse(r, &p) - if n == nil { - _, _ = r.Seek(markedPos, io.SeekStart) - continue - } - - if chunk.String() != "" { - root.AddChild(&GlamourNode{Text: chunk.String()}) - chunk.Reset() - } - - root.AddChild(n) - parsed = true - } - if !parsed { - chunk.WriteByte(b) + if chunk.String() != "" { + root.AddChild(&GlamourNode{Text: chunk.String()}) + chunk.Reset() } + root.AddChild(n) } if chunk.String() != "" { @@ -101,3 +83,21 @@ func (p MarkdownParser) parse(r *bytes.Reader, root Node) Node { return root } + +func (p MarkdownParser) parseNode(r *bytes.Reader, b byte) Node { + parsers, ok := p.registry[b] + if !ok { + return nil + } + + for _, parser := range parsers { + markedPos, _ := r.Seek(0, io.SeekCurrent) + n := parser.Value.Parse(r, &p) + if n != nil { + return n + } + _, _ = r.Seek(markedPos, io.SeekStart) + } + + return nil +} From 56c13dd87f076b61267bba3f825e0452c3e6ca45 Mon Sep 17 00:00:00 2001 From: Konstantinos Artopoulos Date: Thu, 21 Aug 2025 18:48:11 +0300 Subject: [PATCH 07/11] feat(markdown): grid parser --- internal/markdown/grid_parser.go | 174 ++++++++++++++++++++++++++ internal/markdown/grid_parser_test.go | 82 ++++++++++++ internal/markdown/node.go | 3 + internal/markdown/parser_test.go | 39 ++++++ 4 files changed, 298 insertions(+) create mode 100644 internal/markdown/grid_parser.go create mode 100644 internal/markdown/grid_parser_test.go diff --git a/internal/markdown/grid_parser.go b/internal/markdown/grid_parser.go new file mode 100644 index 0000000..8458e2e --- /dev/null +++ b/internal/markdown/grid_parser.go @@ -0,0 +1,174 @@ +package markdown + +import ( + "bytes" + "io" + "log/slog" + "strings" +) + +type GridParser struct{} + +func NewGridParser() *GridParser { + return &GridParser{} +} + +func (p GridParser) Trigger() []byte { + return []byte{'['} +} + +func (p GridParser) Parse(r *bytes.Reader, m *MarkdownParser) Node { + gridNode := &GridNode{} + + if ok, err := isTag(r, "grid"); !ok || err != nil { + return nil + } + + if err := consumeWhitespace(r); err != nil { + slog.Warn("failed to consume whitespace", slog.Any("error", err)) + return nil + } + + var chunk bytes.Buffer + for { + b, err := r.ReadByte() + if err != nil { + slog.Warn("failed to advance reader", slog.Any("error", err)) + return nil + } + + if b == '[' { + marked, _ := r.Seek(0, io.SeekCurrent) + ok, err := isTag(r, "/grid") + if err != nil { + return nil + } + if ok { + if err := consumeWhitespace(r); err != nil { + slog.Warn("failed to consume whitespace", slog.Any("error", err)) + return nil + } + break + } + _, _ = r.Seek(marked, io.SeekStart) + } + + n := m.parseNode(r, b) + if n == nil { + chunk.WriteByte(b) + continue + } + + if c := strings.Trim(chunk.String(), " \n"); c != "" { + gridNode.AddChild(&GlamourNode{Text: chunk.String()}) + chunk.Reset() + } + gridNode.AddChild(n) + } + + if c := strings.Trim(chunk.String(), " \n"); c != "" { + gridNode.AddChild(&GlamourNode{Text: chunk.String()}) + } + + return gridNode +} + +type GridColumnParser struct{} + +func NewGridColumnParser() *GridColumnParser { + return &GridColumnParser{} +} + +func (p GridColumnParser) Trigger() []byte { + return []byte{'['} +} + +func (p GridColumnParser) Parse(r *bytes.Reader, m *MarkdownParser) Node { + columnNode := &GridColumnNode{} + + if ok, err := isTag(r, "column"); !ok || err != nil { + return nil + } + + if err := consumeWhitespace(r); err != nil { + slog.Warn("failed to consume whitespace", slog.Any("error", err)) + return nil + } + + var chunk bytes.Buffer + for { + b, err := r.ReadByte() + if err != nil { + slog.Warn("failed to advance reader", slog.Any("error", err)) + return nil + } + + if b == '[' { + marked, _ := r.Seek(0, io.SeekCurrent) + ok, err := isTag(r, "/column") + if err != nil { + return nil + } + if ok { + if err := consumeWhitespace(r); err != nil { + slog.Warn("failed to consume whitespace", slog.Any("error", err)) + return nil + } + break + } + _, _ = r.Seek(marked, io.SeekStart) + } + + n := m.parseNode(r, b) + if n == nil { + chunk.WriteByte(b) + continue + } + + if c := strings.Trim(chunk.String(), " \n"); c != "" { + columnNode.AddChild(&GlamourNode{Text: c}) + chunk.Reset() + } + columnNode.AddChild(n) + } + + if c := strings.Trim(chunk.String(), " \n"); c != "" { + columnNode.AddChild(&GlamourNode{Text: c}) + } + + return columnNode +} + +func isTag(r *bytes.Reader, tagName string) (bool, error) { + var ( + tag bytes.Buffer + b byte + err error + ) + + for b != ']' { + b, err = r.ReadByte() + if err != nil { + slog.Warn("failed to advance reader", slog.Any("error", err)) + return false, err + } + + tag.WriteByte(b) + } + + return strings.Trim(tag.String(), "[]") == tagName, nil +} + +func consumeWhitespace(r *bytes.Reader) error { + for { + b, err := r.ReadByte() + if err != nil { + slog.Warn("failed to advance reader", slog.Any("error", err)) + return nil + } + + if b != '\n' && b != ' ' { + return r.UnreadByte() + } + } +} diff --git a/internal/markdown/grid_parser_test.go b/internal/markdown/grid_parser_test.go new file mode 100644 index 0000000..cfb3521 --- /dev/null +++ b/internal/markdown/grid_parser_test.go @@ -0,0 +1,82 @@ +package markdown + +import ( + "bytes" + "testing" +) + +func TestGridParser_Parse(t *testing.T) { + tests := []struct { + name string + in []byte + want Node + }{ + { + name: "simple grid", + in: []byte("[grid][column]Some text[/column][column]More text[/column][/grid]"), + want: &GridNode{ + children: []Node{ + &GridColumnNode{ + children: []Node{&GlamourNode{Text: "Some text"}}, + }, + &GridColumnNode{ + children: []Node{&GlamourNode{Text: "More text"}}, + }, + }, + }, + }, + { + name: "nested grid", + in: []byte(`[grid] +[column]Some text[/column] + +[column] +[grid] +[column]Nested text[/column] +[column]Nested text 2[/column] +[/grid] +[/column] +[/grid]`), + want: &GridNode{ + children: []Node{ + &GridColumnNode{ + children: []Node{&GlamourNode{Text: "Some text"}}, + }, + &GridColumnNode{ + children: []Node{ + &GridNode{ + children: []Node{ + &GridColumnNode{ + children: []Node{ + &GlamourNode{Text: "Nested text"}, + }, + }, + &GridColumnNode{ + children: []Node{ + &GlamourNode{Text: "Nested text 2"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := NewGridParser() + m := NewMarkdownParser() + + m.Register(Prioritized[Parser](NewGridParser(), 1)) + m.Register(Prioritized[Parser](NewGridColumnParser(), 2)) + + got := p.Parse(bytes.NewReader(tt.in), m) + if Dump(got) != Dump(tt.want) { + t.Errorf("Parse() got:\n%s\nwant:\n%s", Dump(got), Dump(tt.want)) + } + }) + } + +} diff --git a/internal/markdown/node.go b/internal/markdown/node.go index aaa86b3..7dccff6 100644 --- a/internal/markdown/node.go +++ b/internal/markdown/node.go @@ -180,6 +180,9 @@ func (n GridNode) Children() []Node { } func (n *GridNode) AddChild(node Node) { + if node.Kind() != NodeKindGridColumn { + return + } n.children = append(n.children, node) } diff --git a/internal/markdown/parser_test.go b/internal/markdown/parser_test.go index 6a2770a..2216531 100644 --- a/internal/markdown/parser_test.go +++ b/internal/markdown/parser_test.go @@ -45,12 +45,51 @@ func TestMarkdownParser_Parse(t *testing.T) { }, }}, }, + { + name: "Valid grid layout", + in: []byte(`# This is a string +[grid] +[column]# Some other text[/column] + +[column] +![image](./image.png) +[/column] + +[column] +# Some other column stuff +[/column] +[/grid] + +> And another string`), + want: &MarkdownRootNode{ + children: []Node{ + &GlamourNode{Text: "# This is a string\n"}, + &GridNode{ + children: []Node{ + &GridColumnNode{children: []Node{&GlamourNode{ + Text: "# Some other text", + }}}, + &GridColumnNode{children: []Node{&ImageNode{ + Label: "image", + Path: "./image.png", + }}}, + &GridColumnNode{children: []Node{&GlamourNode{ + Text: "# Some other column stuff", + }}}, + }, + }, + &GlamourNode{Text: "> And another string"}, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { p := NewMarkdownParser() p.Register(Prioritized[Parser](NewImageParser(), 1)) p.Register(Prioritized[Parser](NewCodeBlockParser(), 1)) + p.Register(Prioritized[Parser](NewGridParser(), 1)) + p.Register(Prioritized[Parser](NewGridColumnParser(), 2)) got := p.Parse(tt.in) if Dump(got) != Dump(tt.want) { From 713eb6479414390891b11099a7746d3df0e0aa24 Mon Sep 17 00:00:00 2001 From: Konstantinos Artopoulos Date: Sun, 24 Aug 2025 13:23:04 +0300 Subject: [PATCH 08/11] feat(markdown): keep track of the parent nodes --- internal/markdown/grid_parser.go | 18 ++++-- internal/markdown/grid_parser_test.go | 49 +++++---------- internal/markdown/node.go | 85 ++++++++++----------------- internal/markdown/node_test.go | 64 +++++++++----------- internal/markdown/parser.go | 9 ++- internal/markdown/parser_test.go | 68 +++++++++------------ internal/markdown/util.go | 8 +++ 7 files changed, 130 insertions(+), 171 deletions(-) diff --git a/internal/markdown/grid_parser.go b/internal/markdown/grid_parser.go index 8458e2e..e376315 100644 --- a/internal/markdown/grid_parser.go +++ b/internal/markdown/grid_parser.go @@ -60,14 +60,19 @@ func (p GridParser) Parse(r *bytes.Reader, m *MarkdownParser) Node { } if c := strings.Trim(chunk.String(), " \n"); c != "" { - gridNode.AddChild(&GlamourNode{Text: chunk.String()}) + child := &GlamourNode{Text: chunk.String()} + child.SetParent(gridNode) + gridNode.AddChild(child) chunk.Reset() } + n.SetParent(gridNode) gridNode.AddChild(n) } if c := strings.Trim(chunk.String(), " \n"); c != "" { - gridNode.AddChild(&GlamourNode{Text: chunk.String()}) + child := &GlamourNode{Text: chunk.String()} + child.SetParent(gridNode) + gridNode.AddChild(child) } return gridNode @@ -126,14 +131,19 @@ func (p GridColumnParser) Parse(r *bytes.Reader, m *MarkdownParser) Node { } if c := strings.Trim(chunk.String(), " \n"); c != "" { - columnNode.AddChild(&GlamourNode{Text: c}) + child := &GlamourNode{Text: c} + child.SetParent(columnNode) + columnNode.AddChild(child) chunk.Reset() } + n.SetParent(columnNode) columnNode.AddChild(n) } if c := strings.Trim(chunk.String(), " \n"); c != "" { - columnNode.AddChild(&GlamourNode{Text: c}) + child := &GlamourNode{Text: c} + child.SetParent(columnNode) + columnNode.AddChild(child) } return columnNode diff --git a/internal/markdown/grid_parser_test.go b/internal/markdown/grid_parser_test.go index cfb3521..d9feeba 100644 --- a/internal/markdown/grid_parser_test.go +++ b/internal/markdown/grid_parser_test.go @@ -14,16 +14,11 @@ func TestGridParser_Parse(t *testing.T) { { name: "simple grid", in: []byte("[grid][column]Some text[/column][column]More text[/column][/grid]"), - want: &GridNode{ - children: []Node{ - &GridColumnNode{ - children: []Node{&GlamourNode{Text: "Some text"}}, - }, - &GridColumnNode{ - children: []Node{&GlamourNode{Text: "More text"}}, - }, - }, - }, + want: node( + &GridNode{}, + node(&GridColumnNode{}, &GlamourNode{Text: "Some text"}), + node(&GridColumnNode{}, &GlamourNode{Text: "More text"}), + ), }, { name: "nested grid", @@ -37,31 +32,15 @@ func TestGridParser_Parse(t *testing.T) { [/grid] [/column] [/grid]`), - want: &GridNode{ - children: []Node{ - &GridColumnNode{ - children: []Node{&GlamourNode{Text: "Some text"}}, - }, - &GridColumnNode{ - children: []Node{ - &GridNode{ - children: []Node{ - &GridColumnNode{ - children: []Node{ - &GlamourNode{Text: "Nested text"}, - }, - }, - &GridColumnNode{ - children: []Node{ - &GlamourNode{Text: "Nested text 2"}, - }, - }, - }, - }, - }, - }, - }, - }, + want: node( + &GridNode{}, + node(&GridColumnNode{}, &GlamourNode{Text: "Some text"}), + node(&GridColumnNode{}, node( + &GridNode{}, + node(&GridColumnNode{}, &GlamourNode{Text: "Nested text"}), + node(&GridColumnNode{}, &GlamourNode{Text: "Nested text 2"}), + )), + ), }, } for _, tt := range tests { diff --git a/internal/markdown/node.go b/internal/markdown/node.go index 7dccff6..5d9adac 100644 --- a/internal/markdown/node.go +++ b/internal/markdown/node.go @@ -22,6 +22,8 @@ type Node interface { Kind() NodeKind Children() []Node AddChild(Node) + Parent() Node + SetParent(Node) } func Dump(n Node) string { @@ -54,69 +56,66 @@ func dumpNode(n Node, prefix string, b *strings.Builder) { } } -type MarkdownRootNode struct { +type BaseNode struct { + parent Node children []Node } -func (n MarkdownRootNode) Kind() NodeKind { - return NodeKindMarkdownRoot -} - -func (n MarkdownRootNode) Children() []Node { +func (n BaseNode) Children() []Node { return n.children } -func (n *MarkdownRootNode) AddChild(node Node) { +func (n *BaseNode) AddChild(node Node) { n.children = append(n.children, node) } +func (n BaseNode) Parent() Node { + return n.parent +} + +func (n *BaseNode) SetParent(node Node) { + n.parent = node +} + +type MarkdownRootNode struct { + BaseNode +} + +func (n MarkdownRootNode) Kind() NodeKind { + return NodeKindMarkdownRoot +} + func (n MarkdownRootNode) String() string { return "MarkdownRoot()" } type GlamourNode struct { - Text string + BaseNode - children []Node + Text string } func (n GlamourNode) Kind() NodeKind { return NodeKindGlamour } -func (n GlamourNode) Children() []Node { - return n.children -} - -func (n *GlamourNode) AddChild(node Node) { - n.children = append(n.children, node) -} - func (n GlamourNode) String() string { return fmt.Sprintf(`Glamour(Text: "%s")`, n.Text) } type ImageNode struct { + BaseNode + Label string Path string Width int Height int - - children []Node } func (n ImageNode) Kind() NodeKind { return NodeKindImage } -func (n ImageNode) Children() []Node { - return n.children -} - -func (n *ImageNode) AddChild(node Node) { - n.children = append(n.children, node) -} - func (n ImageNode) String() string { return fmt.Sprintf( `Image(Label: "%s", Path: "%s", Width: %d, Height: %d)`, @@ -133,27 +132,19 @@ type CodeBlockLineRange struct { } type CodeBlockNode struct { + BaseNode + Language string Ranges []CodeBlockLineRange ShowLineNumbers bool StartLine int Code string - - children []Node } func (n CodeBlockNode) Kind() NodeKind { return NodeKindCodeBlock } -func (n CodeBlockNode) Children() []Node { - return n.children -} - -func (n *CodeBlockNode) AddChild(node Node) { - n.children = append(n.children, node) -} - func (n CodeBlockNode) String() string { return fmt.Sprintf( `CodeBlock(Language: %s, Ranges: %v, ShowLineNumbers: %t, StartLine: %d, Code: %s)`, @@ -166,19 +157,15 @@ func (n CodeBlockNode) String() string { } type GridNode struct { - ColumnCount int + BaseNode - children []Node + ColumnCount int } func (n GridNode) Kind() NodeKind { return NodeKindGrid } -func (n GridNode) Children() []Node { - return n.children -} - func (n *GridNode) AddChild(node Node) { if node.Kind() != NodeKindGridColumn { return @@ -191,23 +178,15 @@ func (n GridNode) String() string { } type GridColumnNode struct { - Span int + BaseNode - children []Node + Span int } func (n GridColumnNode) Kind() NodeKind { return NodeKindGridColumn } -func (n GridColumnNode) Children() []Node { - return n.children -} - -func (n *GridColumnNode) AddChild(node Node) { - n.children = append(n.children, node) -} - func (n GridColumnNode) String() string { return fmt.Sprintf(`GridColumn(Span: %d)`, n.Span) } diff --git a/internal/markdown/node_test.go b/internal/markdown/node_test.go index 1da9e9d..90ac091 100644 --- a/internal/markdown/node_test.go +++ b/internal/markdown/node_test.go @@ -10,18 +10,17 @@ func TestDump(t *testing.T) { }{ { name: "simple", - root: &MarkdownRootNode{ - children: []Node{ - &GlamourNode{Text: "test"}, - &ImageNode{ - Label: "img", - Path: "./image.png", - Width: 100, - Height: 50, - }, - &GlamourNode{Text: "test2"}, + root: node( + &MarkdownRootNode{}, + &GlamourNode{Text: "test"}, + &ImageNode{ + Label: "img", + Path: "./image.png", + Width: 100, + Height: 50, }, - }, + &GlamourNode{Text: "test2"}, + ), want: `MarkdownRoot() |-Glamour(Text: "test") |-Image(Label: "img", Path: "./image.png", Width: 100, Height: 50) @@ -29,31 +28,24 @@ func TestDump(t *testing.T) { }, { name: "grid", - root: &MarkdownRootNode{ - children: []Node{ - &GlamourNode{Text: "test"}, - &GridNode{ - ColumnCount: 3, - children: []Node{ - &GridColumnNode{ - Span: 1, - children: []Node{ - &GlamourNode{Text: "Col1"}, - &GlamourNode{Text: "Col1a"}, - }, - }, - &GridColumnNode{ - Span: 2, - children: []Node{ - &GlamourNode{Text: "Col2"}, - &GlamourNode{Text: "Col2a"}, - }, - }, - }, - }, - &GlamourNode{Text: "test2"}, - }, - }, + root: node( + &MarkdownRootNode{}, + &GlamourNode{Text: "test"}, + node( + &GridNode{ColumnCount: 3}, + node( + &GridColumnNode{Span: 1}, + &GlamourNode{Text: "Col1"}, + &GlamourNode{Text: "Col1a"}, + ), + node( + &GridColumnNode{Span: 2}, + &GlamourNode{Text: "Col2"}, + &GlamourNode{Text: "Col2a"}, + ), + ), + &GlamourNode{Text: "test2"}, + ), want: `MarkdownRoot() |-Glamour(Text: "test") |-Grid(ColumnCount: 3) diff --git a/internal/markdown/parser.go b/internal/markdown/parser.go index 5dc77ac..2096fe9 100644 --- a/internal/markdown/parser.go +++ b/internal/markdown/parser.go @@ -71,14 +71,19 @@ func (p MarkdownParser) Parse(in []byte) Node { } if chunk.String() != "" { - root.AddChild(&GlamourNode{Text: chunk.String()}) + child := &GlamourNode{Text: chunk.String()} + child.SetParent(root) + root.AddChild(child) chunk.Reset() } + n.SetParent(root) root.AddChild(n) } if chunk.String() != "" { - root.AddChild(&GlamourNode{Text: chunk.String()}) + child := &GlamourNode{Text: chunk.String()} + child.SetParent(root) + root.AddChild(child) } return root diff --git a/internal/markdown/parser_test.go b/internal/markdown/parser_test.go index 2216531..912c888 100644 --- a/internal/markdown/parser_test.go +++ b/internal/markdown/parser_test.go @@ -11,39 +11,34 @@ func TestMarkdownParser_Parse(t *testing.T) { { name: "Basic single node", in: []byte("# This is a string"), - want: &MarkdownRootNode{children: []Node{ - &GlamourNode{Text: "# This is a string"}, - }}, + want: node(&MarkdownRootNode{}, &GlamourNode{Text: "# This is a string"}), }, { name: "Text followed by image", in: []byte("# This is a string\n![alt text](./image.png)"), - want: &MarkdownRootNode{ - children: []Node{ - &GlamourNode{Text: "# This is a string\n"}, - &ImageNode{Label: "alt text", Path: "./image.png"}, - }, - }, + want: node( + &MarkdownRootNode{}, + &GlamourNode{Text: "# This is a string\n"}, + &ImageNode{Label: "alt text", Path: "./image.png"}, + ), }, { name: "Image in between text", in: []byte("# This is a string\n![alt text](./image.png)\n> Some other string"), - want: &MarkdownRootNode{ - children: []Node{ - &GlamourNode{Text: "# This is a string\n"}, - &ImageNode{Label: "alt text", Path: "./image.png"}, - &GlamourNode{Text: "\n> Some other string"}, - }, - }, + want: node( + &MarkdownRootNode{}, + &GlamourNode{Text: "# This is a string\n"}, + &ImageNode{Label: "alt text", Path: "./image.png"}, + &GlamourNode{Text: "\n> Some other string"}, + ), }, { name: "Try parse invalid image node", in: []byte("# This is a string\n![not_an_image\n> Some other string"), - want: &MarkdownRootNode{children: []Node{ - &GlamourNode{ - Text: "# This is a string\n![not_an_image\n> Some other string", - }, - }}, + want: node( + &MarkdownRootNode{}, + &GlamourNode{Text: "# This is a string\n![not_an_image\n> Some other string"}, + ), }, { name: "Valid grid layout", @@ -61,26 +56,17 @@ func TestMarkdownParser_Parse(t *testing.T) { [/grid] > And another string`), - want: &MarkdownRootNode{ - children: []Node{ - &GlamourNode{Text: "# This is a string\n"}, - &GridNode{ - children: []Node{ - &GridColumnNode{children: []Node{&GlamourNode{ - Text: "# Some other text", - }}}, - &GridColumnNode{children: []Node{&ImageNode{ - Label: "image", - Path: "./image.png", - }}}, - &GridColumnNode{children: []Node{&GlamourNode{ - Text: "# Some other column stuff", - }}}, - }, - }, - &GlamourNode{Text: "> And another string"}, - }, - }, + want: node( + &MarkdownRootNode{}, + &GlamourNode{Text: "# This is a string\n"}, + node( + &GridNode{}, + node(&GridColumnNode{}, &GlamourNode{Text: "# Some other text"}), + node(&GridColumnNode{}, &ImageNode{Label: "image", Path: "./image.png"}), + node(&GridColumnNode{}, &GlamourNode{Text: "# Some other column stuff"}), + ), + &GlamourNode{Text: "> And another string"}, + ), }, } for _, tt := range tests { diff --git a/internal/markdown/util.go b/internal/markdown/util.go index 889c3c9..8fd7171 100644 --- a/internal/markdown/util.go +++ b/internal/markdown/util.go @@ -24,3 +24,11 @@ func (s PrioritizedSlice[T]) Sort() { func Prioritized[T any](v T, priority int) PrioritizedValue[T] { return PrioritizedValue[T]{v, priority} } + +func node(parent Node, children ...Node) Node { + for _, c := range children { + c.SetParent(parent) + parent.AddChild(c) + } + return parent +} From 23b45caf1d4dc4dfdf434a84f0fe200ece5021fc Mon Sep 17 00:00:00 2001 From: Konstantinos Artopoulos Date: Sat, 20 Sep 2025 00:06:14 +0300 Subject: [PATCH 09/11] feat(markdown): grid layout renderer --- go.mod | 3 +- go.sum | 10 +--- internal/markdown/renderer.go | 54 ++++++++++++++++--- internal/markdown/renderer_test.go | 48 +++++++++++++++-- .../testdata/TestRenderer_GridLayout.golden | 13 +++++ internal/tui/slide.go | 13 +++-- internal/tui/tui.go | 2 + 7 files changed, 119 insertions(+), 24 deletions(-) create mode 100644 internal/markdown/testdata/TestRenderer_GridLayout.golden diff --git a/go.mod b/go.mod index bfde797..b70661f 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/charmbracelet/harmonica v0.2.0 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/ansi v0.8.0 + github.com/charmbracelet/x/exp/golden v0.0.0-20250714123521-bc8a1995e079 github.com/fsnotify/fsnotify v1.8.0 github.com/go-viper/mapstructure/v2 v2.2.1 github.com/goccy/go-yaml v1.17.1 @@ -21,7 +22,7 @@ require ( require ( github.com/aymanbagabas/go-udiff v0.3.1 // indirect - github.com/charmbracelet/x/exp/golden v0.0.0-20250714123521-bc8a1995e079 // indirect + github.com/google/go-cmp v0.7.0 // indirect golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect ) diff --git a/go.sum b/go.sum index e1faaca..b665f59 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,6 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= -github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= @@ -30,8 +28,6 @@ github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2ll github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= github.com/charmbracelet/x/exp/golden v0.0.0-20250714123521-bc8a1995e079 h1:HDsCK4LvvlqM6Jc+skOte2IdO7GPpLmj1SGTHbZqt8I= github.com/charmbracelet/x/exp/golden v0.0.0-20250714123521-bc8a1995e079/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= @@ -54,8 +50,8 @@ github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIx github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY= github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= @@ -89,8 +85,6 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= -github.com/ploMP4/chafa-go v0.1.0 h1:na1uideVGSzclKdInQLkv/7v+37cUWXYqPK8Jhs9v7Q= -github.com/ploMP4/chafa-go v0.1.0/go.mod h1:IFfnozJSo6uj7UrnfsPnIWhLuOpqkIi+XNqDEg9hbAY= github.com/ploMP4/chafa-go v0.2.0 h1:5t34lQrMa14u7jZjezG8rm3NE0f8Erw6ZFLmzqbv1Ik= github.com/ploMP4/chafa-go v0.2.0/go.mod h1:IFfnozJSo6uj7UrnfsPnIWhLuOpqkIi+XNqDEg9hbAY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= diff --git a/internal/markdown/renderer.go b/internal/markdown/renderer.go index bf5f79a..2512b87 100644 --- a/internal/markdown/renderer.go +++ b/internal/markdown/renderer.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/glamour" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" + "github.com/muesli/reflow/wrap" "github.com/museslabs/kyma/internal/config" "github.com/museslabs/kyma/internal/img" @@ -38,6 +39,8 @@ func NewRenderer(theme string, options ...RendererOption) (*Renderer, error) { p := NewMarkdownParser() p.Register(Prioritized[Parser](NewImageParser(), 1)) p.Register(Prioritized[Parser](NewCodeBlockParser(), 1)) + p.Register(Prioritized[Parser](NewGridParser(), 1)) + p.Register(Prioritized[Parser](NewGridColumnParser(), 2)) r := &Renderer{ tr: tr, @@ -55,11 +58,11 @@ func NewRenderer(theme string, options ...RendererOption) (*Renderer, error) { return r, nil } -func (r *Renderer) Render(in string, animating bool) (string, error) { - return r.RenderBytes([]byte(in), animating) +func (r *Renderer) Render(in string, animating bool, width, height int) (string, error) { + return r.RenderBytes([]byte(in), animating, width, height) } -func (r *Renderer) RenderBytes(in []byte, animating bool) (string, error) { +func (r *Renderer) RenderBytes(in []byte, animating bool, width, height int) (string, error) { var b strings.Builder // Clear kitty images @@ -67,14 +70,14 @@ func (r *Renderer) RenderBytes(in []byte, animating bool) (string, error) { b.WriteString("\x1b_Ga=d\x1b\\") } - if err := r.renderNode(r.parser.Parse(in), animating, &b); err != nil { + if err := r.renderNode(r.parser.Parse(in), animating, width, height, &b); err != nil { return "", err } return b.String(), nil } -func (r *Renderer) renderNode(n Node, animating bool, b *strings.Builder) error { +func (r *Renderer) renderNode(n Node, animating bool, width, height int, b *strings.Builder) error { if n == nil { return nil } @@ -113,9 +116,9 @@ func (r *Renderer) renderNode(n Node, animating bool, b *strings.Builder) error if !animating { b.WriteString(ansi.SaveCursor) - b.WriteString(limg) - b.WriteString(ansi.RestoreCursor) b.WriteString(himg) + b.WriteString(ansi.RestoreCursor) + b.WriteString(limg) } else { b.WriteString(limg) } @@ -144,12 +147,47 @@ func (r *Renderer) renderNode(n Node, animating bool, b *strings.Builder) error b.WriteString(codeStyle.Render(renderedContent)) + case NodeKindGrid: + var ( + gridBuilder strings.Builder + parts []string + ) + + for _, c := range n.Children() { + if err := r.renderNode(c, animating, width, height, &gridBuilder); err != nil { + return err + } + parts = append(parts, gridBuilder.String()) + gridBuilder.Reset() + } + + b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, parts...)) + return nil + + case NodeKindGridColumn: + var columnBuilder strings.Builder + + columnWidth := (width / len(n.Parent().Children())) - 1 + for _, c := range n.Children() { + if err := r.renderNode(c, animating, width, height, &columnBuilder); err != nil { + return err + } + + if c.Kind() == NodeKindImage { + b.WriteString(columnBuilder.String()) + } else { + b.WriteString(wrap.String(columnBuilder.String(), columnWidth)) + } + columnBuilder.Reset() + } + return nil + default: return fmt.Errorf("invalid node kind: %d", n.Kind()) } for _, c := range n.Children() { - if err := r.renderNode(c, animating, b); err != nil { + if err := r.renderNode(c, animating, width, height, b); err != nil { return err } } diff --git a/internal/markdown/renderer_test.go b/internal/markdown/renderer_test.go index b483b27..93a7012 100644 --- a/internal/markdown/renderer_test.go +++ b/internal/markdown/renderer_test.go @@ -37,7 +37,7 @@ _carduus_ in Carthage and Cordoba. --Samuel Taylor Coleridge, [The Rime of the Ancient Mariner][rime] [rime]: https://poetryfoundation.org/poems/43997/ - `, false) + `, false, 0, 0) if gotErr != nil { t.Errorf("Render() failed: %v", gotErr) } @@ -60,7 +60,7 @@ func main() { } ` - got, gotErr := r.Render(fmt.Sprintf("# Slide\n```go{3,6}\n%s\n```", codeBlock), false) + got, gotErr := r.Render(fmt.Sprintf("# Slide\n```go{3,6}\n%s\n```", codeBlock), false, 0, 0) if gotErr != nil { t.Errorf("Render() failed: %v", gotErr) } @@ -84,7 +84,7 @@ func main() { got, gotErr := r.Render( fmt.Sprintf("# Slide\n```go{3,6} --numbered\n%s\n```\n", codeBlock), - false, + false, 0, 0, ) if gotErr != nil { t.Errorf("Render() failed: %v", gotErr) @@ -111,7 +111,7 @@ func TestRenderer_RenderCodeBlockWithStartFromLine(t *testing.T) { got, gotErr := r.Render( fmt.Sprintf("# Slide\n```go{2-8} --numbered --start-at-line 10\n%s\n```\n", codeBlock), - false, + false, 0, 0, ) if gotErr != nil { t.Errorf("Render() failed: %v", gotErr) @@ -119,3 +119,43 @@ func TestRenderer_RenderCodeBlockWithStartFromLine(t *testing.T) { golden.RequireEqual(t, got) } + +func TestRenderer_GridLayout(t *testing.T) { + r, err := NewRenderer("dark") + if err != nil { + t.Fatalf("could not construct receiver type: %v", err) + } + + got, gotErr := r.Render( + ` +Heading +======= + +[grid] +[column] +## Grid column 1 data +[/column] + +[column] +## Grid column 2 + +> Lorem ipsum dolor sit amet, consectetur adipiscing elit. +[/column] + +[column] +## Grid column 3 + +- item 1 +- item 2 +- item 3 +[/column] +[/grid] + +[Epilogue](https://kyma.ink) + `, false, 0, 0) + if gotErr != nil { + t.Errorf("Render() failed: %v", gotErr) + } + + golden.RequireEqual(t, got) +} diff --git a/internal/markdown/testdata/TestRenderer_GridLayout.golden b/internal/markdown/testdata/TestRenderer_GridLayout.golden new file mode 100644 index 0000000..7beaa06 --- /dev/null +++ b/internal/markdown/testdata/TestRenderer_GridLayout.golden @@ -0,0 +1,13 @@ +_Ga=d\ +  Heading                                                                         + + + ## Grid column 1 data                                                            ## Grid column 2                                                                 ## Grid column 3                                                                 +                                                                                                                                                                    +  │ Lorem ipsum dolor sit amet, consectetur adipiscing elit.                       • item 1                                                                         +  • item 2                                                                         +  • item 3                                                                         + + + Epilogue https://kyma.ink                                                        + diff --git a/internal/tui/slide.go b/internal/tui/slide.go index 6fc0e74..be568e5 100644 --- a/internal/tui/slide.go +++ b/internal/tui/slide.go @@ -56,12 +56,13 @@ func (s *Slide) Update() (*Slide, tea.Cmd) { return s, tea.Batch(cmd) } -func (s *Slide) View(animating bool) string { +func (s *Slide) View(animating bool, width, height int) string { var b strings.Builder out, _ := s.renderer.Render( s.Data, (s.ActiveTransition != nil && s.ActiveTransition.Animating()) || animating, + width, height, ) if s.ActiveTransition != nil && s.ActiveTransition.Animating() { @@ -70,11 +71,17 @@ func (s *Slide) View(animating bool) string { if s.Next == nil { panic("backwards transition at the last slide") } else { - b.WriteString(s.ActiveTransition.View(s.Next.View(true), s.Style.LipGlossStyle.Render(out))) + b.WriteString(s.ActiveTransition.View( + s.Next.View(true, width, height), + s.Style.LipGlossStyle.Render(out), + )) } } else { if s.Prev != nil { - b.WriteString(s.ActiveTransition.View(s.Prev.View(true), s.Style.LipGlossStyle.Render(out))) + b.WriteString(s.ActiveTransition.View( + s.Prev.View(true, width, height), + s.Style.LipGlossStyle.Render(out), + )) } else { b.WriteString(s.Style.LipGlossStyle.Render(out)) } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 51790b4..d739102 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -346,6 +346,8 @@ func (m model) View() string { lipgloss.Center, m.slide.View( (m.slide.ActiveTransition != nil && m.slide.ActiveTransition.Animating()) || hasOverlay, + m.width, + m.height, ), ) From 7bdcf0ec8810eea6bfe84d18edc78747ff32814a Mon Sep 17 00:00:00 2001 From: Konstantinos Artopoulos Date: Sat, 20 Sep 2025 00:07:16 +0300 Subject: [PATCH 10/11] docs: update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a4f5ab9..0581c02 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,6 @@ All contributions are welcome! If you're planning a significant change or you're - ~~Add support for more style options like text color and background color~~ ✅ **Done!** - ~~Allow choosing from any glamour themes~~ ✅ **Done!** - ~~Support for custom JSON theme files~~ ✅ **Done!** -- Create grid-based slide layouts with transitions for each pane +- ~~Create grid-based slide layouts~~ ✅ **Done!** - Add more transition effects - ~~Support image rendering in terminals (e.g., via the Kitty protocol)~~ ✅ **Done!** From f3a3b826f67ba7c4772e343167e268cecb9add76 Mon Sep 17 00:00:00 2001 From: Konstantinos Artopoulos Date: Sat, 20 Sep 2025 00:35:54 +0300 Subject: [PATCH 11/11] feat(docs): add grid section in docs --- docs/presentation.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/docs/presentation.md b/docs/presentation.md index 8e415c5..9c79d46 100644 --- a/docs/presentation.md +++ b/docs/presentation.md @@ -216,6 +216,49 @@ title: More ways to navigate - **Go to slide**: `g` or `:` - Jump directly to a specific slide number - **Jump slides**: `1-9` + `h`/`←` or `l`/`→` - Jump multiple slides backward/forward (e.g., `5h` jumps 5 slides back) +---- +--- +title: Grid layouts +transition: swipeLeft +image_backend: docs +--- + +# Grid Layout + +You can create grid layouts by using the `[grid]` and `[column]` tags with their +respective closing tags that start with a backslash `/` + +[grid] +[column] +```go +package main + +import "fmt" + +func main() { + fmt.Println("Hello World") +} +``` +[/column] +[column] +```c +#include + +int main(void) { + printf("Hello World\n"); + return 0; +} +``` +[/column] +[column] +```rust +fn main() { + println!("Hello World"); +} +``` +[/column] +[/grid] + ---- --- title: Achievements