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	"strings"
 12
 13	"github.com/alecthomas/chroma"
 14	codeHtml "github.com/alecthomas/chroma/formatters/html"
 15	"github.com/alecthomas/chroma/lexers"
 16	"github.com/alecthomas/chroma/styles"
 17	"github.com/gomarkdown/markdown"
 18	"github.com/gomarkdown/markdown/ast"
 19	"github.com/gomarkdown/markdown/html"
 20	"github.com/gomarkdown/markdown/parser"
 21)
 22
 23func mdToHTML(md []byte, readIncludeFn parser.ReadIncludeFunc) []byte {
 24	metadata := []byte("")
 25	if bytes.HasPrefix(md, []byte("---")) {
 26		metadatas := bytes.Split(bytes.TrimPrefix(md, []byte("---")), []byte("---"))
 27		metadata = metadatas[0]
 28		md = bytes.Join(metadatas[1:], []byte("---"))
 29	}
 30
 31	// create markdown parser with extensions
 32	extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock | parser.Includes | parser.Mmark
 33	p := parser.NewWithExtensions(extensions)
 34	p.Opts.ReadIncludeFn = readIncludeFn
 35	doc := modifyAst(p.Parse(md), metadata)
 36
 37	// create HTML renderer with extensions
 38	htmlFlags := html.CommonFlags | html.HrefTargetBlank
 39	opts := html.RendererOptions{Flags: htmlFlags, RenderNodeHook: persoRenderer}
 40	renderer := html.NewRenderer(opts)
 41
 42	return markdown.Render(doc, renderer)
 43}
 44
 45type Break struct {
 46	ast.Container
 47}
 48
 49type Metadata struct {
 50	ast.Container
 51
 52	metadatas map[string]string
 53}
 54
 55func modifyAst(doc ast.Node, metadata []byte) ast.Node {
 56	if len(metadata) > 0 {
 57		meta := &Metadata{metadatas: make(map[string]string)}
 58		for _, d := range bytes.Split(metadata, []byte("\n")) {
 59			if len(bytes.TrimSpace(d)) > 0 {
 60				dd := strings.Split(string(d), ":")
 61				if len(dd) > 1 {
 62					meta.metadatas[strings.TrimSpace(dd[0])] = strings.TrimSpace(dd[1])
 63				} else {
 64					meta.metadatas[string(bytes.TrimSpace(d))] = ""
 65				}
 66			}
 67		}
 68		metadataAsNode := []ast.Node{meta}
 69		doc.SetChildren(append(metadataAsNode, doc.GetChildren()...))
 70	}
 71
 72	isExternalURI := func(uri string) bool {
 73		return (strings.HasPrefix(uri, "https://") || strings.HasPrefix(uri, "http://"))
 74	}
 75	ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
 76		if link, ok := node.(*ast.Link); ok && entering {
 77			if isExternalURI(string(link.Destination)) {
 78				link.AdditionalAttributes = append(link.AdditionalAttributes, `target="_blank"`)
 79			}
 80			cleanDest := strings.Split(string(link.Destination), "#")
 81			if strings.HasSuffix(cleanDest[0], ".md") {
 82				link.Destination = []byte(fmt.Sprintf("%s.html", strings.TrimSuffix(cleanDest[0], ".md")))
 83				if len(cleanDest) > 1 {
 84					link.Destination = bytes.Join([][]byte{link.Destination, []byte(cleanDest[1])}, []byte("#"))
 85				}
 86			}
 87		}
 88
 89		return ast.GoToNext
 90	})
 91
 92	return doc
 93}
 94
 95func persoRenderer(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
 96	if n, ok := node.(*Metadata); ok {
 97		if !entering {
 98			return ast.SkipChildren, true
 99		}
100		return renderMetadata(w, n)
101	}
102	if _, ok := node.(*Break); ok {
103		if !entering {
104			return ast.SkipChildren, true
105		}
106		return renderBreak(w)
107	}
108	if code, ok := node.(*ast.CodeBlock); ok {
109		renderCode(w, code, entering)
110		return ast.GoToNext, true
111	}
112	return ast.GoToNext, false
113}
114
115func renderMetadata(w io.Writer, n *Metadata) (ast.WalkStatus, bool) {
116	tmp := bytes.Buffer{}
117	tmp.WriteString("<dl>")
118	for key, value := range n.metadatas {
119		tmp.WriteString("<dt>")
120		tmp.WriteString(key)
121		tmp.WriteString("</dt>")
122		tmp.WriteString("<dd>")
123		tmp.WriteString(value)
124		tmp.WriteString("</dd>")
125	}
126	tmp.WriteString("</dl>\n\n")
127	w.Write(tmp.Bytes())
128	return ast.SkipChildren, true
129}
130
131func renderBreak(w io.Writer) (ast.WalkStatus, bool) {
132	w.Write([]byte("<hr />\n\n"))
133	return ast.SkipChildren, true
134}
135
136func renderCode(w io.Writer, codeBlock *ast.CodeBlock, entering bool) {
137	defaultLang := ""
138	lang := string(codeBlock.Info)
139	htmlHighlight(w, string(codeBlock.Literal), lang, defaultLang)
140}
141
142func htmlHighlight(w io.Writer, source, lang, defaultLang string) error {
143	if lang == "" {
144		lang = defaultLang
145	}
146	l := lexers.Get(lang)
147	if l == nil {
148		l = lexers.Analyse(source)
149	}
150	if l == nil {
151		l = lexers.Fallback
152	}
153	l = chroma.Coalesce(l)
154
155	it, err := l.Tokenise(nil, source)
156	if err != nil {
157		return err
158	}
159	highlightStyle := styles.Get("dracula")
160	if highlightStyle == nil {
161		return fmt.Errorf("didn't find style '%s'", "dracula")
162	}
163	return codeHtml.New(codeHtml.WithLineNumbers(true)).Format(w, highlightStyle, it)
164}