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