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
5import { JSON } from "json-as";
6import {
7 Commit,
8 File,
9 ForgeConf,
10 InitKind,
11 Plugin,
12 PluginRun,
13 PluginRunWhen,
14 PluginWrite,
15 PluginWriteRight,
16 PluginWriteRightCan,
17 Register,
18 Server,
19} from "plugin/server";
20
21export * from "plugin/exports";
22
23const PLUGIN_MARKDOWN = "pkg:gitroot/gitroot/apex_markdown";
24const PLUGIN_MARKDOWN_FUNC = "renderMd";
25
26namespace FluxKind {
27 export const commits = "commits";
28 export const content = "content";
29}
30
31type FluxKind = string;
32
33@json
34class FluxConf {
35 constructor(
36 public path: string,
37 public title: string,
38 public description: string,
39 public language: string,
40 public kind: FluxKind,
41 ) {}
42
43 static build(
44 path: string,
45 title: string,
46 description: string,
47 language: string,
48 kind: string,
49 ): FluxConf {
50 return new FluxConf(path, title, description, language, kind);
51 }
52
53 generateEmptyRss(repoUrl: string): string {
54 return `<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
55 <channel>
56 <title>${this.title}</title>
57 <link>${repoUrl}</link>
58 <description>${this.description}</description>
59 <language>${this.language}</language>
60 <generator>Pollen plugin - GitRoot</generator>
61 <atom:link href="${repoUrl}/${this.path}" rel="self" type="application/rss+xml" />
62 <!-- next -->
63 </channel>
64</rss>`;
65 }
66}
67
68@json
69class Conf {
70 flux: Array<FluxConf> = [
71 FluxConf.build(
72 "rss/all.xml",
73 "All push",
74 "Every push made in your forge",
75 "en",
76 FluxKind.commits,
77 ),
78 ];
79
80 default(): JSON.Raw {
81 return JSON.Raw.from(JSON.stringify<Conf>(new Conf()));
82 }
83}
84
85const defaultRun: PluginRun = new PluginRun(
86 "**/*",
87 ["*"],
88 [PluginRunWhen.Add, PluginRunWhen.Mod, PluginRunWhen.Del],
89 "",
90 [],
91 new PluginWrite(
92 [],
93 [
94 new PluginWriteRight("rss/*", [
95 PluginWriteRightCan.Add,
96 PluginWriteRightCan.Mod,
97 ]),
98 ],
99 [],
100 [
101 new PluginCallFuncRight(
102 PLUGIN_MARKDOWN,
103 PLUGIN_MARKDOWN_FUNC,
104 JSON.Raw.from("{}"),
105 ),
106 ],
107 ),
108 new Conf().default(),
109);
110
111class Item {
112 constructor(
113 private author: string,
114 private link: string,
115 private title: string,
116 private date: string,
117 private guid: string,
118 private htmlContent: string,
119 ) {}
120
121 static fromCommits(repoUrl: string, commits: Array<Commit>): Item {
122 const beforeDesc = `<p>All commits:</p><ul>`;
123 const afterDesc = `</ul>`;
124 const html =
125 beforeDesc +
126 commits
127 .sort(
128 (a, b) => a.date.getUTCMilliseconds() - b.date.getUTCMilliseconds(),
129 )
130 .reduce((acc, item) => {
131 return `${acc}<li>${item.message.replaceAll("\n", "<br/>")}</li>`;
132 }, "") +
133 afterDesc;
134 return Item.build(
135 `${commits[0].committerEmail} (${commits[0].committerName})`,
136 repoUrl,
137 `Has pushed ${commits.length} commits on ${commits[0].branch}`,
138 commits[0].date.toUTCString(),
139 `${repoUrl}?hash=${commits[0].hash}`,
140 html,
141 );
142 }
143
144 static fromfile(
145 repoUrl: string,
146 file: File,
147 commit: Commit,
148 metadata: Map<string, string>,
149 htmlContent: string,
150 ): Item {
151 let author = `${commit.committerEmail} (${commit.committerName})`;
152 if (metadata.has("author")) {
153 author = metadata.get("author");
154 }
155 let title = file.path;
156 if (metadata.has("title")) {
157 title = metadata.get("title");
158 }
159 let date = commit.date.toUTCString();
160 if (metadata.has("date")) {
161 date = metadata.get("date");
162 }
163
164 return Item.build(
165 author,
166 `${repoUrl}/${file.path}`,
167 title,
168 date,
169 `${repoUrl}/${file.path}`,
170 htmlContent,
171 );
172 }
173
174 private static build(
175 author: string,
176 link: string,
177 title: string,
178 date: string,
179 guid: string,
180 htmlContent: string,
181 ): Item {
182 return new Item(author, link, title, date, guid, htmlContent);
183 }
184
185 generateXml(): string {
186 const before = `<item><author>${this.author}</author><link>${this.link}</link><title>${this.title}</title><pubDate>${this.date}</pubDate><description>`;
187 const after = `</description><guid>${this.guid}</guid></item>`;
188 return (
189 before +
190 this.htmlContent
191 .replaceAll("&", "&")
192 .replaceAll("<", "<")
193 .replaceAll(">", ">")
194 .replaceAll('"', """)
195 .replaceAll("'", "'") +
196 after
197 );
198 }
199}
200
201class Pollen implements Plugin {
202 private server: Server;
203 private conf: Conf | null = null;
204 private repoUrl: string | null = null;
205 private commits: Array<Commit> = new Array<Commit>();
206 private files: Array<File> = new Array<File>();
207 private canCallMdPlugin: bool = false;
208
209 constructor(server: Server) {
210 this.server = server;
211 }
212
213 init(repoName: string, initKind: InitKind, serializedConf: string): void {
214 this.conf = JSON.parse<Conf>(serializedConf);
215
216 const forgeConf: ForgeConf = this.server.forgeConf();
217 this.repoUrl = forgeConf.externalHttpAddr;
218 if (repoName !== forgeConf.rootRepositoryName) {
219 this.repoUrl = `${forgeConf.externalHttpAddr}${repoName}`;
220 }
221
222 const params = new Map<string, string>();
223 params.set("fp", "");
224 params.set("md", "");
225 params.set("extraMedata", "");
226 this.canCallMdPlugin = this.server.canCallFunc(
227 PLUGIN_MARKDOWN,
228 PLUGIN_MARKDOWN_FUNC,
229 params,
230 );
231
232 for (let i = 0; i < this.conf!.flux.length; ++i) {
233 const flux = this.conf!.flux[i];
234 if (!this.server.webcontent().exists(flux.path)) {
235 this.server
236 .webcontent()
237 .writeContent(flux.path, flux.generateEmptyRss(this.repoUrl || ""));
238 }
239 }
240 return;
241 }
242
243 startCommit(commit: Commit): void {
244 this.commits.push(commit);
245 return;
246 }
247
248 addFile(file: File): void {
249 this.files.push(file);
250 return;
251 }
252
253 modFile(file: File): void {
254 return;
255 }
256
257 delFile(file: File): void {
258 return;
259 }
260
261 endCommit(commit: Commit): void {
262 return;
263 }
264
265 finish(): void {
266 if (this.conf == null) {
267 return;
268 }
269 for (let i = 0; i < this.conf!.flux.length; i++) {
270 if (
271 this.commits.length > 0 &&
272 this.conf!.flux[i].kind === FluxKind.commits
273 ) {
274 this.server
275 .webcontent()
276 .replaceContent(
277 this.conf!.flux[i].path,
278 "<!-- next -->",
279 "<!-- next -->\n" +
280 Item.fromCommits(this.repoUrl || "", this.commits).generateXml(),
281 );
282 }
283 if (
284 this.files.length > 0 &&
285 this.conf!.flux[i].kind === FluxKind.content
286 ) {
287 for (let j = 0; j < this.files.length; j++) {
288 let content = this.server.worktree().readAll(this.files[j].path);
289 if (content == null) {
290 continue;
291 }
292 let metadata = new Map<string, string>();
293 if (this.files[j].path.endsWith(".md") && this.canCallMdPlugin) {
294 const params = new Map<string, string>();
295 params.set("fp", this.files[j].path);
296 params.set("md", content);
297 params.set("extraMedata", "{}");
298 const callRes = this.server.callFunc(
299 PLUGIN_MARKDOWN,
300 PLUGIN_MARKDOWN_FUNC,
301 params,
302 );
303 if (callRes.res !== null) {
304 if (callRes.res!.has("html")) {
305 content = callRes.res!.get("html");
306 }
307 if (callRes.res!.has("metas")) {
308 metadata = JSON.parse<Map<string, string>>(
309 callRes.res!.get("metas"),
310 );
311 }
312 }
313 }
314 this.server
315 .webcontent()
316 .replaceContent(
317 this.conf!.flux[i].path,
318 "<!-- next -->",
319 "<!-- next -->\n" +
320 Item.fromfile(
321 this.repoUrl || "",
322 this.files[j],
323 this.commits[0],
324 metadata,
325 content,
326 ).generateXml(),
327 );
328 }
329 }
330 }
331 this.conf = null;
332 this.commits = [];
333 this.files = [];
334 return;
335 }
336}
337
338function build(server: Server): Plugin {
339 return new Pollen(server);
340}
341
342export function install(): void {
343 Register([defaultRun], build);
344}