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 renderMetadatas := false
61 for _, d := range bytes.Split(metadata, []byte("\n")) {
62 if len(bytes.TrimSpace(d)) > 0 {
63 dd := strings.Split(string(d), ":")
64 if len(dd) > 1 {
65 key := strings.TrimSpace(dd[0])
66 val := strings.TrimSpace(dd[1])
67 if key == "apexRenderMetadatas" && val == "true" {
68 renderMetadatas = true
69 } else {
70 meta.metadatas[key] = val
71 }
72 } else {
73 meta.metadatas[string(bytes.TrimSpace(d))] = ""
74 }
75 }
76 }
77 if renderMetadatas {
78 metadataAsNode := []ast.Node{meta}
79 doc.SetChildren(append(metadataAsNode, doc.GetChildren()...))
80 }
81 }
82
83 isExternalURI := func(uri string) bool {
84 return (strings.HasPrefix(uri, "https://") || strings.HasPrefix(uri, "http://"))
85 }
86 ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
87 if link, ok := node.(*ast.Link); ok && entering {
88 if isExternalURI(string(link.Destination)) {
89 link.AdditionalAttributes = append(link.AdditionalAttributes, `target="_blank"`)
90 } else {
91 cleanDest := strings.Split(string(link.Destination), "#")
92 if strings.HasSuffix(cleanDest[0], ".md") {
93 link.Destination = []byte(fmt.Sprintf("%s.html", strings.TrimSuffix(cleanDest[0], ".md")))
94 if len(cleanDest) > 1 {
95 link.Destination = bytes.Join([][]byte{link.Destination, []byte(cleanDest[1])}, []byte("#"))
96 }
97 }
98 dir, _ := filepath.Split(fp)
99 link.Destination = []byte(fmt.Sprintf("./%s", filepath.Clean(filepath.Join(dir, string(link.Destination)))))
100 }
101 }
102
103 return ast.GoToNext
104 })
105
106 return doc, metadatas
107}
108
109func persoRenderer(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
110 if n, ok := node.(*Metadata); ok {
111 if !entering {
112 return ast.SkipChildren, true
113 }
114 return renderMetadata(w, n)
115 }
116 if _, ok := node.(*Break); ok {
117 if !entering {
118 return ast.SkipChildren, true
119 }
120 return renderBreak(w)
121 }
122 if code, ok := node.(*ast.CodeBlock); ok {
123 renderCode(w, code, entering)
124 return ast.GoToNext, true
125 }
126 return ast.GoToNext, false
127}
128
129func renderMetadata(w io.Writer, n *Metadata) (ast.WalkStatus, bool) {
130 tmp := bytes.Buffer{}
131 tmp.WriteString("<dl>")
132 for key, value := range n.metadatas {
133 tmp.WriteString("<dt>")
134 tmp.WriteString(key)
135 tmp.WriteString("</dt>")
136 tmp.WriteString("<dd>")
137 tmp.WriteString(value)
138 tmp.WriteString("</dd>")
139 }
140 tmp.WriteString("</dl>\n\n")
141 w.Write(tmp.Bytes())
142 return ast.SkipChildren, true
143}
144
145func renderBreak(w io.Writer) (ast.WalkStatus, bool) {
146 w.Write([]byte("<hr />\n\n"))
147 return ast.SkipChildren, true
148}
149
150func renderCode(w io.Writer, codeBlock *ast.CodeBlock, entering bool) {
151 defaultLang := ""
152 lang := string(codeBlock.Info)
153 htmlHighlight(w, string(codeBlock.Literal), lang, defaultLang)
154}
155
156func htmlHighlight(w io.Writer, source, lang, defaultLang string) error {
157 if lang == "" {
158 lang = defaultLang
159 }
160 l := lexers.Get(lang)
161 if l == nil {
162 l = lexers.Analyse(source)
163 }
164 if l == nil {
165 l = lexers.Fallback
166 }
167 l = chroma.Coalesce(l)
168
169 it, err := l.Tokenise(nil, source)
170 if err != nil {
171 return err
172 }
173 highlightStyle := styles.Get("dracula")
174 if highlightStyle == nil {
175 return fmt.Errorf("didn't find style '%s'", "dracula")
176 }
177 return codeHtml.New(codeHtml.WithLineNumbers(true), codeHtml.LinkableLineNumbers(true, "L")).Format(w, highlightStyle, it)
178}