GitRoot
craft your forge, build your project, grow your community freely
1// SPDX-FileCopyrightText: 2025 Romain Maneschi <romain@gitroot.dev>
2//
3// SPDX-License-Identifier: EUPL-1.2
4
5package main
6
7import (
8 "bytes"
9 "fmt"
10 "io"
11 "strings"
12
13 "github.com/alecthomas/chroma"
14 codeHtml "github.com/alecthomas/chroma/formatters/html"
15 "github.com/alecthomas/chroma/lexers"
16 "github.com/alecthomas/chroma/styles"
17 "github.com/gomarkdown/markdown"
18 "github.com/gomarkdown/markdown/ast"
19 "github.com/gomarkdown/markdown/html"
20 "github.com/gomarkdown/markdown/parser"
21)
22
23func mdToHTML(md []byte, readIncludeFn parser.ReadIncludeFunc) []byte {
24 metadata := []byte("")
25 if bytes.HasPrefix(md, []byte("---")) {
26 metadatas := bytes.Split(bytes.TrimPrefix(md, []byte("---")), []byte("---"))
27 metadata = metadatas[0]
28 md = bytes.Join(metadatas[1:], []byte("---"))
29 }
30
31 // create markdown parser with extensions
32 extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock | parser.Includes | parser.Mmark
33 p := parser.NewWithExtensions(extensions)
34 p.Opts.ReadIncludeFn = readIncludeFn
35 doc := modifyAst(p.Parse(md), metadata)
36
37 // create HTML renderer with extensions
38 htmlFlags := html.CommonFlags | html.HrefTargetBlank
39 opts := html.RendererOptions{Flags: htmlFlags, RenderNodeHook: persoRenderer}
40 renderer := html.NewRenderer(opts)
41
42 return markdown.Render(doc, renderer)
43}
44
45type Break struct {
46 ast.Container
47}
48
49type Metadata struct {
50 ast.Container
51
52 metadatas map[string]string
53}
54
55func modifyAst(doc ast.Node, metadata []byte) ast.Node {
56 if len(metadata) > 0 {
57 meta := &Metadata{metadatas: make(map[string]string)}
58 for _, d := range bytes.Split(metadata, []byte("\n")) {
59 if len(bytes.TrimSpace(d)) > 0 {
60 dd := strings.Split(string(d), ":")
61 if len(dd) > 1 {
62 meta.metadatas[strings.TrimSpace(dd[0])] = strings.TrimSpace(dd[1])
63 } else {
64 meta.metadatas[string(bytes.TrimSpace(d))] = ""
65 }
66 }
67 }
68 metadataAsNode := []ast.Node{meta}
69 doc.SetChildren(append(metadataAsNode, doc.GetChildren()...))
70 }
71
72 isExternalURI := func(uri string) bool {
73 return (strings.HasPrefix(uri, "https://") || strings.HasPrefix(uri, "http://"))
74 }
75 ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
76 if link, ok := node.(*ast.Link); ok && entering {
77 if isExternalURI(string(link.Destination)) {
78 link.AdditionalAttributes = append(link.AdditionalAttributes, `target="_blank"`)
79 }
80 cleanDest := strings.Split(string(link.Destination), "#")
81 if strings.HasSuffix(cleanDest[0], ".md") {
82 link.Destination = []byte(fmt.Sprintf("%s.html", strings.TrimSuffix(cleanDest[0], ".md")))
83 if len(cleanDest) > 1 {
84 link.Destination = bytes.Join([][]byte{link.Destination, []byte(cleanDest[1])}, []byte("#"))
85 }
86 }
87 }
88
89 return ast.GoToNext
90 })
91
92 return doc
93}
94
95func persoRenderer(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
96 if n, ok := node.(*Metadata); ok {
97 if !entering {
98 return ast.SkipChildren, true
99 }
100 return renderMetadata(w, n)
101 }
102 if _, ok := node.(*Break); ok {
103 if !entering {
104 return ast.SkipChildren, true
105 }
106 return renderBreak(w)
107 }
108 if code, ok := node.(*ast.CodeBlock); ok {
109 renderCode(w, code, entering)
110 return ast.GoToNext, true
111 }
112 return ast.GoToNext, false
113}
114
115func renderMetadata(w io.Writer, n *Metadata) (ast.WalkStatus, bool) {
116 tmp := bytes.Buffer{}
117 tmp.WriteString("<dl>")
118 for key, value := range n.metadatas {
119 tmp.WriteString("<dt>")
120 tmp.WriteString(key)
121 tmp.WriteString("</dt>")
122 tmp.WriteString("<dd>")
123 tmp.WriteString(value)
124 tmp.WriteString("</dd>")
125 }
126 tmp.WriteString("</dl>\n\n")
127 w.Write(tmp.Bytes())
128 return ast.SkipChildren, true
129}
130
131func renderBreak(w io.Writer) (ast.WalkStatus, bool) {
132 w.Write([]byte("<hr />\n\n"))
133 return ast.SkipChildren, true
134}
135
136func renderCode(w io.Writer, codeBlock *ast.CodeBlock, entering bool) {
137 defaultLang := ""
138 lang := string(codeBlock.Info)
139 htmlHighlight(w, string(codeBlock.Literal), lang, defaultLang)
140}
141
142func htmlHighlight(w io.Writer, source, lang, defaultLang string) error {
143 if lang == "" {
144 lang = defaultLang
145 }
146 l := lexers.Get(lang)
147 if l == nil {
148 l = lexers.Analyse(source)
149 }
150 if l == nil {
151 l = lexers.Fallback
152 }
153 l = chroma.Coalesce(l)
154
155 it, err := l.Tokenise(nil, source)
156 if err != nil {
157 return err
158 }
159 highlightStyle := styles.Get("dracula")
160 if highlightStyle == nil {
161 return fmt.Errorf("didn't find style '%s'", "dracula")
162 }
163 return codeHtml.New(codeHtml.WithLineNumbers(true)).Format(w, highlightStyle, it)
164}