diff --git a/internal/markdown/codeblock.go b/internal/markdown/codeblock.go new file mode 100644 index 0000000..9451767 --- /dev/null +++ b/internal/markdown/codeblock.go @@ -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(`
`) + + if hasHeader { + w.WriteString(`
`) + + if language != "" { + iconURL := getLanguageIconURL(language) + if iconURL != "" { + w.WriteString(fmt.Sprintf(`%s`, iconURL, html.EscapeString(language))) + } + } + + if title != "" { + w.WriteString(fmt.Sprintf(`%s`, html.EscapeString(title))) + } else if language != "" { + w.WriteString(fmt.Sprintf(`%s`, html.EscapeString(language))) + } + + w.WriteString(``) + w.WriteString(`
`) + } + + highlighted := r.highlightCode(code, language) + w.WriteString(highlighted) + + w.WriteString(`
`) + + 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("
%s
", html.EscapeString(code)) + } + + var buf strings.Builder + if err := formatter.Format(&buf, style, iterator); err != nil { + return fmt.Sprintf("
%s
", 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} +} diff --git a/internal/markdown/markdown.go b/internal/markdown/markdown.go index 77c0da3..a2b92a8 100644 --- a/internal/markdown/markdown.go +++ b/internal/markdown/markdown.go @@ -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(), diff --git a/internal/markdown/markdown_test.go b/internal/markdown/markdown_test.go new file mode 100644 index 0000000..5aba00c --- /dev/null +++ b/internal/markdown/markdown_test.go @@ -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) +} diff --git a/internal/markdown/themes.go b/internal/markdown/themes.go index 90e73d7..94b2ab7 100644 --- a/internal/markdown/themes.go +++ b/internal/markdown/themes.go @@ -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 +}