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}