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.
- package/.env.example +5 -0
- package/README.md +29 -10
- package/package.json +1 -1
- package/src/config.ts +4 -0
- package/src/dashboard/mount.ts +118 -1
- package/src/dashboard/repo-link.ts +126 -0
- package/src/dashboard/static/components.css +6780 -534
- package/src/dashboard/static/cursor-rules.js +83 -0
- package/src/dashboard/static/cursor.css +105 -0
- package/src/dashboard/static/cursor.js +358 -0
- package/src/dashboard/static/dashboard.css +139 -77
- package/src/dashboard/static/dashboard.js +147 -29
- package/src/dashboard/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/jetbrainsmono-latin.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-cyrillic.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin-ext.woff2 +0 -0
- package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
- package/src/dashboard/static/icons.svg +184 -0
- package/src/dashboard/static/tokens.css +421 -110
- package/src/dashboard/static/ui.js +1992 -0
- package/src/dashboard/views.tsx +600 -282
- package/src/dashboard/word-diff.ts +89 -0
- package/src/db/human-save.ts +112 -0
- package/src/db/migrations.ts +38 -0
- package/src/db/queries.ts +36 -13
- package/src/db/sweep.ts +2 -2
- package/src/mcp/await-approved.ts +17 -10
- package/src/mcp/fetch-approved.ts +11 -6
- package/src/mcp/file-entries.ts +95 -20
- package/src/mcp/list-entries.ts +54 -29
- package/src/mcp/mark-applied.ts +6 -6
- package/src/mcp/route.ts +6 -9
- package/src/mcp/server.ts +8 -3
- package/src/shim/daemon.ts +23 -16
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
###
|
|
91
|
+
### Claude Code plugin
|
|
84
92
|
|
|
85
|
-
the
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
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
|
|
package/src/dashboard/mount.ts
CHANGED
|
@@ -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
|
+
};
|