298 lines
8.5 KiB
Go
298 lines
8.5 KiB
Go
package main
|
||
|
||
import (
|
||
"regexp"
|
||
"strings"
|
||
"sync"
|
||
|
||
"github.com/charmbracelet/glamour"
|
||
glamouransi "github.com/charmbracelet/glamour/ansi"
|
||
"github.com/charmbracelet/glamour/styles"
|
||
"github.com/charmbracelet/lipgloss"
|
||
xansi "github.com/charmbracelet/x/ansi"
|
||
)
|
||
|
||
var commentMarkdownRenderers sync.Map
|
||
var quoteRailStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#777777"))
|
||
var markdownStyleName = "dark"
|
||
var renderedMentionPattern = regexp.MustCompile(
|
||
`(^|[^A-Za-z0-9_-])(@[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)([^A-Za-z0-9-]|$)`,
|
||
)
|
||
var sgrPattern = regexp.MustCompile(`\x1b\[[0-9:;]*m`)
|
||
|
||
func renderCommentMarkdown(markdown string, width int) []string {
|
||
if strings.TrimSpace(markdown) == "" {
|
||
return nil
|
||
}
|
||
width = max(10, width)
|
||
markdown = normalizeGitHubAlerts(markdown)
|
||
var (
|
||
result []string
|
||
block []string
|
||
blockQuote bool
|
||
haveBlock bool
|
||
)
|
||
flush := func() {
|
||
if len(block) == 0 {
|
||
return
|
||
}
|
||
blockWidth := width
|
||
if blockQuote {
|
||
blockWidth = max(8, width-2)
|
||
}
|
||
lines := renderMarkdownFragment(strings.Join(block, "\n"), blockWidth)
|
||
if haveBlock && len(lines) > 0 {
|
||
result = append(result, "")
|
||
}
|
||
if blockQuote {
|
||
rail := quoteRailStyle.Render("│ ")
|
||
for i := range lines {
|
||
lines[i] = rail + lines[i]
|
||
}
|
||
}
|
||
result = append(result, lines...)
|
||
haveBlock = haveBlock || len(lines) > 0
|
||
block = nil
|
||
}
|
||
|
||
for _, line := range strings.Split(markdown, "\n") {
|
||
content, quoted := stripQuoteMarker(line)
|
||
if len(block) > 0 && quoted != blockQuote {
|
||
flush()
|
||
}
|
||
blockQuote = quoted
|
||
block = append(block, content)
|
||
}
|
||
flush()
|
||
return trimMarkdownLines(result)
|
||
}
|
||
|
||
func renderMarkdownFragment(markdown string, width int) []string {
|
||
if strings.TrimSpace(markdown) == "" {
|
||
return nil
|
||
}
|
||
renderer, err := commentMarkdownRenderer(width)
|
||
if err != nil {
|
||
return fallbackCommentLines(markdown, width)
|
||
}
|
||
rendered, err := renderer.Render(markdown)
|
||
if err != nil {
|
||
return fallbackCommentLines(markdown, width)
|
||
}
|
||
lines := strings.Split(strings.Trim(rendered, "\n"), "\n")
|
||
for index := range lines {
|
||
lines[index] = highlightRenderedMentions(lines[index])
|
||
}
|
||
return trimMarkdownLines(lines)
|
||
}
|
||
|
||
func commentMarkdownRenderer(width int) (*glamour.TermRenderer, error) {
|
||
if cached, ok := commentMarkdownRenderers.Load(width); ok {
|
||
return cached.(*glamour.TermRenderer), nil
|
||
}
|
||
style := markdownStyleForTheme()
|
||
zero := uint(0)
|
||
style.Document.Margin = &zero
|
||
style.Code.Prefix = ""
|
||
style.Code.Suffix = ""
|
||
renderer, err := glamour.NewTermRenderer(
|
||
glamour.WithStyles(style),
|
||
glamour.WithChromaFormatter("terminal16m"),
|
||
glamour.WithWordWrap(width),
|
||
glamour.WithTableWrap(true),
|
||
glamour.WithPreservedNewLines(),
|
||
glamour.WithEmoji(),
|
||
)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
actual, _ := commentMarkdownRenderers.LoadOrStore(width, renderer)
|
||
return actual.(*glamour.TermRenderer), nil
|
||
}
|
||
|
||
func markdownStyleForTheme() glamouransi.StyleConfig {
|
||
if markdownStyleName == "notty" {
|
||
return styles.NoTTYStyleConfig
|
||
}
|
||
style := styles.DarkStyleConfig
|
||
if themeIsLight {
|
||
style = styles.LightStyleConfig
|
||
}
|
||
palette := editorMarkdownTheme
|
||
color := func(value string) *string { return &value }
|
||
truth := func(value bool) *bool { return &value }
|
||
|
||
style.Document.Color = color(palette.Text)
|
||
// Leave Text unset so inline text inherits the surrounding heading, link,
|
||
// quote, or paragraph color instead of flattening every Markdown token to
|
||
// the document foreground.
|
||
style.Text.Color = nil
|
||
style.Paragraph.Color = color(palette.Text)
|
||
style.Heading.Color = color(palette.Title)
|
||
style.H1.Color = color(palette.ActiveForeground)
|
||
style.H1.BackgroundColor = color(palette.ActiveBackground)
|
||
style.H2.Color = color(palette.Title)
|
||
style.H3.Color = color(palette.Title)
|
||
style.H4.Color = color(palette.Title)
|
||
style.H5.Color = color(palette.Title)
|
||
style.H6.Color = color(palette.Title)
|
||
style.HorizontalRule.Color = color(palette.Dim)
|
||
style.Item.Color = color(palette.Title)
|
||
style.Enumeration.Color = color(palette.Title)
|
||
style.Task.Color = color(palette.Text)
|
||
style.BlockQuote.Color = color(palette.Quote)
|
||
style.Strong.Color = color(palette.Text)
|
||
style.Strong.Bold = truth(true)
|
||
style.Emph.Color = color(palette.Text)
|
||
style.Link.Color = color(themeAuthorColor(palette, 0))
|
||
style.LinkText.Color = color(themeAuthorColor(palette, 1))
|
||
style.Image.Color = color(themeAuthorColor(palette, 0))
|
||
style.ImageText.Color = color(palette.Dim)
|
||
style.Code.Color = color(palette.Success)
|
||
style.Code.BackgroundColor = color(palette.EditorBackground)
|
||
style.CodeBlock.Color = color(palette.EditorForeground)
|
||
style.CodeBlock.BackgroundColor = color(palette.EditorBackground)
|
||
style.CodeBlock.Theme = palette.SyntaxTheme
|
||
style.CodeBlock.Chroma = nil
|
||
style.Table.Color = color(palette.Text)
|
||
style.Table.CenterSeparator = stringPointer("─")
|
||
style.Table.ColumnSeparator = stringPointer("│")
|
||
style.Table.RowSeparator = stringPointer("─")
|
||
style.DefinitionTerm.Color = color(palette.Title)
|
||
style.DefinitionDescription.Color = color(palette.Text)
|
||
return style
|
||
}
|
||
|
||
func themeAuthorColor(palette themePalette, index int) string {
|
||
if len(palette.AuthorPalette) == 0 {
|
||
return palette.Text
|
||
}
|
||
return palette.AuthorPalette[index%len(palette.AuthorPalette)]
|
||
}
|
||
|
||
func stringPointer(value string) *string {
|
||
return &value
|
||
}
|
||
|
||
func stripQuoteMarker(line string) (string, bool) {
|
||
trimmed := strings.TrimLeft(line, " \t")
|
||
if !strings.HasPrefix(trimmed, ">") {
|
||
return line, false
|
||
}
|
||
content := strings.TrimPrefix(trimmed, ">")
|
||
content = strings.TrimPrefix(content, " ")
|
||
return content, true
|
||
}
|
||
|
||
func normalizeGitHubAlerts(markdown string) string {
|
||
alerts := map[string]string{
|
||
"[!NOTE]": "ℹ **Note**",
|
||
"[!TIP]": "◆ **Tip**",
|
||
"[!IMPORTANT]": "❗ **Important**",
|
||
"[!WARNING]": "⚠ **Warning**",
|
||
"[!CAUTION]": "⛔ **Caution**",
|
||
}
|
||
lines := strings.Split(markdown, "\n")
|
||
for i, line := range lines {
|
||
trimmed := strings.TrimSpace(line)
|
||
if !strings.HasPrefix(trimmed, ">") {
|
||
continue
|
||
}
|
||
label := strings.TrimSpace(strings.TrimPrefix(trimmed, ">"))
|
||
if replacement, ok := alerts[strings.ToUpper(label)]; ok {
|
||
prefix := line[:strings.Index(line, ">")+1]
|
||
lines[i] = prefix + " " + replacement
|
||
}
|
||
}
|
||
return strings.Join(lines, "\n")
|
||
}
|
||
|
||
func fallbackCommentLines(markdown string, width int) []string {
|
||
lines := strings.Split(wrap(markdown, max(10, width)), "\n")
|
||
for index := range lines {
|
||
lines[index] = highlightRenderedMentions(lines[index])
|
||
}
|
||
return lines
|
||
}
|
||
|
||
func highlightRenderedMentions(line string) string {
|
||
visible, offsets := visibleTextOffsets(line)
|
||
var result strings.Builder
|
||
activeStyle := ""
|
||
cursor := 0
|
||
searchFrom := 0
|
||
for searchFrom < len(visible) {
|
||
match := renderedMentionPattern.FindStringSubmatchIndex(visible[searchFrom:])
|
||
if match == nil {
|
||
break
|
||
}
|
||
visibleStart := searchFrom + match[4]
|
||
visibleEnd := searchFrom + match[5]
|
||
mentionStart := offsets[visibleStart]
|
||
mentionEnd := offsets[visibleEnd-1] + 1
|
||
prefix := line[cursor:mentionStart]
|
||
result.WriteString(prefix)
|
||
activeStyle = activeSGR(activeStyle, prefix)
|
||
|
||
mention := visible[visibleStart:visibleEnd]
|
||
result.WriteString(authorStyle(strings.TrimPrefix(mention, "@")).Render(mention))
|
||
result.WriteString(activeStyle)
|
||
cursor = mentionEnd
|
||
searchFrom = visibleEnd
|
||
}
|
||
result.WriteString(line[cursor:])
|
||
return result.String()
|
||
}
|
||
|
||
func visibleTextOffsets(line string) (string, []int) {
|
||
var visible strings.Builder
|
||
offsets := make([]int, 0, len(line))
|
||
var state byte
|
||
for offset := 0; offset < len(line); {
|
||
sequence, _, length, nextState := xansi.GraphemeWidth.DecodeSequenceInString(
|
||
line[offset:], state, nil,
|
||
)
|
||
if length == 0 {
|
||
break
|
||
}
|
||
plain := xansi.Strip(sequence)
|
||
if plain != "" {
|
||
relative := strings.Index(sequence, plain)
|
||
if relative < 0 {
|
||
relative = 0
|
||
}
|
||
visible.WriteString(plain)
|
||
for index := range len(plain) {
|
||
offsets = append(offsets, offset+relative+index)
|
||
}
|
||
}
|
||
offset += length
|
||
state = nextState
|
||
}
|
||
return visible.String(), offsets
|
||
}
|
||
|
||
func activeSGR(active, text string) string {
|
||
for _, sequence := range sgrPattern.FindAllString(text, -1) {
|
||
parameters := strings.TrimSuffix(strings.TrimPrefix(sequence, "\x1b["), "m")
|
||
if parameters == "" || parameters == "0" || strings.HasPrefix(parameters, "0;") ||
|
||
strings.HasPrefix(parameters, "0:") {
|
||
active = ""
|
||
}
|
||
if parameters != "" && parameters != "0" {
|
||
active += sequence
|
||
}
|
||
}
|
||
return active
|
||
}
|
||
|
||
func trimMarkdownLines(lines []string) []string {
|
||
for len(lines) > 0 && strings.TrimSpace(lines[0]) == "" {
|
||
lines = lines[1:]
|
||
}
|
||
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
|
||
lines = lines[:len(lines)-1]
|
||
}
|
||
return lines
|
||
}
|