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/fs"
 11	"maps"
 12	"mime"
 13	"path/filepath"
 14	"regexp"
 15	"strings"
 16
 17	"gitroot.dev/libs/golang/plugin/model"
 18)
 19
 20var regexpTitle = regexp.MustCompile(`(?m)<h1[^>]*>([^<]+)`)
 21
 22type renderer struct {
 23	p      *Plugin
 24	menu   []link
 25	header string
 26	footer string
 27	meta   string
 28	vars   map[string]string
 29
 30	preRendered map[string]string
 31}
 32
 33func (p *Plugin) newRender(repoName string, forgeConf model.ForgeConf) *renderer {
 34	_, filename := filepath.Split(p.config.favicon)
 35	faviconTypeMime := strings.Split(mime.TypeByExtension(filepath.Ext(filename)), ";")[0]
 36
 37	metas := strings.Builder{}
 38	for key, val := range p.config.meta {
 39		metas.WriteString(fmt.Sprintf("<meta name=\"%s\" content=\"%s\" />\n", key, val))
 40	}
 41
 42	repoUrl := fmt.Sprintf("%s%s/", forgeConf.ExternalHttpAddr, repoName)
 43	if repoName == forgeConf.RootRepositoryName {
 44		repoUrl = forgeConf.ExternalHttpAddr
 45	}
 46
 47	r := &renderer{
 48		p:      p,
 49		menu:   p.config.menu,
 50		header: p.config.header,
 51		footer: p.config.footer,
 52		meta:   metas.String(),
 53		vars: map[string]string{
 54			"repo.favicon":           p.config.favicon,
 55			"repo.faviconType":       faviconTypeMime,
 56			"repo.css":               p.config.style,
 57			"repo.name":              repoName,
 58			"repo.url":               repoUrl,
 59			"repo.cloneUrl":          fmt.Sprintf("%s%s/", forgeConf.ExternalSshAddr, repoName),
 60			"forge.domain":           forgeConf.Domain,
 61			"forge.externalHttpAddr": forgeConf.ExternalHttpAddr,
 62			"forge.externalSshAddr":  forgeConf.ExternalSshAddr,
 63		},
 64		preRendered: map[string]string{
 65			layoutSimpleName: interpolateVars(simple, map[string]string{}),
 66			layoutIssueName:  interpolateVars(simple, map[string]string{}),
 67			layoutSlideName:  interpolateVars(slide, map[string]string{}),
 68		},
 69	}
 70	return r
 71}
 72
 73func (r *renderer) layoutByFp(fp string) (string, bool) {
 74	for _, l := range r.p.config.layout {
 75		if l.Glob.Match(fp) {
 76			return l.Path, true
 77		}
 78	}
 79	return "", false
 80}
 81
 82func (r *renderer) extraMetadataForMarkdown(fp string, newFp string) map[string]string {
 83	vars := map[string]string{}
 84	if l, ok := r.layoutByFp(fp); ok {
 85		switch l {
 86		case layoutSlideName:
 87			vars["horizontalRuleMetadatas"] = "section"
 88			vars["footerSlideMetadatas"] = r.vars["repo.url"] + newFp
 89		case layoutIssueName:
 90			vars["horizontalRuleMetadatas"] = "article"
 91			vars["apexRenderMetadatas"] = "true"
 92		}
 93	}
 94	return vars
 95}
 96
 97func (r *renderer) render(fp string, htmlContent string, extraMetadata map[string]string) string {
 98	vars := map[string]string{}
 99	maps.Copy(vars, r.vars)
100	maps.Copy(vars, extraMetadata)
101
102	if fp != "" { //don't render meta in preRender
103		metas := strings.Builder{}
104		metas.WriteString(r.meta)
105		for key, val := range extraMetadata {
106			if key != "layout" {
107				metas.WriteString(fmt.Sprintf("<meta name=\"%s\" content=\"%s\" />\n", key, val))
108			}
109		}
110		vars["meta"] = metas.String()
111	}
112
113	if fp != "" {
114		vars["page.title"] = findTitle(fp, []byte(htmlContent), extraMetadata)
115	}
116	vars["repo.header"] = interpolateVars(r.header, vars)
117	vars["repo.footer"] = interpolateVars(r.footer, vars)
118	if fp != "" {
119		vars["repo.menu"] = r.buildMenu(fp)
120	}
121	if htmlContent != "" {
122		vars["content"] = htmlContent
123	}
124
125	goodLayout := layoutSimpleName
126	if layout, ok := extraMetadata["layout"]; ok && layout != "" {
127		goodLayout = layout
128	} else {
129		if l, ok := r.layoutByFp(fp); ok {
130			goodLayout = l
131		}
132	}
133
134	r.preRenderIfNeeded(goodLayout)
135	subContent := interpolateVars(r.preRendered[goodLayout], vars)
136	// if it's a full render return it, else incorpore it in global layout
137	if strings.Contains(subContent, "<html>") {
138		return subContent
139	}
140	vars["content"] = subContent
141	return interpolateVars(r.preRendered[layoutSimpleName], vars)
142}
143
144func (r *renderer) preRenderIfNeeded(layout string) {
145	if prerenderedLayout, okPreRendered := r.preRendered[layout]; !okPreRendered || prerenderedLayout == "" {
146		if layoutContent, err := fs.ReadFile(r.p.server.Worktree(), layout); err != nil {
147			r.p.server.LogError(fmt.Sprintf("can't find layout %s use default instead", layout), err)
148			r.p.server.ModifyContent(layout, simple)
149			r.p.server.CommitAllIfNeeded(fmt.Sprintf("add layout %s", layout))
150			r.preRendered[layout] = r.preRendered[layoutSimpleName]
151		} else {
152			r.preRendered[layout] = interpolateVars(string(layoutContent), map[string]string{})
153		}
154	}
155}
156
157func interpolateVars(content string, vars map[string]string) string {
158	lines := strings.Split(content, "\n")
159	res := strings.Builder{}
160	for _, line := range lines {
161		for strings.Contains(line, "{{") && strings.Contains(line, "}}") {
162			foundFirst := strings.Index(line, "{{")
163			foundLast := strings.Index(line, "}}")
164			if foundFirst == -1 || foundLast == -1 {
165				continue
166			}
167			key := line[foundFirst+2 : foundLast]
168			pre := line[:foundFirst]
169			res.WriteString(pre)
170			if value, ok := vars[key]; ok {
171				res.WriteString(value)
172			} else {
173				res.WriteString(line[foundFirst : foundLast+2])
174			}
175			line = line[foundLast+2:]
176		}
177		res.WriteString(line)
178		res.WriteString("\n")
179	}
180	return strings.TrimRight(res.String(), "\n")
181}
182
183func findTitle(path string, content []byte, meta map[string]string) string {
184	if val, ok := meta["title"]; ok {
185		return val
186	}
187	if val, ok := meta["og:title"]; ok {
188		return val
189	}
190	if len(content) > 0 {
191		t := regexpTitle.FindSubmatch(content)
192		if len(t) > 1 {
193			return strings.TrimSpace(string(t[1]))
194		}
195	}
196	return path
197}
198
199func (r *renderer) buildMenu(fp string) string {
200	menu := bytes.NewBufferString("<ul>")
201	currentPage := pathToLink(fp)
202	for _, link := range r.menu {
203		targetLink := pathToLink(link.Link)
204		if currentPage == targetLink {
205			menu.WriteString(fmt.Sprintf("<li><a aria-current=\"page\">%s</a></li>", link.Display))
206		} else {
207			menu.WriteString(fmt.Sprintf("<li><a href=\"./%s\">%s</a></li>", targetLink, link.Display))
208		}
209	}
210	menu.WriteString("</ul>")
211	return menu.String()
212}
213
214func pathToLink(fp string) string {
215	targetLink := fp
216	if strings.HasSuffix(targetLink, "/") {
217		targetLink = fmt.Sprintf("%sindex.html", targetLink)
218	} else {
219		targetLink = fmt.Sprintf("%s.html", strings.TrimSuffix(fp, filepath.Ext(fp)))
220	}
221	targetLink = strings.TrimPrefix(targetLink, "/")
222	return targetLink
223}