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}