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