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 "io/fs"
12 "maps"
13 "path/filepath"
14 "strings"
15
16 "github.com/gomarkdown/markdown"
17 "github.com/gomarkdown/markdown/ast"
18 "github.com/gomarkdown/markdown/html"
19 "github.com/gomarkdown/markdown/parser"
20)
21
22const (
23 mermaidLang = "mermaid"
24 apexRenderMetadatas = "apexRenderMetadatas"
25 horizontalRuleMetadatas = "horizontalRuleMetadatas"
26 footerSlideMetadatas = "footerSlideMetadatas"
27)
28
29func (plug *Plugin) mdToHTML(fp string, md []byte, extraMedata map[string]string) (string, map[string]string) {
30 metadatas := map[string]string{}
31 if bytes.HasPrefix(md, []byte("---")) {
32 metadata, body, found := bytes.Cut(bytes.TrimPrefix(md, []byte("---")), []byte("---"))
33 if found {
34 md = body
35 for d := range bytes.SplitSeq(metadata, []byte("\n")) {
36 dString := string(bytes.TrimSpace(d))
37 if i := strings.LastIndex(dString, ": "); i > 0 {
38 prefix := dString[:i]
39 suffix := dString[i+1:]
40 key := strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(prefix), "\""), "\"")
41 val := strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(suffix), "\""), "\"")
42 metadatas[key] = val
43 } else if len(dString) > 0 {
44 metadatas[strings.TrimSuffix(dString, ":")] = ""
45 }
46 }
47 }
48 }
49 allMetaDatas := map[string]string{}
50 maps.Copy(allMetaDatas, extraMedata)
51 maps.Copy(allMetaDatas, metadatas)
52
53 // create markdown parser with extensions
54 extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock | parser.Includes | parser.Mmark
55 p := parser.NewWithExtensions(extensions)
56 p.Opts.Flags |= parser.CommonMark
57 p.Opts.ReadIncludeFn = plug.readInclude
58 doc := plug.modifyAst(p.Parse(md), fp)
59
60 // create HTML renderer with extensions
61 htmlFlags := html.CommonFlags
62 if toc, ok := allMetaDatas["toc"]; ok && toc == "true" {
63 htmlFlags = htmlFlags | html.TOC
64 }
65 opts := html.RendererOptions{Flags: htmlFlags, RenderNodeHook: plug.persoRenderer(allMetaDatas)}
66 renderer := html.NewRenderer(opts)
67
68 res := string(markdown.Render(doc, renderer))
69
70 return res, metadatas
71}
72
73func (p *Plugin) readInclude(from, path string, address []byte) []byte {
74 content, _ := fs.ReadFile(p.server.Worktree(), path)
75 return content
76}
77
78func (plug *Plugin) modifyAst(doc ast.Node, fp string) ast.Node {
79 isExternalURI := func(uri string) bool {
80 external := strings.HasPrefix(uri, "https://") || strings.HasPrefix(uri, "http://")
81 if external {
82 plug.server.Log(fmt.Sprintf("isExternal %s", uri))
83 }
84 return external
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 file := cleanDest[0]
93 dir, fileFp := filepath.Split(fp)
94 if file == "" {
95 file = fileFp
96 }
97 if before, ok := strings.CutSuffix(file, ".md"); ok {
98 link.Destination = fmt.Appendf(nil, "%s.html", before)
99 if len(cleanDest) > 1 {
100 link.Destination = bytes.Join([][]byte{link.Destination, []byte(cleanDest[1])}, []byte("#"))
101 }
102 }
103 link.Destination = []byte(filepath.Clean(filepath.Join(dir, string(link.Destination))))
104 }
105 }
106
107 return ast.GoToNext
108 })
109
110 return doc
111}
112
113func (plug *Plugin) persoRenderer(metadatas map[string]string) func(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
114 apexRenderMetadatasVal, apexRenderMetadatasOk := metadatas[apexRenderMetadatas]
115 horizontalRuleVal, horizontalRuleOk := metadatas[horizontalRuleMetadatas]
116 footerSlideMetadatasVal, footerSlideMetadatasOk := metadatas[footerSlideMetadatas]
117 return func(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
118 if _, ok := node.(*ast.Document); ok {
119 if entering {
120 if apexRenderMetadatasOk && apexRenderMetadatasVal == "true" {
121 renderMetadata(w, metadatas)
122 }
123 if horizontalRuleOk && (horizontalRuleVal == "section" || horizontalRuleVal == "article") {
124 if footerSlideMetadatasOk {
125 fmt.Fprintf(w, "<%s data-footer=\"%s\">", horizontalRuleVal, footerSlideMetadatasVal)
126 } else {
127 fmt.Fprintf(w, "<%s>", horizontalRuleVal)
128 }
129 }
130 } else {
131 if horizontalRuleOk && (horizontalRuleVal == "section" || horizontalRuleVal == "article") {
132 fmt.Fprintf(w, "</%s>", horizontalRuleVal)
133 }
134 }
135 }
136 if _, ok := node.(*ast.HorizontalRule); ok {
137 if !entering {
138 return ast.SkipChildren, true
139 }
140 if horizontalRuleOk && (horizontalRuleVal == "section" || horizontalRuleVal == "article") {
141 if footerSlideMetadatasOk {
142 fmt.Fprintf(w, "\n</%s><%s data-footer=\"%s\">\n", horizontalRuleVal, horizontalRuleVal, footerSlideMetadatasVal)
143 } else {
144 fmt.Fprintf(w, "\n</%s><%s>\n", horizontalRuleVal, horizontalRuleVal)
145 }
146 } else {
147 w.Write([]byte("\n<hr>\n"))
148 }
149 return ast.SkipChildren, true
150 }
151 if code, ok := node.(*ast.CodeBlock); ok {
152 plug.renderCode(w, code)
153 return ast.GoToNext, true
154 }
155 if blockQuote, ok := node.(*ast.BlockQuote); ok {
156 if kind, ok := plug.isBlockQuoteCallout(blockQuote); ok {
157 if entering {
158 plug.renderBlockQuoteCallout(w, kind)
159 return ast.GoToNext, true
160 }
161 } else if title, isOpen, ok := plug.isBlockQuoteSummaryDetails(blockQuote); ok {
162 if entering {
163 plug.renderBlockQuoteSummaryDetailsOpen(w, title, isOpen)
164 return ast.GoToNext, true
165 }
166 } else if blockQuote.Container.Attribute != nil && bytes.Equal(blockQuote.Attrs["data-details"], []byte("true")) {
167 plug.renderBlockQuoteSummaryDetailsClose(w)
168 return ast.GoToNext, true
169 }
170 }
171 return ast.GoToNext, false
172 }
173}
174
175func renderMetadata(w io.Writer, metadatas map[string]string) (ast.WalkStatus, bool) {
176 tmp := bytes.Buffer{}
177 tmp.WriteString("<dl>")
178 for key, value := range metadatas {
179 if key != apexRenderMetadatas && key != horizontalRuleMetadatas {
180 tmp.WriteString("<dt>")
181 tmp.WriteString(key)
182 tmp.WriteString("</dt>")
183 tmp.WriteString("<dd>")
184 tmp.WriteString(value)
185 tmp.WriteString("</dd>")
186 }
187 }
188 tmp.WriteString("</dl>\n\n")
189 w.Write(tmp.Bytes())
190 return ast.SkipChildren, true
191}
192
193func (p *Plugin) renderCode(w io.Writer, codeBlock *ast.CodeBlock) {
194 code := string(codeBlock.Literal)
195 lang := string(codeBlock.Info)
196 var htmlCode map[string]string
197 var err error
198 if lang == mermaidLang && p.canCallMermaidPlugin {
199 htmlCode, err = p.server.CallFunc(PLUGIN_MERMAID, PLUGIN_MERMAID_FUNC, map[string]string{"code": code})
200 } else if p.canCallCodePlugin {
201 htmlCode, err = p.server.CallFunc(PLUGIN_CODE, PLUGIN_CODE_FUNC, map[string]string{"code": code, "lang": lang})
202 } else {
203 htmlCode = map[string]string{"html": fmt.Sprintf("<pre><code>%s</code></pre>", code)}
204 }
205 if err != nil {
206 p.server.LogError("renderCode call renderCode fail", err)
207 return
208 }
209 fmt.Fprint(w, htmlCode["html"])
210}
211
212var calloutColor = map[string]string{
213 "[!NOTE]": "#006ac7",
214 "[!WARNING]": "#be4700",
215 "[!ERROR]": "#f00",
216}
217
218func (p *Plugin) isBlockQuoteCallout(blockQuote *ast.BlockQuote) (string, bool) {
219 if len(blockQuote.Children) > 0 {
220 p, ok := blockQuote.Children[0].(*ast.Paragraph)
221 if ok && len(p.Children) > 0 {
222 l, ok := p.Children[0].(*ast.Text)
223 if ok {
224 kind, rest, _ := strings.Cut(string(l.Literal), "\n")
225 if _, ok := calloutColor[kind]; ok {
226 l.Literal = []byte(rest)
227 return kind, true
228 }
229 }
230 }
231 }
232 return "", false
233}
234
235func (p *Plugin) renderBlockQuoteCallout(w io.Writer, kind string) {
236 fmt.Fprintf(w, "<blockquote style=\"border-color: %s\">\n", calloutColor[kind])
237}
238
239func (p *Plugin) isBlockQuoteSummaryDetails(blockQuote *ast.BlockQuote) (string, bool, bool) {
240 if len(blockQuote.Children) > 0 {
241 p, ok := blockQuote.Children[0].(*ast.Paragraph)
242 if ok && len(p.Children) > 0 {
243 l, ok := p.Children[0].(*ast.Text)
244 if ok {
245 line, rest, _ := strings.Cut(string(l.Literal), "\n")
246 kind, title, _ := strings.Cut(line, " ")
247 if strings.HasPrefix(kind, "[!DETAILS]") {
248 if blockQuote.Container.Attribute == nil {
249 blockQuote.Container.Attribute = &ast.Attribute{Attrs: map[string][]byte{}}
250 }
251 blockQuote.Attrs["data-details"] = []byte("true")
252 l.Literal = []byte(rest)
253 return title, strings.HasPrefix(kind, "[!DETAILS]+"), true
254 }
255 }
256 }
257 }
258 return "", false, false
259}
260
261func (p *Plugin) renderBlockQuoteSummaryDetailsOpen(w io.Writer, title string, isOpen bool) {
262 if isOpen {
263 fmt.Fprintf(w, "<details open=\"true\"><summary>%s</summary>\n", title)
264 return
265 }
266 fmt.Fprintf(w, "<details><summary>%s</summary>\n", title)
267}
268
269func (p *Plugin) renderBlockQuoteSummaryDetailsClose(w io.Writer) {
270 fmt.Fprintf(w, "</details>\n")
271}