reviewzy 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.env.example +5 -0
  2. package/README.md +29 -10
  3. package/package.json +1 -1
  4. package/src/config.ts +4 -0
  5. package/src/dashboard/mount.ts +118 -1
  6. package/src/dashboard/repo-link.ts +126 -0
  7. package/src/dashboard/static/components.css +6780 -534
  8. package/src/dashboard/static/cursor-rules.js +83 -0
  9. package/src/dashboard/static/cursor.css +105 -0
  10. package/src/dashboard/static/cursor.js +358 -0
  11. package/src/dashboard/static/dashboard.css +139 -77
  12. package/src/dashboard/static/dashboard.js +147 -29
  13. package/src/dashboard/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
  14. package/src/dashboard/static/fonts/jetbrainsmono-latin.woff2 +0 -0
  15. package/src/dashboard/static/fonts/onest-cyrillic.woff2 +0 -0
  16. package/src/dashboard/static/fonts/onest-latin-ext.woff2 +0 -0
  17. package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
  18. package/src/dashboard/static/icons.svg +184 -0
  19. package/src/dashboard/static/tokens.css +421 -110
  20. package/src/dashboard/static/ui.js +1992 -0
  21. package/src/dashboard/views.tsx +600 -282
  22. package/src/dashboard/word-diff.ts +89 -0
  23. package/src/db/human-save.ts +112 -0
  24. package/src/db/migrations.ts +38 -0
  25. package/src/db/queries.ts +36 -13
  26. package/src/db/sweep.ts +2 -2
  27. package/src/mcp/await-approved.ts +17 -10
  28. package/src/mcp/fetch-approved.ts +11 -6
  29. package/src/mcp/file-entries.ts +95 -20
  30. package/src/mcp/list-entries.ts +54 -29
  31. package/src/mcp/mark-applied.ts +6 -6
  32. package/src/mcp/route.ts +6 -9
  33. package/src/mcp/server.ts +8 -3
  34. package/src/shim/daemon.ts +23 -16
  35. package/src/shim/main.ts +26 -5
package/.env.example CHANGED
@@ -9,6 +9,11 @@ REVIEWZY_DB=
9
9
  # absolute url the dashboard is reachable at, used in notification links. defaults to http://127.0.0.1:$REVIEWZY_PORT.
10
10
  REVIEWZY_BASE_URL=
11
11
 
12
+ # --- dev ---
13
+ # set to exactly `1` to make the shim drain and respawn the daemon on every session start, so
14
+ # checkout `src/` edits reach it without a version bump. any other value refuses startup.
15
+ REVIEWZY_DEV=
16
+
12
17
  # --- auth ---
13
18
  # both credentials follow one rule: set it and it is required, leave it unset and the surface
14
19
  # is loopback-only with a startup warning.
package/README.md CHANGED
@@ -13,7 +13,7 @@ bulk-audit and rewrite user-facing copy across CLIs, TUIs, web UIs, and websites
13
13
 
14
14
  ---
15
15
 
16
- reviewzy is a self-hosted dashboard and mcp server for human-in-the-loop copy review. a coding agent files the exact strings it wants to write or rewrite. a human authors or approves each one on the dashboard. a later agent session fetches the approved words and applies them in place. no i18n keys, no content layer: each entry points at the string where it already lives.
16
+ reviewzy is a self-hosted dashboard and mcp server for human-in-the-loop copy review. a coding agent files the exact text it wants to write or rewrite. a human authors or approves each one on the dashboard. a later agent session fetches the approved words and applies them in place. no i18n keys, no content layer: each entry points at the text where it already lives.
17
17
 
18
18
  file an entry:
19
19
 
@@ -50,7 +50,7 @@ the call answers with the entry as `draft` and a dashboard link for the batch:
50
50
 
51
51
  - **file + snippet anchors, no i18n keys:** apply-back is a targeted replace in place
52
52
  - **humans author, agents apply:** the status machine is server-enforced; only the dashboard reaches `approved` or `rejected`
53
- - **per-project style guide as an mcp resource:** merged with the global guide, embedded in `fetch_approved`
53
+ - **per-project style guide (mcp resource):** merged with the global guide, embedded in `fetch_approved`
54
54
  - **append-only revision history:** every save is undoable; history survives archiving
55
55
  - **ntfy and webhook notifications, archive retention:** one ping per filing batch; applied entries archive after `ARCHIVE_AFTER_DAYS`
56
56
 
@@ -59,7 +59,7 @@ the call answers with the entry as `draft` and a dashboard link for the batch:
59
59
  | stage | who | what happens |
60
60
  |---|---|---|
61
61
  | file | agent | `file_entries` through the stdio shim, which finds or spawns one shared daemon |
62
- | notify | daemon | pings ntfy and/or the webhook with a dashboard link |
62
+ | notify | daemon | pings ntfy or the webhook with a dashboard link |
63
63
  | author | human | edits, approves, or rejects on the dashboard; saving prose releases it as `approved` |
64
64
  | fetch | agent | `fetch_approved` (or a blocking `await_approved`) returns text, anchor, constraints, and the merged style guide |
65
65
  | apply | agent | replaces the anchor, reports through `mark_applied` |
@@ -71,7 +71,15 @@ the call answers with the entry as `draft` and a dashboard link for the batch:
71
71
  bunx reviewzy@latest
72
72
  ```
73
73
 
74
- requires bun >= 1.3 (the store is `bun:sqlite`, bun-only; no `npx` form). the shim spawns one shared daemon per machine on first use.
74
+ runs the published npm version (the default). to switch to the latest git version:
75
+
76
+ ```sh
77
+ bunx github:uwuclxdy/reviewzy
78
+ ```
79
+
80
+ pin a commit with `bunx github:uwuclxdy/reviewzy#<sha>` to keep it stable.
81
+
82
+ requires bun >= 1.3 (the store is `bun:sqlite`; npm/npx not supported). the shim spawns one shared daemon per machine on first use.
75
83
 
76
84
  from a checkout:
77
85
 
@@ -80,16 +88,26 @@ bun install
80
88
  bun run daemon
81
89
  ```
82
90
 
83
- ### as a Claude Code plugin
91
+ ### Claude Code plugin
84
92
 
85
- the repo is a plugin: it registers the MCP server and adds a `/reviewzy` slash command plus the reviewzy skill.
93
+ the plugin registers the MCP server and adds a `/reviewzy` slash command plus the reviewzy skill.
86
94
 
87
95
  ```
88
96
  /plugin marketplace add uwuclxdy/reviewzy
89
97
  /plugin install reviewzy@reviewzy
90
98
  ```
91
99
 
92
- same bun requirement as the npm form.
100
+ #### run a checkout as the plugin (dev)
101
+
102
+ the marketplace above pins the published version. to run your local checkout instead, first repoint the plugin's mcp server at the linked binary: in `.claude-plugin/plugin.json`, change `mcpServers.reviewzy.command` from `bunx` to `reviewzy` and drop `args`. keep that edit uncommitted. the committed value stays `bunx reviewzy@latest` for publish. set `REVIEWZY_DEV=1` in `~/.claude/settings.json`'s `env` (or export it in your shell) so the shim drains and respawns the daemon from the checkout on every session start. then, from the checkout root:
103
+
104
+ ```sh
105
+ bun link # reviewzy on PATH, symlinked to bin/reviewzy
106
+ claude plugin marketplace add ./ # register the checkout as a marketplace
107
+ claude plugin install reviewzy@reviewzy
108
+ ```
109
+
110
+ without `REVIEWZY_DEV=1`, the shared daemon is keyed on version: a same-version daemon already running from npm keeps serving, so `src/` edits never reach it. bumping `version` or killing the daemon is the manual fallback.
93
111
 
94
112
  ## Usage
95
113
 
@@ -98,7 +116,7 @@ curl http://127.0.0.1:3123/health
98
116
  ```
99
117
 
100
118
  ```json
101
- {"name":"reviewzy","version":"0.1.0","pid":4172598,"nonce":"881f0e93-8dde-4c75-b559-d2e3d3b223f0","startedAt":"2026-08-16T19:31:28.640Z"}
119
+ {"name":"reviewzy","version":"0.3.0","pid":4172598,"nonce":"881f0e93-8dde-4c75-b559-d2e3d3b223f0","startedAt":"2026-08-16T19:31:28.640Z"}
102
120
  ```
103
121
 
104
122
  open http://127.0.0.1:3123/ for the dashboard.
@@ -112,6 +130,7 @@ copy `.env.example` to `.env` and edit. every key is optional; a bad value refus
112
130
  | `REVIEWZY_PORT` | `3123` | dashboard and mcp port; the bind is `127.0.0.1` always |
113
131
  | `REVIEWZY_DB` | `~/.local/share/reviewzy/reviewzy.db` | sqlite file (`$XDG_DATA_HOME` respected) |
114
132
  | `REVIEWZY_BASE_URL` | `http://127.0.0.1:$REVIEWZY_PORT` | dashboard url used in notification links |
133
+ | `REVIEWZY_DEV` | unset | set to `1` to drain and respawn the daemon from the checkout on every session start (dev only) |
115
134
  | `REVIEWZY_TOKEN` | unset | bearer token guarding mcp; set it and it is required |
116
135
  | `DASHBOARD_PASSWORD` | unset | dashboard login; set it and it is required |
117
136
  | `ARCHIVE_AFTER_DAYS` | `90` | days an applied entry waits before archiving |
@@ -151,9 +170,9 @@ the blocking human-in-the-loop mcp family pops a dialog per call: no queue, no p
151
170
 
152
171
  ## FAQ
153
172
 
154
- **How do I approve AI-generated text before it lands in my code?** the agent files strings as drafts, you author or approve them on the dashboard, the agent applies.
173
+ **How do I approve AI-generated text before it lands in my code?** the agent files text as drafts, you author or approve them on the dashboard, the agent applies.
155
174
 
156
- **Can an AI agent rewrite my CLI help text without i18n keys?** yes. entries anchor on the exact current strings; apply-back is a targeted replace.
175
+ **Can an AI agent rewrite my CLI help text without i18n keys?** yes. entries anchor on the exact current text; apply-back is a targeted replace.
157
176
 
158
177
  **How do I bulk-rewrite all user-facing copy in a codebase?** file batches per project, group by project or batch on the dashboard, approve in bulk with a before/after diff, then let a later agent session fetch and apply.
159
178
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reviewzy",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "agents draft user-facing copy, humans sign off on a dashboard, agents apply the approved words back in place",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
package/src/config.ts CHANGED
@@ -11,6 +11,10 @@ const ConfigSchema = z.object({
11
11
  REVIEWZY_DB: z.string().min(1).default(defaultDbPath),
12
12
  REVIEWZY_BASE_URL: z.url().optional(),
13
13
 
14
+ // Dev only: `1` makes the shim drain and respawn the daemon on every session start, so checkout
15
+ // `src/` edits reach it without a version bump. Unset (or empty) is off; any other value refuses.
16
+ REVIEWZY_DEV: z.literal("1").optional(),
17
+
14
18
  REVIEWZY_TOKEN: z.string().min(1).optional(),
15
19
  DASHBOARD_PASSWORD: z.string().min(1).optional(),
16
20
 
@@ -2,7 +2,7 @@ import type { Context, Hono } from "hono";
2
2
  import { serveStatic } from "hono/bun";
3
3
  import { html } from "hono/html";
4
4
  import type { Config } from "../config.ts";
5
- import { approveEntry, batchApproveEntries, rejectEntry, saveHumanText } from "../db/human-save.ts";
5
+ import { appendEntryImage, approveEntry, batchApproveEntries, rejectEntry, removeEntryImage, saveHumanNotes, saveHumanText } from "../db/human-save.ts";
6
6
  import type { ApproveOutcome, BatchApproveResult, RejectOutcome, TransitionRefusal } from "../db/human-save.ts";
7
7
  import { projectIdBySlug } from "../db/queries.ts";
8
8
  import { parseStyleGuideForm, upsertStyleGuide } from "../db/style-guide.ts";
@@ -199,6 +199,96 @@ export function mountDashboard(app: Hono, config: Config, store: Store): void {
199
199
  return c.html(editorViewFragment(vm, state));
200
200
  });
201
201
 
202
+ // The image upload: one multipart file, converted to a data url here at the boundary after the
203
+ // route's own mime and size checks, then appended by the store (which owns the refusals). The
204
+ // cap is the stored data url's length, the same quantity the wire's per-item validation
205
+ // measures: base64 grows a file by ~4/3, so a file under 5 MiB can still store as an over-cap
206
+ // url, and the exact length is computed before any conversion runs.
207
+ app.post("/entries/:id/images", async (c) => {
208
+ const id = c.req.param("id");
209
+ const form = await c.req.formData();
210
+ const file = form.get("file");
211
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
212
+
213
+ let outcome:
214
+ | { readonly ok: true }
215
+ | { readonly ok: false; readonly title: string; readonly body: string };
216
+ if (!(file instanceof File)) {
217
+ outcome = { ok: false, title: "Image not added", body: "The form carried no file. Choose an image file and submit again." };
218
+ } else if (!file.type.startsWith("image/")) {
219
+ outcome = {
220
+ ok: false,
221
+ title: "Image not added",
222
+ body: `The file type is ${file.type === "" ? "unknown" : file.type}; only images are accepted, at most 5 MiB.`,
223
+ };
224
+ } else {
225
+ const urlLength = `data:${file.type};base64,`.length + 4 * Math.ceil(file.size / 3);
226
+ if (urlLength > MAX_IMAGE_BYTES) {
227
+ outcome = {
228
+ ok: false,
229
+ title: "Image not added",
230
+ body: `The file is ${(file.size / 1024 / 1024).toFixed(1)} MiB, which stores as a ${(urlLength / 1024 / 1024).toFixed(1)} MiB data url; the limit is 5 MiB. Send a smaller image.`,
231
+ };
232
+ } else {
233
+ const write = appendEntryImage(store, id, `data:${file.type};base64,${Buffer.from(await file.arrayBuffer()).toString("base64")}`);
234
+ if (!write.ok) {
235
+ if (write.refusal.kind === "too_many") {
236
+ return editorWriteReply(c, store, id, auth.enabled, false, {
237
+ title: "Image not added",
238
+ body: "This entry already has 8 images; the limit is 8. Remove one and try again.",
239
+ });
240
+ }
241
+ return c.html(editorViewGoneFragment());
242
+ }
243
+ outcome = { ok: true };
244
+ }
245
+ }
246
+ return editorWriteReply(
247
+ c,
248
+ store,
249
+ id,
250
+ auth.enabled,
251
+ outcome.ok,
252
+ outcome.ok ? undefined : { title: outcome.title, body: outcome.body },
253
+ );
254
+ });
255
+
256
+ // One image removal, by its index in the stored array; an index past either end or one that
257
+ // never parsed as a number is a refusal naming the position, never a 500.
258
+ app.post("/entries/:id/images/:index/remove", async (c) => {
259
+ const id = c.req.param("id");
260
+ const index = Number(c.req.param("index"));
261
+ const validIndex = Number.isInteger(index) && index >= 0;
262
+ const outcome = validIndex
263
+ ? removeEntryImage(store, id, index)
264
+ : ({ ok: false as const, refusal: { kind: "no_image" as const } });
265
+ if (!outcome.ok && outcome.refusal.kind === "unknown") return c.html(editorViewGoneFragment());
266
+ const body = validIndex
267
+ ? `There is no image ${index + 1}. Refresh and try again.`
268
+ : "There is no image at that position. Refresh and try again.";
269
+ return editorWriteReply(
270
+ c,
271
+ store,
272
+ id,
273
+ auth.enabled,
274
+ outcome.ok,
275
+ outcome.ok ? undefined : { title: "Image not removed", body },
276
+ );
277
+ });
278
+
279
+ // The human's notes: the store owns the write (it works on every status and refuses only an
280
+ // unknown id, so an empty string clears the notes), and this route only formats the outcome. A
281
+ // non-string part (a crafted multipart file) is treated as an empty note.
282
+ app.post("/entries/:id/notes", async (c) => {
283
+ const id = c.req.param("id");
284
+ const form = await c.req.formData();
285
+ const raw = form.get("notes");
286
+ const notes = typeof raw === "string" ? raw : "";
287
+ const outcome = saveHumanNotes(store, { id, notes });
288
+ if (!outcome.ok) return c.html(editorViewGoneFragment());
289
+ return editorWriteReply(c, store, id, auth.enabled, true, undefined);
290
+ });
291
+
202
292
  // The two transitions. One route answers both the editor and the list: the buttons in the editor
203
293
  // form carry a `view=editor` marker (htmx includes the closest form's inputs), the list's buttons
204
294
  // carry the batch form's filters. Everything the store refuses is formatted here; the store's
@@ -344,6 +434,33 @@ function batchNotice(store: Store, results: readonly BatchApproveResult[]): List
344
434
  return { kind: "batch", approved, total: results.length, refused };
345
435
  }
346
436
 
437
+ /**
438
+ * The image and notes routes' answer, in the save route's shapes: a vanished id answers the gone
439
+ * fragment, a plain navigation redirects on success and gets the full page with the callout on a
440
+ * refusal, and htmx gets the whole editor view swapped in either way. The store already refused an
441
+ * unknown id, so a null `vm` here is the same gone-fragment case, not a fresh read.
442
+ */
443
+ function editorWriteReply(
444
+ c: Context,
445
+ store: Store,
446
+ id: string,
447
+ signedIn: boolean,
448
+ ok: boolean,
449
+ refusal: { readonly title: string; readonly body: string } | undefined,
450
+ ) {
451
+ const vm = loadEditor(store, id);
452
+ if (vm === null) return c.html(editorViewGoneFragment());
453
+ const state: EditorState = {
454
+ submittedText: vm.entry.human_text ?? vm.entry.agent_draft ?? "",
455
+ refusal,
456
+ };
457
+ if (c.req.header("HX-Request") === undefined) {
458
+ if (ok) return c.redirect(`/entries/${id}`, 303);
459
+ return c.html(html`<!doctype html>${editorPage(vm, state, signedIn)}`);
460
+ }
461
+ return c.html(editorViewFragment(vm, state));
462
+ }
463
+
347
464
  /** A transition's answer to the editor: the region swapped in place, with any refusal on top. */
348
465
  function transitionEditorReply(
349
466
  c: Context,
@@ -0,0 +1,126 @@
1
+ /** The git hosts the dashboard recognizes, each with its own brand icon. */
2
+ export type RepoProvider =
3
+ | "github"
4
+ | "gitlab"
5
+ | "gitea"
6
+ | "forgejo"
7
+ | "codeberg"
8
+ | "bitbucket"
9
+ | "sourcehut"
10
+ | "gitee"
11
+ | "azure";
12
+
13
+ /** A recognized git remote, reduced to what the repo row renders: the provider, the bare path, and the web link. */
14
+ export type RepoLink = {
15
+ readonly provider: RepoProvider;
16
+ /** The bare repo path shown in place of the remote, e.g. `uwuclxdy/clauth` — no scheme, host, or `.git`. */
17
+ readonly path: string;
18
+ /** The web URL the row links out to, always http(s). */
19
+ readonly href: string;
20
+ };
21
+
22
+ /** The parsed pieces of a git remote, before provider detection decides whether to show it. */
23
+ type Remote = {
24
+ readonly host: string;
25
+ readonly path: string;
26
+ readonly scheme: string;
27
+ readonly port: string;
28
+ };
29
+
30
+ /**
31
+ * Splits a git remote into its host and path, covering the two shapes git writes: the scp-like
32
+ * `[user@]host:path` and the scheme-ful `https://`/`ssh://`/`git://` forms. Anything else — a local
33
+ * path, a bare word — has no host to name, so it returns null.
34
+ */
35
+ function splitRemote(raw: string): Remote | null {
36
+ const value = raw.trim();
37
+ if (value === "") return null;
38
+
39
+ // scp-like: git@github.com:owner/repo.git — no scheme, and the colon sits before any slash, so
40
+ // it is not the `://` of a scheme-ful remote.
41
+ const scp = /^(?:[^@/]+@)?([^/:]+):(?!\/\/)(.+)$/.exec(value);
42
+ if (scp !== null) {
43
+ const host = scp[1];
44
+ const path = scp[2];
45
+ if (host === undefined || path === undefined) return null;
46
+ return { host: host.toLowerCase(), path, scheme: "", port: "" };
47
+ }
48
+
49
+ let url: URL;
50
+ try {
51
+ url = new URL(value);
52
+ } catch {
53
+ return null;
54
+ }
55
+ if (url.hostname === "") return null;
56
+ return { host: url.hostname, path: url.pathname, scheme: url.protocol.replace(/:$/, ""), port: url.port };
57
+ }
58
+
59
+ /** Strips the URL's leading slash and git's trailing `.git`, leaving the bare owner/repo path. */
60
+ function cleanPath(raw: string): string | null {
61
+ const path = raw.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "");
62
+ return path === "" ? null : path;
63
+ }
64
+
65
+ /**
66
+ * Names the host's provider from its hostname. A host naming no known provider but still carrying
67
+ * "git" (`git.example.com`, `git.kernel.org`) is a self-hosted git frontend, shown under the gitea
68
+ * brand — the check runs last so the known hosts that also contain "git" match first.
69
+ */
70
+ function detectProvider(host: string): RepoProvider | null {
71
+ const h = host.toLowerCase();
72
+ if (h.includes("github")) return "github";
73
+ if (h.includes("gitlab")) return "gitlab";
74
+ if (h.includes("codeberg")) return "codeberg";
75
+ if (h.includes("forgejo")) return "forgejo";
76
+ if (h.includes("gitea")) return "gitea";
77
+ if (h.includes("bitbucket")) return "bitbucket";
78
+ if (h.includes("sr.ht") || h.includes("sourcehut")) return "sourcehut";
79
+ if (h.includes("gitee")) return "gitee";
80
+ if (h.includes("dev.azure.com") || h.includes("visualstudio.com")) return "azure";
81
+ if (h.includes("git")) return "gitea";
82
+ return null;
83
+ }
84
+
85
+ /** The web URL to open, preferring the remote's own http(s) scheme and keeping a non-default port. */
86
+ function buildHref(remote: Remote, path: string): string {
87
+ const scheme = remote.scheme === "http" || remote.scheme === "https" ? remote.scheme : "https";
88
+ const authority = remote.port === "" ? remote.host : `${remote.host}:${remote.port}`;
89
+ return `${scheme}://${authority}/${path}`;
90
+ }
91
+
92
+ /**
93
+ * Recognizes a git remote into `{ provider, path, href }`, or null when the host is not a known
94
+ * provider. Null means the repo row falls back to showing the raw string as filed.
95
+ */
96
+ export function parseRepoLink(raw: string): RepoLink | null {
97
+ const remote = splitRemote(raw);
98
+ if (remote === null) return null;
99
+ const path = cleanPath(remote.path);
100
+ if (path === null) return null;
101
+ const provider = detectProvider(remote.host);
102
+ if (provider === null) return null;
103
+ return { provider, path, href: buildHref(remote, path) };
104
+ }
105
+
106
+ /** The simple-icons brand glyph per provider, one `<path d>` each, drawn filled at 24×24. */
107
+ export const REPO_ICON: Record<RepoProvider, string> = {
108
+ github:
109
+ "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12",
110
+ gitlab:
111
+ "m23.6004 9.5927-.0337-.0862L20.3.9814a.851.851 0 0 0-.3362-.405.8748.8748 0 0 0-.9997.0539.8748.8748 0 0 0-.29.4399l-2.2055 6.748H7.5375l-2.2057-6.748a.8573.8573 0 0 0-.29-.4412.8748.8748 0 0 0-.9997-.0537.8585.8585 0 0 0-.3362.4049L.4332 9.5015l-.0325.0862a6.0657 6.0657 0 0 0 2.0119 7.0105l.0113.0087.03.0213 4.976 3.7264 2.462 1.8633 1.4995 1.1321a1.0085 1.0085 0 0 0 1.2197 0l1.4995-1.1321 2.4619-1.8633 5.006-3.7489.0125-.01a6.0682 6.0682 0 0 0 2.0094-7.003z",
112
+ gitea:
113
+ "M4.209 4.603c-.247 0-.525.02-.84.088-.333.07-1.28.283-2.054 1.027C-.403 7.25.035 9.685.089 10.052c.065.446.263 1.687 1.21 2.768 1.749 2.141 5.513 2.092 5.513 2.092s.462 1.103 1.168 2.119c.955 1.263 1.936 2.248 2.89 2.367 2.406 0 7.212-.004 7.212-.004s.458.004 1.08-.394c.535-.324 1.013-.893 1.013-.893s.492-.527 1.18-1.73c.21-.37.385-.729.538-1.068 0 0 2.107-4.471 2.107-8.823-.042-1.318-.367-1.55-.443-1.627-.156-.156-.366-.153-.366-.153s-4.475.252-6.792.306c-.508.011-1.012.023-1.512.027v4.474l-.634-.301c0-1.39-.004-4.17-.004-4.17-1.107.016-3.405-.084-3.405-.084s-5.399-.27-5.987-.324c-.187-.011-.401-.032-.648-.032zm.354 1.832h.111s.271 2.269.6 3.597C5.549 11.147 6.22 13 6.22 13s-.996-.119-1.641-.348c-.99-.324-1.409-.714-1.409-.714s-.73-.511-1.096-1.52C1.444 8.73 2.021 7.7 2.021 7.7s.32-.859 1.47-1.145c.395-.106.863-.12 1.072-.12zm8.33 2.554c.26.003.509.127.509.127l.868.422-.529 1.075a.686.686 0 0 0-.614.359.685.685 0 0 0 .072.756l-.939 1.924a.69.69 0 0 0-.66.527.687.687 0 0 0 .347.763.686.686 0 0 0 .867-.206.688.688 0 0 0-.069-.882l.916-1.874a.667.667 0 0 0 .237-.02.657.657 0 0 0 .271-.137 8.826 8.826 0 0 1 1.016.512.761.761 0 0 1 .286.282c.073.21-.073.569-.073.569-.087.29-.702 1.55-.702 1.55a.692.692 0 0 0-.676.477.681.681 0 1 0 1.157-.252c.073-.141.141-.282.214-.431.19-.397.515-1.16.515-1.16.035-.066.218-.394.103-.814-.095-.435-.48-.638-.48-.638-.467-.301-1.116-.58-1.116-.58s0-.156-.042-.27a.688.688 0 0 0-.148-.241l.516-1.062 2.89 1.401s.48.218.583.619c.073.282-.019.534-.069.657-.24.587-2.1 4.317-2.1 4.317s-.232.554-.748.588a1.065 1.065 0 0 1-.393-.045l-.202-.08-4.31-2.1s-.417-.218-.49-.596c-.083-.31.104-.691.104-.691l2.073-4.272s.183-.37.466-.497a.855.855 0 0 1 .35-.077z",
114
+ forgejo:
115
+ "M16.7773 0c1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.1175a7.0759 7.0759 0 0 1 4.148-1.4205l.1176-.001 1.3385.0002c.4973-.8827 1.4434-1.4788 2.5288-1.4788 1.6018 0 2.9004 1.2986 2.9004 2.9005s-1.2986 2.9004-2.9004 2.9004c-1.0854 0-2.0315-.596-2.5288-1.4787H12.91c-2.3322 0-4.2272 1.8718-4.2649 4.195l-.0007 2.319c.8827.4973 1.4788 1.4434 1.4788 2.5287 0 1.602-1.2986 2.9005-2.9005 2.9005-1.6018 0-2.9004-1.2986-2.9004-2.9005 0-1.0853.596-2.0314 1.4788-2.5287l-.0002-9.9831c0-3.887 3.1195-7.0453 6.9915-7.108l.1176-.001h1.3385C14.7458.5962 15.692 0 16.7773 0ZM7.2227 19.9052c-.6596 0-1.1943.5347-1.1943 1.1943s.5347 1.1943 1.1943 1.1943 1.1944-.5347 1.1944-1.1943-.5348-1.1943-1.1944-1.1943Zm9.5546-10.4644c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Zm0-7.7346c-.6596 0-1.1944.5347-1.1944 1.1943s.5348 1.1943 1.1944 1.1943c.6596 0 1.1943-.5347 1.1943-1.1943s-.5347-1.1943-1.1943-1.1943Z",
116
+ codeberg:
117
+ "M11.999.747A11.974 11.974 0 0 0 0 12.75c0 2.254.635 4.465 1.833 6.376L11.837 6.19c.072-.092.251-.092.323 0l4.178 5.402h-2.992l.065.239h3.113l.882 1.138h-3.674l.103.374h3.86l.777 1.003h-4.358l.135.483h4.593l.695.894h-5.038l.165.589h5.326l.609.785h-5.717l.182.65h6.038l.562.727h-6.397l.183.65h6.717A12.003 12.003 0 0 0 24 12.75 11.977 11.977 0 0 0 11.999.747zm3.654 19.104.182.65h5.326c.173-.204.353-.433.513-.65zm.385 1.377.18.65h3.563c.233-.198.485-.428.712-.65zm.383 1.377.182.648h1.203c.356-.204.685-.412 1.042-.648zz",
118
+ bitbucket:
119
+ "M.778 1.213a.768.768 0 00-.768.892l3.263 19.81c.084.5.515.868 1.022.873H19.95a.772.772 0 00.77-.646l3.27-20.03a.768.768 0 00-.768-.891zM14.52 15.53H9.522L8.17 8.466h7.561z",
120
+ sourcehut:
121
+ "M12 0C5.371 0 0 5.371 0 12s5.371 12 12 12 12-5.371 12-12S18.629 0 12 0Zm0 21.677A9.675 9.675 0 0 1 2.323 12 9.675 9.675 0 0 1 12 2.323 9.675 9.675 0 0 1 21.677 12 9.675 9.675 0 0 1 12 21.677Z",
122
+ gitee:
123
+ "M11.984 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.016 0zm6.09 5.333c.328 0 .593.266.592.593v1.482a.594.594 0 0 1-.593.592H9.777c-.982 0-1.778.796-1.778 1.778v5.63c0 .327.266.592.593.592h5.63c.982 0 1.778-.796 1.778-1.778v-.296a.593.593 0 0 0-.592-.593h-4.15a.592.592 0 0 1-.592-.592v-1.482a.593.593 0 0 1 .593-.592h6.815c.327 0 .593.265.593.592v3.408a4 4 0 0 1-4 4H5.926a.593.593 0 0 1-.593-.593V9.778a4.444 4.444 0 0 1 4.445-4.444h8.296Z",
124
+ azure:
125
+ "M0 8.877L2.247 5.91l8.405-3.416V.022l7.37 5.393L2.966 8.338v8.225L0 15.707zm24-4.45v14.651l-5.753 4.9-9.303-3.057v3.056l-5.978-7.416 15.057 1.798V5.415z",
126
+ };