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		for _, d := range bytes.Split(metadata, []byte("\n")) {
 61			if len(bytes.TrimSpace(d)) > 0 {
 62				dd := strings.Split(string(d), ":")
 63				if len(dd) > 1 {
 64					meta.metadatas[strings.TrimSpace(dd[0])] = strings.TrimSpace(dd[1])
 65				} else {
 66					meta.metadatas[string(bytes.TrimSpace(d))] = ""
 67				}
 68			}
 69		}
 70		metadataAsNode := []ast.Node{meta}
 71		doc.SetChildren(append(metadataAsNode, doc.GetChildren()...))
 72	}
 73
 74	isExternalURI := func(uri string) bool {
 75		return (strings.HasPrefix(uri, "https://") || strings.HasPrefix(uri, "http://"))
 76	}
 77	ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus {
 78		if link, ok := node.(*ast.Link); ok && entering {
 79			if isExternalURI(string(link.Destination)) {
 80				link.AdditionalAttributes = append(link.AdditionalAttributes, `target="_blank"`)
 81			}
 82			cleanDest := strings.Split(string(link.Destination), "#")
 83			if strings.HasSuffix(cleanDest[0], ".md") {
 84				link.Destination = []byte(fmt.Sprintf("%s.html", strings.TrimSuffix(cleanDest[0], ".md")))
 85				if len(cleanDest) > 1 {
 86					link.Destination = bytes.Join([][]byte{link.Destination, []byte(cleanDest[1])}, []byte("#"))
 87				}
 88			}
 89			dir, _ := filepath.Split(fp)
 90			link.Destination = []byte(fmt.Sprintf("./%s", filepath.Clean(filepath.Join(dir, string(link.Destination)))))
 91		}
 92
 93		return ast.GoToNext
 94	})
 95
 96	return doc, metadatas
 97}
 98
 99func persoRenderer(w io.Writer, node ast.Node, entering bool) (ast.WalkStatus, bool) {
100	if n, ok := node.(*Metadata); ok {
101		if !entering {
102			return ast.SkipChildren, true
103		}
104		return renderMetadata(w, n)
105	}
106	if _, ok := node.(*Break); ok {
107		if !entering {
108			return ast.SkipChildren, true
109		}
110		return renderBreak(w)
111	}
112	if code, ok := node.(*ast.CodeBlock); ok {
113		renderCode(w, code, entering)
114		return ast.GoToNext, true
115	}
116	return ast.GoToNext, false
117}
118
119func renderMetadata(w io.Writer, n *Metadata) (ast.WalkStatus, bool) {
120	tmp := bytes.Buffer{}
121	tmp.WriteString("<dl>")
122	for key, value := range n.metadatas {
123		tmp.WriteString("<dt>")
124		tmp.WriteString(key)
125		tmp.WriteString("</dt>")
126		tmp.WriteString("<dd>")
127		tmp.WriteString(value)
128		tmp.WriteString("</dd>")
129	}
130	tmp.WriteString("</dl>\n\n")
131	w.Write(tmp.Bytes())
132	return ast.SkipChildren, true
133}
134
135func renderBreak(w io.Writer) (ast.WalkStatus, bool) {
136	w.Write([]byte("<hr />\n\n"))
137	return ast.SkipChildren, true
138}
139
140func renderCode(w io.Writer, codeBlock *ast.CodeBlock, entering bool) {
141	defaultLang := ""
142	lang := string(codeBlock.Info)
143	htmlHighlight(w, string(codeBlock.Literal), lang, defaultLang)
144}
145
146func htmlHighlight(w io.Writer, source, lang, defaultLang string) error {
147	if lang == "" {
148		lang = defaultLang
149	}
150	l := lexers.Get(lang)
151	if l == nil {
152		l = lexers.Analyse(source)
153	}
154	if l == nil {
155		l = lexers.Fallback
156	}
157	l = chroma.Coalesce(l)
158
159	it, err := l.Tokenise(nil, source)
160	if err != nil {
161		return err
162	}
163	highlightStyle := styles.Get("dracula")
164	if highlightStyle == nil {
165		return fmt.Errorf("didn't find style '%s'", "dracula")
166	}
167	return codeHtml.New(codeHtml.WithLineNumbers(true), codeHtml.LinkableLineNumbers(true, "L")).Format(w, highlightStyle, it)
168}