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}