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}