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:
parent
6e2959f619
commit
771ff7615a
4 changed files with 323 additions and 4 deletions
225
internal/markdown/codeblock.go
Normal file
225
internal/markdown/codeblock.go
Normal 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}
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,6 @@ import (
|
||||||
|
|
||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
emoji "github.com/yuin/goldmark-emoji"
|
emoji "github.com/yuin/goldmark-emoji"
|
||||||
highlighting "github.com/yuin/goldmark-highlighting/v2"
|
|
||||||
"github.com/yuin/goldmark/extension"
|
"github.com/yuin/goldmark/extension"
|
||||||
"github.com/yuin/goldmark/parser"
|
"github.com/yuin/goldmark/parser"
|
||||||
"github.com/yuin/goldmark/renderer/html"
|
"github.com/yuin/goldmark/renderer/html"
|
||||||
|
|
@ -41,9 +40,7 @@ func getRenderer(codeTheme string) goldmark.Markdown {
|
||||||
extension.GFM,
|
extension.GFM,
|
||||||
extension.Typographer,
|
extension.Typographer,
|
||||||
emoji.Emoji,
|
emoji.Emoji,
|
||||||
highlighting.NewHighlighting(
|
NewCodeBlockExtension(codeTheme),
|
||||||
highlighting.WithStyle(codeTheme),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
goldmark.WithParserOptions(
|
goldmark.WithParserOptions(
|
||||||
parser.WithAutoHeadingID(),
|
parser.WithAutoHeadingID(),
|
||||||
|
|
|
||||||
81
internal/markdown/markdown_test.go
Normal file
81
internal/markdown/markdown_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/alecthomas/chroma/v2"
|
"github.com/alecthomas/chroma/v2"
|
||||||
|
chromahtml "github.com/alecthomas/chroma/v2/formatters/html"
|
||||||
"github.com/alecthomas/chroma/v2/styles"
|
"github.com/alecthomas/chroma/v2/styles"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -150,3 +151,18 @@ func GenerateHljsCSS(themeName string) (string, error) {
|
||||||
func ListThemes() []string {
|
func ListThemes() []string {
|
||||||
return styles.Names()
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue