Files
diple/markdown.go
2026-07-30 16:05:10 +02:00

350 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 commentMarkdownLines = newMarkdownLineCache(512)
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`)
type markdownLineCacheKey struct {
markdown string
width int
}
type markdownLineCache struct {
mu sync.Mutex
limit int
entries map[markdownLineCacheKey][]string
order []markdownLineCacheKey
}
func newMarkdownLineCache(limit int) *markdownLineCache {
return &markdownLineCache{
limit: limit, entries: make(map[markdownLineCacheKey][]string),
}
}
func (c *markdownLineCache) get(key markdownLineCacheKey) ([]string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
lines, ok := c.entries[key]
return append([]string(nil), lines...), ok
}
func (c *markdownLineCache) put(key markdownLineCacheKey, lines []string) []string {
c.mu.Lock()
defer c.mu.Unlock()
if cached, ok := c.entries[key]; ok {
return append([]string(nil), cached...)
}
if len(c.entries) >= c.limit {
delete(c.entries, c.order[0])
c.order = c.order[1:]
}
c.entries[key] = append([]string(nil), lines...)
c.order = append(c.order, key)
return append([]string(nil), lines...)
}
func (c *markdownLineCache) clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.entries = make(map[markdownLineCacheKey][]string)
c.order = nil
}
func renderCommentMarkdown(markdown string, width int) []string {
if strings.TrimSpace(markdown) == "" {
return nil
}
width = max(10, width)
markdown = normalizeGitHubAlerts(markdown)
cacheKey := markdownLineCacheKey{markdown: markdown, width: width}
if lines, ok := commentMarkdownLines.get(cacheKey); ok {
return lines
}
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 commentMarkdownLines.put(cacheKey, 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
}