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