feat(markdown): add enhanced code blocks with syntax highlighting

- Add codeblock.go for custom Goldmark renderer
- Add code block header with language icon, filename, copy button
- Use Chroma for syntax highlighting with class-based output
- Add GenerateChromaCSS for theme CSS generation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Josh 2026-01-12 02:01:09 +02:00
parent 6e2959f619
commit 771ff7615a
4 changed files with 323 additions and 4 deletions

View file

@ -0,0 +1,225 @@
package markdown
import (
"fmt"
"html"
"regexp"
"strings"
"github.com/alecthomas/chroma/v2"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/lexers"
"github.com/alecthomas/chroma/v2/styles"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/util"
)
var languageIcons = map[string]string{
"javascript": "javascript",
"js": "javascript",
"typescript": "typescript",
"ts": "typescript",
"html": "html5",
"css": "css3",
"scss": "sass",
"sass": "sass",
"less": "less",
"react": "react",
"jsx": "react",
"tsx": "react",
"vue": "vuedotjs",
"svelte": "svelte",
"angular": "angular",
"astro": "astro",
"python": "python",
"py": "python",
"go": "go",
"golang": "go",
"rust": "rust",
"java": "openjdk",
"kotlin": "kotlin",
"scala": "scala",
"ruby": "ruby",
"rb": "ruby",
"php": "php",
"csharp": "csharp",
"cs": "csharp",
"cpp": "cplusplus",
"c": "c",
"swift": "swift",
"json": "json",
"yaml": "yaml",
"yml": "yaml",
"toml": "toml",
"xml": "xml",
"bash": "gnubash",
"sh": "gnubash",
"shell": "gnubash",
"zsh": "gnubash",
"powershell": "powershell",
"ps1": "powershell",
"sql": "postgresql",
"mysql": "mysql",
"postgres": "postgresql",
"postgresql": "postgresql",
"mongodb": "mongodb",
"redis": "redis",
"graphql": "graphql",
"gql": "graphql",
"docker": "docker",
"dockerfile": "docker",
"markdown": "markdown",
"md": "markdown",
"lua": "lua",
"elixir": "elixir",
"erlang": "erlang",
"haskell": "haskell",
"clojure": "clojure",
"zig": "zig",
"nim": "nim",
"r": "r",
"julia": "julia",
"dart": "dart",
"flutter": "flutter",
"solidity": "solidity",
"terraform": "terraform",
"nginx": "nginx",
"apache": "apache",
}
var titleRegex = regexp.MustCompile(`title=["']([^"']+)["']`)
func parseCodeInfo(info string) (language, title string) {
info = strings.TrimSpace(info)
if info == "" {
return "", ""
}
if match := titleRegex.FindStringSubmatch(info); match != nil {
title = match[1]
info = strings.TrimSpace(titleRegex.ReplaceAllString(info, ""))
}
parts := strings.Fields(info)
if len(parts) > 0 {
language = parts[0]
}
return language, title
}
func getLanguageIconURL(language string) string {
slug, ok := languageIcons[strings.ToLower(language)]
if !ok {
return ""
}
return fmt.Sprintf("https://api.iconify.design/simple-icons/%s.svg?color=%%2371717a", slug)
}
type codeBlockRenderer struct {
style string
}
func (r *codeBlockRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock)
}
func (r *codeBlockRenderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*ast.FencedCodeBlock)
var info string
if n.Info != nil {
info = string(n.Info.Segment.Value(source))
}
language, title := parseCodeInfo(info)
var codeContent strings.Builder
lines := n.Lines()
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
codeContent.Write(line.Value(source))
}
code := codeContent.String()
hasHeader := title != "" || language != ""
w.WriteString(`<div class="code-block">`)
if hasHeader {
w.WriteString(`<div class="code-header">`)
if language != "" {
iconURL := getLanguageIconURL(language)
if iconURL != "" {
w.WriteString(fmt.Sprintf(`<img src="%s" alt="%s" class="code-icon" />`, iconURL, html.EscapeString(language)))
}
}
if title != "" {
w.WriteString(fmt.Sprintf(`<span class="code-title">%s</span>`, html.EscapeString(title)))
} else if language != "" {
w.WriteString(fmt.Sprintf(`<span class="code-title">%s</span>`, html.EscapeString(language)))
}
w.WriteString(`<button class="code-copy" onclick="navigator.clipboard.writeText(this.closest('.code-block').querySelector('code').textContent)">Copy</button>`)
w.WriteString(`</div>`)
}
highlighted := r.highlightCode(code, language)
w.WriteString(highlighted)
w.WriteString(`</div>`)
return ast.WalkContinue, nil
}
func (r *codeBlockRenderer) highlightCode(code, language string) string {
lexer := lexers.Get(language)
if lexer == nil {
lexer = lexers.Fallback
}
lexer = chroma.Coalesce(lexer)
style := styles.Get(r.style)
if style == nil {
style = styles.Fallback
}
formatter := chromahtml.New(
chromahtml.WithClasses(true),
chromahtml.PreventSurroundingPre(false),
)
iterator, err := lexer.Tokenise(nil, code)
if err != nil {
return fmt.Sprintf("<pre><code>%s</code></pre>", html.EscapeString(code))
}
var buf strings.Builder
if err := formatter.Format(&buf, style, iterator); err != nil {
return fmt.Sprintf("<pre><code>%s</code></pre>", html.EscapeString(code))
}
return buf.String()
}
type codeBlockExtension struct {
style string
}
func (e *codeBlockExtension) Extend(m goldmark.Markdown) {
m.Renderer().AddOptions(
renderer.WithNodeRenderers(
util.Prioritized(&codeBlockRenderer{style: e.style}, 100),
),
)
}
func NewCodeBlockExtension(style string) goldmark.Extender {
return &codeBlockExtension{style: style}
}

View file

@ -6,7 +6,6 @@ import (
"github.com/yuin/goldmark"
emoji "github.com/yuin/goldmark-emoji"
highlighting "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
@ -41,9 +40,7 @@ func getRenderer(codeTheme string) goldmark.Markdown {
extension.GFM,
extension.Typographer,
emoji.Emoji,
highlighting.NewHighlighting(
highlighting.WithStyle(codeTheme),
),
NewCodeBlockExtension(codeTheme),
),
goldmark.WithParserOptions(
parser.WithAutoHeadingID(),

View file

@ -0,0 +1,81 @@
package markdown
import (
"strings"
"testing"
)
func TestCodeBlockWithTitle(t *testing.T) {
input := "```typescript title=\"app.ts\"\nconst x = 1\n```"
result, err := Render(input)
if err != nil {
t.Fatalf("Render failed: %v", err)
}
// Check that the code-block wrapper is present
if !strings.Contains(result, `class="code-block"`) {
t.Error("Expected code-block wrapper class")
}
// Check that the header with title is present
if !strings.Contains(result, `class="code-title"`) {
t.Error("Expected code-title class")
}
// Check that the title text is rendered
if !strings.Contains(result, "app.ts") {
t.Error("Expected title 'app.ts' in output")
}
// Check that the icon is present
if !strings.Contains(result, `class="code-icon"`) {
t.Error("Expected code-icon class")
}
// Check that the copy button is present
if !strings.Contains(result, `class="code-copy"`) {
t.Error("Expected code-copy class")
}
t.Log("Rendered output:")
t.Log(result)
}
func TestCodeBlockWithoutTitle(t *testing.T) {
input := "```go\nfmt.Println(\"hello\")\n```"
result, err := Render(input)
if err != nil {
t.Fatalf("Render failed: %v", err)
}
// Should still have header with language
if !strings.Contains(result, `class="code-block"`) {
t.Error("Expected code-block wrapper for language-only block")
}
if !strings.Contains(result, "go") {
t.Error("Expected language 'go' in output")
}
t.Log("Rendered output:")
t.Log(result)
}
func TestCodeBlockPlain(t *testing.T) {
input := "```\nplain code\n```"
result, err := Render(input)
if err != nil {
t.Fatalf("Render failed: %v", err)
}
// Plain code blocks without language/title should not have header
if strings.Contains(result, `class="code-header"`) {
t.Error("Plain code blocks should not have header")
}
t.Log("Rendered output:")
t.Log(result)
}

View file

@ -4,6 +4,7 @@ import (
"strings"
"github.com/alecthomas/chroma/v2"
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/styles"
)
@ -150,3 +151,18 @@ func GenerateHljsCSS(themeName string) (string, error) {
func ListThemes() []string {
return styles.Names()
}
func GenerateChromaCSS(themeName string) (string, error) {
style := styles.Get(themeName)
if style == nil {
style = styles.Get("github")
}
formatter := chromahtml.New(chromahtml.WithClasses(true))
var css strings.Builder
css.WriteString("/* Chroma theme: " + themeName + " */\n")
if err := formatter.WriteCSS(&css, style); err != nil {
return "", err
}
return css.String(), nil
}