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