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.Split(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.ReadIncludeFn = plug.readInclude
 57	doc := plug.modifyAst(p.Parse(md), fp)
 58
 59	// create HTML renderer with extensions
 60	htmlFlags := html.CommonFlags
 61	opts := html.RendererOptions{Flags: htmlFlags, RenderNodeHook: plug.persoRenderer(allMetaDatas)}
 62	renderer := html.NewRenderer(opts)
 63
 64	res := string(markdown.Render(doc, renderer))
 65
 66	return res, metadatas
 67}
 68
 69func (p *Plugin) readInclude(from, path string, address []byte) []byte {
 70	content, _ := fs.ReadFile(p.server.Worktree(), path)
 71	return content
 72}
 73
 74func (plug *Plugin) modifyAst(doc ast.Node, fp string) ast.Node {
 75	isExternalURI := func(uri string) bool {
 76		external := strings.HasPrefix(uri, "https://") || strings.HasPrefix(uri, "http://")
 77		if external {
 78			plug.server.Log(fmt.Sprintf("isExternal %s", uri))
 79		}
 80		return external
 81	}
 82	ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
 83		if link, ok := node.(*ast.Link); ok && entering {
 84			if isExternalURI(string(link.Destination)) {
 85				link.AdditionalAttributes = append(link.AdditionalAttributes, `target="_blank"`)
 86			} else {
 87				cleanDest := strings.Split(string(link.Destination), "#")
 88				file := cleanDest[0]
 89				dir, fileFp := filepath.Split(fp)
 90				if file == "" {
 91					file = fileFp
 92				}
 93				if strings.HasSuffix(file, ".md") {
 94					link.Destination = fmt.Appendf(nil, "%s.html", strings.TrimSuffix(file, ".md"))
 95					if len(cleanDest) > 1 {
 96						link.Destination = bytes.Join([][]byte{link.Destination, []byte(cleanDest[1])}, []byte("#"))
 97					}
 98				}
 99				link.Destination = []byte(filepath.Clean(filepath.Join(dir, string(link.Destination))))
100			}
101		}
102
103		return ast.GoToNext
104	})
105
106	return doc
107}
108
109func (plug *Plugin) persoRenderer(metadatas map[string]string) func(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
110	apexRenderMetadatasVal, apexRenderMetadatasOk := metadatas[apexRenderMetadatas]
111	horizontalRuleVal, horizontalRuleOk := metadatas[horizontalRuleMetadatas]
112	footerSlideMetadatasVal, footerSlideMetadatasOk := metadatas[footerSlideMetadatas]
113	return func(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
114		if _, ok := node.(*ast.Document); ok {
115			if entering {
116				if apexRenderMetadatasOk && apexRenderMetadatasVal == "true" {
117					renderMetadata(w, metadatas)
118				}
119				if horizontalRuleOk && (horizontalRuleVal == "section" || horizontalRuleVal == "article") {
120					if footerSlideMetadatasOk {
121						fmt.Fprintf(w, "<%s data-footer=\"%s\">", horizontalRuleVal, footerSlideMetadatasVal)
122					} else {
123						fmt.Fprintf(w, "<%s>", horizontalRuleVal)
124					}
125				}
126			} else {
127				if horizontalRuleOk && (horizontalRuleVal == "section" || horizontalRuleVal == "article") {
128					fmt.Fprintf(w, "</%s>", horizontalRuleVal)
129				}
130			}
131		}
132		if _, ok := node.(*ast.HorizontalRule); ok {
133			if !entering {
134				return ast.SkipChildren, true
135			}
136			if horizontalRuleOk && (horizontalRuleVal == "section" || horizontalRuleVal == "article") {
137				if footerSlideMetadatasOk {
138					fmt.Fprintf(w, "\n</%s><%s data-footer=\"%s\">\n", horizontalRuleVal, horizontalRuleVal, footerSlideMetadatasVal)
139				} else {
140					fmt.Fprintf(w, "\n</%s><%s>\n", horizontalRuleVal, horizontalRuleVal)
141				}
142			} else {
143				w.Write([]byte("\n<hr>\n"))
144			}
145			return ast.SkipChildren, true
146		}
147		if code, ok := node.(*ast.CodeBlock); ok {
148			plug.renderCode(w, code)
149			return ast.GoToNext, true
150		}
151		return ast.GoToNext, false
152	}
153}
154
155func renderMetadata(w io.Writer, metadatas map[string]string) (ast.WalkStatus, bool) {
156	tmp := bytes.Buffer{}
157	tmp.WriteString("<dl>")
158	for key, value := range metadatas {
159		if key != apexRenderMetadatas && key != horizontalRuleMetadatas {
160			tmp.WriteString("<dt>")
161			tmp.WriteString(key)
162			tmp.WriteString("</dt>")
163			tmp.WriteString("<dd>")
164			tmp.WriteString(value)
165			tmp.WriteString("</dd>")
166		}
167	}
168	tmp.WriteString("</dl>\n\n")
169	w.Write(tmp.Bytes())
170	return ast.SkipChildren, true
171}
172
173func (p *Plugin) renderCode(w io.Writer, codeBlock *ast.CodeBlock) {
174	code := string(codeBlock.Literal)
175	lang := string(codeBlock.Info)
176	var htmlCode map[string]string
177	var err error
178	if lang == mermaidLang && p.canCallMermaidPlugin {
179		htmlCode, err = p.server.CallFunc(PLUGIN_MERMAID, PLUGIN_MERMAID_FUNC, map[string]string{"code": code})
180	} else if p.canCallCodePlugin {
181		htmlCode, err = p.server.CallFunc(PLUGIN_CODE, PLUGIN_CODE_FUNC, map[string]string{"code": code, "lang": lang})
182	} else {
183		htmlCode = map[string]string{"html": fmt.Sprintf("<pre><code>%s</code></pre>", code)}
184	}
185	if err != nil {
186		p.server.LogError("renderCode call renderCode fail", err)
187		return
188	}
189	fmt.Fprint(w, htmlCode["html"])
190}