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("&", "&amp;")
193        .replaceAll("<", "&lt;")
194        .replaceAll(">", "&gt;")
195        .replaceAll('"', "&quot;")
196        .replaceAll("'", "&#39;") +
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}