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