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(`
%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
+}