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}