Skip to content

Commit af9379b

Browse files
committed
feat(tui): add selective code highlighting for markdown blocks
- Add `parseHighlightSyntax` to parse line range syntax like `{1-3,5}` - Add `CodeHighlightInfo` struct to store language and highlight ranges - Add `renderCustomCodeBlock` with chroma-based syntax highlighting - Add `processMarkdownWithHighlighting` to handle mixed markdown content - Support highlighting specific lines within code blocks using ranges - Integrate with existing glamour rendering for non-highlighted content
1 parent 7da11f9 commit af9379b

File tree

2 files changed

+229
-5
lines changed

2 files changed

+229
-5
lines changed

internal/tui/highlight.go

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
package tui
2+
3+
import (
4+
"regexp"
5+
"strconv"
6+
"strings"
7+
8+
"github.com/alecthomas/chroma/v2"
9+
"github.com/alecthomas/chroma/v2/formatters"
10+
"github.com/alecthomas/chroma/v2/lexers"
11+
"github.com/charmbracelet/glamour"
12+
"github.com/charmbracelet/lipgloss"
13+
14+
"github.com/museslabs/kyma/internal/config"
15+
)
16+
17+
type LineRange struct {
18+
Start int
19+
End int
20+
}
21+
22+
type CodeHighlightInfo struct {
23+
Language string
24+
Ranges []LineRange
25+
}
26+
27+
func parseHighlightSyntax(syntax string) []LineRange {
28+
if syntax == "" {
29+
return nil
30+
}
31+
32+
syntax = strings.Trim(syntax, "{}")
33+
34+
var ranges []LineRange
35+
parts := strings.Split(syntax, ",")
36+
37+
for _, part := range parts {
38+
part = strings.TrimSpace(part)
39+
if strings.Contains(part, "-") {
40+
// Range like "1-3"
41+
rangeParts := strings.Split(part, "-")
42+
if len(rangeParts) == 2 {
43+
start, err1 := strconv.Atoi(strings.TrimSpace(rangeParts[0]))
44+
end, err2 := strconv.Atoi(strings.TrimSpace(rangeParts[1]))
45+
if err1 == nil && err2 == nil && start <= end {
46+
ranges = append(ranges, LineRange{Start: start, End: end})
47+
}
48+
}
49+
} else {
50+
// Single line like "5"
51+
line, err := strconv.Atoi(part)
52+
if err == nil {
53+
ranges = append(ranges, LineRange{Start: line, End: line})
54+
}
55+
}
56+
}
57+
58+
return ranges
59+
}
60+
61+
func shouldHighlightLine(lineNum int, ranges []LineRange) bool {
62+
for _, r := range ranges {
63+
if lineNum >= r.Start && lineNum <= r.End {
64+
return true
65+
}
66+
}
67+
return false
68+
}
69+
70+
func renderCustomCodeBlock(content string, info CodeHighlightInfo, themeName string) string {
71+
lexer := lexers.Get(info.Language)
72+
if lexer == nil {
73+
lexer = lexers.Fallback
74+
}
75+
lexer = chroma.Coalesce(lexer)
76+
77+
style := config.GetChromaStyle(themeName)
78+
return renderWithStyle(content, info, lexer, style)
79+
}
80+
81+
func renderWithStyle(content string, info CodeHighlightInfo, lexer chroma.Lexer, style *chroma.Style) string {
82+
formatter := formatters.Get("terminal256")
83+
if formatter == nil {
84+
formatter = formatters.Fallback
85+
}
86+
87+
lines := strings.Split(content, "\n")
88+
89+
highlightStyle := lipgloss.NewStyle()
90+
91+
var result strings.Builder
92+
for lineNum, line := range lines {
93+
if !shouldHighlightLine(lineNum+1, info.Ranges) {
94+
result.WriteString(line)
95+
if lineNum < len(lines)-1 {
96+
result.WriteString("\n")
97+
}
98+
continue
99+
}
100+
101+
// Handle highlighted lines
102+
lineIterator, err := lexer.Tokenise(nil, line)
103+
if err != nil {
104+
result.WriteString(highlightStyle.Render(line))
105+
if lineNum < len(lines)-1 {
106+
result.WriteString("\n")
107+
}
108+
continue
109+
}
110+
111+
var lineBuf strings.Builder
112+
err = formatter.Format(&lineBuf, style, lineIterator)
113+
if err != nil {
114+
result.WriteString(highlightStyle.Render(line))
115+
if lineNum < len(lines)-1 {
116+
result.WriteString("\n")
117+
}
118+
continue
119+
}
120+
121+
syntaxHighlighted := lineBuf.String()
122+
syntaxHighlighted = strings.TrimRight(syntaxHighlighted, " \t\n\r")
123+
result.WriteString(highlightStyle.Render(syntaxHighlighted))
124+
125+
if lineNum < len(lines)-1 {
126+
result.WriteString("\n")
127+
}
128+
}
129+
130+
codeStyle := lipgloss.NewStyle().
131+
Padding(1, 2).
132+
Width(78).
133+
MarginTop(1).
134+
MarginBottom(1)
135+
136+
return codeStyle.Render(result.String())
137+
}
138+
139+
func processMarkdownWithHighlighting(markdown string, themeName string) (string, error) {
140+
re := regexp.MustCompile("(?s)```([a-zA-Z0-9_+-]*)({[^}]*})?\n(.*?)\n```")
141+
142+
matches := re.FindAllStringSubmatch(markdown, -1)
143+
if len(matches) == 0 {
144+
// No code blocks found, render the entire markdown with Glamour
145+
return glamour.Render(markdown, themeName)
146+
}
147+
148+
var parts []string
149+
lastIndex := 0
150+
indices := re.FindAllStringIndex(markdown, -1)
151+
152+
for i, match := range matches {
153+
if len(match) < 4 {
154+
continue
155+
}
156+
157+
matchStart := indices[i][0]
158+
matchEnd := indices[i][1]
159+
160+
// Add the text before this code block
161+
if matchStart > lastIndex {
162+
beforeText := markdown[lastIndex:matchStart]
163+
if strings.TrimSpace(beforeText) != "" {
164+
// Render regular markdown content with Glamour
165+
rendered, err := glamour.Render(beforeText, themeName)
166+
if err != nil {
167+
parts = append(parts, beforeText)
168+
} else {
169+
parts = append(parts, rendered)
170+
}
171+
}
172+
}
173+
174+
// Extract code block info
175+
language := match[1] // e.g., "typescript"
176+
highlightSyntax := ""
177+
if len(match) > 2 && match[2] != "" {
178+
highlightSyntax = match[2] // e.g., "{1-2}"
179+
}
180+
content := match[3] // The actual code content
181+
182+
info := CodeHighlightInfo{
183+
Language: language,
184+
Ranges: parseHighlightSyntax(highlightSyntax),
185+
}
186+
187+
if len(info.Ranges) > 0 {
188+
customRendered := renderCustomCodeBlock(content, info, themeName)
189+
parts = append(parts, customRendered)
190+
} else {
191+
// No highlighting ranges, use Glamour directly for the entire code block
192+
codeBlock := match[0]
193+
rendered, err := glamour.Render(codeBlock, themeName)
194+
if err != nil {
195+
parts = append(parts, codeBlock)
196+
} else {
197+
parts = append(parts, rendered)
198+
}
199+
}
200+
201+
lastIndex = matchEnd
202+
}
203+
204+
// Add any remaining text after the last code block
205+
if lastIndex < len(markdown) {
206+
remainingText := markdown[lastIndex:]
207+
if strings.TrimSpace(remainingText) != "" {
208+
// Render remaining markdown content with Glamour
209+
rendered, err := glamour.Render(remainingText, themeName)
210+
if err != nil {
211+
parts = append(parts, remainingText)
212+
} else {
213+
parts = append(parts, rendered)
214+
}
215+
}
216+
}
217+
218+
return strings.Join(parts, ""), nil
219+
}

internal/tui/slide.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,17 @@ func (s Slide) view() string {
5858
themeName = s.Style.Theme.Name
5959
}
6060

61-
out, err := glamour.Render(s.Data, themeName)
61+
// Pre-process markdown to handle custom highlighting syntax
62+
out, err := processMarkdownWithHighlighting(s.Data, themeName)
6263
if err != nil {
63-
b.WriteString("\n\n" + lipgloss.NewStyle().
64-
Foreground(lipgloss.Color("9")). // Red
65-
Render("Error: "+err.Error()))
66-
return b.String()
64+
// If preprocessing fails, fall back to regular Glamour rendering
65+
out, err = glamour.Render(s.Data, themeName)
66+
if err != nil {
67+
b.WriteString("\n\n" + lipgloss.NewStyle().
68+
Foreground(lipgloss.Color("9")). // Red
69+
Render("Error: "+err.Error()))
70+
return b.String()
71+
}
6772
}
6873

6974
if s.ActiveTransition != nil && s.ActiveTransition.Animating() {

0 commit comments

Comments
 (0)