Altug's feedback: visibility, appearance, Add people, avatars, highlighting and a visual pass (#23)
Repositories and packages are public or private (internal becomes private). An Appearance setting, one Add people flow for orgs and repositories, profile pictures for people and orgs, Shiki syntax highlighting, and a visual pass over every page in light and dark. Only owners can invite owners, and the last owner can't be demoted. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QMFviVKD61QaDPxdrh7C3f
Canberk Demirci authored · GitHub committed signed1 parent 7800b99commit d10e3db84a384e2f2350b2061c102f68166aed50Browse files
42 files changed+2487−389
Files (42)
- .github/workflows/live-test.yml+48−0
- README.md+1−1
- apps/cli/src/commands/migrate.ts+2−1
- apps/cli/src/commands/repo.ts+2−3
- apps/diffinery/islands/kumo.tsx+6−1
- apps/diffinery/migrations/0008_profile_appearance_avatars.sql+13−0
- apps/diffinery/package.json+5−0
- apps/diffinery/scripts/make-ui-icons.mjs+72−0
- apps/diffinery/src/app.ts+6−2
- apps/diffinery/src/http/api.ts+4−4
- apps/diffinery/src/http/avatars.ts+229−0
- apps/diffinery/src/http/files.ts+1−12
- apps/diffinery/src/http/gh.ts+1−1
- apps/diffinery/src/lib/directory.ts+100−13
- apps/diffinery/src/lib/image.ts+281−0
- apps/diffinery/src/mail/samples.ts+1−1
- apps/diffinery/src/mail/templates.ts+7−4
- apps/diffinery/src/mail/types.ts+1−1
- apps/diffinery/src/mail/web.tsx+4−4
- apps/diffinery/src/repo/actions.ts+1−1
- apps/diffinery/src/text-modules.d.ts+6−0
- apps/diffinery/src/web/account.tsx+320−153
- apps/diffinery/src/web/assets.ts+91−67
- apps/diffinery/src/web/auth.tsx+6−2
- apps/diffinery/src/web/code.tsx+7−3
- apps/diffinery/src/web/collab.tsx+9−10
- apps/diffinery/src/web/csrf.ts+7−0
- apps/diffinery/src/web/highlight.ts+326−0
- apps/diffinery/src/web/icons.ui.generated.ts+55−0
- apps/diffinery/src/web/markdown.ts+17−2
- apps/diffinery/src/web/packages.tsx+5−5
- apps/diffinery/src/web/releases.tsx+4−4
- apps/diffinery/src/web/repo-settings.tsx+39−47
- apps/diffinery/src/web/ui.tsx+97−42
- apps/diffinery/test/e2e/e2e.test.mjs+103−1
- apps/diffinery/test/e2e/passkeys.test.mjs+61−4
- apps/diffinery/test/e2e/security.test.mjs+10−0
- apps/diffinery/test/unit/avatars.test.ts+30−0
- apps/diffinery/test/unit/highlight.test.ts+202−0
- apps/diffinery/test/unit/image.test.ts+242−0
- apps/diffinery/vitest.config.ts+23−0
- pnpm-lock.yaml+42−0
modified.github/workflows/live-test.yml+48−0View file
| @@ -46,6 +46,54 @@ | ||
| 46 | 46 | echo "OWNER=$login" >> "$GITHUB_ENV" |
| 47 | 47 | echo "testing as $login in $login/$REPO" |
| 48 | 48 | |
| 49 | + - name: Profile picture upload (WASM resize in the Worker) | |
| 50 | + if: steps.token.outputs.skip != 'true' | |
| 51 | + run: | | |
| 52 | + set -euo pipefail | |
| 53 | + av="$DIFFINERY/api/avatars/$OWNER" | |
| 54 | + # Keep the account's current picture, if it has one, to put it back afterwards. | |
| 55 | + had=$(curl -fsS -o before.webp -w '%{content_type}' "$av?s=256&v=1") | |
| 56 | + restore() { | |
| 57 | + if [ "$had" = image/webp ]; then | |
| 58 | + curl -fsS -X PUT "$DIFFINERY/api/v3/user/avatar" -H "Authorization: Bearer $TOKEN" -H "content-type: image/webp" --data-binary @before.webp | jq -r '"restored the previous picture: \(.avatar_url)"' | |
| 59 | + else | |
| 60 | + curl -fsS -o /dev/null -X DELETE "$DIFFINERY/api/v3/user/avatar" -H "Authorization: Bearer $TOKEN" | |
| 61 | + fi | |
| 62 | + } | |
| 63 | + trap restore EXIT | |
| 64 | + # A 600×400 PNG gradient; the server crops it to a square and makes small WebPs. | |
| 65 | + python3 - <<'EOF' | |
| 66 | + import struct, zlib | |
| 67 | + w, h = 600, 400 | |
| 68 | + rows = b"".join(b"\0" + bytes(c for x in range(w) for c in (x * 255 // w, y * 255 // h, 128)) for y in range(h)) | |
| 69 | + chunk = lambda t, d: struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d)) | |
| 70 | + open("avatar.png", "wb").write(b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(rows)) + chunk(b"IEND", b"")) | |
| 71 | + EOF | |
| 72 | + url=$(curl -fsS -X PUT "$DIFFINERY/api/v3/user/avatar" -H "Authorization: Bearer $TOKEN" -H "content-type: image/png" --data-binary @avatar.png | jq -r .avatar_url) | |
| 73 | + echo "uploaded: $url" | |
| 74 | + v=${url##*v=} | |
| 75 | + type=$(curl -fsS -o small.webp -w '%{content_type}' "$av?s=64&v=$v") | |
| 76 | + [ "$type" = image/webp ] || { echo "::error::expected image/webp, got $type"; exit 1; } | |
| 77 | + python3 - <<'EOF' | |
| 78 | + import struct, sys | |
| 79 | + b = open("small.webp", "rb").read() | |
| 80 | + ok = b[:4] == b"RIFF" and b[8:12] == b"WEBP" | |
| 81 | + if b[12:16] == b"VP8 ": | |
| 82 | + size = (struct.unpack("<H", b[26:28])[0] & 0x3FFF, struct.unpack("<H", b[28:30])[0] & 0x3FFF) | |
| 83 | + elif b[12:16] == b"VP8X": | |
| 84 | + size = (int.from_bytes(b[24:27], "little") + 1, int.from_bytes(b[27:30], "little") + 1) | |
| 85 | + else: | |
| 86 | + size = None | |
| 87 | + print(f"64 px avatar: {len(b)} bytes, {size}") | |
| 88 | + if not ok or size != (64, 64) or len(b) > 5000: | |
| 89 | + print("::error::the 64 px avatar is not a 64x64 WebP under 5 KB"); sys.exit(1) | |
| 90 | + EOF | |
| 91 | + curl -fsS -X DELETE "$DIFFINERY/api/v3/user/avatar" -H "Authorization: Bearer $TOKEN" >/dev/null | |
| 92 | + # A `v` that is not current bypasses the edge cache, so this sees the removal from any colo. | |
| 93 | + type=$(curl -fsS -o /dev/null -w '%{content_type}' "$av?s=64&v=1") | |
| 94 | + [[ "$type" == image/svg+xml* ]] || { echo "::error::after removal expected the initials SVG, got $type"; exit 1; } | |
| 95 | + echo "removed: initials again ($type)" | |
| 96 | + | |
| 49 | 97 | - name: Email sending is configured |
| 50 | 98 | if: steps.token.outputs.skip != 'true' |
| 51 | 99 | run: | |
modifiedREADME.md+1−1View file
| @@ -232,7 +232,7 @@ | ||
| 232 | 232 | macOS, GPUs or private networks. |
| 233 | 233 | - Reusable workflows: `jobs.<id>.uses: ./.github/workflows/x.yml` from the same |
| 234 | 234 | commit, or `owner/repo/.github/workflows/x.yml@ref` from another repository |
| 235 | - on this instance (public or internal ones, or private ones with the same | |
| 235 | + on this instance (public ones, or private ones with the same | |
| 236 | 236 | owner). `with`, `secrets` (a mapping or `inherit`), `on.workflow_call` |
| 237 | 237 | outputs and nesting up to four levels deep all work. A matrix on the calling |
| 238 | 238 | job isn't supported yet. |
modifiedapps/cli/src/commands/migrate.ts+2−1View file
| @@ -101,7 +101,8 @@ | ||
| 101 | 101 | ctx.out(`Using existing repository ${to.owner}/${to.name}`); |
| 102 | 102 | return { repo: existing.data, created: false }; |
| 103 | 103 | } |
| 104 | - const visibility = forcePrivate ? "private" : (gh.visibility ?? (gh.private ? "private" : "public")); | |
| 104 | + // Diffinery has no "internal": a GitHub internal repository becomes private. | |
| 105 | + const visibility = forcePrivate || gh.private || (gh.visibility && gh.visibility !== "public") ? "private" : "public"; | |
| 105 | 106 | const body = { name: to.name, description: gh.description ?? undefined, visibility }; |
| 106 | 107 | const repo = await api.post<RepoJson>(to.owner === me ? "/user/repos" : `/orgs/${encodeURIComponent(to.owner)}/repos`, body); |
| 107 | 108 | ctx.out(`${ctx.c.green("✓")} Created ${repo.full_name} (${repo.visibility})`); |
modifiedapps/cli/src/commands/repo.ts+2−3View file
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | full_name: string; |
| 12 | 12 | owner: { login: string; type: string }; |
| 13 | 13 | private: boolean; |
| 14 | - visibility: "public" | "internal" | "private"; | |
| 14 | + visibility: "public" | "private"; | |
| 15 | 15 | description: string | null; |
| 16 | 16 | default_branch: string; |
| 17 | 17 | archived: boolean; |
| @@ -30,7 +30,6 @@ | ||
| 30 | 30 | options: { |
| 31 | 31 | org: { type: "string", desc: "Create it in this organization", arg: "ORG" }, |
| 32 | 32 | private: { type: "boolean", desc: "Only collaborators can see it (default)" }, |
| 33 | - internal: { type: "boolean", desc: "Every signed-in member of this Diffinery can see it" }, | |
| 34 | 33 | public: { type: "boolean", desc: "Anyone can see it" }, |
| 35 | 34 | description: { type: "string", short: "d", desc: "Description", arg: "TEXT" }, |
| 36 | 35 | "default-branch": { type: "string", desc: "Default branch name", arg: "BRANCH" }, |
| @@ -39,7 +38,7 @@ | ||
| 39 | 38 | minArgs: 1, |
| 40 | 39 | maxArgs: 1, |
| 41 | 40 | async run(ctx, o, [name]) { |
| 42 | - const visibility = exactlyOne(o, ["private", "internal", "public"], "private"); | |
| 41 | + const visibility = exactlyOne(o, ["private", "public"], "private"); | |
| 43 | 42 | const api = await ctx.authedApi(); |
| 44 | 43 | const org = str(o, "org"); |
| 45 | 44 | const r = await api.post<RepoJson>(org ? `/orgs/${enc(org)}/repos` : "/user/repos", { name, description: str(o, "description"), visibility, default_branch: str(o, "default-branch") }); |
modifiedapps/diffinery/islands/kumo.tsx+6−1View file
| @@ -140,8 +140,13 @@ | ||
| 140 | 140 | radio: (p: RadioProps) => <KRadio p={p} />, |
| 141 | 141 | }; |
| 142 | 142 | |
| 143 | -/** Kumo's dark theme keys off data-mode; follow the system setting like the rest of the site. */ | |
| 143 | +/** Kumo's dark theme keys off data-mode: the Appearance setting (data-theme) if chosen, else the system setting. */ | |
| 144 | 144 | function syncMode() { |
| 145 | + const fixed = document.documentElement.dataset.theme; | |
| 146 | + if (fixed === "light" || fixed === "dark") { | |
| 147 | + document.documentElement.dataset.mode = fixed; | |
| 148 | + return; | |
| 149 | + } | |
| 145 | 150 | const mq = matchMedia("(prefers-color-scheme: dark)"); |
| 146 | 151 | const apply = () => (document.documentElement.dataset.mode = mq.matches ? "dark" : "light"); |
| 147 | 152 | apply(); |
addedapps/diffinery/migrations/0008_profile_appearance_avatars.sql+13−0View file
| @@ -1,0 +1,13 @@ | ||
| 1 | +-- Altug's feedback: profile pictures, the Appearance setting, and no more "internal" visibility. | |
| 2 | + | |
| 3 | +-- Profile pictures for people and organizations. The WebP files live in R2 under | |
| 4 | +-- avatars/<account id>/<version>/<size>.webp; NULL means the generated initials. | |
| 5 | +ALTER TABLE accounts ADD COLUMN avatar_version INTEGER; | |
| 6 | + | |
| 7 | +-- Appearance: 'light', 'dark' or NULL (follow the system setting). | |
| 8 | +ALTER TABLE accounts ADD COLUMN appearance TEXT; | |
| 9 | + | |
| 10 | +-- Repositories and packages are public or private. "Internal" meant private to the | |
| 11 | +-- organization, which private already covers, so existing internal ones become private. | |
| 12 | +UPDATE repos SET visibility = 'private' WHERE visibility = 'internal'; | |
| 13 | +UPDATE packages SET visibility = 'private' WHERE visibility = 'internal'; |
modifiedapps/diffinery/package.json+5−0View file
| @@ -19,8 +19,13 @@ | ||
| 19 | 19 | "@diffinery/git-core": "workspace:*", |
| 20 | 20 | "@diffinery/shared": "workspace:*", |
| 21 | 21 | "@diffinery/workflow-yaml": "workspace:*", |
| 22 | + "@jsquash/jpeg": "1.6.0", | |
| 23 | + "@jsquash/png": "3.1.1", | |
| 24 | + "@jsquash/resize": "2.1.1", | |
| 25 | + "@jsquash/webp": "1.5.0", | |
| 22 | 26 | "@simplewebauthn/server": "14.0.3", |
| 23 | 27 | "hono": "^4.13.0", |
| 28 | + "shiki": "4.5.0", | |
| 24 | 29 | "yaml": "^2.9.1" |
| 25 | 30 | }, |
| 26 | 31 | "devDependencies": { |
addedapps/diffinery/scripts/make-ui-icons.mjs+72−0View file
| @@ -1,0 +1,72 @@ | ||
| 1 | +// Renders the UI icons from Phosphor (the icon set Kumo uses) into static SVG strings: | |
| 2 | +// node scripts/make-ui-icons.mjs -> src/web/icons.ui.generated.ts | |
| 3 | +import { createElement } from "react"; | |
| 4 | +import { renderToStaticMarkup } from "react-dom/server"; | |
| 5 | +import * as P from "@phosphor-icons/react/dist/ssr"; | |
| 6 | +import { writeFile } from "node:fs/promises"; | |
| 7 | + | |
| 8 | +/** name -> [Phosphor icon, weight] */ | |
| 9 | +const MAP = { | |
| 10 | + success: ["CheckCircle", "fill"], | |
| 11 | + failure: ["XCircle", "fill"], | |
| 12 | + running: ["CircleNotch", "bold"], | |
| 13 | + queued: ["DotsThreeCircle", "fill"], | |
| 14 | + cancelled: ["Prohibit", "bold"], | |
| 15 | + skipped: ["MinusCircle", "regular"], | |
| 16 | + neutral: ["MinusCircle", "regular"], | |
| 17 | + issueOpen: ["Record", "regular"], | |
| 18 | + issueClosed: ["CheckCircle", "regular"], | |
| 19 | + pr: ["GitPullRequest", "regular"], | |
| 20 | + prMerged: ["GitMerge", "regular"], | |
| 21 | + prClosed: ["GitPullRequest", "regular"], | |
| 22 | + dir: ["FolderSimple", "fill"], | |
| 23 | + file: ["File", "regular"], | |
| 24 | + submodule: ["FolderSimpleDashed", "regular"], | |
| 25 | + branch: ["GitBranch", "regular"], | |
| 26 | + tag: ["Tag", "regular"], | |
| 27 | + commit: ["GitCommit", "regular"], | |
| 28 | + lock: ["LockSimple", "regular"], | |
| 29 | + globe: ["Globe", "regular"], | |
| 30 | + search: ["MagnifyingGlass", "regular"], | |
| 31 | + bell: ["Bell", "regular"], | |
| 32 | + plus: ["Plus", "regular"], | |
| 33 | + gear: ["Gear", "regular"], | |
| 34 | + play: ["PlayCircle", "regular"], | |
| 35 | + code: ["Code", "regular"], | |
| 36 | + comment: ["ChatCircle", "regular"], | |
| 37 | + eye: ["Eye", "regular"], | |
| 38 | + bot: ["Robot", "regular"], | |
| 39 | + history: ["ClockCounterClockwise", "regular"], | |
| 40 | + copy: ["Copy", "regular"], | |
| 41 | + download: ["DownloadSimple", "regular"], | |
| 42 | + edit: ["PencilSimple", "regular"], | |
| 43 | + x: ["X", "regular"], | |
| 44 | + check: ["Check", "regular"], | |
| 45 | + people: ["Users", "regular"], | |
| 46 | + key: ["Key", "regular"], | |
| 47 | + shield: ["ShieldCheck", "regular"], | |
| 48 | + repo: ["BookBookmark", "regular"], | |
| 49 | + org: ["Buildings", "regular"], | |
| 50 | + zap: ["Lightning", "regular"], | |
| 51 | + package: ["Package", "regular"], | |
| 52 | + upload: ["UploadSimple", "regular"], | |
| 53 | + trash: ["Trash", "regular"], | |
| 54 | + userPlus: ["UserPlus", "regular"], | |
| 55 | + envelope: ["EnvelopeSimple", "regular"], | |
| 56 | + sun: ["Sun", "regular"], | |
| 57 | + moon: ["Moon", "regular"], | |
| 58 | + signOut: ["SignOut", "regular"], | |
| 59 | + caret: ["CaretDown", "regular"], | |
| 60 | + link: ["LinkSimple", "regular"], | |
| 61 | + rocket: ["RocketLaunch", "regular"], | |
| 62 | +}; | |
| 63 | + | |
| 64 | +const out = {}; | |
| 65 | +for (const [name, [icon, weight]] of Object.entries(MAP)) { | |
| 66 | + const C = P[icon] ?? P[`${icon}Icon`]; | |
| 67 | + if (!C) throw new Error(`no Phosphor icon ${icon}`); | |
| 68 | + out[name] = renderToStaticMarkup(createElement(C, { size: 16, weight, "aria-hidden": "true", focusable: "false" })); | |
| 69 | +} | |
| 70 | +const body = Object.entries(out).map(([k, v]) => ` ${k}: ${JSON.stringify(v)},`).join("\n"); | |
| 71 | +await writeFile(new URL("../src/web/icons.ui.generated.ts", import.meta.url), `// Generated by scripts/make-ui-icons.mjs from Phosphor icons (MIT). Do not edit.\nexport const UI_ICONS = {\n${body}\n} as const;\n`); | |
| 72 | +console.log(`${Object.keys(out).length} icons`); |
modifiedapps/diffinery/src/app.ts+6−2View file
| @@ -12,6 +12,7 @@ | ||
| 12 | 12 | import { git } from "./http/git.js"; |
| 13 | 13 | import { runner } from "./http/runner.js"; |
| 14 | 14 | import { api, wellKnown } from "./http/api.js"; |
| 15 | +import { avatars } from "./http/avatars.js"; | |
| 15 | 16 | import { mailApi } from "./mail/api.js"; |
| 16 | 17 | import { files } from "./http/files.js"; |
| 17 | 18 | import { nodeId, numericId } from "./http/gh.js"; |
| @@ -46,7 +47,9 @@ | ||
| 46 | 47 | c.env = d1.env; |
| 47 | 48 | const dir = new Directory(c.env); |
| 48 | 49 | c.set("dir", dir); |
| 49 | - c.set("principal", await resolvePrincipal(c.req.raw, c.env, dir)); | |
| 50 | + // Avatar images are public and on every page many times over: skip the session lookup for them. | |
| 51 | + const avatar = (c.req.method === "GET" || c.req.method === "HEAD") && c.req.path.startsWith("/api/avatars/"); | |
| 52 | + c.set("principal", avatar ? { type: "anonymous" } : await resolvePrincipal(c.req.raw, c.env, dir)); | |
| 50 | 53 | await next(); |
| 51 | 54 | if (c.res.status === 101) return; |
| 52 | 55 | const h = c.res.headers; |
| @@ -55,7 +58,7 @@ | ||
| 55 | 58 | h.set(BOOKMARK_HEADER, bookmark); |
| 56 | 59 | // Browsers: remember it once they have written something or are signed in. |
| 57 | 60 | const browser = !token && (!["GET", "HEAD"].includes(c.req.method) || cookie !== undefined || getCookie(c, SESSION_COOKIE) !== undefined); |
| 58 | - if (browser && bookmark !== cookie) h.append("set-cookie", `${BOOKMARK_COOKIE}=${bookmark}; Max-Age=86400; Path=/; HttpOnly; Secure; SameSite=Lax`); | |
| 61 | + if (browser && !avatar && bookmark !== cookie) h.append("set-cookie", `${BOOKMARK_COOKIE}=${bookmark}; Max-Age=86400; Path=/; HttpOnly; Secure; SameSite=Lax`); | |
| 59 | 62 | } |
| 60 | 63 | h.set("strict-transport-security", "max-age=63072000; includeSubDomains; preload"); |
| 61 | 64 | h.set("x-content-type-options", "nosniff"); |
| @@ -66,6 +69,7 @@ | ||
| 66 | 69 | |
| 67 | 70 | app.get("/healthz", (c) => c.json({ ok: true })); |
| 68 | 71 | |
| 72 | +app.route("/", avatars); | |
| 69 | 73 | app.route("/", wellKnown); |
| 70 | 74 | app.route("/", git); |
| 71 | 75 | app.route("/", runner); |
modifiedapps/diffinery/src/http/api.ts+4−4View file
| @@ -109,9 +109,9 @@ | ||
| 109 | 109 | api.get("/user/repos", async (c) => c.json(paginate(c, (await c.get("dir").reposVisibleTo(requireAccount(c))).map((r) => repoJson(c, r))))); |
| 110 | 110 | api.post("/user/repos", async (c) => { |
| 111 | 111 | const a = requireAccount(c); |
| 112 | - const b = await body<{ name: string; description?: string; private?: boolean; visibility?: Repo["visibility"]; default_branch?: string }>(c); | |
| 112 | + const b = await body<{ name: string; description?: string; private?: boolean; visibility?: string; default_branch?: string }>(c); | |
| 113 | 113 | const owner = a.kind === "agent" ? (await c.get("dir").account(a.owner_id!))! : a; |
| 114 | - const r = await c.get("dir").createRepo(owner, a, { name: b.name, description: b.description, visibility: b.visibility ?? (b.private === false ? "internal" : "private"), defaultBranch: b.default_branch }); | |
| 114 | + const r = await c.get("dir").createRepo(owner, a, { name: b.name, description: b.description, visibility: b.visibility ?? (b.private === false ? "public" : "private"), defaultBranch: b.default_branch }); | |
| 115 | 115 | c.executionCtx.waitUntil(repoCreated(c.env, c.get("dir"), r, a, { viaToken: viaToken(c) })); |
| 116 | 116 | return c.json(repoJson(c, r), 201); |
| 117 | 117 | }); |
| @@ -131,8 +131,8 @@ | ||
| 131 | 131 | const a = requireAccount(c); |
| 132 | 132 | const org = await c.get("dir").accountByLogin(c.req.param("org")); |
| 133 | 133 | if (!org || org.kind !== "org") throw new HttpError(404, "organization not found"); |
| 134 | - const b = await body<{ name: string; description?: string; private?: boolean; visibility?: Repo["visibility"]; default_branch?: string }>(c); | |
| 135 | - const r = await c.get("dir").createRepo(org, a, { name: b.name, description: b.description, visibility: b.visibility ?? (b.private === false ? "internal" : "private"), defaultBranch: b.default_branch }); | |
| 134 | + const b = await body<{ name: string; description?: string; private?: boolean; visibility?: string; default_branch?: string }>(c); | |
| 135 | + const r = await c.get("dir").createRepo(org, a, { name: b.name, description: b.description, visibility: b.visibility ?? (b.private === false ? "public" : "private"), defaultBranch: b.default_branch }); | |
| 136 | 136 | c.executionCtx.waitUntil(repoCreated(c.env, c.get("dir"), r, a, { viaToken: viaToken(c) })); |
| 137 | 137 | return c.json(repoJson(c, r), 201); |
| 138 | 138 | }); |
addedapps/diffinery/src/http/avatars.ts+229−0View file
| @@ -1,0 +1,229 @@ | ||
| 1 | +import { Hono } from "hono"; | |
| 2 | +import type { Env } from "../env.js"; | |
| 3 | +import { requireAccount, type AppContext, type AppVars } from "../auth/principal.js"; | |
| 4 | +import type { Account } from "../lib/directory.js"; | |
| 5 | +import { AVATAR_SIZES, MAX_AVATAR_BYTES, makeAvatarImages, type AvatarSize } from "../lib/image.js"; | |
| 6 | +import { HttpError, isValidName } from "../lib/util.js"; | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * Profile pictures for people, organizations and agents. | |
| 10 | + * | |
| 11 | + * GET /api/avatars/:login?s=64|128|256[&v=<version>] the picture (WebP) or generated initials (SVG) | |
| 12 | + * PUT /api/v3/user/avatar raw image body (Diffinery extension) | |
| 13 | + * DELETE /api/v3/user/avatar | |
| 14 | + * PUT /api/v3/orgs/:org/avatar org owners and admins | |
| 15 | + * DELETE /api/v3/orgs/:org/avatar | |
| 16 | + * | |
| 17 | + * Uploads become square WebPs in R2 at avatars/<account id>/<version>/<size>.webp, and | |
| 18 | + * accounts.avatar_version points at the current set (NULL: initials). Every upload gets a new | |
| 19 | + * version, so `?v=` URLs are immutable; the old version's files are deleted. | |
| 20 | + */ | |
| 21 | +export const avatars = new Hono<{ Bindings: Env; Variables: AppVars }>(); | |
| 22 | + | |
| 23 | +// ---------------------------------------------------------------- helpers shared with the web UI | |
| 24 | + | |
| 25 | +/** Stable hue for a login's initials avatar. */ | |
| 26 | +export function avatarHue(login: string): number { | |
| 27 | + let h = 0; | |
| 28 | + for (let i = 0; i < login.length; i++) h = (h * 31 + login.charCodeAt(i)) >>> 0; | |
| 29 | + return h % 360; | |
| 30 | +} | |
| 31 | + | |
| 32 | +export const avatarInitial = (login: string): string => (login.replace(/[^A-Za-z0-9]/g, "")[0] ?? "?").toUpperCase(); | |
| 33 | + | |
| 34 | +/** The stored size for a requested pixel size: the smallest one at least that big. */ | |
| 35 | +export function avatarSize(px: number): AvatarSize { | |
| 36 | + return AVATAR_SIZES.find((s) => px <= s) ?? AVATAR_SIZES[AVATAR_SIZES.length - 1]!; | |
| 37 | +} | |
| 38 | + | |
| 39 | +/** Path of a login's avatar image; pass the account's avatar_version so a new upload shows at once. */ | |
| 40 | +export function avatarPath(login: string, size?: number, version?: number | null): string { | |
| 41 | + const q = new URLSearchParams(); | |
| 42 | + if (size && size !== 128) q.set("s", String(avatarSize(size))); | |
| 43 | + if (version) q.set("v", String(version)); | |
| 44 | + const qs = q.toString(); | |
| 45 | + return `/api/avatars/${encodeURIComponent(login)}${qs ? `?${qs}` : ""}`; | |
| 46 | +} | |
| 47 | + | |
| 48 | +export const avatarUrl = (c: AppContext, login: string, version?: number | null): string => `${c.env.PUBLIC_URL.replace(/\/$/, "")}${avatarPath(login, undefined, version)}`; | |
| 49 | + | |
| 50 | +/** The initials avatar as a square SVG, matching the `.av` look (font size as at 20, 40 and 96 CSS px). */ | |
| 51 | +export function initialsSvg(login: string, size: AvatarSize): string { | |
| 52 | + const font = size <= 64 ? 55 : size <= 128 ? 45 : 42; | |
| 53 | + return ( | |
| 54 | + `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 100 100">` + | |
| 55 | + `<rect width="100" height="100" fill="hsl(${avatarHue(login)},55%,55%)"/>` + | |
| 56 | + `<text x="50" y="50" dy=".35em" text-anchor="middle" fill="#fff" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue','Noto Sans',Arial,sans-serif" font-size="${font}" font-weight="700">${avatarInitial(login)}</text>` + | |
| 57 | + `</svg>` | |
| 58 | + ); | |
| 59 | +} | |
| 60 | + | |
| 61 | +const objectKey = (accountId: string, version: number, size: AvatarSize) => `avatars/${accountId}/${version}/${size}.webp`; | |
| 62 | + | |
| 63 | +const SHORT = "public, max-age=600, stale-while-revalidate=86400"; | |
| 64 | +const IMMUTABLE = "public, max-age=86400, immutable"; | |
| 65 | +/** What the Cache API keeps: short, because a purge only reaches the colo it runs in. */ | |
| 66 | +const EDGE = "public, max-age=600"; | |
| 67 | + | |
| 68 | +const edgeCache = (): Cache | null => (typeof caches === "undefined" ? null : (caches as unknown as { default: Cache }).default); | |
| 69 | + | |
| 70 | +function cacheKey(origin: string, login: string, size: AvatarSize, version: number | null): Request { | |
| 71 | + return new Request(`${origin}/api/avatars/${encodeURIComponent(login)}?s=${size}${version ? `&v=${version}` : ""}`); | |
| 72 | +} | |
| 73 | + | |
| 74 | +const origin = (c: AppContext) => new URL(c.req.url).origin; | |
| 75 | + | |
| 76 | +/** Forget cached copies of a login's avatar (every size, without `v` and with each given version). */ | |
| 77 | +export async function purgeAvatarCache(c: AppContext, login: string, versions: Array<number | null | undefined>): Promise<void> { | |
| 78 | + const cache = edgeCache(); | |
| 79 | + if (!cache) return; | |
| 80 | + const vs = [null, ...new Set(versions.filter((v): v is number => !!v))]; | |
| 81 | + await Promise.all(AVATAR_SIZES.flatMap((s) => vs.map((v) => cache.delete(cacheKey(origin(c), login, s, v)).catch(() => false)))); | |
| 82 | +} | |
| 83 | + | |
| 84 | +/** Delete every stored file of an account's avatars except the version to keep. */ | |
| 85 | +async function deleteStored(env: Env, accountId: string, keep: number | null): Promise<void> { | |
| 86 | + const prefix = `avatars/${accountId}/`; | |
| 87 | + const listed = await env.BUCKET.list({ prefix, limit: 1000 }); | |
| 88 | + const stale = listed.objects.map((o) => o.key).filter((k) => keep === null || !k.startsWith(`${prefix}${keep}/`)); | |
| 89 | + if (stale.length) await env.BUCKET.delete(stale); | |
| 90 | +} | |
| 91 | + | |
| 92 | +/** Store a new picture for an account (a user, an org or an agent) and return its version. */ | |
| 93 | +export async function setAvatar(c: AppContext, account: Account, bytes: Uint8Array): Promise<number> { | |
| 94 | + const images = await makeAvatarImages(bytes); | |
| 95 | + const version = Math.max((account.avatar_version ?? 0) + 1, Math.floor(Date.now() / 1000)); | |
| 96 | + await Promise.all([...images].map(([size, webp]) => c.env.BUCKET.put(objectKey(account.id, version, size), webp, { httpMetadata: { contentType: "image/webp", cacheControl: IMMUTABLE } }))); | |
| 97 | + await c.get("dir").setAvatarVersion(account.id, version); | |
| 98 | + await Promise.all([purgeAvatarCache(c, account.login, [account.avatar_version, version]), deleteStored(c.env, account.id, version)]); | |
| 99 | + return version; | |
| 100 | +} | |
| 101 | + | |
| 102 | +/** Go back to initials. */ | |
| 103 | +export async function removeAvatar(c: AppContext, account: Account): Promise<void> { | |
| 104 | + await c.get("dir").setAvatarVersion(account.id, null); | |
| 105 | + await Promise.all([purgeAvatarCache(c, account.login, [account.avatar_version]), deleteStored(c.env, account.id, null)]); | |
| 106 | +} | |
| 107 | + | |
| 108 | +/** Read a request body, refusing more than `limit` bytes without buffering them. */ | |
| 109 | +async function readLimited(req: Request, limit: number): Promise<Uint8Array> { | |
| 110 | + const tooBig = () => new HttpError(413, "That picture is larger than 5 MB. Use a smaller file."); | |
| 111 | + if (Number(req.headers.get("content-length") ?? 0) > limit) throw tooBig(); | |
| 112 | + if (!req.body) throw new HttpError(422, "Send the picture as the request body."); | |
| 113 | + const parts: Uint8Array[] = []; | |
| 114 | + let total = 0; | |
| 115 | + const reader = req.body.getReader(); | |
| 116 | + for (;;) { | |
| 117 | + const { done, value } = await reader.read(); | |
| 118 | + if (done) break; | |
| 119 | + total += value.byteLength; | |
| 120 | + if (total > limit) { | |
| 121 | + await reader.cancel().catch(() => {}); | |
| 122 | + throw tooBig(); | |
| 123 | + } | |
| 124 | + parts.push(value); | |
| 125 | + } | |
| 126 | + const out = new Uint8Array(total); | |
| 127 | + let at = 0; | |
| 128 | + for (const p of parts) { | |
| 129 | + out.set(p, at); | |
| 130 | + at += p.byteLength; | |
| 131 | + } | |
| 132 | + return out; | |
| 133 | +} | |
| 134 | + | |
| 135 | +// ---------------------------------------------------------------- GET /api/avatars/:login | |
| 136 | + | |
| 137 | +avatars.get("/api/avatars/:login", async (c) => { | |
| 138 | + const login = c.req.param("login"); | |
| 139 | + const size = avatarSize(Number(c.req.query("s")) || 128); | |
| 140 | + const v = /^\d{1,15}$/.test(c.req.query("v") ?? "") ? Number(c.req.query("v")) : null; | |
| 141 | + const cache = edgeCache(); | |
| 142 | + const key = cacheKey(origin(c), login, size, v); | |
| 143 | + const hit = await cache?.match(key); | |
| 144 | + if (hit) { | |
| 145 | + const res = new Response(hit.body, hit); | |
| 146 | + res.headers.set("cache-control", v ? IMMUTABLE : SHORT); | |
| 147 | + return res; | |
| 148 | + } | |
| 149 | + | |
| 150 | + // Unknown logins get initials too, so the route does not tell who exists. | |
| 151 | + const account = isValidName(login) ? await c.get("dir").accountByLogin(login) : null; | |
| 152 | + const current = account?.avatar_version ?? null; | |
| 153 | + const obj = account && current ? await c.env.BUCKET.get(objectKey(account.id, current, size)) : null; | |
| 154 | + const headers = new Headers({ "x-content-type-options": "nosniff" }); | |
| 155 | + let body: ArrayBuffer | string; | |
| 156 | + if (obj) { | |
| 157 | + body = await obj.arrayBuffer(); | |
| 158 | + headers.set("content-type", "image/webp"); | |
| 159 | + } else { | |
| 160 | + body = initialsSvg(login, size); | |
| 161 | + headers.set("content-type", "image/svg+xml; charset=utf-8"); | |
| 162 | + headers.set("content-security-policy", "default-src 'none'; style-src 'unsafe-inline'"); | |
| 163 | + } | |
| 164 | + // A `v` that is not the current version (an old link, or a guess) is served but neither cached here nor marked immutable. | |
| 165 | + const versioned = v !== null && v === current; | |
| 166 | + if (v === null || versioned) { | |
| 167 | + const stored = new Response(body, { headers }); | |
| 168 | + stored.headers.set("cache-control", EDGE); | |
| 169 | + c.executionCtx.waitUntil(cache?.put(key, stored).catch(() => {}) ?? Promise.resolve()); | |
| 170 | + } | |
| 171 | + headers.set("cache-control", versioned ? IMMUTABLE : SHORT); | |
| 172 | + return new Response(body, { headers }); | |
| 173 | +}); | |
| 174 | + | |
| 175 | +// ---------------------------------------------------------------- REST | |
| 176 | + | |
| 177 | +/** Only full-account credentials change a profile: browser sessions, or tokens with write or admin scope for all repositories. */ | |
| 178 | +function requireProfileWrite(c: AppContext): Account { | |
| 179 | + const me = requireAccount(c); | |
| 180 | + const p = c.get("principal"); | |
| 181 | + if (p.type === "account" && p.via === "token" && (p.repoIds || !p.scopes.some((s) => s === "write" || s === "admin"))) { | |
| 182 | + throw new HttpError(403, "changing a profile picture needs a write or admin token that is not limited to repositories"); | |
| 183 | + } | |
| 184 | + return me; | |
| 185 | +} | |
| 186 | + | |
| 187 | +async function orgForAvatar(c: AppContext, me: Account): Promise<Account> { | |
| 188 | + const dir = c.get("dir"); | |
| 189 | + const org = await dir.accountByLogin(c.req.param("org") ?? ""); | |
| 190 | + if (!org || org.kind !== "org") throw new HttpError(404, "not found"); | |
| 191 | + const role = await dir.orgRole(org.id, me.id); | |
| 192 | + if (!role && !me.site_admin) throw new HttpError(404, "not found"); | |
| 193 | + if (role !== "owner" && role !== "admin" && !me.site_admin) throw new HttpError(403, "only organization owners and admins can change its picture"); | |
| 194 | + return org; | |
| 195 | +} | |
| 196 | + | |
| 197 | +function audit(c: AppContext, me: Account, action: "avatar.update" | "avatar.remove", target: Account): void { | |
| 198 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action, target: target.login, detail: { kind: target.kind, via: "api" } })); | |
| 199 | +} | |
| 200 | + | |
| 201 | +avatars.put("/api/v3/user/avatar", async (c) => { | |
| 202 | + const me = requireProfileWrite(c); | |
| 203 | + const version = await setAvatar(c, me, await readLimited(c.req.raw, MAX_AVATAR_BYTES)); | |
| 204 | + audit(c, me, "avatar.update", me); | |
| 205 | + return c.json({ avatar_url: avatarUrl(c, me.login, version) }); | |
| 206 | +}); | |
| 207 | + | |
| 208 | +avatars.delete("/api/v3/user/avatar", async (c) => { | |
| 209 | + const me = requireProfileWrite(c); | |
| 210 | + await removeAvatar(c, me); | |
| 211 | + audit(c, me, "avatar.remove", me); | |
| 212 | + return c.json({ avatar_url: avatarUrl(c, me.login) }); | |
| 213 | +}); | |
| 214 | + | |
| 215 | +avatars.put("/api/v3/orgs/:org/avatar", async (c) => { | |
| 216 | + const me = requireProfileWrite(c); | |
| 217 | + const org = await orgForAvatar(c, me); | |
| 218 | + const version = await setAvatar(c, org, await readLimited(c.req.raw, MAX_AVATAR_BYTES)); | |
| 219 | + audit(c, me, "avatar.update", org); | |
| 220 | + return c.json({ avatar_url: avatarUrl(c, org.login, version) }); | |
| 221 | +}); | |
| 222 | + | |
| 223 | +avatars.delete("/api/v3/orgs/:org/avatar", async (c) => { | |
| 224 | + const me = requireProfileWrite(c); | |
| 225 | + const org = await orgForAvatar(c, me); | |
| 226 | + await removeAvatar(c, org); | |
| 227 | + audit(c, me, "avatar.remove", org); | |
| 228 | + return c.json({ avatar_url: avatarUrl(c, org.login) }); | |
| 229 | +}); |
modifiedapps/diffinery/src/http/files.ts+1−12View file
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | /** |
| 11 | 11 | * Byte routes outside /api/v3, at the paths GitHub clients expect: |
| 12 | 12 | * release asset uploads (`upload_url`), browser downloads of release assets |
| 13 | - * and source archives, and avatars for the `avatar_url` in user objects. | |
| 13 | + * and source archives. | |
| 14 | 14 | */ |
| 15 | 15 | export const files = new Hono<{ Bindings: Env; Variables: AppVars }>(); |
| 16 | 16 | |
| @@ -119,14 +119,3 @@ | ||
| 119 | 119 | return archiveResponse(c, stub, repo.owner_login, repo.name, m[1]!, m[2] as "zip" | "tar.gz"); |
| 120 | 120 | }); |
| 121 | 121 | |
| 122 | -// Letter avatars for `avatar_url`: a coloured square with the login's first letter. | |
| 123 | -files.get("/api/avatars/:login", (c) => { | |
| 124 | - const login = c.req.param("login").slice(0, 100); | |
| 125 | - let hash = 0; | |
| 126 | - for (const ch of login) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0; | |
| 127 | - const hue = hash % 360; | |
| 128 | - const letter = (login.match(/[A-Za-z0-9]/)?.[0] ?? "?").toUpperCase(); | |
| 129 | - const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" viewBox="0 0 96 96"><rect width="96" height="96" rx="12" fill="hsl(${hue} 55% 45%)"/><text x="48" y="64" font-family="system-ui, sans-serif" font-size="48" font-weight="600" text-anchor="middle" fill="#fff">${letter}</text></svg>`; | |
| 130 | - return c.body(svg, 200, { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400", "content-security-policy": "default-src 'none'; style-src 'unsafe-inline'" }); | |
| 131 | -}); | |
| 132 | - |
modifiedapps/diffinery/src/http/gh.ts+1−1View file
| @@ -77,7 +77,7 @@ | ||
| 77 | 77 | owner_kind: string; |
| 78 | 78 | name: string; |
| 79 | 79 | description: string; |
| 80 | - visibility: "private" | "internal" | "public"; | |
| 80 | + visibility: "private" | "public"; | |
| 81 | 81 | default_branch: string; |
| 82 | 82 | archived: number; |
| 83 | 83 | created_at: string; |
modifiedapps/diffinery/src/lib/directory.ts+100−13View file
| @@ -15,6 +15,10 @@ | ||
| 15 | 15 | site_admin: number; |
| 16 | 16 | owner_id: string | null; |
| 17 | 17 | agent_policy: string | null; |
| 18 | + /** Profile picture version (R2 avatars/<id>/<version>/<size>.webp), null for initials. */ | |
| 19 | + avatar_version: number | null; | |
| 20 | + /** "light", "dark" or null (follow the system). */ | |
| 21 | + appearance: string | null; | |
| 18 | 22 | created_at: string; |
| 19 | 23 | } |
| 20 | 24 | |
| @@ -25,7 +29,7 @@ | ||
| 25 | 29 | owner_kind: "user" | "org" | "agent"; |
| 26 | 30 | name: string; |
| 27 | 31 | description: string; |
| 28 | - visibility: "private" | "internal" | "public"; | |
| 32 | + visibility: "private" | "public"; | |
| 29 | 33 | default_branch: string; |
| 30 | 34 | archived: number; |
| 31 | 35 | created_at: string; |
| @@ -37,6 +41,14 @@ | ||
| 37 | 41 | |
| 38 | 42 | export type TokenScope = "read" | "write" | "admin"; |
| 39 | 43 | |
| 44 | +/** Repositories are public or private. GitHub's "internal" (private to the organization) maps to private. */ | |
| 45 | +export function repoVisibility(v: unknown): Repo["visibility"] | undefined { | |
| 46 | + if (v === undefined || v === null || v === "") return undefined; | |
| 47 | + if (v === "public") return "public"; | |
| 48 | + if (v === "private" || v === "internal") return "private"; | |
| 49 | + throw new HttpError(422, "visibility must be public or private"); | |
| 50 | +} | |
| 51 | + | |
| 40 | 52 | /** Data access for the global directory in D1. */ |
| 41 | 53 | export class Directory { |
| 42 | 54 | constructor(private env: Env) {} |
| @@ -110,6 +122,15 @@ | ||
| 110 | 122 | } |
| 111 | 123 | } |
| 112 | 124 | |
| 125 | + async setAppearance(id: string, appearance: "light" | "dark" | null): Promise<void> { | |
| 126 | + await this.db.prepare("UPDATE accounts SET appearance = ? WHERE id = ?").bind(appearance, id).run(); | |
| 127 | + } | |
| 128 | + | |
| 129 | + /** Point an account at a new profile picture version (null: back to initials). */ | |
| 130 | + async setAvatarVersion(id: string, version: number | null): Promise<void> { | |
| 131 | + await this.db.prepare("UPDATE accounts SET avatar_version = ? WHERE id = ?").bind(version, id).run(); | |
| 132 | + } | |
| 133 | + | |
| 113 | 134 | async createOrg(login: string, name: string, creator: Account): Promise<Account> { |
| 114 | 135 | if (!isValidLogin(login)) throw new HttpError(422, "invalid or reserved organization name"); |
| 115 | 136 | if (await this.accountByLogin(login)) throw new HttpError(422, "name already taken"); |
| @@ -137,19 +158,76 @@ | ||
| 137 | 158 | return r.results; |
| 138 | 159 | } |
| 139 | 160 | |
| 140 | - async setOrgMember(orgId: string, userId: string, role: "owner" | "admin" | "member" | null): Promise<void> { | |
| 161 | + async setOrgMember(orgId: string, userId: string, role: "owner" | "admin" | "member" | null, by?: Account): Promise<void> { | |
| 141 | 162 | const previous = await this.orgRole(orgId, userId); |
| 142 | 163 | await this.writeOrgMember(orgId, userId, role); |
| 143 | 164 | if (previous !== role) { |
| 144 | 165 | const org = await this.account(orgId); |
| 145 | - if (org) await enqueueMail(this.env, [{ kind: "org_membership", data: { org: org.login, role, previous }, to: { accountId: userId }, reason: "account", at: now() }]); | |
| 166 | + if (!org) return; | |
| 167 | + await enqueueMail(this.env, [{ kind: "org_membership", data: { org: org.login, role, previous, by: by && by.id !== userId ? by.login : undefined }, to: { accountId: userId }, reason: "account", at: now() }]); | |
| 168 | + // A heads-up on Diffinery too when someone else adds you. | |
| 169 | + if (role && !previous && by && by.id !== userId) await this.notify([userId], { kind: "org_membership", title: `${by.login} added you to ${org.login} as ${role === "admin" ? "an admin" : `a${role === "owner" ? "n" : ""} ${role}`}`, url: `/${org.login}` }); | |
| 146 | 170 | } |
| 147 | 171 | } |
| 148 | 172 | |
| 173 | + /** | |
| 174 | + * Add someone to an organization by username or email, like GitHub's "Add people": people with an | |
| 175 | + * account join right away and get a heads-up; an email without an account gets an invitation that | |
| 176 | + * creates the account and joins the organization. | |
| 177 | + */ | |
| 178 | + async addOrgPerson(org: Account, by: Account, who: string, role: "owner" | "admin" | "member"): Promise<{ added: Account } | { invited: { email: string; url: string } }> { | |
| 179 | + const q = who.trim().replace(/^@/, ""); | |
| 180 | + if (!q) throw new HttpError(422, "enter a username or an email address"); | |
| 181 | + const email = q.includes("@"); | |
| 182 | + if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q)) throw new HttpError(422, "enter a valid email address"); | |
| 183 | + const user = email ? await this.accountByEmail(q) : await this.accountByLogin(q); | |
| 184 | + if (user) { | |
| 185 | + if (user.kind !== "user") throw new HttpError(422, `${user.login} is not a person; agents belong to an organization through Settings → Agents`); | |
| 186 | + if (user.status === "disabled") throw new HttpError(422, `${user.login}'s account is disabled`); | |
| 187 | + if (await this.orgRole(org.id, user.id)) throw new HttpError(422, `${user.login} is already a member of ${org.login}`); | |
| 188 | + // An admin vouching for someone waiting for an invite lets them in. | |
| 189 | + if (user.status === "pending") await this.setStatus(user.id, "active"); | |
| 190 | + await this.setOrgMember(org.id, user.id, role, by); | |
| 191 | + return { added: user }; | |
| 192 | + } | |
| 193 | + if (!email) throw new HttpError(404, `no one has the username ${q}; to invite someone new, enter their email address`); | |
| 194 | + const pending = await this.db.prepare("SELECT 1 FROM invites WHERE org_id = ? AND email = ? AND accepted_at IS NULL AND expires_at > ?").bind(org.id, q, now()).first(); | |
| 195 | + if (pending) throw new HttpError(422, `${q} already has a pending invitation; revoke it first to send a new one`); | |
| 196 | + const inv = await this.createInvite(by, { email: q, orgId: org.id, role }); | |
| 197 | + return { invited: { email: q, url: inv.url } }; | |
| 198 | + } | |
| 199 | + | |
| 200 | + /** Add a collaborator by username (a person or an agent) or email; a new email gets an invitation. */ | |
| 201 | + async addRepoPerson(repo: Repo, by: Account, who: string, role: Role): Promise<{ added: Account } | { invited: { email: string; url: string } }> { | |
| 202 | + const q = who.trim().replace(/^@/, ""); | |
| 203 | + if (!q) throw new HttpError(422, "enter a username or an email address"); | |
| 204 | + const email = q.includes("@"); | |
| 205 | + if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(q)) throw new HttpError(422, "enter a valid email address"); | |
| 206 | + const account = email ? await this.accountByEmail(q) : await this.accountByLogin(q); | |
| 207 | + if (account) { | |
| 208 | + if (account.kind === "org") throw new HttpError(422, `${account.login} is an organization; add its people instead`); | |
| 209 | + if (account.status === "disabled") throw new HttpError(422, `${account.login}'s account is disabled`); | |
| 210 | + if (account.status === "pending") await this.setStatus(account.id, "active"); | |
| 211 | + await this.setCollaborator(repo.id, account.id, role); | |
| 212 | + if (account.id !== by.id) await this.notify([account.id], { repoId: repo.id, kind: "repo_access", title: `${by.login} gave you ${role} access to ${repo.owner_login}/${repo.name}`, url: `/${repo.owner_login}/${repo.name}` }); | |
| 213 | + return { added: account }; | |
| 214 | + } | |
| 215 | + if (!email) throw new HttpError(404, `no one has the username ${q}; to invite someone new, enter their email address`); | |
| 216 | + const inv = await this.createInvite(by, { email: q, repoId: repo.id, role }); | |
| 217 | + return { invited: { email: q, url: inv.url } }; | |
| 218 | + } | |
| 219 | + | |
| 220 | + async revokeInvite(id: string, scope: { orgId?: string; repoId?: string }): Promise<void> { | |
| 221 | + await this.db.prepare("DELETE FROM invites WHERE id = ? AND accepted_at IS NULL AND (org_id IS ? OR ? IS NULL) AND (repo_id IS ? OR ? IS NULL)").bind(id, scope.orgId ?? null, scope.orgId ?? null, scope.repoId ?? null, scope.repoId ?? null).run(); | |
| 222 | + } | |
| 223 | + | |
| 149 | 224 | private async writeOrgMember(orgId: string, userId: string, role: "owner" | "admin" | "member" | null): Promise<void> { |
| 150 | - if (role === null) { | |
| 225 | + // Removing or demoting the last owner would leave nobody able to manage the organization. | |
| 226 | + if (role !== "owner" && (await this.orgRole(orgId, userId)) === "owner") { | |
| 151 | 227 | const owners = await this.db.prepare("SELECT count(*) AS n FROM org_members WHERE org_id = ? AND role = 'owner' AND user_id != ?").bind(orgId, userId).first<{ n: number }>(); |
| 152 | - if ((await this.orgRole(orgId, userId)) === "owner" && !owners?.n) throw new HttpError(422, "an organization needs at least one owner"); | |
| 228 | + if (!owners?.n) throw new HttpError(422, "an organization needs at least one owner; make someone else an owner first"); | |
| 229 | + } | |
| 230 | + if (role === null) { | |
| 153 | 231 | await this.db.prepare("DELETE FROM org_members WHERE org_id = ? AND user_id = ?").bind(orgId, userId).run(); |
| 154 | 232 | // Removing someone revokes their tokens' reach immediately: roles are computed per request. |
| 155 | 233 | return; |
| @@ -237,6 +315,10 @@ | ||
| 237 | 315 | // ---------------------------------------------------------------- invites |
| 238 | 316 | |
| 239 | 317 | async createInvite(inviter: Account, input: { email: string; orgId?: string; repoId?: string; role: string }): Promise<{ id: string; url: string; token: string }> { |
| 318 | + if (input.orgId) { | |
| 319 | + if (input.role !== "member" && input.role !== "admin" && input.role !== "owner") throw new HttpError(422, "role must be member, admin or owner"); | |
| 320 | + if (input.role === "owner" && !inviter.site_admin && (await this.orgRole(input.orgId, inviter.id)) !== "owner") throw new HttpError(403, "only owners can invite owners"); | |
| 321 | + } | |
| 240 | 322 | const token = randomToken(24); |
| 241 | 323 | const id = newId(); |
| 242 | 324 | await this.db |
| @@ -261,9 +343,14 @@ | ||
| 261 | 343 | return { id, url, token }; |
| 262 | 344 | } |
| 263 | 345 | |
| 264 | - async invites(filter: { orgId?: string }): Promise<Array<{ id: string; email: string; role: string; createdAt: string; expiresAt: string; acceptedAt: string | null }>> { | |
| 265 | - const r = await this.db.prepare("SELECT * FROM invites WHERE (? IS NULL OR org_id = ?) ORDER BY created_at DESC LIMIT 200").bind(filter.orgId ?? null, filter.orgId ?? null).all<Record<string, string | null>>(); | |
| 266 | - return r.results.map((i) => ({ id: i.id!, email: i.email!, role: i.role!, createdAt: i.created_at!, expiresAt: i.expires_at!, acceptedAt: i.accepted_at ?? null })); | |
| 346 | + async invites(filter: { orgId?: string; repoId?: string; pending?: boolean }): Promise<Array<{ id: string; email: string; role: string; createdAt: string; expiresAt: string; acceptedAt: string | null; invitedBy: string | null }>> { | |
| 347 | + const r = await this.db | |
| 348 | + .prepare( | |
| 349 | + `SELECT i.*, a.login AS inviter FROM invites i LEFT JOIN accounts a ON a.id = i.invited_by WHERE (? IS NULL OR i.org_id = ?) AND (? IS NULL OR i.repo_id = ?)${filter.pending ? " AND i.accepted_at IS NULL AND i.expires_at > ?" : ""} ORDER BY i.created_at DESC LIMIT 200`, | |
| 350 | + ) | |
| 351 | + .bind(filter.orgId ?? null, filter.orgId ?? null, filter.repoId ?? null, filter.repoId ?? null, ...(filter.pending ? [now()] : [])) | |
| 352 | + .all<Record<string, string | null>>(); | |
| 353 | + return r.results.map((i) => ({ id: i.id!, email: i.email!, role: i.role!, createdAt: i.created_at!, expiresAt: i.expires_at!, acceptedAt: i.accepted_at ?? null, invitedBy: i.inviter ?? null })); | |
| 267 | 354 | } |
| 268 | 355 | |
| 269 | 356 | async acceptInvite(token: string, user: Account): Promise<{ orgId: string | null; repoId: string | null }> { |
| @@ -330,7 +417,7 @@ | ||
| 330 | 417 | let q = "SELECT r.*, a.login AS owner_login, a.kind AS owner_kind FROM repos r JOIN accounts a ON a.id = r.owner_id"; |
| 331 | 418 | const subjectId = account.kind === "agent" ? account.owner_id! : account.id; |
| 332 | 419 | if (!account.site_admin) { |
| 333 | - q += ` WHERE (r.visibility IN ('internal', 'public') OR r.owner_id = ? OR r.owner_id IN (SELECT org_id FROM org_members WHERE user_id = ?) OR r.id IN (SELECT repo_id FROM repo_access WHERE account_id IN (?, ?)))`; | |
| 420 | + q += ` WHERE (r.visibility = 'public' OR r.owner_id = ? OR r.owner_id IN (SELECT org_id FROM org_members WHERE user_id = ?) OR r.id IN (SELECT repo_id FROM repo_access WHERE account_id IN (?, ?)))`; | |
| 334 | 421 | params.push(subjectId, subjectId, account.id, subjectId); |
| 335 | 422 | } else q += " WHERE 1 = 1"; |
| 336 | 423 | if (ownerId) { |
| @@ -351,7 +438,7 @@ | ||
| 351 | 438 | ).results; |
| 352 | 439 | } |
| 353 | 440 | |
| 354 | - async createRepo(owner: Account, creator: Account, input: { name: string; description?: string; visibility?: Repo["visibility"]; defaultBranch?: string }): Promise<Repo> { | |
| 441 | + async createRepo(owner: Account, creator: Account, input: { name: string; description?: string; visibility?: string; defaultBranch?: string }): Promise<Repo> { | |
| 355 | 442 | if (!isValidName(input.name)) throw new HttpError(422, "invalid repository name: use letters, numbers, '.', '-' and '_'"); |
| 356 | 443 | if (owner.kind === "org") { |
| 357 | 444 | const role = await this.orgRole(owner.id, creator.kind === "agent" ? creator.owner_id! : creator.id); |
| @@ -363,18 +450,18 @@ | ||
| 363 | 450 | const branch = input.defaultBranch || "main"; |
| 364 | 451 | await this.db |
| 365 | 452 | .prepare("INSERT INTO repos (id, owner_id, name, description, visibility, default_branch, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") |
| 366 | - .bind(id, owner.id, input.name, input.description ?? "", input.visibility ?? "private", branch, t, t) | |
| 453 | + .bind(id, owner.id, input.name, input.description ?? "", repoVisibility(input.visibility) ?? "private", branch, t, t) | |
| 367 | 454 | .run(); |
| 368 | 455 | const meta: RepoMeta = { repoId: id, ownerId: owner.id, owner: owner.login, name: input.name, defaultBranch: branch, ownerKind: owner.kind === "org" ? "org" : "user" }; |
| 369 | 456 | await this.env.REPO.get(this.env.REPO.idFromName(id)).init(meta); |
| 370 | 457 | return (await this.repoById(id))!; |
| 371 | 458 | } |
| 372 | 459 | |
| 373 | - async updateRepo(repo: Repo, patch: { name?: string; description?: string; visibility?: Repo["visibility"]; defaultBranch?: string; archived?: boolean }): Promise<Repo> { | |
| 460 | + async updateRepo(repo: Repo, patch: { name?: string; description?: string; visibility?: string; defaultBranch?: string; archived?: boolean }): Promise<Repo> { | |
| 374 | 461 | if (patch.name && !isValidName(patch.name)) throw new HttpError(422, "invalid repository name"); |
| 375 | 462 | await this.db |
| 376 | 463 | .prepare("UPDATE repos SET name = COALESCE(?, name), description = COALESCE(?, description), visibility = COALESCE(?, visibility), default_branch = COALESCE(?, default_branch), archived = COALESCE(?, archived), updated_at = ? WHERE id = ?") |
| 377 | - .bind(patch.name ?? null, patch.description ?? null, patch.visibility ?? null, patch.defaultBranch ?? null, patch.archived === undefined ? null : patch.archived ? 1 : 0, now(), repo.id) | |
| 464 | + .bind(patch.name ?? null, patch.description ?? null, repoVisibility(patch.visibility) ?? null, patch.defaultBranch ?? null, patch.archived === undefined ? null : patch.archived ? 1 : 0, now(), repo.id) | |
| 378 | 465 | .run(); |
| 379 | 466 | if (patch.name || patch.defaultBranch) await this.env.REPO.get(this.env.REPO.idFromName(repo.id)).updateMeta({ name: patch.name ?? repo.name, defaultBranch: patch.defaultBranch ?? repo.default_branch }); |
| 380 | 467 | return (await this.repoById(repo.id))!; |
addedapps/diffinery/src/lib/image.ts+281−0View file
| @@ -1,0 +1,281 @@ | ||
| 1 | +/** | |
| 2 | + * Profile pictures: check an uploaded JPEG, PNG or WebP and turn it into small square WebPs. | |
| 3 | + * | |
| 4 | + * Everything runs in the Worker on jSquash's WASM codecs. Wrangler bundles each `*.wasm` import | |
| 5 | + * as a compiled WebAssembly.Module (its default CompiledWasm rule), and each codec gets its module | |
| 6 | + * through `init(module)`, so nothing is fetched at runtime. | |
| 7 | + * | |
| 8 | + * Memory: a decoded picture costs width × height × 4 bytes, twice over (the codec's WASM heap and | |
| 9 | + * the JS copy), so dimensions are read from the file header and checked before decoding. Right after | |
| 10 | + * decoding, the centre square is box-filtered in plain JS to at most ~2× the largest size we keep, and | |
| 11 | + * only that small copy goes through the resampler and the encoder. | |
| 12 | + */ | |
| 13 | +import decodeJpeg, { init as initJpegDecode } from "@jsquash/jpeg/decode.js"; | |
| 14 | +import { decode as decodePng, init as initPngDecode } from "@jsquash/png/decode.js"; | |
| 15 | +import decodeWebp, { init as initWebpDecode } from "@jsquash/webp/decode.js"; | |
| 16 | +import encodeWebp, { init as initWebpEncode } from "@jsquash/webp/encode.js"; | |
| 17 | +import resize, { initResize } from "@jsquash/resize"; | |
| 18 | +import JPEG_DEC_WASM from "@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm?module"; | |
| 19 | +import PNG_DEC_WASM from "@jsquash/png/codec/pkg/squoosh_png_bg.wasm?module"; | |
| 20 | +import WEBP_DEC_WASM from "@jsquash/webp/codec/dec/webp_dec.wasm?module"; | |
| 21 | +// @jsquash/webp's encoder picks its SIMD build when the runtime supports WASM SIMD, which V8 (workerd, Node) does. | |
| 22 | +import WEBP_ENC_WASM from "@jsquash/webp/codec/enc/webp_enc_simd.wasm?module"; | |
| 23 | +import RESIZE_WASM from "@jsquash/resize/lib/resize/pkg/squoosh_resize_bg.wasm?module"; | |
| 24 | +import { HttpError } from "./util.js"; | |
| 25 | + | |
| 26 | +/** Square sizes we store, in pixels: 2× the 20, 40 (`.av.lg`) and 96 (`.av.xl`, rounded up) CSS pixel avatars. */ | |
| 27 | +export const AVATAR_SIZES = [64, 128, 256] as const; | |
| 28 | +export type AvatarSize = (typeof AVATAR_SIZES)[number]; | |
| 29 | +export const MAX_AVATAR_BYTES = 5 * 1024 * 1024; | |
| 30 | +/** Decoding holds the pixels twice (4 bytes each); 3072² keeps that well under a Worker's 128 MB. Browsers shrink to 512 px before uploading. */ | |
| 31 | +export const MAX_AVATAR_SIDE = 3072; | |
| 32 | +/** WebP quality: small files that still look crisp at 2×. */ | |
| 33 | +export const AVATAR_QUALITY = 80; | |
| 34 | + | |
| 35 | +export const UNSUPPORTED_IMAGE = "Use a JPEG, PNG or WebP image."; | |
| 36 | + | |
| 37 | +export type ImageFormat = "jpeg" | "png" | "webp"; | |
| 38 | + | |
| 39 | +export interface Pixels { | |
| 40 | + data: Uint8ClampedArray; | |
| 41 | + width: number; | |
| 42 | + height: number; | |
| 43 | +} | |
| 44 | + | |
| 45 | +// Workers and Node have no ImageData; the codecs construct it, so provide the plain shape they need. | |
| 46 | +const g = globalThis as { ImageData?: unknown }; | |
| 47 | +g.ImageData ??= class ImageData { | |
| 48 | + constructor( | |
| 49 | + public data: Uint8ClampedArray, | |
| 50 | + public width: number, | |
| 51 | + public height: number, | |
| 52 | + ) {} | |
| 53 | +}; | |
| 54 | + | |
| 55 | +// ---------------------------------------------------------------- headers | |
| 56 | + | |
| 57 | +/** The format from the file's magic bytes (the content type is not trusted). */ | |
| 58 | +export function sniffImage(b: Uint8Array): ImageFormat | null { | |
| 59 | + if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "jpeg"; | |
| 60 | + if (b.length >= 8 && [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((x, i) => b[i] === x)) return "png"; | |
| 61 | + if (b.length >= 12 && ascii(b, 0, 4) === "RIFF" && ascii(b, 8, 4) === "WEBP") return "webp"; | |
| 62 | + return null; | |
| 63 | +} | |
| 64 | + | |
| 65 | +const ascii = (b: Uint8Array, at: number, n: number): string => String.fromCharCode(...b.subarray(at, at + n)); | |
| 66 | +const u16be = (b: Uint8Array, at: number): number => (b[at]! << 8) | b[at + 1]!; | |
| 67 | +const u16le = (b: Uint8Array, at: number): number => b[at]! | (b[at + 1]! << 8); | |
| 68 | +const u24le = (b: Uint8Array, at: number): number => b[at]! | (b[at + 1]! << 8) | (b[at + 2]! << 16); | |
| 69 | +const u32be = (b: Uint8Array, at: number): number => ((b[at]! << 24) | (b[at + 1]! << 16) | (b[at + 2]! << 8) | b[at + 3]!) >>> 0; | |
| 70 | + | |
| 71 | +/** Pixel dimensions from the file header, without decoding; null when the header is malformed. */ | |
| 72 | +export function imageSize(b: Uint8Array, format: ImageFormat): { width: number; height: number } | null { | |
| 73 | + if (format === "png") { | |
| 74 | + if (b.length < 24 || ascii(b, 12, 4) !== "IHDR") return null; | |
| 75 | + return { width: u32be(b, 16), height: u32be(b, 20) }; | |
| 76 | + } | |
| 77 | + if (format === "webp") { | |
| 78 | + if (b.length < 30) return null; | |
| 79 | + const chunk = ascii(b, 12, 4); | |
| 80 | + // Lossy: frame tag (3 bytes), start code 9d 01 2a, then 14-bit width and height. | |
| 81 | + if (chunk === "VP8 ") return b[23] === 0x9d && b[24] === 0x01 && b[25] === 0x2a ? { width: u16le(b, 26) & 0x3fff, height: u16le(b, 28) & 0x3fff } : null; | |
| 82 | + // Lossless: signature 0x2f, then width-1 and height-1 in 14 bits each. | |
| 83 | + if (chunk === "VP8L") { | |
| 84 | + if (b[20] !== 0x2f) return null; | |
| 85 | + const bits = (b[21]! | (b[22]! << 8) | (b[23]! << 16) | (b[24]! << 24)) >>> 0; | |
| 86 | + return { width: (bits & 0x3fff) + 1, height: ((bits >>> 14) & 0x3fff) + 1 }; | |
| 87 | + } | |
| 88 | + // Extended: canvas width-1 and height-1 as 24-bit numbers. | |
| 89 | + if (chunk === "VP8X") return { width: u24le(b, 24) + 1, height: u24le(b, 27) + 1 }; | |
| 90 | + return null; | |
| 91 | + } | |
| 92 | + // JPEG: walk the segments to the first start-of-frame marker. | |
| 93 | + let i = 2; | |
| 94 | + while (i + 9 < b.length) { | |
| 95 | + if (b[i] !== 0xff) return null; | |
| 96 | + const m = b[i + 1]!; | |
| 97 | + if (m === 0xff) { | |
| 98 | + i++; | |
| 99 | + continue; | |
| 100 | + } | |
| 101 | + if (m === 0x01 || (m >= 0xd0 && m <= 0xd9)) { | |
| 102 | + i += 2; | |
| 103 | + continue; | |
| 104 | + } | |
| 105 | + if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { width: u16be(b, i + 7), height: u16be(b, i + 5) }; | |
| 106 | + i += 2 + u16be(b, i + 2); | |
| 107 | + } | |
| 108 | + return null; | |
| 109 | +} | |
| 110 | + | |
| 111 | +/** The EXIF orientation (1–8) of a JPEG; 1 when there is none. */ | |
| 112 | +export function jpegOrientation(b: Uint8Array): number { | |
| 113 | + let i = 2; | |
| 114 | + while (i + 4 < b.length && b[i] === 0xff) { | |
| 115 | + const m = b[i + 1]!; | |
| 116 | + if (m === 0xda || m === 0xd9) break; // start of scan: no more metadata | |
| 117 | + const len = u16be(b, i + 2); | |
| 118 | + if (m === 0xe1 && len >= 8 && ascii(b, i + 4, 6) === "Exif\0\0") { | |
| 119 | + const t = i + 10; // TIFF header | |
| 120 | + const le = ascii(b, t, 2) === "II"; | |
| 121 | + const r16 = (at: number) => (le ? u16le(b, at) : u16be(b, at)); | |
| 122 | + const r32 = (at: number) => (le ? (u16le(b, at) | (u16le(b, at + 2) << 16)) >>> 0 : u32be(b, at)); | |
| 123 | + if (t + 8 > b.length) return 1; | |
| 124 | + const ifd = t + r32(t + 4); | |
| 125 | + if (ifd + 2 > b.length) return 1; | |
| 126 | + const n = r16(ifd); | |
| 127 | + for (let k = 0; k < n; k++) { | |
| 128 | + const e = ifd + 2 + k * 12; | |
| 129 | + if (e + 12 > b.length || e + 12 > i + 2 + len) break; | |
| 130 | + if (r16(e) === 0x0112) { | |
| 131 | + const o = r16(e + 8); | |
| 132 | + return o >= 1 && o <= 8 ? o : 1; | |
| 133 | + } | |
| 134 | + } | |
| 135 | + return 1; | |
| 136 | + } | |
| 137 | + i += 2 + len; | |
| 138 | + } | |
| 139 | + return 1; | |
| 140 | +} | |
| 141 | + | |
| 142 | +// ---------------------------------------------------------------- pixels | |
| 143 | + | |
| 144 | +/** The centred square of a width × height picture. */ | |
| 145 | +export function squareCrop(width: number, height: number): { x: number; y: number; side: number } { | |
| 146 | + const side = Math.min(width, height); | |
| 147 | + return { x: Math.floor((width - side) / 2), y: Math.floor((height - side) / 2), side }; | |
| 148 | +} | |
| 149 | + | |
| 150 | +/** Integer box-filter factor that brings `side` down to no less than `min` pixels. */ | |
| 151 | +export function shrinkFactor(side: number, min: number): number { | |
| 152 | + return Math.max(1, Math.floor(side / min)); | |
| 153 | +} | |
| 154 | + | |
| 155 | +/** | |
| 156 | + * Cut the centred square out of a picture and box-filter it by an integer factor, so the result is | |
| 157 | + * between `min` and 2 × `min` pixels on a side (or the square itself when it is smaller than that). | |
| 158 | + * Alpha-weighted, so transparent pixels do not darken the edges. Always returns a fresh buffer. | |
| 159 | + */ | |
| 160 | +export function cropAndShrink(img: Pixels, min = 512): Pixels { | |
| 161 | + const crop = squareCrop(img.width, img.height); | |
| 162 | + const k = shrinkFactor(crop.side, min); | |
| 163 | + const side = Math.floor(crop.side / k); | |
| 164 | + const x0 = crop.x + Math.floor((crop.side - side * k) / 2); | |
| 165 | + const y0 = crop.y + Math.floor((crop.side - side * k) / 2); | |
| 166 | + const src = img.data; | |
| 167 | + const out = new Uint8ClampedArray(side * side * 4); | |
| 168 | + const n = k * k; | |
| 169 | + for (let oy = 0; oy < side; oy++) { | |
| 170 | + for (let ox = 0; ox < side; ox++) { | |
| 171 | + let r = 0, | |
| 172 | + gr = 0, | |
| 173 | + bl = 0, | |
| 174 | + a = 0; | |
| 175 | + for (let dy = 0; dy < k; dy++) { | |
| 176 | + let p = ((y0 + oy * k + dy) * img.width + x0 + ox * k) * 4; | |
| 177 | + for (let dx = 0; dx < k; dx++, p += 4) { | |
| 178 | + const al = src[p + 3]!; | |
| 179 | + r += src[p]! * al; | |
| 180 | + gr += src[p + 1]! * al; | |
| 181 | + bl += src[p + 2]! * al; | |
| 182 | + a += al; | |
| 183 | + } | |
| 184 | + } | |
| 185 | + const q = (oy * side + ox) * 4; | |
| 186 | + if (a > 0) { | |
| 187 | + out[q] = r / a; | |
| 188 | + out[q + 1] = gr / a; | |
| 189 | + out[q + 2] = bl / a; | |
| 190 | + } | |
| 191 | + out[q + 3] = a / n; | |
| 192 | + } | |
| 193 | + } | |
| 194 | + return { data: out, width: side, height: side }; | |
| 195 | +} | |
| 196 | + | |
| 197 | +/** Apply an EXIF orientation to a square picture, so it shows the way the camera held it. */ | |
| 198 | +export function orientSquare(img: Pixels, orientation: number): Pixels { | |
| 199 | + if (orientation <= 1 || orientation > 8 || img.width !== img.height) return img; | |
| 200 | + const s = img.width; | |
| 201 | + const e = s - 1; | |
| 202 | + const src = new Uint32Array(img.data.buffer, img.data.byteOffset, s * s); | |
| 203 | + const out = new Uint8ClampedArray(s * s * 4); | |
| 204 | + const dst = new Uint32Array(out.buffer); | |
| 205 | + // For each displayed pixel (x, y), the stored pixel it comes from. | |
| 206 | + const from: Record<number, (x: number, y: number) => number> = { | |
| 207 | + 2: (x, y) => y * s + (e - x), // mirrored | |
| 208 | + 3: (x, y) => (e - y) * s + (e - x), // rotated 180° | |
| 209 | + 4: (x, y) => (e - y) * s + x, // flipped | |
| 210 | + 5: (x, y) => x * s + y, // transposed | |
| 211 | + 6: (x, y) => (e - x) * s + y, // rotated 90° clockwise | |
| 212 | + 7: (x, y) => (e - x) * s + (e - y), // transversed | |
| 213 | + 8: (x, y) => x * s + (e - y), // rotated 90° counter-clockwise | |
| 214 | + }; | |
| 215 | + const f = from[orientation]!; | |
| 216 | + for (let y = 0; y < s; y++) for (let x = 0; x < s; x++) dst[y * s + x] = src[f(x, y)]!; | |
| 217 | + return { data: out, width: s, height: s }; | |
| 218 | +} | |
| 219 | + | |
| 220 | +// ---------------------------------------------------------------- codecs | |
| 221 | + | |
| 222 | +type InitWith = (module: WebAssembly.Module) => Promise<unknown>; | |
| 223 | +let shared: Promise<unknown> | undefined; | |
| 224 | + | |
| 225 | +/** Encoder and resampler are small and shared; decoders get a fresh instance per picture (below). */ | |
| 226 | +function sharedCodecs(): Promise<unknown> { | |
| 227 | + shared ??= Promise.all([(initWebpEncode as unknown as InitWith)(WEBP_ENC_WASM), initResize(RESIZE_WASM), initPngDecode(PNG_DEC_WASM)]).catch((e) => { | |
| 228 | + shared = undefined; | |
| 229 | + throw e; | |
| 230 | + }); | |
| 231 | + return shared; | |
| 232 | +} | |
| 233 | + | |
| 234 | +async function decode(bytes: Uint8Array, format: ImageFormat): Promise<Pixels> { | |
| 235 | + const buf = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; | |
| 236 | + // A WASM heap never shrinks, so a new Emscripten instance per picture lets a big decode's heap be collected. | |
| 237 | + if (format === "jpeg") { | |
| 238 | + await (initJpegDecode as unknown as InitWith)(JPEG_DEC_WASM); | |
| 239 | + return decodeJpeg(buf); | |
| 240 | + } | |
| 241 | + if (format === "webp") { | |
| 242 | + await (initWebpDecode as unknown as InitWith)(WEBP_DEC_WASM); | |
| 243 | + return decodeWebp(buf); | |
| 244 | + } | |
| 245 | + return decodePng(buf); | |
| 246 | +} | |
| 247 | + | |
| 248 | +/** Resample a square picture with Lanczos3 (no-op at the same size). */ | |
| 249 | +async function resample(img: Pixels, size: number): Promise<Pixels> { | |
| 250 | + if (img.width === size && img.height === size) return img; | |
| 251 | + return resize(img as Parameters<typeof resize>[0], { width: size, height: size, method: "lanczos3", fitMethod: "stretch", premultiply: true, linearRGB: true }); | |
| 252 | +} | |
| 253 | + | |
| 254 | +/** | |
| 255 | + * Check, decode, centre-crop, scale and encode an uploaded picture. | |
| 256 | + * Returns one WebP per entry of AVATAR_SIZES. Client mistakes throw HttpError 413/422. | |
| 257 | + */ | |
| 258 | +export async function makeAvatarImages(bytes: Uint8Array): Promise<Map<AvatarSize, Uint8Array>> { | |
| 259 | + if (bytes.byteLength > MAX_AVATAR_BYTES) throw new HttpError(413, "That picture is larger than 5 MB. Use a smaller file."); | |
| 260 | + const format = sniffImage(bytes); | |
| 261 | + if (!format) throw new HttpError(422, UNSUPPORTED_IMAGE); | |
| 262 | + const dims = imageSize(bytes, format); | |
| 263 | + if (!dims || !dims.width || !dims.height) throw new HttpError(422, `That picture could not be read. ${UNSUPPORTED_IMAGE}`); | |
| 264 | + if (dims.width > MAX_AVATAR_SIDE || dims.height > MAX_AVATAR_SIDE) throw new HttpError(422, `That picture is ${dims.width}×${dims.height} pixels; the limit is ${MAX_AVATAR_SIDE}×${MAX_AVATAR_SIDE}.`); | |
| 265 | + await sharedCodecs(); | |
| 266 | + let decoded: Pixels; | |
| 267 | + try { | |
| 268 | + decoded = await decode(bytes, format); | |
| 269 | + } catch { | |
| 270 | + throw new HttpError(422, `That picture could not be read. ${UNSUPPORTED_IMAGE}`); | |
| 271 | + } | |
| 272 | + if (decoded.width > MAX_AVATAR_SIDE || decoded.height > MAX_AVATAR_SIDE) throw new HttpError(422, `That picture is larger than ${MAX_AVATAR_SIDE}×${MAX_AVATAR_SIDE} pixels.`); | |
| 273 | + let square = cropAndShrink(decoded, 2 * AVATAR_SIZES[AVATAR_SIZES.length - 1]!); | |
| 274 | + if (format === "jpeg") square = orientSquare(square, jpegOrientation(bytes)); | |
| 275 | + const out = new Map<AvatarSize, Uint8Array>(); | |
| 276 | + for (const size of AVATAR_SIZES) { | |
| 277 | + const px = await resample(square, size); | |
| 278 | + out.set(size, new Uint8Array(await encodeWebp(px as Parameters<typeof encodeWebp>[0], { quality: AVATAR_QUALITY, method: 6 }))); | |
| 279 | + } | |
| 280 | + return out; | |
| 281 | +} |
modifiedapps/diffinery/src/mail/samples.ts+1−1View file
| @@ -74,7 +74,7 @@ | ||
| 74 | 74 | item("welcome", "Welcome", { kind: "welcome", reason: "account", data: { login: me } }), |
| 75 | 75 | item("signin-link", "Sign-in link", { kind: "signin_link", reason: "account", data: { url: `${pub}/signin/sample`, admin: "admin", expiresAt: new Date(Date.now() + 86400_000).toISOString() } }), |
| 76 | 76 | item("access-request", "Access request", { kind: "access_request", reason: "admin", data: { email: "newcomer@example.com", login: "newcomer", note: "I'm joining the platform team next week." } }), |
| 77 | - item("org-member", "Organization membership", { kind: "org_membership", reason: "account", data: { org: repo.split("/")[0]!, role: "admin", previous: "member" } }), | |
| 77 | + item("org-member", "Organization membership", { kind: "org_membership", reason: "account", data: { org: repo.split("/")[0]!, role: "admin", previous: "member", by: "octo-dev" } }), | |
| 78 | 78 | item("repo-access", "Repository access", { kind: "repo_access", reason: "account", data: { repo, role: "write", previous: null } }), |
| 79 | 79 | item("repo-created", "Repository created", { kind: "repo_created", reason: "your_activity", data: { repo, creator: me, visibility: "private", defaultBranch: "main", description: "Marketing site" } }), |
| 80 | 80 | item("repo-deleted", "Repository deleted", { kind: "repo_deleted", reason: "admin", data: { repo, actor: "kim" } }), |
modifiedapps/diffinery/src/mail/templates.ts+7−4View file
| @@ -81,7 +81,7 @@ | ||
| 81 | 81 | .map((x) => { |
| 82 | 82 | if (typeof x === "string") return esc(x); |
| 83 | 83 | if ("b" in x) return `<strong style="font-weight:600">${esc(x.b)}</strong>`; |
| 84 | - if ("code" in x) return `<code style="font-family:${MONO};font-size:12.5px;background:${C.codeBg};border-radius:4px;padding:1px 5px">${esc(x.code)}</code>`; | |
| 84 | + if ("code" in x) return `<code class="code" style="font-family:${MONO};font-size:12.5px;background:${C.codeBg};border-radius:4px;padding:1px 5px">${esc(x.code)}</code>`; | |
| 85 | 85 | return `<a href="${esc(x.href)}" style="color:${C.accent};text-decoration:none">${esc(x.a)}</a>`; |
| 86 | 86 | }) |
| 87 | 87 | .join(""); |
| @@ -185,7 +185,7 @@ | ||
| 185 | 185 | @media (prefers-color-scheme:dark){ |
| 186 | 186 | .bg{background:#0a0a0a !important}.card{background:#171717 !important;border-color:#2e2e2e !important} |
| 187 | 187 | .fg,.fg a,h1.fg{color:#f4f4f5 !important}.muted{color:#a3a3a3 !important}.box{border-color:#2e2e2e !important} |
| 188 | -.box td{border-color:#2e2e2e !important}.md pre,.md code,pre.md{background:#262626 !important;color:#f4f4f5 !important} | |
| 188 | +.box td{border-color:#2e2e2e !important}.md,.md td,.md th{color:#e4e4e7 !important}.md pre,.md code,pre.md,code.code{background:#262626 !important;color:#f4f4f5 !important} | |
| 189 | 189 | .btn2{background:#262626 !important;color:#f4f4f5 !important;border-color:#3f3f46 !important}} |
| 190 | 190 | @media (max-width:620px){.card{border-radius:0 !important;padding:22px 18px !important}.outer{padding:0 !important}} |
| 191 | 191 | </style></head> |
| @@ -571,7 +571,10 @@ | ||
| 571 | 571 | preheader: d.role ? `You can see ${d.org}'s repositories according to your role.` : `You no longer have access to ${d.org}'s private repositories.`, |
| 572 | 572 | eyebrow: { text: "Organization", tone: d.role ? "blue" : "neutral" }, |
| 573 | 573 | title, |
| 574 | - blocks: [{ kv: [["Organization", d.org], ["Role", d.role ? roleText(d.role) : "none"], ...(d.previous ? ([["Previously", roleText(d.previous)]] as Array<[string, string]>) : [])] }, ...(d.role ? [{ button: { label: `Open ${d.org}`, url } } as Block] : [])], | |
| 574 | + blocks: [ | |
| 575 | + { kv: [["Organization", d.org], ["Role", d.role ? roleText(d.role) : "none"], ...(d.previous ? ([["Previously", roleText(d.previous)]] as Array<[string, string]>) : []), ...(d.by ? ([[d.role && !d.previous ? "Added by" : "Changed by", d.by]] as Array<[string, string]>) : [])] }, | |
| 576 | + ...(d.role ? [{ button: { label: `Open ${d.org}`, url } } as Block] : []), | |
| 577 | + ], | |
| 575 | 578 | context: { label: d.org, url }, |
| 576 | 579 | url: d.role ? url : undefined, |
| 577 | 580 | }; |
| @@ -582,7 +585,7 @@ | ||
| 582 | 585 | const title: Inline[] = d.role ? (d.previous ? ["Your access to ", { b: d.repo }, ` changed to ${roleText(d.role)}`] : ["You now have ", roleText(d.role), " access to ", { b: d.repo }]) : ["Your access to ", { b: d.repo }, " was removed"]; |
| 583 | 586 | return { |
| 584 | 587 | subject: `[${d.repo}] ${inlineText(title)}`, |
| 585 | - preheader: d.role ? `Open ${d.repo} on Diffinery.` : "You can no longer see it unless it's internal or public.", | |
| 588 | + preheader: d.role ? `Open ${d.repo} on Diffinery.` : "You can no longer see it unless it's public.", | |
| 586 | 589 | eyebrow: { text: "Repository access", tone: d.role ? "blue" : "neutral" }, |
| 587 | 590 | title, |
| 588 | 591 | blocks: [{ kv: [["Repository", d.repo], ["Role", d.role ? roleText(d.role) : "none"], ...(d.previous ? ([["Previously", roleText(d.previous)]] as Array<[string, string]>) : [])] }, ...(d.role ? [{ button: { label: "Open repository", url } } as Block] : [])], |
modifiedapps/diffinery/src/mail/types.ts+1−1View file
| @@ -84,7 +84,7 @@ | ||
| 84 | 84 | | { kind: "welcome"; data: { login: string } } |
| 85 | 85 | | { kind: "signin_link"; data: { url: string; admin: string; expiresAt: string } } |
| 86 | 86 | | { kind: "access_request"; data: { email: string; login: string; note: string } } |
| 87 | - | { kind: "org_membership"; data: { org: string; role: string | null; previous: string | null } } | |
| 87 | + | { kind: "org_membership"; data: { org: string; role: string | null; previous: string | null; by?: string } } | |
| 88 | 88 | | { kind: "repo_access"; data: { repo: string; role: string | null; previous: string | null } } |
| 89 | 89 | | { kind: "repo_created"; data: { repo: string; creator: string; visibility: string; defaultBranch: string; description: string } } |
| 90 | 90 | | { kind: "repo_deleted"; data: { repo: string; actor: string } } |
modifiedapps/diffinery/src/mail/web.tsx+4−4View file
| @@ -396,20 +396,20 @@ | ||
| 396 | 396 | </div> |
| 397 | 397 | <div class="row mt mb"> |
| 398 | 398 | <Form action="/settings/notifications/test" t={t} class="inline"> |
| 399 | - <button class="btn sm">Send me a test email</button> | |
| 399 | + <button class="btn">Send me a test email</button> | |
| 400 | 400 | </Form> |
| 401 | 401 | <Form action="/settings/admin/email/samples" t={t} class="inline" confirm={`Send all ${samples.length} templates to ${me.email}?`}> |
| 402 | - <button class="btn sm">Send every template to me</button> | |
| 402 | + <button class="btn">Send every template to me</button> | |
| 403 | 403 | </Form> |
| 404 | 404 | </div> |
| 405 | 405 | |
| 406 | 406 | <h2>Templates</h2> |
| 407 | 407 | <form method="get" action="/settings/admin/email" class="row mb"> |
| 408 | 408 | <KSelect name="sample" options={samples.map((s) => ({ value: s.id, label: s.label }))} value={pick.id} label="Template" autosubmit /> |
| 409 | - <a class="btn sm" href={`/settings/admin/email/preview/${pick.id}`} target="_blank" rel="noopener"> | |
| 409 | + <a class="btn" href={`/settings/admin/email/preview/${pick.id}`} target="_blank" rel="noopener"> | |
| 410 | 410 | Open HTML |
| 411 | 411 | </a> |
| 412 | - <a class="btn sm" href={`/settings/admin/email/preview/${pick.id}?format=text`} target="_blank" rel="noopener"> | |
| 412 | + <a class="btn" href={`/settings/admin/email/preview/${pick.id}?format=text`} target="_blank" rel="noopener"> | |
| 413 | 413 | Plain text |
| 414 | 414 | </a> |
| 415 | 415 | </form> |
modifiedapps/diffinery/src/repo/actions.ts+1−1View file
| @@ -618,7 +618,7 @@ | ||
| 618 | 618 | else if (ref.owner.toLowerCase() === m.owner.toLowerCase() && ref.repo.toLowerCase() === m.name.toLowerCase()) repoId = m.repoId; |
| 619 | 619 | else { |
| 620 | 620 | const target = await new Directory(this.env).repo(ref.owner, ref.repo); |
| 621 | - // Like GitHub: public and internal workflows can be shared; private ones only within the same owner. | |
| 621 | + // Like GitHub: public workflows can be shared; private ones only within the same owner. | |
| 622 | 622 | if (!target || (target.visibility === "private" && target.owner_id !== m.ownerId)) throw new Error(`repository ${ref.owner}/${ref.repo} not found, or its workflows are not shared with ${m.owner}/${m.name}`); |
| 623 | 623 | repoId = target.id; |
| 624 | 624 | } |
modifiedapps/diffinery/src/text-modules.d.ts+6−0View file
| @@ -3,3 +3,9 @@ | ||
| 3 | 3 | const text: string; |
| 4 | 4 | export default text; |
| 5 | 5 | } |
| 6 | + | |
| 7 | +// Compiled WebAssembly modules (wrangler's default CompiledWasm rule; the wasm plugin in vitest.config.ts under tests). | |
| 8 | +declare module "*.wasm?module" { | |
| 9 | + const module: WebAssembly.Module; | |
| 10 | + export default module; | |
| 11 | +} |
modifiedapps/diffinery/src/web/account.tsx+320−153View file
| @@ -12,7 +12,11 @@ | ||
| 12 | 12 | import { Auth } from "../auth/sessions.js"; |
| 13 | 13 | import { repoCreated } from "../mail/notify.js"; |
| 14 | 14 | import { browserAccount, deviceLabel, invitePasskeyPage } from "./auth.js"; |
| 15 | -import { Actor, Avatar, CopyButton, Form, ROLES, Time, act, ago, post, publicUrl, render, repoUrl, type Stub, type WebApp, prm } from "./ui.js"; | |
| 15 | +import { deleteCookie, setCookie } from "hono/cookie"; | |
| 16 | +import { APPEARANCE_COOKIE, Actor, Avatar, CopyButton, Form, ROLES, Time, cap, act, ago, flash, post, publicUrl, render, repoUrl, type Stub, type WebApp, prm } from "./ui.js"; | |
| 17 | +import type { FormData } from "./csrf.js"; | |
| 18 | +import { purgeAvatarCache, removeAvatar, setAvatar } from "../http/avatars.js"; | |
| 19 | +import { MAX_AVATAR_BYTES } from "../lib/image.js"; | |
| 16 | 20 | |
| 17 | 21 | // ---------------------------------------------------------------- shared bits |
| 18 | 22 | |
| @@ -58,6 +62,69 @@ | ||
| 58 | 62 | return role === "owner" || role === "admin"; |
| 59 | 63 | } |
| 60 | 64 | |
| 65 | +// ---------------------------------------------------------------- profile pictures (people and organizations) | |
| 66 | + | |
| 67 | +const TOO_BIG = "That picture is larger than 5 MB. Use a smaller file."; | |
| 68 | + | |
| 69 | +/** Current picture, an upload control and a Remove button. Uploads go to `action`, removal to `${action}/delete`. */ | |
| 70 | +const AvatarSection: FC<{ account: Account; action: string; t: string }> = ({ account, action, t }) => ( | |
| 71 | + <div class="box"> | |
| 72 | + <div class="head"> | |
| 73 | + <h2>Profile picture</h2> | |
| 74 | + </div> | |
| 75 | + <div class="body avpick"> | |
| 76 | + <span data-avatar-preview> | |
| 77 | + <Avatar login={account.login} kind={account.kind} size="xl" version={account.avatar_version} /> | |
| 78 | + </span> | |
| 79 | + <div class="grow"> | |
| 80 | + <Form action={action} t={t} avatar> | |
| 81 | + <div class="field"> | |
| 82 | + <label for="avatar-file">Upload a new picture</label> | |
| 83 | + <input id="avatar-file" name="avatar" type="file" accept="image/jpeg,image/png,image/webp" required /> | |
| 84 | + <div class="hint">JPEG, PNG or WebP, up to 5 MB. It is cropped to a centred square.</div> | |
| 85 | + </div> | |
| 86 | + <div class="row"> | |
| 87 | + <button class="btn primary">Save picture</button> | |
| 88 | + <span class="muted small" data-avatar-status aria-live="polite"></span> | |
| 89 | + </div> | |
| 90 | + </Form> | |
| 91 | + {account.avatar_version ? ( | |
| 92 | + <Form action={`${action}/delete`} t={t} class="mt" confirm={account.kind === "org" ? `Remove the picture of ${account.login}?` : "Remove your profile picture?"}> | |
| 93 | + <button class="btn sm danger">Remove picture</button> | |
| 94 | + <span class="muted small" style="margin-left:8px"> | |
| 95 | + Shows initials instead. | |
| 96 | + </span> | |
| 97 | + </Form> | |
| 98 | + ) : null} | |
| 99 | + </div> | |
| 100 | + </div> | |
| 101 | + </div> | |
| 102 | +); | |
| 103 | + | |
| 104 | +/** Refuse oversized uploads before the form body is parsed. */ | |
| 105 | +function avatarSizeGuard(web: WebApp, path: string, back: (c: AppContext) => string): void { | |
| 106 | + web.use(path, async (c, next) => { | |
| 107 | + if (c.req.method === "POST" && Number(c.req.header("content-length") ?? 0) > MAX_AVATAR_BYTES + 64 * 1024) { | |
| 108 | + flash(c, "error", TOO_BIG); | |
| 109 | + return c.redirect(back(c), 303); | |
| 110 | + } | |
| 111 | + await next(); | |
| 112 | + }); | |
| 113 | +} | |
| 114 | + | |
| 115 | +async function uploadAvatar(c: AppContext, f: FormData, me: Account, account: Account): Promise<void> { | |
| 116 | + const file = f.file("avatar"); | |
| 117 | + if (!file) throw new HttpError(422, "Choose a picture to upload."); | |
| 118 | + if (file.size > MAX_AVATAR_BYTES) throw new HttpError(413, TOO_BIG); | |
| 119 | + await setAvatar(c, account, new Uint8Array(await file.arrayBuffer())); | |
| 120 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "avatar.update", target: account.login, detail: { kind: account.kind, via: "web" } })); | |
| 121 | +} | |
| 122 | + | |
| 123 | +async function deleteAvatar(c: AppContext, me: Account, account: Account): Promise<void> { | |
| 124 | + await removeAvatar(c, account); | |
| 125 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "avatar.remove", target: account.login, detail: { kind: account.kind, via: "web" } })); | |
| 126 | +} | |
| 127 | + | |
| 61 | 128 | const SCOPES: TokenScope[] = ["read", "write", "admin"]; |
| 62 | 129 | |
| 63 | 130 | // ---------------------------------------------------------------- landing / dashboard |
| @@ -95,6 +162,17 @@ | ||
| 95 | 162 | </div> |
| 96 | 163 | ); |
| 97 | 164 | |
| 165 | +/** The icon for a notification, by the event that raised it. */ | |
| 166 | +function noteIcon(kind: string): string { | |
| 167 | + if (kind === "run_failed") return ICONS.failure; | |
| 168 | + if (kind.startsWith("pull_request")) return ICONS.pr; | |
| 169 | + if (kind === "issues") return ICONS.issueOpen; | |
| 170 | + if (kind.endsWith("comment")) return ICONS.comment; | |
| 171 | + if (kind === "org_membership") return ICONS.org; | |
| 172 | + if (kind === "repo_access") return ICONS.repo; | |
| 173 | + return ICONS.bell; | |
| 174 | +} | |
| 175 | + | |
| 98 | 176 | export function registerAccount(web: WebApp): void { |
| 99 | 177 | web.get("/", async (c) => { |
| 100 | 178 | const p = c.get("principal"); |
| @@ -165,7 +243,7 @@ | ||
| 165 | 243 | <div class="nav"> |
| 166 | 244 | {orgs.map((o) => ( |
| 167 | 245 | <a href={`/${o.login}`}> |
| 168 | - <Avatar login={o.login} /> {o.login} <span class="faint small">{o.role}</span> | |
| 246 | + <Avatar login={o.login} version={o.avatar_version} /> {o.login} <span class="faint small">{o.role}</span> | |
| 169 | 247 | </a> |
| 170 | 248 | ))} |
| 171 | 249 | <a href="/settings/orgs" class="muted"> |
| @@ -279,7 +357,6 @@ | ||
| 279 | 357 | items={( |
| 280 | 358 | [ |
| 281 | 359 | ["private", "Private", "Only you, collaborators and organization members can see it."], |
| 282 | - ["internal", "Internal", "Everyone signed in to this Diffinery instance can see it."], | |
| 283 | 360 | ["public", "Public", "Anyone on the internet can see and clone it."], |
| 284 | 361 | ] as const |
| 285 | 362 | ).map(([v, l, d]) => ({ value: v, label: l, description: d }))} |
| @@ -308,7 +385,7 @@ | ||
| 308 | 385 | try { |
| 309 | 386 | const owner = values.owner === me.login ? me : await dir.accountByLogin(values.owner); |
| 310 | 387 | if (!owner || owner.kind === "agent") throw new HttpError(422, "unknown owner"); |
| 311 | - const visibility = (["private", "internal", "public"] as const).find((v) => v === values.visibility) ?? "private"; | |
| 388 | + const visibility = (["private", "public"] as const).find((v) => v === values.visibility) ?? "private"; | |
| 312 | 389 | const repo = await dir.createRepo(owner, me, { name: values.name, description: values.description, visibility, defaultBranch: values.default_branch || "main" }); |
| 313 | 390 | c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "repo.create", target: `${repo.owner_login}/${repo.name}` })); |
| 314 | 391 | c.executionCtx.waitUntil(repoCreated(c.env, dir, repo, me, { viaToken: false })); |
| @@ -343,8 +420,11 @@ | ||
| 343 | 420 | </a> |
| 344 | 421 | </div> |
| 345 | 422 | <Form action="/notifications/read" t={t} class="inline"> |
| 346 | - <button class="btn sm">Mark all as read</button> | |
| 423 | + <button class="btn">{raw(ICONS.check)} Mark all as read</button> | |
| 347 | 424 | </Form> |
| 425 | + <a class="btn icon" href="/settings/notifications" title="Notification settings" aria-label="Notification settings"> | |
| 426 | + {raw(ICONS.gear)} | |
| 427 | + </a> | |
| 348 | 428 | </div> |
| 349 | 429 | </div> |
| 350 | 430 | <div class="box"> |
| @@ -352,8 +432,8 @@ | ||
| 352 | 432 | <ul class="list"> |
| 353 | 433 | {notes.map((n) => ( |
| 354 | 434 | <li> |
| 355 | - <span class={`st ${n.readAt ? "faint" : ""}`} style={n.readAt ? "" : "color:var(--accent)"}> | |
| 356 | - {raw(n.kind.startsWith("pull") ? ICONS.pr : n.kind.includes("run") || n.kind.includes("workflow") ? ICONS.play : ICONS.bell)} | |
| 435 | + <span class={`st ${n.readAt ? "faint" : ""}`} style={n.readAt ? "" : n.kind === "run_failed" ? "color:var(--red)" : "color:var(--accent)"}> | |
| 436 | + {raw(noteIcon(n.kind))} | |
| 357 | 437 | </span> |
| 358 | 438 | <div class="grow"> |
| 359 | 439 | <a class="title" href={`/notifications/${n.id}`} style={n.readAt ? "font-weight:400" : ""}> |
| @@ -494,13 +574,13 @@ | ||
| 494 | 574 | |
| 495 | 575 | // ---------------------------------------------------------------- /settings |
| 496 | 576 | |
| 497 | -export type SettingsTab = "profile" | "security" | "tokens" | "agents" | "orgs" | "notifications" | "admin" | "email" | "audit"; | |
| 577 | +export type SettingsTab = "profile" | "appearance" | "security" | "tokens" | "agents" | "orgs" | "notifications" | "admin" | "email" | "audit"; | |
| 498 | 578 | |
| 499 | 579 | export const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => ( |
| 500 | 580 | <aside class="side"> |
| 501 | 581 | <section> |
| 502 | 582 | <div class="row mb"> |
| 503 | - <Avatar login={me.login} size="lg" /> | |
| 583 | + <Avatar login={me.login} size="lg" version={me.avatar_version} /> | |
| 504 | 584 | <div> |
| 505 | 585 | <b>{me.name ?? me.login}</b> |
| 506 | 586 | <div class="muted small">{me.login}</div> |
| @@ -510,6 +590,9 @@ | ||
| 510 | 590 | <a href="/settings" class={tab === "profile" ? "on" : ""}> |
| 511 | 591 | Profile |
| 512 | 592 | </a> |
| 593 | + <a href="/settings/appearance" class={tab === "appearance" ? "on" : ""}> | |
| 594 | + Appearance | |
| 595 | + </a> | |
| 513 | 596 | <a href="/settings/security" class={tab === "security" ? "on" : ""}> |
| 514 | 597 | Passkeys and sessions |
| 515 | 598 | </a> |
| @@ -527,8 +610,9 @@ | ||
| 527 | 610 | </a> |
| 528 | 611 | {me.site_admin ? ( |
| 529 | 612 | <> |
| 613 | + <h4>Site administration</h4> | |
| 530 | 614 | <a href="/settings/admin" class={tab === "admin" ? "on" : ""}> |
| 531 | - Site administration | |
| 615 | + People and invitations | |
| 532 | 616 | </a> |
| 533 | 617 | <a href="/settings/admin/email" class={tab === "email" ? "on" : ""}> |
| 534 | 618 | |
| @@ -550,7 +634,7 @@ | ||
| 550 | 634 | <p class="muted">Copy it now — it will not be shown again.</p> |
| 551 | 635 | <div class="row"> |
| 552 | 636 | <div class="secret grow">{token}</div> |
| 553 | - <CopyButton text={token} /> | |
| 637 | + <CopyButton text={token} size="base" /> | |
| 554 | 638 | </div> |
| 555 | 639 | {note} |
| 556 | 640 | <details class="mt"> |
| @@ -587,6 +671,7 @@ | ||
| 587 | 671 | "Settings", |
| 588 | 672 | <> |
| 589 | 673 | <h1>Profile</h1> |
| 674 | + <AvatarSection account={me} action="/settings/avatar" t={t} /> | |
| 590 | 675 | <Form action="/settings/profile" t={t} class="box"> |
| 591 | 676 | <div class="body"> |
| 592 | 677 | <div class="field"> |
| @@ -625,11 +710,72 @@ | ||
| 625 | 710 | async () => { |
| 626 | 711 | const login = f.s("login").trim(); |
| 627 | 712 | await c.get("dir").updateAccount(me.id, { login: login && login !== me.login ? login : undefined, name: f.s("name").trim() }); |
| 713 | + // The old login's cached picture must not outlive the rename. | |
| 714 | + if (login && login !== me.login) c.executionCtx.waitUntil(purgeAvatarCache(c, me.login, [me.avatar_version])); | |
| 628 | 715 | }, |
| 629 | 716 | "Profile saved.", |
| 630 | 717 | ); |
| 631 | 718 | }); |
| 632 | 719 | |
| 720 | + // Appearance | |
| 721 | + web.get("/settings/appearance", async (c) => { | |
| 722 | + const me = requireAccount(c); | |
| 723 | + const current = me.appearance === "light" || me.appearance === "dark" ? me.appearance : "auto"; | |
| 724 | + return page( | |
| 725 | + c, | |
| 726 | + me, | |
| 727 | + "appearance", | |
| 728 | + "Appearance", | |
| 729 | + <> | |
| 730 | + <h1>Appearance</h1> | |
| 731 | + <p class="muted">Choose how Diffinery looks to you. The choice follows your account to every browser you sign in to.</p> | |
| 732 | + <Form action="/settings/appearance" t={csrfToken(c)} class="box"> | |
| 733 | + <div class="body"> | |
| 734 | + <div class="field"> | |
| 735 | + <label>Theme</label> | |
| 736 | + <KRadio | |
| 737 | + name="appearance" | |
| 738 | + label="Theme" | |
| 739 | + value={current} | |
| 740 | + items={( | |
| 741 | + [ | |
| 742 | + ["auto", "Sync with system", "Light or dark, following your device's setting."], | |
| 743 | + ["light", "Light", "Always light."], | |
| 744 | + ["dark", "Dark", "Always dark."], | |
| 745 | + ] as const | |
| 746 | + ).map(([v, l, d]) => ({ value: v, label: <span class="theme-opt"><span class={`theme-prev ${v}`} aria-hidden="true"><i /><i /><i /></span>{l}</span>, description: d }))} | |
| 747 | + /> | |
| 748 | + </div> | |
| 749 | + <button class="btn primary">Save appearance</button> | |
| 750 | + </div> | |
| 751 | + </Form> | |
| 752 | + </>, | |
| 753 | + ); | |
| 754 | + }); | |
| 755 | + | |
| 756 | + post(web, "/settings/appearance", async (c, f) => { | |
| 757 | + const me = requireAccount(c); | |
| 758 | + const v = f.s("appearance"); | |
| 759 | + const appearance = v === "light" || v === "dark" ? v : null; | |
| 760 | + await c.get("dir").setAppearance(me.id, appearance); | |
| 761 | + // Signed-out pages (sign-in, invites) in this browser keep the choice too. | |
| 762 | + if (appearance) setCookie(c, APPEARANCE_COOKIE, appearance, { path: "/", secure: true, sameSite: "Lax", maxAge: 400 * 86400 }); | |
| 763 | + else deleteCookie(c, APPEARANCE_COOKIE, { path: "/", secure: true }); | |
| 764 | + flash(c, "notice", "Appearance saved."); | |
| 765 | + return c.redirect("/settings/appearance", 303); | |
| 766 | + }); | |
| 767 | + | |
| 768 | + avatarSizeGuard(web, "/settings/avatar", () => "/settings"); | |
| 769 | + post(web, "/settings/avatar", async (c, f) => { | |
| 770 | + const me = requireAccount(c); | |
| 771 | + return act(c, "/settings", () => uploadAvatar(c, f, me, me), "Profile picture updated."); | |
| 772 | + }); | |
| 773 | + | |
| 774 | + post(web, "/settings/avatar/delete", async (c) => { | |
| 775 | + const me = requireAccount(c); | |
| 776 | + return act(c, "/settings", () => deleteAvatar(c, me, me), "Profile picture removed."); | |
| 777 | + }); | |
| 778 | + | |
| 633 | 779 | // Passkeys and sessions |
| 634 | 780 | web.get("/settings/security", async (c) => { |
| 635 | 781 | const me = requireAccount(c); |
| @@ -677,13 +823,15 @@ | ||
| 677 | 823 | <div class="empty">No passkeys yet. Add one so you can sign in.</div> |
| 678 | 824 | )} |
| 679 | 825 | <div class="body" style="border-top:1px solid var(--border)"> |
| 680 | - <form data-passkey="register" data-next="/settings/security" class="row" style="align-items:flex-end"> | |
| 681 | - <div class="field grow m0"> | |
| 682 | - <label for="pklabel">Add a passkey</label> | |
| 683 | - <input id="pklabel" name="label" type="text" maxlength={100} placeholder={deviceLabel(c.req.header("user-agent"))} /> | |
| 684 | - <div class="hint">Add one on each device you use, or keep it in a password manager that syncs.</div> | |
| 826 | + <form data-passkey="register" data-next="/settings/security"> | |
| 827 | + <div class="formrow"> | |
| 828 | + <div class="field grow"> | |
| 829 | + <label for="pklabel">Add a passkey</label> | |
| 830 | + <input id="pklabel" name="label" type="text" maxlength={100} placeholder={deviceLabel(c.req.header("user-agent"))} /> | |
| 831 | + </div> | |
| 832 | + <button class="btn primary">{raw(ICONS.key)} Add a passkey</button> | |
| 685 | 833 | </div> |
| 686 | - <button class="btn primary">Add a passkey</button> | |
| 834 | + <div class="hint">Add one on each device you use, or keep it in a password manager that syncs.</div> | |
| 687 | 835 | </form> |
| 688 | 836 | <div class="mt"> |
| 689 | 837 | <div class="flash error" data-passkey-msg role="alert" hidden></div> |
| @@ -824,7 +972,7 @@ | ||
| 824 | 972 | </div> |
| 825 | 973 | <div class="field"> |
| 826 | 974 | <label>Scope</label> |
| 827 | - <KRadio name="scope" label="Scope" orientation="horizontal" value="write" items={SCOPES.map((s) => ({ value: s, label: s }))} /> | |
| 975 | + <KRadio name="scope" label="Scope" orientation="horizontal" value="write" items={SCOPES.map((s) => ({ value: s, label: cap(s) }))} /> | |
| 828 | 976 | <div class="hint">read: clone and view · write: push, issues, pull requests, runs (up to maintain) · admin: everything you can do, including settings.</div> |
| 829 | 977 | </div> |
| 830 | 978 | <div class="field"> |
| @@ -907,7 +1055,7 @@ | ||
| 907 | 1055 | return ( |
| 908 | 1056 | <li style="display:block"> |
| 909 | 1057 | <div class="row"> |
| 910 | - <Actor login={a.login} kind="agent" /> | |
| 1058 | + <Actor login={a.login} kind="agent" version={a.avatar_version} /> | |
| 911 | 1059 | <span class="muted small"> |
| 912 | 1060 | owned by <b>{ownerName.get(a.owner_id ?? "") ?? "?"}</b> · max role <b>{policy.maxRole ?? "write"}</b> · {a.status} |
| 913 | 1061 | </span> |
| @@ -941,7 +1089,7 @@ | ||
| 941 | 1089 | </div> |
| 942 | 1090 | <div class="field m0"> |
| 943 | 1091 | <label>Scope</label> |
| 944 | - <KSelect name="scope" label="Scope" value="write" options={SCOPES.map((s) => ({ value: s, label: s }))} /> | |
| 1092 | + <KSelect name="scope" label="Scope" value="write" options={SCOPES.map((s) => ({ value: s, label: cap(s) }))} /> | |
| 945 | 1093 | </div> |
| 946 | 1094 | <div class="field m0" style="width:110px"> |
| 947 | 1095 | <label>Days</label> |
| @@ -981,14 +1129,14 @@ | ||
| 981 | 1129 | name="owner" |
| 982 | 1130 | block |
| 983 | 1131 | label="Owner" |
| 984 | - value="" | |
| 985 | - options={[{ value: "", label: `${me.login} (you)` }, ...orgs.map((o) => ({ value: String(o.id), label: `${o.login} (organization)` }))]} | |
| 1132 | + value="me" | |
| 1133 | + options={[{ value: "me", label: `${me.login} (you)` }, ...orgs.map((o) => ({ value: String(o.id), label: `${o.login} (organization)` }))]} | |
| 986 | 1134 | /> |
| 987 | 1135 | <div class="hint">An agent can never do more than its owner.</div> |
| 988 | 1136 | </div> |
| 989 | 1137 | <div class="field grow"> |
| 990 | 1138 | <label for="arole">Maximum role</label> |
| 991 | - <KSelect id="arole" name="maxRole" block label="Maximum role" value="write" options={ROLES.map((r) => ({ value: r, label: r }))} /> | |
| 1139 | + <KSelect id="arole" name="maxRole" block label="Maximum role" value="write" options={ROLES.map((r) => ({ value: r, label: cap(r) }))} /> | |
| 992 | 1140 | </div> |
| 993 | 1141 | </div> |
| 994 | 1142 | <button class="btn primary">Create agent</button> |
| @@ -1008,7 +1156,7 @@ | ||
| 1008 | 1156 | "/settings/agents", |
| 1009 | 1157 | async () => { |
| 1010 | 1158 | const maxRole = ROLES.find((r) => r === f.s("maxRole")) ?? "write"; |
| 1011 | - const a = await dir.createAgent(me, { login: f.s("login").trim(), name: f.s("name").trim() || undefined, orgId: f.s("owner") || undefined, policy: { maxRole } }); | |
| 1159 | + const a = await dir.createAgent(me, { login: f.s("login").trim(), name: f.s("name").trim() || undefined, orgId: f.s("owner") && f.s("owner") !== "me" ? f.s("owner") : undefined, policy: { maxRole } }); | |
| 1012 | 1160 | c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "agent.create", target: a.login, detail: { maxRole } })); |
| 1013 | 1161 | }, |
| 1014 | 1162 | "Agent created. Create a token for it below.", |
| @@ -1065,7 +1213,7 @@ | ||
| 1065 | 1213 | <ul class="list"> |
| 1066 | 1214 | {orgs.map((o) => ( |
| 1067 | 1215 | <li> |
| 1068 | - <Avatar login={o.login} /> | |
| 1216 | + <Avatar login={o.login} version={o.avatar_version} /> | |
| 1069 | 1217 | <div class="grow"> |
| 1070 | 1218 | <a class="title" href={`/${o.login}`}> |
| 1071 | 1219 | {o.login} |
| @@ -1137,7 +1285,7 @@ | ||
| 1137 | 1285 | <p class="muted">{invite.note}</p> |
| 1138 | 1286 | <div class="row"> |
| 1139 | 1287 | <div class="secret grow">{invite.url}</div> |
| 1140 | - <CopyButton text={invite.url} /> | |
| 1288 | + <CopyButton text={invite.url} size="base" /> | |
| 1141 | 1289 | </div> |
| 1142 | 1290 | </div> |
| 1143 | 1291 | </div> |
| @@ -1151,7 +1299,7 @@ | ||
| 1151 | 1299 | <ul class="list"> |
| 1152 | 1300 | {requests.map((r) => ( |
| 1153 | 1301 | <li> |
| 1154 | - <Avatar login={r.account.login} /> | |
| 1302 | + <Avatar login={r.account.login} version={r.account.avatar_version} /> | |
| 1155 | 1303 | <div class="grow"> |
| 1156 | 1304 | <b>{r.account.email ?? r.account.login}</b> <span class="muted small">requested <Time at={r.createdAt} /></span> |
| 1157 | 1305 | {r.note ? <div class="meta" style="white-space:pre-wrap">{r.note}</div> : null} |
| @@ -1175,13 +1323,15 @@ | ||
| 1175 | 1323 | <div class="head"> |
| 1176 | 1324 | <h2>Invite someone to this instance</h2> |
| 1177 | 1325 | </div> |
| 1178 | - <div class="body row" style="align-items:flex-end"> | |
| 1179 | - <div class="field grow m0"> | |
| 1180 | - <label for="iemail">Email address</label> | |
| 1181 | - <input id="iemail" name="email" type="email" required placeholder="friend@example.com" /> | |
| 1182 | - <div class="hint">Opening the link lets them pick a username and create a passkey.</div> | |
| 1326 | + <div class="body"> | |
| 1327 | + <div class="formrow"> | |
| 1328 | + <div class="field grow"> | |
| 1329 | + <label for="iemail">Email address</label> | |
| 1330 | + <input id="iemail" name="email" type="email" required placeholder="friend@example.com" /> | |
| 1331 | + </div> | |
| 1332 | + <button class="btn primary">{raw(ICONS.envelope)} Send invitation</button> | |
| 1183 | 1333 | </div> |
| 1184 | - <button class="btn primary">Create invite</button> | |
| 1334 | + <div class="hint">We email them a link. Opening it lets them pick a username and create a passkey.</div> | |
| 1185 | 1335 | </div> |
| 1186 | 1336 | </Form> |
| 1187 | 1337 | <div class="box"> |
| @@ -1203,7 +1353,7 @@ | ||
| 1203 | 1353 | {users.map((u) => ( |
| 1204 | 1354 | <tr> |
| 1205 | 1355 | <td> |
| 1206 | - <Actor login={u.login} kind="user" /> {u.site_admin ? <span class="badge blue">admin</span> : null} | |
| 1356 | + <Actor login={u.login} kind="user" version={u.avatar_version} /> {u.site_admin ? <span class="badge blue">admin</span> : null} | |
| 1207 | 1357 | </td> |
| 1208 | 1358 | <td class="small">{u.email ?? "—"}</td> |
| 1209 | 1359 | <td class="small">{ago(u.created_at)}</td> |
| @@ -1212,7 +1362,7 @@ | ||
| 1212 | 1362 | <span class="badge green">{u.status}</span> |
| 1213 | 1363 | ) : ( |
| 1214 | 1364 | <Form action={`/settings/admin/users/${u.id}/status`} t={t} class="row" style="gap:6px;flex-wrap:nowrap"> |
| 1215 | - <KSelect name="status" size="sm" label={`Status of ${u.login}`} value={u.status} options={(["active", "pending", "disabled"] as const).map((s) => ({ value: s, label: s }))} /> | |
| 1365 | + <KSelect name="status" size="sm" label={`Status of ${u.login}`} value={u.status} options={(["active", "pending", "disabled"] as const).map((s) => ({ value: s, label: cap(s) }))} /> | |
| 1216 | 1366 | <button class="btn sm">Save</button> |
| 1217 | 1367 | </Form> |
| 1218 | 1368 | )} |
| @@ -1324,8 +1474,24 @@ | ||
| 1324 | 1474 | |
| 1325 | 1475 | // ---------------------------------------------------------------- /:owner profiles |
| 1326 | 1476 | |
| 1327 | -type OrgTab = "repositories" | "people" | "invites" | "agents" | "settings"; | |
| 1477 | +type OrgTab = "repositories" | "people" | "agents" | "settings"; | |
| 1328 | 1478 | |
| 1479 | +const ROLE_LABEL: Record<string, string> = { owner: "Owner", admin: "Admin", member: "Member" }; | |
| 1480 | + | |
| 1481 | +/** The invitation link right after inviting someone by email (it's also in their inbox). */ | |
| 1482 | +const InviteOnce: FC<{ email: string; url: string }> = ({ email, url }) => ( | |
| 1483 | + <div class="box" style="border-color:var(--amber)"> | |
| 1484 | + <div class="body"> | |
| 1485 | + <h3>Invitation sent to {email}</h3> | |
| 1486 | + <p class="muted">They don't have an account yet, so we emailed them an invitation. You can also send them this link yourself. It works once and expires in 14 days: opening it creates their account and passkey and adds them here.</p> | |
| 1487 | + <div class="row nowrap"> | |
| 1488 | + <div class="secret grow">{url}</div> | |
| 1489 | + <CopyButton text={url} size="base" /> | |
| 1490 | + </div> | |
| 1491 | + </div> | |
| 1492 | + </div> | |
| 1493 | +); | |
| 1494 | + | |
| 1329 | 1495 | function registerProfiles(web: WebApp): void { |
| 1330 | 1496 | const orgContext = async (c: AppContext) => { |
| 1331 | 1497 | const me = requireAccount(c); |
| @@ -1355,7 +1521,7 @@ | ||
| 1355 | 1521 | { title: acct.login }, |
| 1356 | 1522 | <div style="max-width:720px;margin:24px auto" class="box"> |
| 1357 | 1523 | <div class="body row" style="gap:18px"> |
| 1358 | - <Avatar login={acct.login} kind="agent" size="xl" /> | |
| 1524 | + <Avatar login={acct.login} kind="agent" size="xl" version={acct.avatar_version} /> | |
| 1359 | 1525 | <div> |
| 1360 | 1526 | <h1 class="m0"> |
| 1361 | 1527 | {acct.name ?? acct.login} <span class="badge agent">agent</span> |
| @@ -1388,7 +1554,7 @@ | ||
| 1388 | 1554 | <div class="grid left"> |
| 1389 | 1555 | <aside class="side"> |
| 1390 | 1556 | <section> |
| 1391 | - <Avatar login={acct.login} size="xl" /> | |
| 1557 | + <Avatar login={acct.login} size="xl" version={acct.avatar_version} /> | |
| 1392 | 1558 | <h1 class="mt m0">{acct.name ?? acct.login}</h1> |
| 1393 | 1559 | <p class="muted">{acct.login}</p> |
| 1394 | 1560 | {acct.id === me.id ? ( |
| @@ -1403,7 +1569,7 @@ | ||
| 1403 | 1569 | <div class="nav"> |
| 1404 | 1570 | {orgs.map((o) => ( |
| 1405 | 1571 | <a href={`/${o.login}`}> |
| 1406 | - <Avatar login={o.login} /> {o.login} | |
| 1572 | + <Avatar login={o.login} version={o.avatar_version} /> {o.login} | |
| 1407 | 1573 | </a> |
| 1408 | 1574 | ))} |
| 1409 | 1575 | </div> |
| @@ -1437,8 +1603,9 @@ | ||
| 1437 | 1603 | const isAdmin = !!me.site_admin || role === "owner" || role === "admin"; |
| 1438 | 1604 | const isOwner = !!me.site_admin || role === "owner"; |
| 1439 | 1605 | const requested = (c.req.query("tab") ?? "repositories") as OrgTab; |
| 1440 | - const tab: OrgTab = invite ? "invites" : (["repositories", "people", "invites", "agents", "settings"] as const).includes(requested) ? requested : "repositories"; | |
| 1441 | - if ((tab === "invites" && !isAdmin) || (tab === "settings" && !isOwner)) throw new HttpError(403, "organization admins only"); | |
| 1606 | + // The old Invites tab is part of People now. | |
| 1607 | + const tab: OrgTab = invite || (requested as string) === "invites" ? "people" : (["repositories", "people", "agents", "settings"] as const).includes(requested) ? requested : "repositories"; | |
| 1608 | + if (tab === "settings" && !isOwner) throw new HttpError(403, "organization owners only"); | |
| 1442 | 1609 | let body: Child = null; |
| 1443 | 1610 | const base = `/${acct.login}`; |
| 1444 | 1611 | if (tab === "repositories") { |
| @@ -1458,10 +1625,39 @@ | ||
| 1458 | 1625 | </> |
| 1459 | 1626 | ); |
| 1460 | 1627 | } else if (tab === "people") { |
| 1461 | - const members = await dir.orgMembers(acct.id); | |
| 1628 | + const [members, invites] = await Promise.all([dir.orgMembers(acct.id), isAdmin ? dir.invites({ orgId: acct.id, pending: true }) : Promise.resolve([])]); | |
| 1629 | + const roles = (isOwner ? ["member", "admin", "owner"] : ["member", "admin"]).map((r) => ({ value: r, label: ROLE_LABEL[r]! })); | |
| 1462 | 1630 | body = ( |
| 1463 | 1631 | <> |
| 1464 | - <h2>People</h2> | |
| 1632 | + <div class="row between mb"> | |
| 1633 | + <h2 class="m0">People</h2> | |
| 1634 | + <span class="muted small"> | |
| 1635 | + {members.length} member{members.length === 1 ? "" : "s"} | |
| 1636 | + {invites.length ? ` · ${invites.length} pending invitation${invites.length === 1 ? "" : "s"}` : ""} | |
| 1637 | + </span> | |
| 1638 | + </div> | |
| 1639 | + {invite ? <InviteOnce email={invite.email} url={invite.url} /> : null} | |
| 1640 | + {isAdmin ? ( | |
| 1641 | + <Form action={`${base}/-/org/people`} t={t} class="box"> | |
| 1642 | + <div class="head"> | |
| 1643 | + <h3>{raw(ICONS.userPlus)} Add people</h3> | |
| 1644 | + </div> | |
| 1645 | + <div class="body"> | |
| 1646 | + <div class="formrow"> | |
| 1647 | + <div class="field grow"> | |
| 1648 | + <label for="who">Username or email</label> | |
| 1649 | + <input id="who" name="who" type="text" required autocomplete="off" spellcheck={false} placeholder="octocat or name@example.com" /> | |
| 1650 | + </div> | |
| 1651 | + <div class="field"> | |
| 1652 | + <label for="role">Role</label> | |
| 1653 | + <KSelect id="role" name="role" label="Role" value="member" options={roles} /> | |
| 1654 | + </div> | |
| 1655 | + <button class="btn primary">Add</button> | |
| 1656 | + </div> | |
| 1657 | + <div class="hint">People who already have an account join right away and get a heads-up by email and on Diffinery. Anyone else gets an email invitation: opening it creates their account and adds them to {acct.login}.</div> | |
| 1658 | + </div> | |
| 1659 | + </Form> | |
| 1660 | + ) : null} | |
| 1465 | 1661 | <div class="box"> |
| 1466 | 1662 | <table class="tbl"> |
| 1467 | 1663 | <thead> |
| @@ -1475,17 +1671,17 @@ | ||
| 1475 | 1671 | {members.map((m) => ( |
| 1476 | 1672 | <tr> |
| 1477 | 1673 | <td> |
| 1478 | - <Actor login={m.login} kind="user" /> {m.name && m.name !== m.login ? <span class="muted small">{m.name}</span> : null} | |
| 1674 | + <Actor login={m.login} kind="user" version={m.avatar_version} /> {m.name && m.name !== m.login ? <span class="muted small">{m.name}</span> : null} | |
| 1479 | 1675 | </td> |
| 1480 | 1676 | <td> |
| 1481 | 1677 | {isAdmin && (isOwner || m.role !== "owner") ? ( |
| 1482 | - <Form action={`${base}/-/org/members`} t={t} class="row" style="gap:6px;flex-wrap:nowrap"> | |
| 1678 | + <Form action={`${base}/-/org/members`} t={t} class="row nowrap" style="gap:6px"> | |
| 1483 | 1679 | <input type="hidden" name="login" value={m.login} /> |
| 1484 | - <KSelect name="role" size="sm" label={`Role of ${m.login}`} value={m.role} options={(isOwner ? ["owner", "admin", "member"] : ["admin", "member"]).map((r) => ({ value: r, label: r }))} /> | |
| 1680 | + <KSelect name="role" size="sm" label={`Role of ${m.login}`} value={m.role} options={(isOwner ? ["owner", "admin", "member"] : ["admin", "member"]).map((r) => ({ value: r, label: ROLE_LABEL[r]! }))} /> | |
| 1485 | 1681 | <button class="btn sm">Save</button> |
| 1486 | 1682 | </Form> |
| 1487 | 1683 | ) : ( |
| 1488 | - <span class="badge">{m.role}</span> | |
| 1684 | + <span class="badge">{ROLE_LABEL[m.role] ?? m.role}</span> | |
| 1489 | 1685 | )} |
| 1490 | 1686 | </td> |
| 1491 | 1687 | {isAdmin ? ( |
| @@ -1504,105 +1700,36 @@ | ||
| 1504 | 1700 | </tbody> |
| 1505 | 1701 | </table> |
| 1506 | 1702 | </div> |
| 1507 | - {isAdmin ? ( | |
| 1508 | - <div class="row" style="align-items:stretch"> | |
| 1509 | - <Form action={`${base}/-/org/members`} t={t} class="box grow"> | |
| 1510 | - <div class="head"> | |
| 1511 | - <h3>Add an existing user</h3> | |
| 1512 | - </div> | |
| 1513 | - <div class="body row" style="align-items:flex-end"> | |
| 1514 | - <div class="field grow m0"> | |
| 1515 | - <label>Username</label> | |
| 1516 | - <input name="login" type="text" required /> | |
| 1517 | - </div> | |
| 1518 | - <div class="field m0"> | |
| 1519 | - <label>Role</label> | |
| 1520 | - <KSelect name="role" block label="Role" value="member" options={(isOwner ? ["member", "admin", "owner"] : ["member", "admin"]).map((r) => ({ value: r, label: r }))} /> | |
| 1521 | - </div> | |
| 1522 | - <button class="btn primary">Add</button> | |
| 1523 | - </div> | |
| 1524 | - </Form> | |
| 1525 | - <div class="box grow"> | |
| 1526 | - <div class="head"> | |
| 1527 | - <h3>Invite by email</h3> | |
| 1528 | - </div> | |
| 1529 | - <div class="body"> | |
| 1530 | - <p class="muted small"> | |
| 1531 | - New people join from an invite link, where they create a passkey. <a href={`${base}?tab=invites`}>Create an invite →</a> | |
| 1532 | - </p> | |
| 1533 | - </div> | |
| 1703 | + {invites.length ? ( | |
| 1704 | + <div class="box"> | |
| 1705 | + <div class="head"> | |
| 1706 | + <h3>{raw(ICONS.envelope)} Pending invitations</h3> | |
| 1534 | 1707 | </div> |
| 1535 | - </div> | |
| 1536 | - ) : null} | |
| 1537 | - </> | |
| 1538 | - ); | |
| 1539 | - } else if (tab === "invites") { | |
| 1540 | - const invites = await dir.invites({ orgId: acct.id }); | |
| 1541 | - body = ( | |
| 1542 | - <> | |
| 1543 | - <h2>Invites</h2> | |
| 1544 | - {invite ? ( | |
| 1545 | - <div class="box" style="border-color:var(--amber)"> | |
| 1546 | - <div class="body"> | |
| 1547 | - <h3>Invite created for {invite.email}</h3> | |
| 1548 | - <p class="muted">We emailed this link to them; you can also send it yourself. It expires in 14 days. Opening it lets them create a passkey and join.</p> | |
| 1549 | - <div class="row"> | |
| 1550 | - <div class="secret grow">{invite.url}</div> | |
| 1551 | - <CopyButton text={invite.url} /> | |
| 1552 | - </div> | |
| 1553 | - </div> | |
| 1554 | - </div> | |
| 1555 | - ) : null} | |
| 1556 | - <Form action={`${base}/-/org/invites`} t={t} class="box"> | |
| 1557 | - <div class="head"> | |
| 1558 | - <h3>Invite someone</h3> | |
| 1559 | - </div> | |
| 1560 | - <div class="body row" style="align-items:flex-end"> | |
| 1561 | - <div class="field grow m0"> | |
| 1562 | - <label>Email address</label> | |
| 1563 | - <input name="email" type="email" required placeholder="friend@example.com" /> | |
| 1564 | - </div> | |
| 1565 | - <div class="field m0"> | |
| 1566 | - <label>Role</label> | |
| 1567 | - <KSelect name="role" block label="Role" value="member" options={(isOwner ? ["member", "admin", "owner"] : ["member", "admin"]).map((r) => ({ value: r, label: r }))} /> | |
| 1568 | - </div> | |
| 1569 | - <button class="btn primary">Create invite</button> | |
| 1570 | - </div> | |
| 1571 | - </Form> | |
| 1572 | - <div class="box"> | |
| 1573 | - {invites.length ? ( | |
| 1574 | 1708 | <table class="tbl"> |
| 1575 | - <thead> | |
| 1576 | - <tr> | |
| 1577 | - <th>Email</th> | |
| 1578 | - <th>Role</th> | |
| 1579 | - <th>Created</th> | |
| 1580 | - <th>Status</th> | |
| 1581 | - </tr> | |
| 1582 | - </thead> | |
| 1583 | 1709 | <tbody> |
| 1584 | 1710 | {invites.map((i) => ( |
| 1585 | 1711 | <tr> |
| 1586 | - <td>{i.email}</td> | |
| 1587 | - <td>{i.role}</td> | |
| 1588 | - <td class="small">{ago(i.createdAt)}</td> | |
| 1589 | - <td class="small"> | |
| 1590 | - {i.acceptedAt ? ( | |
| 1591 | - <span class="badge green">accepted</span> | |
| 1592 | - ) : Date.parse(i.expiresAt) < Date.now() ? ( | |
| 1593 | - <span class="badge">expired</span> | |
| 1594 | - ) : ( | |
| 1595 | - <span class="badge amber">pending</span> | |
| 1596 | - )} | |
| 1712 | + <td> | |
| 1713 | + <b>{i.email}</b> | |
| 1714 | + <div class="muted small"> | |
| 1715 | + Invited {i.invitedBy ? `by ${i.invitedBy} ` : ""} | |
| 1716 | + <Time at={i.createdAt} /> · expires {new Date(i.expiresAt).toLocaleDateString("en-US", { dateStyle: "medium" })} | |
| 1717 | + </div> | |
| 1597 | 1718 | </td> |
| 1719 | + <td> | |
| 1720 | + <span class="badge">{ROLE_LABEL[i.role] ?? i.role}</span> | |
| 1721 | + </td> | |
| 1722 | + <td class="right"> | |
| 1723 | + <Form action={`${base}/-/org/invites/${i.id}/revoke`} t={t} class="inline" confirm={`Revoke the invitation for ${i.email}? The link stops working.`}> | |
| 1724 | + <button class="btn sm danger">Revoke</button> | |
| 1725 | + </Form> | |
| 1726 | + </td> | |
| 1598 | 1727 | </tr> |
| 1599 | 1728 | ))} |
| 1600 | 1729 | </tbody> |
| 1601 | 1730 | </table> |
| 1602 | - ) : ( | |
| 1603 | - <div class="empty">No invites yet.</div> | |
| 1604 | - )} | |
| 1605 | - </div> | |
| 1731 | + </div> | |
| 1732 | + ) : null} | |
| 1606 | 1733 | </> |
| 1607 | 1734 | ); |
| 1608 | 1735 | } else if (tab === "agents") { |
| @@ -1622,7 +1749,7 @@ | ||
| 1622 | 1749 | <ul class="list"> |
| 1623 | 1750 | {agents.map((a) => ( |
| 1624 | 1751 | <li> |
| 1625 | - <Actor login={a.login} kind="agent" /> | |
| 1752 | + <Actor login={a.login} kind="agent" version={a.avatar_version} /> | |
| 1626 | 1753 | <span class="grow" /> |
| 1627 | 1754 | <span class="muted small">max role {parsePolicy(a).maxRole ?? "write"}</span> |
| 1628 | 1755 | <span class={`badge ${a.status === "active" ? "green" : ""}`}>{a.status}</span> |
| @@ -1639,6 +1766,7 @@ | ||
| 1639 | 1766 | body = ( |
| 1640 | 1767 | <> |
| 1641 | 1768 | <h2>Settings</h2> |
| 1769 | + <AvatarSection account={acct} action={`${base}/-/org/avatar`} t={t} /> | |
| 1642 | 1770 | <Form action={`${base}/-/org/settings`} t={t} class="box"> |
| 1643 | 1771 | <div class="body"> |
| 1644 | 1772 | <div class="field"> |
| @@ -1661,7 +1789,7 @@ | ||
| 1661 | 1789 | { title: acct.login }, |
| 1662 | 1790 | <> |
| 1663 | 1791 | <div class="row mb" style="gap:16px"> |
| 1664 | - <Avatar login={acct.login} size="lg" /> | |
| 1792 | + <Avatar login={acct.login} size="lg" version={acct.avatar_version} /> | |
| 1665 | 1793 | <div> |
| 1666 | 1794 | <h1 class="m0">{acct.name ?? acct.login}</h1> |
| 1667 | 1795 | <div class="muted small"> |
| @@ -1675,7 +1803,6 @@ | ||
| 1675 | 1803 | [ |
| 1676 | 1804 | ["repositories", "Repositories", true], |
| 1677 | 1805 | ["people", "People", true], |
| 1678 | - ["invites", "Invites", isAdmin], | |
| 1679 | 1806 | ["agents", "Agents", true], |
| 1680 | 1807 | ["settings", "Settings", isOwner], |
| 1681 | 1808 | ] as const |
| @@ -1695,22 +1822,44 @@ | ||
| 1695 | 1822 | |
| 1696 | 1823 | web.get("/:owner", async (c) => profile(c)); |
| 1697 | 1824 | |
| 1698 | - post(web, "/:owner/-/org/invites", async (c, f) => { | |
| 1825 | + // Add people: a username or an email. Existing accounts join now; new emails get an invitation. | |
| 1826 | + post(web, "/:owner/-/org/people", async (c, f) => { | |
| 1699 | 1827 | const { me, org, isAdmin, isOwner, dir } = await orgContext(c); |
| 1700 | - if (!isAdmin) throw new HttpError(403, "organization admins only"); | |
| 1701 | - const email = f.s("email").trim(); | |
| 1828 | + const back = `/${org.login}?tab=people`; | |
| 1702 | 1829 | const role = (["member", "admin", "owner"] as const).find((r) => r === f.s("role")) ?? "member"; |
| 1703 | - if (role === "owner" && !isOwner) throw new HttpError(403, "only owners can invite owners"); | |
| 1704 | - if (!/^[^\s@]+@[^\s@]+$/.test(email)) { | |
| 1705 | - return act(c, `/${org.login}?tab=invites`, async () => { | |
| 1706 | - throw new HttpError(422, "enter a valid email address"); | |
| 1707 | - }); | |
| 1708 | - } | |
| 1709 | - const inv = await dir.createInvite(me, { email, orgId: org.id, role }); | |
| 1710 | - c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.invite", target: org.login, detail: { email, role } })); | |
| 1711 | - return profile(c, { url: inv.url, email }); | |
| 1830 | + let invited: { email: string; url: string } | undefined; | |
| 1831 | + const res = await act(c, back, async () => { | |
| 1832 | + if (!isAdmin) throw new HttpError(403, "organization admins only"); | |
| 1833 | + if (role === "owner" && !isOwner) throw new HttpError(403, "only owners can add owners"); | |
| 1834 | + const r = await dir.addOrgPerson(org, me, f.s("who"), role); | |
| 1835 | + if ("added" in r) { | |
| 1836 | + flash(c, "notice", `Added ${r.added.login} to ${org.login} as ${ROLE_LABEL[role]!.toLowerCase()}. We let them know.`); | |
| 1837 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.member.set", target: org.login, detail: { user: r.added.login, role } })); | |
| 1838 | + } else { | |
| 1839 | + invited = r.invited; | |
| 1840 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.invite", target: org.login, detail: { email: r.invited.email, role } })); | |
| 1841 | + } | |
| 1842 | + }); | |
| 1843 | + return invited ? profile(c, invited) : res; | |
| 1712 | 1844 | }); |
| 1713 | 1845 | |
| 1846 | + // The old invite form posts here. | |
| 1847 | + post(web, "/:owner/-/org/invites", async (c) => c.redirect(`/${prm(c, "owner")}?tab=people`, 303)); | |
| 1848 | + | |
| 1849 | + post(web, "/:owner/-/org/invites/:id/revoke", async (c) => { | |
| 1850 | + const { me, org, isAdmin, dir } = await orgContext(c); | |
| 1851 | + return act( | |
| 1852 | + c, | |
| 1853 | + `/${org.login}?tab=people`, | |
| 1854 | + async () => { | |
| 1855 | + if (!isAdmin) throw new HttpError(403, "organization admins only"); | |
| 1856 | + await dir.revokeInvite(prm(c, "id"), { orgId: org.id }); | |
| 1857 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.invite.revoke", target: org.login, detail: { id: prm(c, "id") } })); | |
| 1858 | + }, | |
| 1859 | + "Invitation revoked.", | |
| 1860 | + ); | |
| 1861 | + }); | |
| 1862 | + | |
| 1714 | 1863 | post(web, "/:owner/-/org/members", async (c, f) => { |
| 1715 | 1864 | const { me, org, isAdmin, isOwner, dir } = await orgContext(c); |
| 1716 | 1865 | const back = `/${org.login}?tab=people`; |
| @@ -1721,8 +1870,8 @@ | ||
| 1721 | 1870 | const current = await dir.orgRole(org.id, user.id); |
| 1722 | 1871 | const role = f.s("role"); |
| 1723 | 1872 | if ((current === "owner" || role === "owner") && !isOwner) throw new HttpError(403, "only owners can manage owners"); |
| 1724 | - if (role === "remove") await dir.setOrgMember(org.id, user.id, null); | |
| 1725 | - else if (role === "owner" || role === "admin" || role === "member") await dir.setOrgMember(org.id, user.id, role); | |
| 1873 | + if (role === "remove") await dir.setOrgMember(org.id, user.id, null, me); | |
| 1874 | + else if (role === "owner" || role === "admin" || role === "member") await dir.setOrgMember(org.id, user.id, role, me); | |
| 1726 | 1875 | else throw new HttpError(422, "invalid role"); |
| 1727 | 1876 | c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: role === "remove" ? "org.member.remove" : "org.member.set", target: org.login, detail: { user: user.login, role } })); |
| 1728 | 1877 | }); |
| @@ -1734,8 +1883,26 @@ | ||
| 1734 | 1883 | if (!isOwner) throw new HttpError(403, "organization owners only"); |
| 1735 | 1884 | const login = f.s("login").trim(); |
| 1736 | 1885 | await dir.updateAccount(org.id, { login: login && login !== org.login ? login : undefined, name: f.s("name").trim() }); |
| 1886 | + if (login && login !== org.login) c.executionCtx.waitUntil(purgeAvatarCache(c, org.login, [org.avatar_version])); | |
| 1737 | 1887 | c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.update", target: org.login, detail: { login } })); |
| 1738 | 1888 | return `/${login || org.login}?tab=settings`; |
| 1739 | 1889 | }, "Organization updated."); |
| 1740 | 1890 | }); |
| 1891 | + | |
| 1892 | + avatarSizeGuard(web, "/:owner/-/org/avatar", (c) => `/${c.req.param("owner")}?tab=settings`); | |
| 1893 | + post(web, "/:owner/-/org/avatar", async (c, f) => { | |
| 1894 | + const { me, org, isOwner } = await orgContext(c); | |
| 1895 | + return act(c, `/${org.login}?tab=settings`, async () => { | |
| 1896 | + if (!isOwner) throw new HttpError(403, "organization owners only"); | |
| 1897 | + await uploadAvatar(c, f, me, org); | |
| 1898 | + }, "Organization picture updated."); | |
| 1899 | + }); | |
| 1900 | + | |
| 1901 | + post(web, "/:owner/-/org/avatar/delete", async (c) => { | |
| 1902 | + const { me, org, isOwner } = await orgContext(c); | |
| 1903 | + return act(c, `/${org.login}?tab=settings`, async () => { | |
| 1904 | + if (!isOwner) throw new HttpError(403, "organization owners only"); | |
| 1905 | + await deleteAvatar(c, me, org); | |
| 1906 | + }, "Organization picture removed."); | |
| 1907 | + }); | |
| 1741 | 1908 | } |
modifiedapps/diffinery/src/web/assets.ts+91−67View file
| @@ -1,5 +1,28 @@ | ||
| 1 | +import { UI_ICONS } from "./icons.ui.generated.js"; | |
| 2 | + | |
| 1 | 3 | /** Inline stylesheet, client script and icons for the web UI (no external assets). */ |
| 2 | 4 | |
| 5 | +/** | |
| 6 | + * Dark theme tokens. Settings → Appearance puts data-theme="light" or "dark" on <html>; without it the | |
| 7 | + * system setting decides. Dark-only rules elsewhere use the same two selectors (see `dark()`). | |
| 8 | + */ | |
| 9 | +const DARK_TOKENS = `--canvas:oklch(10% 0 0);--bg:oklch(17% 0 0);--bg2:oklch(12% 0 0);--bg3:oklch(15% 0 0);--bg-hover:oklch(26.9% 0 0); | |
| 10 | +--fg:oklch(97% 0 0);--fg2:oklch(70.8% 0 0);--fg3:oklch(55.6% 0 0); | |
| 11 | +--border:oklch(32% 0 0);--border2:oklch(26.9% 0 0); | |
| 12 | +--accent:oklch(48.8% 0.243 264.376);--accent-hover:oklch(54.6% 0.245 262.881);--accent-fg:#fff;--accent-soft:oklch(38% 0.145 265.5 / .22); | |
| 13 | +--link:oklch(70.7% 0.165 254.624);--focus:oklch(93.5% 0 0); | |
| 14 | +--green:oklch(76.5% 0.177 163.223);--green-soft:oklch(39.3% 0.096 152.3 / .2);--red:oklch(70.4% 0.191 22.216);--red-soft:oklch(42.9% 0.176 28.7 / .17); | |
| 15 | +--amber:oklch(75% 0.183 55.934);--amber-soft:oklch(35.3% 0.079 65 / .37); | |
| 16 | +--purple:oklch(71.4% 0.203 305.504);--purple-soft:oklch(49.6% 0.265 301.924 / .2);--blue:oklch(70.7% 0.165 254.624);--blue-soft:oklch(38% 0.145 265.5 / .22); | |
| 17 | +--add-bg:oklch(39.3% 0.096 152.3 / .2);--add-ln:oklch(39.3% 0.096 152.3 / .4);--del-bg:oklch(42.9% 0.176 28.7 / .17);--del-ln:oklch(42.9% 0.176 28.7 / .35);--hunk-bg:oklch(38% 0.145 265.5 / .22);--hl:oklch(55.4% 0.135 66.442 / .5); | |
| 18 | +--code-bg:oklch(15% 0 0);--shadow:0 0 0 1px oklch(100% 0 0 / .1),0 8px 24px oklch(0% 0 0 / .3)`; | |
| 19 | +const under = (prefix: string, sel: string): string => sel.split(",").map((x) => `${prefix} ${x.trim()}`).join(","); | |
| 20 | +/** A dark-mode rule: applies with data-theme="dark", and with the system setting unless data-theme="light". */ | |
| 21 | +export const dark = (sel: string, decls: string): string => | |
| 22 | + `@media (prefers-color-scheme:dark){${under(":root:not([data-theme=light])", sel)}{${decls}}}${under(":root[data-theme=dark]", sel)}{${decls}}`; | |
| 23 | +const DARK = `@media (prefers-color-scheme:dark){:root:not([data-theme=light]){${DARK_TOKENS}}}:root[data-theme=dark]{${DARK_TOKENS}} | |
| 24 | +:root[data-theme=light]{color-scheme:light}:root[data-theme=dark]{color-scheme:dark}`; | |
| 25 | + | |
| 3 | 26 | export const CSS = ` |
| 4 | 27 | /* Cascade order: the site's own styles first, then Kumo's (build/islands/kumo.css, confined to .dfy-k |
| 5 | 28 | islands), then island layout. kumo-reset reverts site styles inside islands so Kumo controls look |
| @@ -21,18 +44,10 @@ | ||
| 21 | 44 | --code-bg:oklch(96% 0 0);--shadow:0 0 0 1px oklch(0% 0 0 / .12),0 8px 24px oklch(0% 0 0 / .08);--r:.5rem;--r-sm:.375rem; |
| 22 | 45 | --mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; |
| 23 | 46 | --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"} |
| 24 | -@media (prefers-color-scheme:dark){:root{ | |
| 25 | ---canvas:oklch(10% 0 0);--bg:oklch(17% 0 0);--bg2:oklch(12% 0 0);--bg3:oklch(15% 0 0);--bg-hover:oklch(26.9% 0 0); | |
| 26 | ---fg:oklch(97% 0 0);--fg2:oklch(70.8% 0 0);--fg3:oklch(55.6% 0 0); | |
| 27 | ---border:oklch(32% 0 0);--border2:oklch(26.9% 0 0); | |
| 28 | ---accent:oklch(48.8% 0.243 264.376);--accent-hover:oklch(54.6% 0.245 262.881);--accent-fg:#fff;--accent-soft:oklch(38% 0.145 265.5 / .22); | |
| 29 | ---link:oklch(70.7% 0.165 254.624);--focus:oklch(93.5% 0 0); | |
| 30 | ---green:oklch(76.5% 0.177 163.223);--green-soft:oklch(39.3% 0.096 152.3 / .2);--red:oklch(70.4% 0.191 22.216);--red-soft:oklch(42.9% 0.176 28.7 / .17); | |
| 31 | ---amber:oklch(75% 0.183 55.934);--amber-soft:oklch(35.3% 0.079 65 / .37); | |
| 32 | ---purple:oklch(71.4% 0.203 305.504);--purple-soft:oklch(49.6% 0.265 301.924 / .2);--blue:oklch(70.7% 0.165 254.624);--blue-soft:oklch(38% 0.145 265.5 / .22); | |
| 33 | ---add-bg:oklch(39.3% 0.096 152.3 / .2);--add-ln:oklch(39.3% 0.096 152.3 / .4);--del-bg:oklch(42.9% 0.176 28.7 / .17);--del-ln:oklch(42.9% 0.176 28.7 / .35);--hunk-bg:oklch(38% 0.145 265.5 / .22);--hl:oklch(55.4% 0.135 66.442 / .5); | |
| 34 | ---code-bg:oklch(15% 0 0);--shadow:0 0 0 1px oklch(100% 0 0 / .1),0 8px 24px oklch(0% 0 0 / .3)}} | |
| 47 | +${DARK} | |
| 35 | 48 | *{box-sizing:border-box} |
| 49 | +/* Phosphor icons sit on the text's center line when inline; flex containers center them themselves. */ | |
| 50 | +svg[viewBox="0 0 256 256"]{vertical-align:-3px;flex:none} | |
| 36 | 51 | html{-webkit-text-size-adjust:100%} |
| 37 | 52 | body{margin:0;background:var(--canvas);color:var(--fg);font:14px/1.5 var(--sans)} |
| 38 | 53 | a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline} |
| @@ -58,32 +73,44 @@ | ||
| 58 | 73 | .flash.error{border-color:color-mix(in srgb,var(--red) 40%,transparent);background:var(--red-soft);color:var(--red)} |
| 59 | 74 | .flash.notice{border-color:color-mix(in srgb,var(--green) 40%,transparent);background:var(--green-soft);color:var(--green)} |
| 60 | 75 | .flash.info{border-color:color-mix(in srgb,var(--blue) 40%,transparent);background:var(--blue-soft)} |
| 61 | -.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:6px 12px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--fg);font:500 13px/1.4 var(--sans);cursor:pointer;white-space:nowrap;text-decoration:none!important} | |
| 76 | +/* Controls follow Kumo sizes: base 36px (buttons, inputs, selects), sm 26px. */ | |
| 77 | +.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;height:36px;padding:0 12px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--fg);font:500 14px/1 var(--sans);cursor:pointer;white-space:nowrap;text-decoration:none!important;vertical-align:middle;flex:none} | |
| 78 | +.btn svg{flex:none} | |
| 62 | 79 | .btn:hover{background:var(--bg-hover);border-color:var(--fg3)} |
| 63 | 80 | .btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-fg);box-shadow:inset 0 1px 0 0 oklch(100% 0 0 / .15)}.btn.primary:hover{background:var(--accent-hover)} |
| 64 | 81 | .btn.green{background:var(--accent);border-color:var(--accent);color:var(--accent-fg)}.btn.green:hover{background:var(--accent-hover)} |
| 65 | 82 | .btn.danger{color:var(--red)}.btn.danger:hover{background:var(--red);border-color:var(--red);color:#fff} |
| 66 | -.btn.sm{padding:3px 8px;font-size:12px}.btn.link{border-color:transparent;background:none;color:var(--accent);padding:0} | |
| 83 | +.btn.sm{height:26px;padding:0 8px;gap:4px;font-size:12px;border-radius:var(--r-sm)}.btn.sm svg{width:14px;height:14px}.btn.icon{width:36px;padding:0}.btn.sm.icon{width:26px} | |
| 84 | +.btn.link{border-color:transparent;background:none;color:var(--accent);padding:0;height:auto} | |
| 67 | 85 | .btn[disabled]{opacity:.5;cursor:not-allowed} |
| 68 | 86 | input[type=text],input[type=search],input[type=email],input[type=number],input[type=url],input[type=password],input:not([type]),textarea{ |
| 69 | -width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--fg);font:inherit} | |
| 70 | -textarea{min-height:120px;resize:vertical;font-family:var(--sans)}textarea.code{font-family:var(--mono);font-size:12.5px;min-height:420px;tab-size:4;white-space:pre} | |
| 87 | +width:100%;height:36px;padding:0 12px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--fg);font:inherit} | |
| 88 | +input[type=file]{display:block;width:100%;font:inherit;font-size:13px;color:var(--fg2);padding:6px 0} | |
| 89 | +input[type=file]::file-selector-button{height:30px;margin-right:10px;padding:0 12px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--fg);font:500 13px/1 var(--sans);cursor:pointer} | |
| 90 | +input[type=file]::file-selector-button:hover{background:var(--bg-hover)} | |
| 91 | +.checklist{display:flex;flex-direction:column;align-items:flex-start;gap:8px} | |
| 92 | +textarea{height:auto;padding:8px 12px;line-height:1.5;min-height:120px;resize:vertical;font-family:var(--sans)}textarea.code{font-family:var(--mono);font-size:12.5px;min-height:420px;tab-size:4;white-space:pre} | |
| 71 | 93 | input:focus,textarea:focus{outline:2px solid color-mix(in srgb,var(--focus) 25%,transparent);outline-offset:0;border-color:color-mix(in srgb,var(--focus) 50%,transparent)} |
| 72 | 94 | :focus-visible{outline:2px solid color-mix(in srgb,var(--focus) 35%,transparent);outline-offset:2px} |
| 73 | 95 | label{display:block;font-weight:550;margin:0 0 4px}label.inline{display:inline-flex;align-items:center;gap:6px;font-weight:400} |
| 74 | 96 | .field{margin:0 0 14px}.field .hint{font-size:12px;color:var(--fg2);margin-top:4px;font-weight:400} |
| 75 | 97 | .row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.row.end{justify-content:flex-end}.row.between{justify-content:space-between} |
| 76 | 98 | .grow{flex:1;min-width:0} |
| 99 | +.row.nowrap{flex-wrap:nowrap} | |
| 100 | +/* A one-line form: fields and the submit button share a baseline at the control's bottom edge. */ | |
| 101 | +.formrow{display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap}.formrow>.field{margin:0}.formrow>.field.grow{min-width:220px} | |
| 102 | +.formrow+.hint{font-size:12px;color:var(--fg2);margin-top:8px} | |
| 77 | 103 | .grid{display:grid;grid-template-columns:minmax(0,1fr) 296px;gap:24px} |
| 78 | 104 | .grid.left{grid-template-columns:260px minmax(0,1fr)} |
| 79 | 105 | @media (max-width:900px){.grid,.grid.left{grid-template-columns:minmax(0,1fr)}.pal-btn{min-width:0}.pal-btn span{display:none}} |
| 80 | 106 | .box{border:1px solid var(--border);border-radius:var(--r);background:var(--bg);margin:0 0 16px;overflow:hidden} |
| 81 | 107 | .box>.head{display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--bg2);border-bottom:1px solid var(--border);flex-wrap:wrap} |
| 82 | 108 | .box>.body{padding:14px} |
| 83 | -.box>.head h2,.box>.head h3{margin:0;font-size:14px} | |
| 109 | +.box>.head h2,.box>.head h3{margin:0;font-size:14px;display:inline-flex;align-items:center;gap:6px} | |
| 84 | 110 | .list{list-style:none;margin:0;padding:0} |
| 85 | 111 | .list>li{display:flex;gap:10px;align-items:flex-start;padding:10px 14px;border-top:1px solid var(--border2)} |
| 86 | 112 | .list>li:first-child{border-top:0}.list>li:hover{background:var(--bg2)} |
| 113 | +.list>li>.st:first-child,.list>li>svg:first-child{margin-top:2px} | |
| 87 | 114 | .list .title{font-weight:600;color:var(--fg)}.list .title:hover{color:var(--accent)} |
| 88 | 115 | .list .meta{font-size:12px;color:var(--fg2);margin-top:2px} |
| 89 | 116 | .empty{padding:32px 16px;text-align:center;color:var(--fg2)} |
| @@ -98,8 +125,13 @@ | ||
| 98 | 125 | .state{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;border-radius:999px;font-weight:600;font-size:13px;color:#fff} |
| 99 | 126 | .state.open{background:var(--green)}.state.closed{background:var(--red)}.state.merged{background:var(--purple)}.state.draft{background:var(--fg3)} |
| 100 | 127 | .lbl{display:inline-block;padding:0 8px;border-radius:999px;font-size:12px;font-weight:550;line-height:20px;background:color-mix(in srgb,var(--lc) 18%,transparent);color:color-mix(in srgb,var(--lc) 70%,var(--fg));border:1px solid color-mix(in srgb,var(--lc) 40%,transparent);margin-right:4px} |
| 101 | -.av{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;background:hsl(var(--h) 55% 55%);color:#fff;font-size:11px;font-weight:700;flex:none;vertical-align:middle} | |
| 102 | -.av.lg{width:40px;height:40px;font-size:18px}.av.xl{width:96px;height:96px;font-size:40px}.av.bot{border-radius:6px} | |
| 128 | +.av{display:inline-block;width:20px;height:20px;border-radius:50%;flex:none;vertical-align:middle;object-fit:cover;background:var(--bg3);outline:1px solid var(--border);outline-offset:-1px} | |
| 129 | +.av.lg{width:40px;height:40px}.av.xl{width:96px;height:96px}.av.bot{border-radius:6px}.av.xl.bot{border-radius:16px} | |
| 130 | +input[type=file]{max-width:100%;font:13px/1.4 var(--sans);color:var(--fg2)} | |
| 131 | +input[type=file]::file-selector-button{margin-right:10px;padding:6px 12px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--fg);font:500 13px/1.4 var(--sans);cursor:pointer} | |
| 132 | +input[type=file]::file-selector-button:hover{background:var(--bg-hover);border-color:var(--fg3)} | |
| 133 | +.avpick{display:flex;gap:20px;align-items:center;flex-wrap:wrap}.avpick .grow{min-width:240px} | |
| 134 | +.avpick [data-avatar-status]:empty{display:none} | |
| 103 | 135 | .who{display:inline-flex;align-items:center;gap:6px;font-weight:600;color:var(--fg)} |
| 104 | 136 | .repohead{padding:18px 0 0;border-bottom:1px solid var(--border);background:var(--bg2)} |
| 105 | 137 | .repohead h1{font-size:19px;font-weight:500;display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 6px} |
| @@ -110,8 +142,9 @@ | ||
| 110 | 142 | .tabs a.on{color:var(--fg);font-weight:600;border-bottom-color:var(--accent)} |
| 111 | 143 | .tabs .n{background:var(--bg3);border-radius:999px;padding:0 7px;font-size:12px;color:var(--fg2)} |
| 112 | 144 | .subnav{display:flex;gap:0;border:1px solid var(--border);border-radius:7px;overflow:hidden;width:max-content;max-width:100%} |
| 113 | -.subnav a{padding:5px 12px;color:var(--fg2);border-left:1px solid var(--border)}.subnav a:first-child{border-left:0} | |
| 145 | +.subnav a{display:inline-flex;align-items:center;gap:6px;height:34px;padding:0 12px;color:var(--fg2);border-left:1px solid var(--border)}.subnav a:first-child{border-left:0} | |
| 114 | 146 | .subnav a.on{background:var(--accent-soft);color:var(--accent);font-weight:600}.subnav a:hover{text-decoration:none;background:var(--bg-hover)} |
| 147 | +.side .nav h4{font-size:11px;color:var(--fg3);text-transform:uppercase;letter-spacing:.04em;margin:14px 10px 4px} | |
| 115 | 148 | .side h3{font-size:12px;color:var(--fg2);text-transform:uppercase;letter-spacing:.04em;margin:0 0 8px} |
| 116 | 149 | .side section{padding:0 0 16px;margin:0 0 16px;border-bottom:1px solid var(--border2)} |
| 117 | 150 | .side .nav a{display:block;padding:6px 10px;border-radius:6px;color:var(--fg)}.side .nav a.on{background:var(--accent-soft);color:var(--accent);font-weight:600}.side .nav a:hover{background:var(--bg-hover);text-decoration:none} |
| @@ -131,6 +164,7 @@ | ||
| 131 | 164 | .clone{display:flex;gap:6px;align-items:center} |
| 132 | 165 | .clone input{font-family:var(--mono);font-size:12px} |
| 133 | 166 | pre.cmd{background:var(--code-bg);border:1px solid var(--border2);border-radius:7px;padding:12px 14px;overflow:auto;margin:0 0 12px} |
| 167 | +pre.cmd.one{padding:7px 12px;line-height:20px;margin:0;white-space:pre-wrap;word-break:break-all} | |
| 134 | 168 | .code-wrap{overflow:auto} |
| 135 | 169 | table.blob{border-collapse:collapse;width:100%;font-family:var(--mono);font-size:12.5px;line-height:20px} |
| 136 | 170 | table.blob td{padding:0 12px;vertical-align:top;white-space:pre} |
| @@ -148,6 +182,10 @@ | ||
| 148 | 182 | table.diff .addc{visibility:hidden;border:0;background:var(--accent);color:#fff;border-radius:4px;width:18px;height:18px;line-height:16px;cursor:pointer;font-weight:700;padding:0} |
| 149 | 183 | table.diff tr:hover .addc{visibility:visible} |
| 150 | 184 | table.diff tr.thread td{background:var(--bg2);padding:8px 12px;font-family:var(--sans);font-size:14px;white-space:normal} |
| 185 | +/* Shiki tokens carry both GitHub themes as custom properties; untokened text keeps the site's color. */ | |
| 186 | +.sh>span{color:var(--shiki-light);font-style:var(--shiki-light-font-style);font-weight:var(--shiki-light-font-weight);text-decoration:var(--shiki-light-text-decoration)} | |
| 187 | +:root[data-theme=dark] .sh>span{color:var(--shiki-dark);font-style:var(--shiki-dark-font-style);font-weight:var(--shiki-dark-font-weight);text-decoration:var(--shiki-dark-text-decoration)} | |
| 188 | +@media (prefers-color-scheme:dark){:root:not([data-theme=light]) .sh>span{color:var(--shiki-dark);font-style:var(--shiki-dark-font-style);font-weight:var(--shiki-dark-font-weight);text-decoration:var(--shiki-dark-text-decoration)}} | |
| 151 | 189 | .filehead{display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg2);border-bottom:1px solid var(--border);cursor:pointer;position:sticky;top:52px;z-index:5;flex-wrap:wrap} |
| 152 | 190 | details.file{border:1px solid var(--border);border-radius:var(--r);margin:0 0 14px;overflow:clip} |
| 153 | 191 | details.file>summary{list-style:none}details.file>summary::-webkit-details-marker{display:none} |
| @@ -222,9 +260,12 @@ | ||
| 222 | 260 | table.tbl{width:100%;border-collapse:collapse;font-size:13px} |
| 223 | 261 | table.tbl th{text-align:left;font-weight:600;color:var(--fg2);padding:8px 12px;border-bottom:1px solid var(--border);background:var(--bg2);white-space:nowrap} |
| 224 | 262 | table.tbl td{padding:8px 12px;border-top:1px solid var(--border2);vertical-align:middle} |
| 225 | -.secret{font-family:var(--mono);background:var(--amber-soft);border:1px solid color-mix(in srgb,var(--amber) 45%,transparent);border-radius:7px;padding:10px 12px;word-break:break-all} | |
| 263 | +.secret{font-family:var(--mono);background:var(--amber-soft);border:1px solid color-mix(in srgb,var(--amber) 45%,transparent);border-radius:var(--r);padding:7px 12px;line-height:20px;min-height:36px;word-break:break-all} | |
| 226 | 264 | .pager{display:flex;gap:8px;justify-content:center;margin:16px 0} |
| 227 | 265 | .sha{font-family:var(--mono);font-size:12px;padding:1px 6px;border-radius:5px;background:var(--bg3);color:var(--fg)} |
| 266 | +/* Buttons joined into one control (copy · sha · browse). */ | |
| 267 | +.btngroup{display:inline-flex;flex:none}.btngroup>.btn{border-radius:0;margin-left:-1px}.btngroup>.btn:first-child{border-radius:var(--r-sm) 0 0 var(--r-sm);margin-left:0}.btngroup>.btn:last-child{border-radius:0 var(--r-sm) var(--r-sm) 0}.btngroup>.btn:hover{position:relative;z-index:1} | |
| 268 | +.btn.mono{font-family:var(--mono)} | |
| 228 | 269 | a.sha:hover{text-decoration:none;background:var(--accent-soft);color:var(--accent)} |
| 229 | 270 | .right{text-align:right}.mt{margin-top:16px}.mb{margin-bottom:16px}.m0{margin:0} |
| 230 | 271 | form.inline{display:inline} |
| @@ -237,7 +278,7 @@ | ||
| 237 | 278 | .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)} |
| 238 | 279 | .task-box{display:inline-block;width:14px;height:14px;margin:0 4px -2px 0;border-radius:4px;box-shadow:inset 0 0 0 1px var(--border);background:var(--bg)} |
| 239 | 280 | .task-box.on{background:var(--fg) url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Cpath fill='white' d='M232.49 80.49l-128 128a12 12 0 0 1-17 0l-56-56a12 12 0 1 1 17-17L96 183 215.51 63.51a12 12 0 0 1 17 17Z'/%3E%3C/svg%3E") center/10px no-repeat;box-shadow:none} |
| 240 | -@media (prefers-color-scheme:dark){.task-box.on{filter:invert(1)}} | |
| 281 | +${dark(".task-box.on", "filter:invert(1)")} | |
| 241 | 282 | } |
| 242 | 283 | @layer kumo-reset{.dfy-k,.dfy-k :not(svg,svg *){all:revert}} |
| 243 | 284 | @layer islands{ |
| @@ -261,6 +302,12 @@ | ||
| 261 | 302 | .kf-dot{border-radius:50%}.kf-dot.on{box-shadow:inset 0 0 0 5px var(--fg)} |
| 262 | 303 | .kf-radio{display:flex;flex-direction:column;gap:8px}.kf-radio.horizontal{flex-direction:row;gap:16px;flex-wrap:wrap} |
| 263 | 304 | .kf-desc{display:block;color:var(--fg2);font-size:12px} |
| 305 | +.theme-opt{display:flex;align-items:center;gap:8px} | |
| 306 | +.theme-prev{display:inline-flex;flex-direction:column;justify-content:center;gap:2px;width:30px;height:20px;padding:0 5px;border-radius:4px;box-shadow:0 0 0 1px var(--border);flex:none} | |
| 307 | +.theme-prev i{display:block;height:2px;border-radius:2px;background:#a1a1aa}.theme-prev i:first-child{width:55%;background:oklch(48.8% 0.243 264.376)}.theme-prev i:last-child{width:75%} | |
| 308 | +.theme-prev.light{background:#fff}.theme-prev.light i:not(:first-child){background:#d4d4d8} | |
| 309 | +.theme-prev.dark{background:#1c1c1e}.theme-prev.dark i:not(:first-child){background:#52525b} | |
| 310 | +.theme-prev.auto{background:linear-gradient(135deg,#fff 50%,#1c1c1e 50%)} | |
| 264 | 311 | } |
| 265 | 312 | `; |
| 266 | 313 | |
| @@ -271,7 +318,7 @@ | ||
| 271 | 318 | const enc=p=>p.split('/').map(encodeURIComponent).join('/'); |
| 272 | 319 | d.addEventListener('click',e=>{ |
| 273 | 320 | const c=e.target.closest('[data-copy]'); |
| 274 | - if(c){e.preventDefault();const v=c.dataset.copy;(navigator.clipboard?navigator.clipboard.writeText(v):Promise.reject()).then(()=>{const o=c.textContent;c.textContent='Copied';setTimeout(()=>{c.textContent=o},1200)}).catch(()=>{window.prompt('Copy:',v)});return} | |
| 321 | + if(c){e.preventDefault();const v=c.dataset.copy;(navigator.clipboard?navigator.clipboard.writeText(v):Promise.reject()).then(()=>{const o=c.innerHTML;if(c.dataset.copyIcon)c.innerHTML=${JSON.stringify(UI_ICONS.check)};else c.textContent='Copied';setTimeout(()=>{c.innerHTML=o},1200)}).catch(()=>{window.prompt('Copy:',v)});return} | |
| 275 | 322 | const p=e.target.closest('[data-palette]');if(p){e.preventDefault();openPalette();return} |
| 276 | 323 | const a=e.target.closest('[data-comment-line]');if(a){e.preventDefault();lineComment(a);return} |
| 277 | 324 | const x=e.target.closest('[data-cancel-line]');if(x){e.preventDefault();const tr=x.closest('tr');tr&&tr.remove()} |
| @@ -310,6 +357,26 @@ | ||
| 310 | 357 | row.querySelector('[name=side]').value=btn.dataset.side||'RIGHT';row.querySelector('.where').textContent=btn.dataset.path+':'+btn.dataset.commentLine; |
| 311 | 358 | row.querySelector('textarea').focus(); |
| 312 | 359 | } |
| 360 | +// Profile pictures: preview the centred square, then upload it at most 512 px wide (the server makes the small WebPs). | |
| 361 | +// Files this browser cannot decode (HEIC in Chrome, say) go up as they are; the server's answer comes back as the flash message. | |
| 362 | +let webpOk=null; | |
| 363 | +async function squareBlob(file){const bmp=await createImageBitmap(file);const side=Math.min(bmp.width,bmp.height),out=Math.min(512,side); | |
| 364 | + const cv=d.createElement('canvas');cv.width=cv.height=out;const ctx=cv.getContext('2d');ctx.imageSmoothingQuality='high'; | |
| 365 | + ctx.drawImage(bmp,(bmp.width-side)/2,(bmp.height-side)/2,side,side,0,0,out,out);if(bmp.close)bmp.close(); | |
| 366 | + if(webpOk===null){const t=d.createElement('canvas');t.width=t.height=1;webpOk=t.toDataURL('image/webp').startsWith('data:image/webp')} | |
| 367 | + return new Promise((ok,no)=>cv.toBlob(x=>x?ok(x):no(new Error('encode failed')),webpOk?'image/webp':'image/png',0.92))} | |
| 368 | +$$('form[data-avatar]').forEach(f=>{const inp=$('input[type=file]',f),st=$('[data-avatar-status]',f),box=f.closest('.avpick')||d,img=$('[data-avatar-preview] img',box); | |
| 369 | + if(!inp)return;let blob=null,url='';const orig=img&&img.getAttribute('src');const say=t=>{if(st)st.textContent=t}; | |
| 370 | + inp.addEventListener('change',async()=>{blob=null;if(url){URL.revokeObjectURL(url);url=''}const file=inp.files&&inp.files[0]; | |
| 371 | + if(!file){if(img&&orig)img.src=orig;say('');return} | |
| 372 | + try{blob=await squareBlob(file);url=URL.createObjectURL(blob);if(img){img.loading='eager';img.src=url}say('Preview. Save to use this picture.')} | |
| 373 | + catch(_){blob=null;if(img&&orig)img.src=orig;say('No preview for this file; it will be checked when you save.')}}); | |
| 374 | + f.addEventListener('submit',async e=>{const file=inp.files&&inp.files[0];if(e.defaultPrevented||!file)return;e.preventDefault(); | |
| 375 | + const fd=new FormData(f);if(blob)fd.set(inp.name,blob,blob.type==='image/webp'?'avatar.webp':'avatar.png'); | |
| 376 | + say('Uploading…');$$('button',f).forEach(x=>{x.disabled=true}); | |
| 377 | + // The answer is a redirect that sets the flash message; follow it by reloading the page. | |
| 378 | + try{await fetch(f.action,{method:'POST',body:fd,credentials:'same-origin',redirect:'manual'})}catch(_){} | |
| 379 | + location.reload()})}); | |
| 313 | 380 | // Palette |
| 314 | 381 | const dlg=$('#palette'),inp=dlg&&$('input',dlg),ul=dlg&&$('ul',dlg);let items=null,shown=[],sel=0,loading=false; |
| 315 | 382 | function base(){const a=[['Dashboard','/'],['New repository','/new'],['Notifications','/notifications'],['Settings','/settings'],['Tokens','/settings/tokens'],['Agents','/settings/agents']].map(([t,u])=>({t,u,k:'Go to'})); |
| @@ -372,53 +439,10 @@ | ||
| 372 | 439 | connect()} |
| 373 | 440 | })();`; |
| 374 | 441 | |
| 375 | -/** Inline SVG icons (16×16, stroke-based). Static strings only. */ | |
| 376 | -const I = (body: string, extra = "") => `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"${extra}>${body}</svg>`; | |
| 377 | - | |
| 442 | +/** Inline SVG icons (16×16, Phosphor, see scripts/make-ui-icons.mjs) plus the logo. Static strings only. */ | |
| 378 | 443 | export const ICONS = { |
| 379 | 444 | logo: `<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M6 8.5v7M8.3 7.2c4 1 7.4 2.2 7.6 3.3M8.3 16.8c4-1 7.4-2.2 7.6-3.3"/></svg>`, |
| 380 | - success: I('<circle cx="8" cy="8" r="6.5"/><path d="M5.2 8.2l1.9 1.9 3.7-4"/>'), | |
| 381 | - package: I('<path d="M8 1.5l6 3v7l-6 3-6-3v-7z"/><path d="M2 4.5l6 3 6-3M8 7.5v7"/>'), | |
| 382 | - failure: I('<circle cx="8" cy="8" r="6.5"/><path d="M5.8 5.8l4.4 4.4M10.2 5.8l-4.4 4.4"/>'), | |
| 383 | - running: I('<circle cx="8" cy="8" r="6.5" opacity=".25"/><path d="M8 1.5a6.5 6.5 0 0 1 6.5 6.5"/>'), | |
| 384 | - queued: I('<circle cx="8" cy="8" r="6.5"/><circle cx="8" cy="8" r="1.6" fill="currentColor" stroke="none"/>'), | |
| 385 | - cancelled: I('<circle cx="8" cy="8" r="6.5"/><path d="M3.4 12.6l9.2-9.2"/>'), | |
| 386 | - skipped: I('<circle cx="8" cy="8" r="6.5"/><path d="M5 8h6"/>'), | |
| 387 | - neutral: I('<circle cx="8" cy="8" r="6.5"/><path d="M5 8h6"/>'), | |
| 388 | - issueOpen: I('<circle cx="8" cy="8" r="6.5"/><circle cx="8" cy="8" r="1.4" fill="currentColor"/>'), | |
| 389 | - issueClosed: I('<circle cx="8" cy="8" r="6.5"/><path d="M5.2 8.2l1.9 1.9 3.7-4"/>'), | |
| 390 | - pr: I('<circle cx="4" cy="3.5" r="1.8"/><circle cx="4" cy="12.5" r="1.8"/><circle cx="12" cy="12.5" r="1.8"/><path d="M4 5.3v5.4M12 10.7V6.5a2 2 0 0 0-2-2H7.5M9 3l-1.5 1.5L9 6"/>'), | |
| 391 | - prMerged: I('<circle cx="4" cy="3.5" r="1.8"/><circle cx="4" cy="12.5" r="1.8"/><circle cx="12" cy="8" r="1.8"/><path d="M4 5.3v5.4M4 5.3c0 2 1.5 2.7 6.2 2.7"/>'), | |
| 392 | - prClosed: I('<circle cx="4" cy="3.5" r="1.8"/><circle cx="4" cy="12.5" r="1.8"/><circle cx="12" cy="12.5" r="1.8"/><path d="M4 5.3v5.4M12 10.7V8M10.3 2.3l3.4 3.4M13.7 2.3l-3.4 3.4"/>'), | |
| 393 | - dir: I('<path d="M1.8 3.5h4.3l1.5 1.6h6.6v7.4H1.8z" fill="currentColor" fill-opacity=".15"/>'), | |
| 394 | - file: I('<path d="M3.5 1.8h5.6l3.4 3.4v9H3.5z"/><path d="M9 1.8v3.5h3.5"/>'), | |
| 395 | - submodule: I('<rect x="2" y="3" width="12" height="10" rx="1.5"/><path d="M5 8h6"/>'), | |
| 396 | - branch: I('<circle cx="4.5" cy="3.5" r="1.7"/><circle cx="4.5" cy="12.5" r="1.7"/><circle cx="11.5" cy="5" r="1.7"/><path d="M4.5 5.2v5.6M11.5 6.7c0 3-7 2-7 4.1"/>'), | |
| 397 | - tag: I('<path d="M2 2.5h5.5l6.5 6.5-5 5-6.5-6.5z"/><circle cx="5" cy="5.5" r="1" fill="currentColor"/>'), | |
| 398 | - commit: I('<circle cx="8" cy="8" r="2.5"/><path d="M1 8h4.5M10.5 8H15"/>'), | |
| 399 | - lock: I('<rect x="3" y="7" width="10" height="7" rx="1.5"/><path d="M5.5 7V5a2.5 2.5 0 0 1 5 0v2"/>'), | |
| 400 | - globe: I('<circle cx="8" cy="8" r="6.5"/><path d="M1.5 8h13M8 1.5c2 2.2 2 10.8 0 13M8 1.5c-2 2.2-2 10.8 0 13"/>'), | |
| 401 | - search: I('<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5l3.5 3.5"/>'), | |
| 402 | - bell: I('<path d="M4 11V7a4 4 0 0 1 8 0v4l1.5 1.5h-11zM6.5 14h3"/>'), | |
| 403 | - plus: I('<path d="M8 3v10M3 8h10"/>'), | |
| 404 | - gear: I('<circle cx="8" cy="8" r="2.2"/><path d="M8 1.5v2M8 12.5v2M1.5 8h2M12.5 8h2M3.4 3.4l1.4 1.4M11.2 11.2l1.4 1.4M3.4 12.6l1.4-1.4M11.2 4.8l1.4-1.4"/>'), | |
| 405 | - play: I('<path d="M5 3.5v9l7-4.5z"/>'), | |
| 406 | - code: I('<path d="M5.5 4.5L2 8l3.5 3.5M10.5 4.5L14 8l-3.5 3.5"/>'), | |
| 407 | - comment: I('<path d="M2.5 3h11v7.5H7L4 13v-2.5H2.5z"/>'), | |
| 408 | - eye: I('<path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8 12 12.5 8 12.5 1.5 8 1.5 8z"/><circle cx="8" cy="8" r="2"/>'), | |
| 409 | - bot: I('<rect x="2.5" y="5" width="11" height="8" rx="2"/><path d="M8 2.5V5M5.5 9h.01M10.5 9h.01"/>'), | |
| 410 | - history: I('<path d="M2 8a6 6 0 1 0 1.8-4.3M2 2.5v3h3"/><path d="M8 5v3l2 1.5"/>'), | |
| 411 | - copy: I('<rect x="5" y="5" width="8.5" height="8.5" rx="1.5"/><path d="M3 10.5H2.5v-8h8V3"/>'), | |
| 412 | - download: I('<path d="M8 2v8M4.5 7L8 10.5 11.5 7M2.5 13.5h11"/>'), | |
| 413 | - edit: I('<path d="M10.5 2.5l3 3-8 8H2.5v-3z"/>'), | |
| 414 | - x: I('<path d="M4 4l8 8M12 4l-8 8"/>'), | |
| 415 | - check: I('<path d="M3 8.5l3 3 7-7"/>'), | |
| 416 | - people: I('<circle cx="6" cy="5.5" r="2.3"/><path d="M1.8 13.5c0-2.4 1.9-4 4.2-4s4.2 1.6 4.2 4"/><path d="M10.5 3.4a2.3 2.3 0 0 1 0 4.4M12 9.7c1.3.5 2.2 1.9 2.2 3.8"/>'), | |
| 417 | - key: I('<circle cx="5" cy="11" r="3"/><path d="M7.2 8.8L13.5 2.5M11 5l2 2"/>'), | |
| 418 | - shield: I('<path d="M8 1.5l5.5 2v4c0 3.5-2.4 6-5.5 7-3.1-1-5.5-3.5-5.5-7v-4z"/>'), | |
| 419 | - repo: I('<path d="M3 2.5h9.5v9H4.5A1.5 1.5 0 0 0 3 13z"/><path d="M3 13a1.5 1.5 0 0 0 1.5 1.5h8v-3"/>'), | |
| 420 | - org: I('<rect x="2.5" y="2" width="11" height="12.5" rx="1"/><path d="M5.5 5h1M9.5 5h1M5.5 8h1M9.5 8h1M7 14.5V11.5h2v3"/>'), | |
| 421 | - zap: I('<path d="M9 1.5L3 9h4.5L7 14.5 13 7H8.5z"/>'), | |
| 445 | + ...UI_ICONS, | |
| 422 | 446 | } as const; |
| 423 | 447 | |
| 424 | 448 | export type IconName = keyof typeof ICONS; |
modifiedapps/diffinery/src/web/auth.tsx+6−2View file
| @@ -360,7 +360,8 @@ | ||
| 360 | 360 | return c.json({ redirect: "/settings/security" }); |
| 361 | 361 | } else if (s.kind === "invite") { |
| 362 | 362 | // Re-check: the invite or the username may have been taken while the browser was busy. |
| 363 | - if (!(await auth.inviteByHash(s.inviteHash))) throw new HttpError(404, "This invite was used or expired in the meantime."); | |
| 363 | + const inv = await auth.inviteByHash(s.inviteHash); | |
| 364 | + if (!inv) throw new HttpError(404, "This invite was used or expired in the meantime."); | |
| 364 | 365 | if (await dir.accountByEmail(s.email)) throw new HttpError(409, `${s.email} already has an account.`); |
| 365 | 366 | if (await dir.accountByLogin(s.login)) throw new HttpError(409, `The username ${s.login} was just taken. Pick another one.`); |
| 366 | 367 | // Creating the account accepts every open invite for this email, including this one. |
| @@ -368,7 +369,10 @@ | ||
| 368 | 369 | await auth.addPasskey({ ...key, account_id: account.id }); |
| 369 | 370 | c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "invite.accept", detail: { method: "passkey", passkeyId: key.id, ...signinContext(c) } })); |
| 370 | 371 | flash(c, "notice", `Welcome to Diffinery, ${account.login}. You can sign in with this passkey from now on.`); |
| 371 | - redirect = "/"; | |
| 372 | + // Land where they were invited to: the organization or the repository. | |
| 373 | + const repo = inv.repo_id ? await dir.repoById(inv.repo_id) : null; | |
| 374 | + const org = !repo && inv.org_id ? await dir.account(inv.org_id) : null; | |
| 375 | + redirect = repo ? `/${repo.owner_login}/${repo.name}` : org ? `/${org.login}` : "/"; | |
| 372 | 376 | } else if (s.kind === "link") { |
| 373 | 377 | if (!(await auth.useSigninLink(s.linkHash))) throw new HttpError(404, "This sign-in link was used or expired in the meantime."); |
| 374 | 378 | const found = await dir.account(s.accountId); |
modifiedapps/diffinery/src/web/code.tsx+7−3View file
| @@ -10,6 +10,7 @@ | ||
| 10 | 10 | import { csrfToken, type FormData } from "./csrf.js"; |
| 11 | 11 | import { CheckName } from "./checks.js"; |
| 12 | 12 | import { ReleasesSection } from "./releases.js"; |
| 13 | +import { highlightLines, langForPath } from "./highlight.js"; | |
| 13 | 14 | import { |
| 14 | 15 | CommitRow, |
| 15 | 16 | CopyButton, |
| @@ -37,6 +38,7 @@ | ||
| 37 | 38 | splitRefPath, |
| 38 | 39 | statusOf, |
| 39 | 40 | Status, |
| 41 | + tokens, | |
| 40 | 42 | type DiffFile, |
| 41 | 43 | type WebApp, |
| 42 | 44 | prm, |
| @@ -76,7 +78,7 @@ | ||
| 76 | 78 | const CloneBox: FC<{ url: string }> = ({ url }) => ( |
| 77 | 79 | <div class="clone"> |
| 78 | 80 | <input type="text" readonly value={url} aria-label="Clone URL" /> |
| 79 | - <CopyButton text={url} /> | |
| 81 | + <CopyButton text={url} size="base" /> | |
| 80 | 82 | </div> |
| 81 | 83 | ); |
| 82 | 84 | |
| @@ -581,15 +583,17 @@ | ||
| 581 | 583 | </div> |
| 582 | 584 | ); |
| 583 | 585 | } else { |
| 586 | + const shown = lines.map((l) => l.replace(/\r$/, "")); | |
| 587 | + const toks = await highlightLines(shown, langForPath(path)); | |
| 584 | 588 | content = ( |
| 585 | 589 | <div class="code-wrap"> |
| 586 | 590 | <table class="blob"> |
| 587 | - {lines.map((line, i) => ( | |
| 591 | + {shown.map((line, i) => ( | |
| 588 | 592 | <tr id={`L${i + 1}`}> |
| 589 | 593 | <td class="ln"> |
| 590 | 594 | <a href={`#L${i + 1}`}>{i + 1}</a> |
| 591 | 595 | </td> |
| 592 | - <td>{line.replace(/\r$/, "")}</td> | |
| 596 | + {toks?.[i] ? <td class="sh">{tokens(toks[i])}</td> : <td>{line}</td>} | |
| 593 | 597 | </tr> |
| 594 | 598 | ))} |
| 595 | 599 | </table> |
modifiedapps/diffinery/src/web/collab.tsx+9−10View file
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | import { CheckName } from "./checks.js"; |
| 15 | 15 | import { |
| 16 | 16 | Actor, |
| 17 | + Avatar, | |
| 17 | 18 | CommitRow, |
| 18 | 19 | DiffStats, |
| 19 | 20 | DiffView, |
| @@ -143,15 +144,15 @@ | ||
| 143 | 144 | <input type="search" name="q" value={q} placeholder={`Search ${kind === "pull" ? "pull requests" : "issues"}… ( / )`} class="grow" style="flex:1" /> |
| 144 | 145 | </form> |
| 145 | 146 | <div class="row"> |
| 146 | - <a class="btn sm" href={`${base}/labels`}> | |
| 147 | - Labels | |
| 147 | + <a class="btn" href={`${base}/labels`}> | |
| 148 | + {raw(ICONS.tag)} Labels | |
| 148 | 149 | </a> |
| 149 | 150 | {kind === "pull" ? ( |
| 150 | - <a class="btn sm primary" href={`${base}/compare`}> | |
| 151 | + <a class="btn primary" href={`${base}/compare`}> | |
| 151 | 152 | New pull request |
| 152 | 153 | </a> |
| 153 | 154 | ) : isAccount && !repo.archived ? ( |
| 154 | - <a class="btn sm primary" href={`${base}/issues/new`}> | |
| 155 | + <a class="btn primary" href={`${base}/issues/new`}> | |
| 155 | 156 | New issue |
| 156 | 157 | </a> |
| 157 | 158 | ) : null} |
| @@ -544,9 +545,7 @@ | ||
| 544 | 545 | <div class="row" style="gap:4px"> |
| 545 | 546 | {participants.map((a) => ( |
| 546 | 547 | <a href={`/${encodeURIComponent(a.login)}`} title={a.login}> |
| 547 | - <span class={`av ${a.kind === "agent" ? "bot" : ""}`} style={`--h:${[...a.login].reduce((h, ch) => (h * 31 + ch.charCodeAt(0)) >>> 0, 0) % 360}`}> | |
| 548 | - {(a.login.replace(/[^A-Za-z0-9]/g, "")[0] ?? "?").toUpperCase()} | |
| 549 | - </span> | |
| 548 | + <Avatar login={a.login} kind={a.kind} /> | |
| 550 | 549 | </a> |
| 551 | 550 | ))} |
| 552 | 551 | </div> |
| @@ -560,7 +559,7 @@ | ||
| 560 | 559 | if (!can) return null; |
| 561 | 560 | return ( |
| 562 | 561 | <details class="menu"> |
| 563 | - <summary class="btn sm">Edit</summary> | |
| 562 | + <summary class="btn">{raw(ICONS.edit)} Edit</summary> | |
| 564 | 563 | <div class="pop right" style="width:min(560px,90vw)"> |
| 565 | 564 | <Form action={`${repoUrl(x.l.repo)}/issues/${x.issue.number}/edit`} t={csrfToken(x.c)} style="padding:8px"> |
| 566 | 565 | <div class="field"> |
| @@ -571,7 +570,7 @@ | ||
| 571 | 570 | <label>Description</label> |
| 572 | 571 | <textarea name="body">{x.issue.body}</textarea> |
| 573 | 572 | </div> |
| 574 | - <button class="btn primary sm">Save</button> | |
| 573 | + <button class="btn primary">Save</button> | |
| 575 | 574 | </Form> |
| 576 | 575 | </div> |
| 577 | 576 | </details> |
| @@ -590,7 +589,7 @@ | ||
| 590 | 589 | <div class="row"> |
| 591 | 590 | <TitleEditor x={x} /> |
| 592 | 591 | {i.kind === "issue" ? ( |
| 593 | - <a class="btn sm primary" href={`${base}/issues/new`}> | |
| 592 | + <a class="btn primary" href={`${base}/issues/new`}> | |
| 594 | 593 | New issue |
| 595 | 594 | </a> |
| 596 | 595 | ) : null} |
modifiedapps/diffinery/src/web/csrf.ts+7−0View file
| @@ -35,6 +35,8 @@ | ||
| 35 | 35 | b(name: string): boolean; |
| 36 | 36 | /** Field names present in the form. */ |
| 37 | 37 | keys(): string[]; |
| 38 | + /** An uploaded file (multipart forms); null when the field is missing or empty. */ | |
| 39 | + file(name: string): File | null; | |
| 38 | 40 | } |
| 39 | 41 | |
| 40 | 42 | /** Form bodies bigger than this are refused before they are read (routes taking files pass a larger cap). */ |
| @@ -70,6 +72,11 @@ | ||
| 70 | 72 | return v !== undefined && v !== "" && v !== "false" && v !== "0" && v !== "off"; |
| 71 | 73 | }, |
| 72 | 74 | keys: () => Object.keys(raw), |
| 75 | + file: (name) => { | |
| 76 | + const v = raw[name]; | |
| 77 | + const f = (Array.isArray(v) ? v : [v]).find((x): x is File => typeof x === "object" && x !== null && typeof (x as File).arrayBuffer === "function"); | |
| 78 | + return f && f.size > 0 ? f : null; | |
| 79 | + }, | |
| 73 | 80 | }; |
| 74 | 81 | } |
| 75 | 82 |
addedapps/diffinery/src/web/highlight.ts+326−0View file
| @@ -1,0 +1,326 @@ | ||
| 1 | +/** | |
| 2 | + * Syntax highlighting with Shiki (JavaScript regex engine, fine-grained grammars | |
| 3 | + * loaded on first use). Tokens carry both GitHub themes as CSS variables | |
| 4 | + * (--shiki-light / --shiki-dark); the site stylesheet picks one. Everything here | |
| 5 | + * fails soft: an unknown language, an oversized input or an error means plain text. | |
| 6 | + */ | |
| 7 | +import { createHighlighterCore, type HighlighterCore, type LanguageRegistration, type ThemedToken, type ThemeRegistration } from "shiki/core"; | |
| 8 | +import { createJavaScriptRegexEngine } from "shiki/engine/javascript"; | |
| 9 | +import { escapeHtml } from "./markdown.js"; | |
| 10 | + | |
| 11 | +type Grammar = () => Promise<{ default: LanguageRegistration[] }>; | |
| 12 | + | |
| 13 | +const GRAMMARS = { | |
| 14 | + astro: () => import("shiki/langs/astro.mjs"), | |
| 15 | + bat: () => import("shiki/langs/bat.mjs"), | |
| 16 | + c: () => import("shiki/langs/c.mjs"), | |
| 17 | + clojure: () => import("shiki/langs/clojure.mjs"), | |
| 18 | + cmake: () => import("shiki/langs/cmake.mjs"), | |
| 19 | + cpp: () => import("shiki/langs/cpp.mjs"), | |
| 20 | + csharp: () => import("shiki/langs/csharp.mjs"), | |
| 21 | + css: () => import("shiki/langs/css.mjs"), | |
| 22 | + dart: () => import("shiki/langs/dart.mjs"), | |
| 23 | + diff: () => import("shiki/langs/diff.mjs"), | |
| 24 | + docker: () => import("shiki/langs/docker.mjs"), | |
| 25 | + dotenv: () => import("shiki/langs/dotenv.mjs"), | |
| 26 | + elixir: () => import("shiki/langs/elixir.mjs"), | |
| 27 | + erlang: () => import("shiki/langs/erlang.mjs"), | |
| 28 | + fish: () => import("shiki/langs/fish.mjs"), | |
| 29 | + fsharp: () => import("shiki/langs/fsharp.mjs"), | |
| 30 | + go: () => import("shiki/langs/go.mjs"), | |
| 31 | + graphql: () => import("shiki/langs/graphql.mjs"), | |
| 32 | + groovy: () => import("shiki/langs/groovy.mjs"), | |
| 33 | + haskell: () => import("shiki/langs/haskell.mjs"), | |
| 34 | + hcl: () => import("shiki/langs/hcl.mjs"), | |
| 35 | + html: () => import("shiki/langs/html.mjs"), | |
| 36 | + ini: () => import("shiki/langs/ini.mjs"), | |
| 37 | + java: () => import("shiki/langs/java.mjs"), | |
| 38 | + javascript: () => import("shiki/langs/javascript.mjs"), | |
| 39 | + json: () => import("shiki/langs/json.mjs"), | |
| 40 | + json5: () => import("shiki/langs/json5.mjs"), | |
| 41 | + jsonc: () => import("shiki/langs/jsonc.mjs"), | |
| 42 | + jsx: () => import("shiki/langs/jsx.mjs"), | |
| 43 | + julia: () => import("shiki/langs/julia.mjs"), | |
| 44 | + kotlin: () => import("shiki/langs/kotlin.mjs"), | |
| 45 | + less: () => import("shiki/langs/less.mjs"), | |
| 46 | + lua: () => import("shiki/langs/lua.mjs"), | |
| 47 | + make: () => import("shiki/langs/make.mjs"), | |
| 48 | + markdown: () => import("shiki/langs/markdown.mjs"), | |
| 49 | + nginx: () => import("shiki/langs/nginx.mjs"), | |
| 50 | + nix: () => import("shiki/langs/nix.mjs"), | |
| 51 | + "objective-c": () => import("shiki/langs/objective-c.mjs"), | |
| 52 | + ocaml: () => import("shiki/langs/ocaml.mjs"), | |
| 53 | + perl: () => import("shiki/langs/perl.mjs"), | |
| 54 | + php: () => import("shiki/langs/php.mjs"), | |
| 55 | + powershell: () => import("shiki/langs/powershell.mjs"), | |
| 56 | + proto: () => import("shiki/langs/proto.mjs"), | |
| 57 | + python: () => import("shiki/langs/python.mjs"), | |
| 58 | + r: () => import("shiki/langs/r.mjs"), | |
| 59 | + ruby: () => import("shiki/langs/ruby.mjs"), | |
| 60 | + rust: () => import("shiki/langs/rust.mjs"), | |
| 61 | + sass: () => import("shiki/langs/sass.mjs"), | |
| 62 | + scala: () => import("shiki/langs/scala.mjs"), | |
| 63 | + scss: () => import("shiki/langs/scss.mjs"), | |
| 64 | + shellscript: () => import("shiki/langs/shellscript.mjs"), | |
| 65 | + sql: () => import("shiki/langs/sql.mjs"), | |
| 66 | + svelte: () => import("shiki/langs/svelte.mjs"), | |
| 67 | + swift: () => import("shiki/langs/swift.mjs"), | |
| 68 | + terraform: () => import("shiki/langs/terraform.mjs"), | |
| 69 | + toml: () => import("shiki/langs/toml.mjs"), | |
| 70 | + tsx: () => import("shiki/langs/tsx.mjs"), | |
| 71 | + typescript: () => import("shiki/langs/typescript.mjs"), | |
| 72 | + vue: () => import("shiki/langs/vue.mjs"), | |
| 73 | + xml: () => import("shiki/langs/xml.mjs"), | |
| 74 | + yaml: () => import("shiki/langs/yaml.mjs"), | |
| 75 | + zig: () => import("shiki/langs/zig.mjs"), | |
| 76 | +} satisfies Record<string, Grammar>; | |
| 77 | + | |
| 78 | +export type Lang = keyof typeof GRAMMARS; | |
| 79 | +export const LANGS = Object.keys(GRAMMARS) as Lang[]; | |
| 80 | + | |
| 81 | +const EXT: Record<string, Lang> = { | |
| 82 | + ts: "typescript", mts: "typescript", cts: "typescript", tsx: "tsx", | |
| 83 | + js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "jsx", | |
| 84 | + json: "json", webmanifest: "json", geojson: "json", jsonc: "jsonc", json5: "json5", | |
| 85 | + yaml: "yaml", yml: "yaml", toml: "toml", | |
| 86 | + md: "markdown", markdown: "markdown", mdown: "markdown", mkd: "markdown", | |
| 87 | + html: "html", htm: "html", xhtml: "html", | |
| 88 | + css: "css", scss: "scss", sass: "sass", less: "less", | |
| 89 | + py: "python", pyi: "python", pyw: "python", go: "go", rs: "rust", java: "java", kt: "kotlin", kts: "kotlin", swift: "swift", | |
| 90 | + c: "c", h: "c", cc: "cpp", cpp: "cpp", cxx: "cpp", "c++": "cpp", hpp: "cpp", hh: "cpp", hxx: "cpp", ino: "cpp", | |
| 91 | + cs: "csharp", csx: "csharp", php: "php", phtml: "php", rb: "ruby", rake: "ruby", gemspec: "ruby", ru: "ruby", | |
| 92 | + sh: "shellscript", bash: "shellscript", zsh: "shellscript", ksh: "shellscript", command: "shellscript", fish: "fish", | |
| 93 | + ps1: "powershell", psm1: "powershell", psd1: "powershell", bat: "bat", cmd: "bat", | |
| 94 | + sql: "sql", graphql: "graphql", gql: "graphql", | |
| 95 | + xml: "xml", xsd: "xml", xsl: "xml", xslt: "xml", svg: "xml", plist: "xml", csproj: "xml", fsproj: "xml", vbproj: "xml", props: "xml", targets: "xml", resx: "xml", xaml: "xml", rss: "xml", atom: "xml", | |
| 96 | + lua: "lua", dart: "dart", scala: "scala", sc: "scala", sbt: "scala", r: "r", pl: "perl", pm: "perl", | |
| 97 | + ex: "elixir", exs: "elixir", erl: "erlang", hrl: "erlang", hs: "haskell", | |
| 98 | + vue: "vue", svelte: "svelte", astro: "astro", tf: "terraform", tfvars: "terraform", hcl: "hcl", nomad: "hcl", | |
| 99 | + ini: "ini", cfg: "ini", conf: "ini", properties: "ini", env: "dotenv", | |
| 100 | + mk: "make", mak: "make", cmake: "cmake", diff: "diff", patch: "diff", proto: "proto", zig: "zig", | |
| 101 | + m: "objective-c", groovy: "groovy", gradle: "groovy", gvy: "groovy", clj: "clojure", cljs: "clojure", cljc: "clojure", edn: "clojure", | |
| 102 | + ml: "ocaml", mli: "ocaml", fs: "fsharp", fsi: "fsharp", fsx: "fsharp", jl: "julia", nix: "nix", | |
| 103 | +}; | |
| 104 | + | |
| 105 | +const NAMES: Record<string, Lang | null> = { | |
| 106 | + dockerfile: "docker", containerfile: "docker", makefile: "make", gnumakefile: "make", "cmakelists.txt": "cmake", | |
| 107 | + gemfile: "ruby", rakefile: "ruby", podfile: "ruby", vagrantfile: "ruby", brewfile: "ruby", guardfile: "ruby", fastfile: "ruby", "gemfile.lock": null, | |
| 108 | + jenkinsfile: "groovy", "nginx.conf": "nginx", "cargo.lock": "toml", "go.mod": null, "go.sum": null, | |
| 109 | + ".bashrc": "shellscript", ".bash_profile": "shellscript", ".bash_aliases": "shellscript", ".zshrc": "shellscript", ".zprofile": "shellscript", ".zshenv": "shellscript", ".profile": "shellscript", | |
| 110 | + ".gitconfig": "ini", ".editorconfig": "ini", ".npmrc": "ini", ".gitmodules": "ini", | |
| 111 | + ".babelrc": "json", ".eslintrc": "json", ".prettierrc": "json", ".swcrc": "json", | |
| 112 | + ".gitignore": null, ".dockerignore": null, ".npmignore": null, ".gitattributes": null, ".prettierignore": null, ".eslintignore": null, | |
| 113 | +}; | |
| 114 | + | |
| 115 | +/** Shiki language for a repository path, or null when unknown (rendered as plain text). */ | |
| 116 | +export function langForPath(path: string): Lang | null { | |
| 117 | + const name = (path.split("/").pop() ?? "").toLowerCase(); | |
| 118 | + if (name in NAMES) return NAMES[name]!; | |
| 119 | + if (name === ".env" || name.startsWith(".env.")) return "dotenv"; | |
| 120 | + if (name.startsWith("dockerfile.") || name.endsWith(".dockerfile")) return "docker"; | |
| 121 | + if (name.startsWith("makefile.")) return "make"; | |
| 122 | + if (/^(tsconfig|jsconfig)(\.[\w-]+)*\.json$/.test(name) || /(^|\/)\.vscode\/[^/]+\.json$/i.test(path) || name === "devcontainer.json") return "jsonc"; | |
| 123 | + const dot = name.lastIndexOf("."); | |
| 124 | + return dot >= 0 ? (EXT[name.slice(dot + 1)] ?? null) : null; | |
| 125 | +} | |
| 126 | + | |
| 127 | +const ALIASES: Record<string, Lang> = { | |
| 128 | + ...EXT, | |
| 129 | + typescript: "typescript", javascript: "javascript", node: "javascript", python: "python", python3: "python", py3: "python", golang: "go", rust: "rust", | |
| 130 | + kotlin: "kotlin", csharp: "csharp", "c#": "csharp", cpp: "cpp", "objective-c": "objective-c", objc: "objective-c", objectivec: "objective-c", | |
| 131 | + ruby: "ruby", shell: "shellscript", shellscript: "shellscript", console: "shellscript", shellsession: "shellscript", powershell: "powershell", pwsh: "powershell", ps: "powershell", | |
| 132 | + batch: "bat", docker: "docker", dockerfile: "docker", make: "make", makefile: "make", perl: "perl", elixir: "elixir", erlang: "erlang", haskell: "haskell", | |
| 133 | + terraform: "terraform", nginx: "nginx", dotenv: "dotenv", protobuf: "proto", "f#": "fsharp", fsharp: "fsharp", ocaml: "ocaml", julia: "julia", clojure: "clojure", | |
| 134 | + postgres: "sql", postgresql: "sql", psql: "sql", mysql: "sql", sqlite: "sql", plpgsql: "sql", jsonl: "json", "objective-cpp": "cpp", | |
| 135 | +}; | |
| 136 | + | |
| 137 | +/** Shiki language for a Markdown fence info string ("ts", "yaml", "c++" …), or null. */ | |
| 138 | +export function langForFence(info: string): Lang | null { | |
| 139 | + const name = info.trim().toLowerCase().replace(/^\{?\.?/, "").replace(/\}$/, ""); | |
| 140 | + return name in GRAMMARS ? (name as Lang) : (ALIASES[name] ?? null); | |
| 141 | +} | |
| 142 | + | |
| 143 | +// ---------------------------------------------------------------- highlighter | |
| 144 | + | |
| 145 | +/** Highlighting limits: beyond them, plain text. Workers have little CPU to spare. */ | |
| 146 | +export const LIMITS = { lines: 2000, bytes: 200_000, lineLength: 2000 }; | |
| 147 | + | |
| 148 | +export interface Tok { | |
| 149 | + text: string; | |
| 150 | + /** CSS custom properties (--shiki-light etc.); absent for the default foreground. */ | |
| 151 | + style?: string; | |
| 152 | +} | |
| 153 | +export type TokLine = Tok[]; | |
| 154 | + | |
| 155 | +const PAIR = "github-pair"; | |
| 156 | + | |
| 157 | +/** | |
| 158 | + * GitHub's light and dark default themes share one rule list (same scopes and font styles, | |
| 159 | + * different colors). Tokenizing once with a theme whose rule colors are | |
| 160 | + * placeholders, then decoding each placeholder to both palettes, gives exactly | |
| 161 | + * Shiki's dual-theme output at half the CPU (dual themes tokenize once per theme). | |
| 162 | + */ | |
| 163 | +function pairThemes(light: ThemeRegistration, dark: ThemeRegistration): { theme: ThemeRegistration; palette: Map<string, [string, string]> } { | |
| 164 | + const lr = light.tokenColors ?? []; | |
| 165 | + const dr = dark.tokenColors ?? []; | |
| 166 | + const fg = (t: ThemeRegistration) => (t.fg ?? t.colors?.["editor.foreground"] ?? "").toUpperCase(); | |
| 167 | + const palette = new Map<string, [string, string]>([["#000000", [fg(light), fg(dark)]]]); | |
| 168 | + if (lr.length !== dr.length || !palette.get("#000000")!.every(Boolean)) throw new Error("themes do not pair"); | |
| 169 | + const tokenColors = lr.map((r, i) => { | |
| 170 | + const d = dr[i]!; | |
| 171 | + if (JSON.stringify(r.scope) !== JSON.stringify(d.scope) || r.settings.fontStyle !== d.settings.fontStyle || !r.settings.foreground !== !d.settings.foreground) throw new Error("themes do not pair"); | |
| 172 | + const settings: { fontStyle?: string; foreground?: string } = r.settings.fontStyle !== undefined ? { fontStyle: r.settings.fontStyle } : {}; | |
| 173 | + if (r.settings.foreground) { | |
| 174 | + settings.foreground = `#${(i + 1).toString(16).padStart(6, "0").toUpperCase()}`; | |
| 175 | + palette.set(settings.foreground, [r.settings.foreground.toUpperCase(), d.settings.foreground!.toUpperCase()]); | |
| 176 | + } | |
| 177 | + return { scope: r.scope, settings }; | |
| 178 | + }); | |
| 179 | + return { theme: { name: PAIR, type: "light", fg: "#000000", bg: "#ffffff", colors: { "editor.foreground": "#000000", "editor.background": "#ffffff" }, tokenColors }, palette }; | |
| 180 | +} | |
| 181 | + | |
| 182 | +let core: Promise<HighlighterCore> | null = null; | |
| 183 | +let palette = new Map<string, [string, string]>(); | |
| 184 | +const styles = new Map<string, string | undefined>(); | |
| 185 | +const loading = new Map<Lang, Promise<void>>(); | |
| 186 | + | |
| 187 | +function highlighter(): Promise<HighlighterCore> { | |
| 188 | + core ??= Promise.all([import("shiki/themes/github-light-default.mjs"), import("shiki/themes/github-dark-default.mjs")]) | |
| 189 | + .then(([l, d]) => { | |
| 190 | + const pair = pairThemes(l.default, d.default); | |
| 191 | + palette = pair.palette; | |
| 192 | + return createHighlighterCore({ themes: [pair.theme], langs: [], engine: createJavaScriptRegexEngine({ forgiving: true }) }); | |
| 193 | + }) | |
| 194 | + .catch((e) => { | |
| 195 | + core = null; | |
| 196 | + throw e; | |
| 197 | + }); | |
| 198 | + return core; | |
| 199 | +} | |
| 200 | + | |
| 201 | +/** The highlighter with these grammars loaded; null when anything fails. */ | |
| 202 | +export async function ready(langs: Iterable<Lang>): Promise<HighlighterCore | null> { | |
| 203 | + try { | |
| 204 | + const hl = await highlighter(); | |
| 205 | + await Promise.all( | |
| 206 | + [...new Set(langs)].map((l) => { | |
| 207 | + let p = loading.get(l); | |
| 208 | + if (!p) { | |
| 209 | + p = hl.loadLanguage(GRAMMARS[l]()).catch((e) => { | |
| 210 | + loading.delete(l); | |
| 211 | + throw e; | |
| 212 | + }); | |
| 213 | + loading.set(l, p); | |
| 214 | + } | |
| 215 | + return p; | |
| 216 | + }), | |
| 217 | + ); | |
| 218 | + return hl; | |
| 219 | + } catch (e) { | |
| 220 | + console.warn("highlight: load failed", e); | |
| 221 | + return null; | |
| 222 | + } | |
| 223 | +} | |
| 224 | + | |
| 225 | +/** Shiki's dual-theme custom properties for a placeholder color and font style; undefined for plain text. */ | |
| 226 | +function styleOf(color: string | undefined, fontStyle: number | undefined): string | undefined { | |
| 227 | + const key = `${color}/${fontStyle}`; | |
| 228 | + if (styles.has(key)) return styles.get(key); | |
| 229 | + const plain = palette.get("#000000")!; | |
| 230 | + const [l, d] = (color && palette.get(color.toUpperCase())) || plain; | |
| 231 | + const fs = fontStyle && fontStyle > 0 ? fontStyle : 0; | |
| 232 | + let style: string | undefined; | |
| 233 | + if (l !== plain[0] || d !== plain[1] || fs) { | |
| 234 | + const deco = [fs & 4 ? "underline" : "", fs & 8 ? "line-through" : ""].filter(Boolean).join(" "); | |
| 235 | + const font = (side: string) => `${fs & 1 ? `;--shiki-${side}-font-style:italic` : ""}${fs & 2 ? `;--shiki-${side}-font-weight:bold` : ""}${deco ? `;--shiki-${side}-text-decoration:${deco}` : ""}`; | |
| 236 | + style = `--shiki-light:${l}${font("light")};--shiki-dark:${d}${font("dark")}`; | |
| 237 | + } | |
| 238 | + styles.set(key, style); | |
| 239 | + return style; | |
| 240 | +} | |
| 241 | + | |
| 242 | +function toks(line: ThemedToken[]): TokLine { | |
| 243 | + const out: TokLine = []; | |
| 244 | + for (const t of line) { | |
| 245 | + const style = styleOf(t.color, t.fontStyle); | |
| 246 | + const last = out[out.length - 1]; | |
| 247 | + if (last && last.style === style) last.text += t.content; | |
| 248 | + else out.push(style ? { text: t.content, style } : { text: t.content }); | |
| 249 | + } | |
| 250 | + return out; | |
| 251 | +} | |
| 252 | + | |
| 253 | +/** Tokenize code with a highlighter that already has `lang` loaded; null when over the limits or on error. */ | |
| 254 | +export function tokenize(hl: HighlighterCore, code: string, lang: Lang): TokLine[] | null { | |
| 255 | + if (code.length > LIMITS.bytes) return null; | |
| 256 | + try { | |
| 257 | + const { tokens } = hl.codeToTokens(code, { lang, theme: PAIR, tokenizeMaxLineLength: LIMITS.lineLength, tokenizeTimeLimit: 200 }); | |
| 258 | + return tokens.map(toks); | |
| 259 | + } catch (e) { | |
| 260 | + console.warn("highlight: tokenize failed", e); | |
| 261 | + return null; | |
| 262 | + } | |
| 263 | +} | |
| 264 | + | |
| 265 | +/** Highlight whole-file lines (as shown in the file view); null means render plain text. */ | |
| 266 | +export async function highlightLines(lines: string[], lang: Lang | null): Promise<TokLine[] | null> { | |
| 267 | + if (!lang || !lines.length || lines.length > LIMITS.lines) return null; | |
| 268 | + let bytes = 0; | |
| 269 | + for (const l of lines) bytes += l.length + 1; | |
| 270 | + if (bytes > LIMITS.bytes) return null; | |
| 271 | + const hl = await ready([lang]); | |
| 272 | + const out = hl && tokenize(hl, lines.join("\n"), lang); | |
| 273 | + return out && out.length === lines.length ? out : null; | |
| 274 | +} | |
| 275 | + | |
| 276 | +/** | |
| 277 | + * Tokens for each line of a unified-diff hunk. The old side (context and deletions) | |
| 278 | + * and the new side (context and additions) are tokenized as contiguous snippets so | |
| 279 | + * grammar state carries from line to line, then mapped back to rows. Rows that are | |
| 280 | + * not code ("\ No newline at end of file") get null, as does every row on failure. | |
| 281 | + */ | |
| 282 | +export function diffLineTokens(lines: string[], tokenizeSide: (code: string) => TokLine[] | null): Array<TokLine | null> { | |
| 283 | + const olds: string[] = []; | |
| 284 | + const news: string[] = []; | |
| 285 | + for (const l of lines) { | |
| 286 | + const k = l[0]; | |
| 287 | + if (k === "\\") continue; | |
| 288 | + if (k !== "+") olds.push(l.slice(1)); | |
| 289 | + if (k !== "-") news.push(l.slice(1)); | |
| 290 | + } | |
| 291 | + const o = olds.length ? tokenizeSide(olds.join("\n")) : []; | |
| 292 | + const n = news.length ? tokenizeSide(news.join("\n")) : []; | |
| 293 | + if (!o || !n || o.length !== olds.length || n.length !== news.length) return lines.map(() => null); | |
| 294 | + let oi = 0; | |
| 295 | + let ni = 0; | |
| 296 | + return lines.map((l) => { | |
| 297 | + const k = l[0]; | |
| 298 | + if (k === "\\") return null; | |
| 299 | + if (k === "+") return n[ni++] ?? null; | |
| 300 | + if (k === "-") return o[oi++] ?? null; | |
| 301 | + oi++; | |
| 302 | + return n[ni++] ?? null; | |
| 303 | + }); | |
| 304 | +} | |
| 305 | + | |
| 306 | +/** Render tokens as HTML (for the Markdown renderer, which builds strings). */ | |
| 307 | +export const tokensHtml = (lines: TokLine[]): string => | |
| 308 | + lines.map((l) => l.map((t) => (t.style ? `<span style="${escapeHtml(t.style)}">${escapeHtml(t.text)}</span>` : escapeHtml(t.text))).join("")).join("\n"); | |
| 309 | + | |
| 310 | +/** A sync fence highlighter for renderMarkdown, with the grammars the document's fences name already loaded. */ | |
| 311 | +export async function fenceHighlighter(langs: string[]): Promise<((code: string, info: string) => string | null) | undefined> { | |
| 312 | + const wanted = [...new Set(langs.map(langForFence).filter((l): l is Lang => l !== null))].slice(0, 8); | |
| 313 | + if (!wanted.length) return undefined; | |
| 314 | + const hl = await ready(wanted); | |
| 315 | + if (!hl) return undefined; | |
| 316 | + let budget = LIMITS.lines; | |
| 317 | + return (code, info) => { | |
| 318 | + const lang = langForFence(info); | |
| 319 | + if (!lang || !wanted.includes(lang)) return null; | |
| 320 | + const n = code.split("\n").length; | |
| 321 | + if (n > budget) return null; | |
| 322 | + budget -= n; | |
| 323 | + const t = tokenize(hl, code, lang); | |
| 324 | + return t ? tokensHtml(t) : null; | |
| 325 | + }; | |
| 326 | +} |
addedapps/diffinery/src/web/icons.ui.generated.ts+55−0View file
| @@ -1,0 +1,55 @@ | ||
| 1 | +// Generated by scripts/make-ui-icons.mjs from Phosphor icons (MIT). Do not edit. | |
| 2 | +export const UI_ICONS = { | |
| 3 | + success: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm45.66,85.66-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32Z\"></path></svg>", | |
| 4 | + failure: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm37.66,130.34a8,8,0,0,1-11.32,11.32L128,139.31l-26.34,26.35a8,8,0,0,1-11.32-11.32L116.69,128,90.34,101.66a8,8,0,0,1,11.32-11.32L128,116.69l26.34-26.35a8,8,0,0,1,11.32,11.32L139.31,128Z\"></path></svg>", | |
| 5 | + running: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M236,128a108,108,0,0,1-216,0c0-42.52,24.73-81.34,63-98.9A12,12,0,1,1,93,50.91C63.24,64.57,44,94.83,44,128a84,84,0,0,0,168,0c0-33.17-19.24-63.43-49-77.09A12,12,0,1,1,173,29.1C211.27,46.66,236,85.48,236,128Z\"></path></svg>", | |
| 6 | + queued: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M128,24A104,104,0,1,0,232,128,104.13,104.13,0,0,0,128,24ZM84,140a12,12,0,1,1,12-12A12,12,0,0,1,84,140Zm44,0a12,12,0,1,1,12-12A12,12,0,0,1,128,140Zm44,0a12,12,0,1,1,12-12A12,12,0,0,1,172,140Z\"></path></svg>", | |
| 7 | + cancelled: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm84,108a83.6,83.6,0,0,1-16.75,50.28L77.72,60.75A84,84,0,0,1,212,128ZM44,128A83.6,83.6,0,0,1,60.75,77.72L178.28,195.25A84,84,0,0,1,44,128Z\"></path></svg>", | |
| 8 | + skipped: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M176,128a8,8,0,0,1-8,8H88a8,8,0,0,1,0-16h80A8,8,0,0,1,176,128Zm56,0A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z\"></path></svg>", | |
| 9 | + neutral: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M176,128a8,8,0,0,1-8,8H88a8,8,0,0,1,0-16h80A8,8,0,0,1,176,128Zm56,0A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z\"></path></svg>", | |
| 10 | + issueOpen: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm0-160a72,72,0,1,0,72,72A72.08,72.08,0,0,0,128,56Zm0,128a56,56,0,1,1,56-56A56.06,56.06,0,0,1,128,184Z\"></path></svg>", | |
| 11 | + issueClosed: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M173.66,98.34a8,8,0,0,1,0,11.32l-56,56a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35A8,8,0,0,1,173.66,98.34ZM232,128A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z\"></path></svg>", | |
| 12 | + pr: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M104,64A32,32,0,1,0,64,95v66a32,32,0,1,0,16,0V95A32.06,32.06,0,0,0,104,64ZM56,64A16,16,0,1,1,72,80,16,16,0,0,1,56,64ZM88,192a16,16,0,1,1-16-16A16,16,0,0,1,88,192Zm120-31V110.63a23.85,23.85,0,0,0-7-17L163.31,56H192a8,8,0,0,0,0-16H144a8,8,0,0,0-8,8V96a8,8,0,0,0,16,0V67.31L189.66,105a8,8,0,0,1,2.34,5.66V161a32,32,0,1,0,16,0Zm-8,47a16,16,0,1,1,16-16A16,16,0,0,1,200,208Z\"></path></svg>", | |
| 13 | + prMerged: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M208,112a32.05,32.05,0,0,0-30.69,23l-42.21-6a8,8,0,0,1-4.95-2.71L94.43,84.55A32,32,0,1,0,72,87v82a32,32,0,1,0,16,0V101.63l30,35a24,24,0,0,0,14.83,8.14l44,6.28A32,32,0,1,0,208,112ZM64,56A16,16,0,1,1,80,72,16,16,0,0,1,64,56ZM96,200a16,16,0,1,1-16-16A16,16,0,0,1,96,200Zm112-40a16,16,0,1,1,16-16A16,16,0,0,1,208,160Z\"></path></svg>", | |
| 14 | + prClosed: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M104,64A32,32,0,1,0,64,95v66a32,32,0,1,0,16,0V95A32.06,32.06,0,0,0,104,64ZM56,64A16,16,0,1,1,72,80,16,16,0,0,1,56,64ZM88,192a16,16,0,1,1-16-16A16,16,0,0,1,88,192Zm120-31V110.63a23.85,23.85,0,0,0-7-17L163.31,56H192a8,8,0,0,0,0-16H144a8,8,0,0,0-8,8V96a8,8,0,0,0,16,0V67.31L189.66,105a8,8,0,0,1,2.34,5.66V161a32,32,0,1,0,16,0Zm-8,47a16,16,0,1,1,16-16A16,16,0,0,1,200,208Z\"></path></svg>", | |
| 15 | + dir: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M232,88V200.89A15.13,15.13,0,0,1,216.89,216H40a16,16,0,0,1-16-16V64A16,16,0,0,1,40,48H93.33a16.12,16.12,0,0,1,9.6,3.2L130.67,72H216A16,16,0,0,1,232,88Z\"></path></svg>", | |
| 16 | + file: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Z\"></path></svg>", | |
| 17 | + submodule: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M24,80V64A16,16,0,0,1,40,48H93.33a16.12,16.12,0,0,1,9.6,3.2L132.8,73.6a8,8,0,1,1-9.6,12.8L93.33,64H40V80a8,8,0,0,1-16,0ZM88,200H40v-8a8,8,0,0,0-16,0v8.62A15.4,15.4,0,0,0,39.38,216H88a8,8,0,0,0,0-16Zm72,0H128a8,8,0,0,0,0,16h32a8,8,0,0,0,0-16Zm64-56a8,8,0,0,0-8,8v48H200a8,8,0,0,0,0,16h16.89A15.13,15.13,0,0,0,232,200.89V152A8,8,0,0,0,224,144Zm-8-72H168a8,8,0,0,0,0,16h48v24a8,8,0,0,0,16,0V88A16,16,0,0,0,216,72ZM32,160a8,8,0,0,0,8-8V120a8,8,0,0,0-16,0v32A8,8,0,0,0,32,160Z\"></path></svg>", | |
| 18 | + branch: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M232,64a32,32,0,1,0-40,31v17a8,8,0,0,1-8,8H96a23.84,23.84,0,0,0-8,1.38V95a32,32,0,1,0-16,0v66a32,32,0,1,0,16,0V144a8,8,0,0,1,8-8h88a24,24,0,0,0,24-24V95A32.06,32.06,0,0,0,232,64ZM64,64A16,16,0,1,1,80,80,16,16,0,0,1,64,64ZM96,192a16,16,0,1,1-16-16A16,16,0,0,1,96,192ZM200,80a16,16,0,1,1,16-16A16,16,0,0,1,200,80Z\"></path></svg>", | |
| 19 | + tag: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M243.31,136,144,36.69A15.86,15.86,0,0,0,132.69,32H40a8,8,0,0,0-8,8v92.69A15.86,15.86,0,0,0,36.69,144L136,243.31a16,16,0,0,0,22.63,0l84.68-84.68a16,16,0,0,0,0-22.63Zm-96,96L48,132.69V48h84.69L232,147.31ZM96,84A12,12,0,1,1,84,72,12,12,0,0,1,96,84Z\"></path></svg>", | |
| 20 | + commit: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M248,120H183.42a56,56,0,0,0-110.84,0H8a8,8,0,0,0,0,16H72.58a56,56,0,0,0,110.84,0H248a8,8,0,0,0,0-16ZM128,168a40,40,0,1,1,40-40A40,40,0,0,1,128,168Z\"></path></svg>", | |
| 21 | + lock: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M208,80H176V56a48,48,0,0,0-96,0V80H48A16,16,0,0,0,32,96V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V96A16,16,0,0,0,208,80ZM96,56a32,32,0,0,1,64,0V80H96ZM208,208H48V96H208V208Z\"></path></svg>", | |
| 22 | + globe: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm88,104a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48ZM40,128a87.61,87.61,0,0,1,3.33-24H81.84a157.44,157.44,0,0,0,0,48H43.33A87.61,87.61,0,0,1,40,128ZM154,88H102a115.11,115.11,0,0,1,26-45A115.27,115.27,0,0,1,154,88Zm52.33,0H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM107.59,42.4A135.28,135.28,0,0,0,85.29,88H49.63A88.29,88.29,0,0,1,107.59,42.4ZM49.63,168H85.29a135.28,135.28,0,0,0,22.3,45.6A88.29,88.29,0,0,1,49.63,168Zm98.78,45.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z\"></path></svg>", | |
| 23 | + search: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M229.66,218.34l-50.07-50.06a88.11,88.11,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.32ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z\"></path></svg>", | |
| 24 | + bell: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M221.8,175.94C216.25,166.38,208,139.33,208,104a80,80,0,1,0-160,0c0,35.34-8.26,62.38-13.81,71.94A16,16,0,0,0,48,200H88.81a40,40,0,0,0,78.38,0H208a16,16,0,0,0,13.8-24.06ZM128,216a24,24,0,0,1-22.62-16h45.24A24,24,0,0,1,128,216ZM48,184c7.7-13.24,16-43.92,16-80a64,64,0,1,1,128,0c0,36.05,8.28,66.73,16,80Z\"></path></svg>", | |
| 25 | + plus: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z\"></path></svg>", | |
| 26 | + gear: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M128,80a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Zm88-29.84q.06-2.16,0-4.32l14.92-18.64a8,8,0,0,0,1.48-7.06,107.21,107.21,0,0,0-10.88-26.25,8,8,0,0,0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186,40.54a8,8,0,0,0-3.94-6,107.71,107.71,0,0,0-26.25-10.87,8,8,0,0,0-7.06,1.49L130.16,40Q128,40,125.84,40L107.2,25.11a8,8,0,0,0-7.06-1.48A107.6,107.6,0,0,0,73.89,34.51a8,8,0,0,0-3.93,6L67.32,64.27q-1.56,1.49-3,3L40.54,70a8,8,0,0,0-6,3.94,107.71,107.71,0,0,0-10.87,26.25,8,8,0,0,0,1.49,7.06L40,125.84Q40,128,40,130.16L25.11,148.8a8,8,0,0,0-1.48,7.06,107.21,107.21,0,0,0,10.88,26.25,8,8,0,0,0,6,3.93l23.72,2.64q1.49,1.56,3,3L70,215.46a8,8,0,0,0,3.94,6,107.71,107.71,0,0,0,26.25,10.87,8,8,0,0,0,7.06-1.49L125.84,216q2.16.06,4.32,0l18.64,14.92a8,8,0,0,0,7.06,1.48,107.21,107.21,0,0,0,26.25-10.88,8,8,0,0,0,3.93-6l2.64-23.72q1.56-1.48,3-3L215.46,186a8,8,0,0,0,6-3.94,107.71,107.71,0,0,0,10.87-26.25,8,8,0,0,0-1.49-7.06Zm-16.1-6.5a73.93,73.93,0,0,1,0,8.68,8,8,0,0,0,1.74,5.48l14.19,17.73a91.57,91.57,0,0,1-6.23,15L187,173.11a8,8,0,0,0-5.1,2.64,74.11,74.11,0,0,1-6.14,6.14,8,8,0,0,0-2.64,5.1l-2.51,22.58a91.32,91.32,0,0,1-15,6.23l-17.74-14.19a8,8,0,0,0-5-1.75h-.48a73.93,73.93,0,0,1-8.68,0,8,8,0,0,0-5.48,1.74L100.45,215.8a91.57,91.57,0,0,1-15-6.23L82.89,187a8,8,0,0,0-2.64-5.1,74.11,74.11,0,0,1-6.14-6.14,8,8,0,0,0-5.1-2.64L46.43,170.6a91.32,91.32,0,0,1-6.23-15l14.19-17.74a8,8,0,0,0,1.74-5.48,73.93,73.93,0,0,1,0-8.68,8,8,0,0,0-1.74-5.48L40.2,100.45a91.57,91.57,0,0,1,6.23-15L69,82.89a8,8,0,0,0,5.1-2.64,74.11,74.11,0,0,1,6.14-6.14A8,8,0,0,0,82.89,69L85.4,46.43a91.32,91.32,0,0,1,15-6.23l17.74,14.19a8,8,0,0,0,5.48,1.74,73.93,73.93,0,0,1,8.68,0,8,8,0,0,0,5.48-1.74L155.55,40.2a91.57,91.57,0,0,1,15,6.23L173.11,69a8,8,0,0,0,2.64,5.1,74.11,74.11,0,0,1,6.14,6.14,8,8,0,0,0,5.1,2.64l22.58,2.51a91.32,91.32,0,0,1,6.23,15l-14.19,17.74A8,8,0,0,0,199.87,123.66Z\"></path></svg>", | |
| 27 | + play: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm48.24-94.78-64-40A8,8,0,0,0,100,88v80a8,8,0,0,0,12.24,6.78l64-40a8,8,0,0,0,0-13.56ZM116,153.57V102.43L156.91,128Z\"></path></svg>", | |
| 28 | + code: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M69.12,94.15,28.5,128l40.62,33.85a8,8,0,1,1-10.24,12.29l-48-40a8,8,0,0,1,0-12.29l48-40a8,8,0,0,1,10.24,12.3Zm176,27.7-48-40a8,8,0,1,0-10.24,12.3L227.5,128l-40.62,33.85a8,8,0,1,0,10.24,12.29l48-40a8,8,0,0,0,0-12.29ZM162.73,32.48a8,8,0,0,0-10.25,4.79l-64,176a8,8,0,0,0,4.79,10.26A8.14,8.14,0,0,0,96,224a8,8,0,0,0,7.52-5.27l64-176A8,8,0,0,0,162.73,32.48Z\"></path></svg>", | |
| 29 | + comment: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M128,24A104,104,0,0,0,36.18,176.88L24.83,210.93a16,16,0,0,0,20.24,20.24l34.05-11.35A104,104,0,1,0,128,24Zm0,192a87.87,87.87,0,0,1-44.06-11.81,8,8,0,0,0-6.54-.67L40,216,52.47,178.6a8,8,0,0,0-.66-6.54A88,88,0,1,1,128,216Z\"></path></svg>", | |
| 30 | + eye: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.47,133.47,0,0,1,25,128,133.33,133.33,0,0,1,48.07,97.25C70.33,75.19,97.22,64,128,64s57.67,11.19,79.93,33.25A133.46,133.46,0,0,1,231.05,128C223.84,141.46,192.43,192,128,192Zm0-112a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z\"></path></svg>", | |
| 31 | + bot: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M200,48H136V16a8,8,0,0,0-16,0V48H56A32,32,0,0,0,24,80V192a32,32,0,0,0,32,32H200a32,32,0,0,0,32-32V80A32,32,0,0,0,200,48Zm16,144a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V80A16,16,0,0,1,56,64H200a16,16,0,0,1,16,16Zm-52-56H92a28,28,0,0,0,0,56h72a28,28,0,0,0,0-56Zm-24,16v24H116V152ZM80,164a12,12,0,0,1,12-12h8v24H92A12,12,0,0,1,80,164Zm84,12h-8V152h8a12,12,0,0,1,0,24ZM72,108a12,12,0,1,1,12,12A12,12,0,0,1,72,108Zm88,0a12,12,0,1,1,12,12A12,12,0,0,1,160,108Z\"></path></svg>", | |
| 32 | + history: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M136,80v43.47l36.12,21.67a8,8,0,0,1-8.24,13.72l-40-24A8,8,0,0,1,120,128V80a8,8,0,0,1,16,0Zm-8-48A95.44,95.44,0,0,0,60.08,60.15C52.81,67.51,46.35,74.59,40,82V64a8,8,0,0,0-16,0v40a8,8,0,0,0,8,8H72a8,8,0,0,0,0-16H49c7.15-8.42,14.27-16.35,22.39-24.57a80,80,0,1,1,1.66,114.75,8,8,0,1,0-11,11.64A96,96,0,1,0,128,32Z\"></path></svg>", | |
| 33 | + copy: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z\"></path></svg>", | |
| 34 | + download: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M224,144v64a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V144a8,8,0,0,1,16,0v56H208V144a8,8,0,0,1,16,0Zm-101.66,5.66a8,8,0,0,0,11.32,0l40-40a8,8,0,0,0-11.32-11.32L136,124.69V32a8,8,0,0,0-16,0v92.69L93.66,98.34a8,8,0,0,0-11.32,11.32Z\"></path></svg>", | |
| 35 | + edit: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM92.69,208H48V163.31l88-88L180.69,120ZM192,108.68,147.31,64l24-24L216,84.68Z\"></path></svg>", | |
| 36 | + x: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M205.66,194.34a8,8,0,0,1-11.32,11.32L128,139.31,61.66,205.66a8,8,0,0,1-11.32-11.32L116.69,128,50.34,61.66A8,8,0,0,1,61.66,50.34L128,116.69l66.34-66.35a8,8,0,0,1,11.32,11.32L139.31,128Z\"></path></svg>", | |
| 37 | + check: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z\"></path></svg>", | |
| 38 | + people: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M117.25,157.92a60,60,0,1,0-66.5,0A95.83,95.83,0,0,0,3.53,195.63a8,8,0,1,0,13.4,8.74,80,80,0,0,1,134.14,0,8,8,0,0,0,13.4-8.74A95.83,95.83,0,0,0,117.25,157.92ZM40,108a44,44,0,1,1,44,44A44.05,44.05,0,0,1,40,108Zm210.14,98.7a8,8,0,0,1-11.07-2.33A79.83,79.83,0,0,0,172,168a8,8,0,0,1,0-16,44,44,0,1,0-16.34-84.87,8,8,0,1,1-5.94-14.85,60,60,0,0,1,55.53,105.64,95.83,95.83,0,0,1,47.22,37.71A8,8,0,0,1,250.14,206.7Z\"></path></svg>", | |
| 39 | + key: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M216.57,39.43A80,80,0,0,0,83.91,120.78L28.69,176A15.86,15.86,0,0,0,24,187.31V216a16,16,0,0,0,16,16H72a8,8,0,0,0,8-8V208H96a8,8,0,0,0,8-8V184h16a8,8,0,0,0,5.66-2.34l9.56-9.57A79.73,79.73,0,0,0,160,176h.1A80,80,0,0,0,216.57,39.43ZM224,98.1c-1.09,34.09-29.75,61.86-63.89,61.9H160a63.7,63.7,0,0,1-23.65-4.51,8,8,0,0,0-8.84,1.68L116.69,168H96a8,8,0,0,0-8,8v16H72a8,8,0,0,0-8,8v16H40V187.31l58.83-58.82a8,8,0,0,0,1.68-8.84A63.72,63.72,0,0,1,96,95.92c0-34.14,27.81-62.8,61.9-63.89A64,64,0,0,1,224,98.1ZM192,76a12,12,0,1,1-12-12A12,12,0,0,1,192,76Z\"></path></svg>", | |
| 40 | + shield: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M208,40H48A16,16,0,0,0,32,56v56c0,52.72,25.52,84.67,46.93,102.19,23.06,18.86,46,25.26,47,25.53a8,8,0,0,0,4.2,0c1-.27,23.91-6.67,47-25.53C198.48,196.67,224,164.72,224,112V56A16,16,0,0,0,208,40Zm0,72c0,37.07-13.66,67.16-40.6,89.42A129.3,129.3,0,0,1,128,223.62a128.25,128.25,0,0,1-38.92-21.81C61.82,179.51,48,149.3,48,112l0-56,160,0ZM82.34,141.66a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32l-56,56a8,8,0,0,1-11.32,0Z\"></path></svg>", | |
| 41 | + repo: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M208,24H72A32,32,0,0,0,40,56V224a8,8,0,0,0,8,8H192a8,8,0,0,0,0-16H56a16,16,0,0,1,16-16H208a8,8,0,0,0,8-8V32A8,8,0,0,0,208,24ZM120,40h48v72L148.79,97.6a8,8,0,0,0-9.6,0L120,112Zm80,144H72a31.82,31.82,0,0,0-16,4.29V56A16,16,0,0,1,72,40h32v88a8,8,0,0,0,12.8,6.4L144,114l27.21,20.4A8,8,0,0,0,176,136a8,8,0,0,0,8-8V40h16Z\"></path></svg>", | |
| 42 | + org: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M240,208H224V96a16,16,0,0,0-16-16H144V32a16,16,0,0,0-24.88-13.32L39.12,72A16,16,0,0,0,32,85.34V208H16a8,8,0,0,0,0,16H240a8,8,0,0,0,0-16ZM208,96V208H144V96ZM48,85.34,128,32V208H48ZM112,112v16a8,8,0,0,1-16,0V112a8,8,0,1,1,16,0Zm-32,0v16a8,8,0,0,1-16,0V112a8,8,0,1,1,16,0Zm0,56v16a8,8,0,0,1-16,0V168a8,8,0,0,1,16,0Zm32,0v16a8,8,0,0,1-16,0V168a8,8,0,0,1,16,0Z\"></path></svg>", | |
| 43 | + zap: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M215.79,118.17a8,8,0,0,0-5-5.66L153.18,90.9l14.66-73.33a8,8,0,0,0-13.69-7l-112,120a8,8,0,0,0,3,13l57.63,21.61L88.16,238.43a8,8,0,0,0,13.69,7l112-120A8,8,0,0,0,215.79,118.17ZM109.37,214l10.47-52.38a8,8,0,0,0-5-9.06L62,132.71l84.62-90.66L136.16,94.43a8,8,0,0,0,5,9.06l52.8,19.8Z\"></path></svg>", | |
| 44 | + package: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M223.68,66.15,135.68,18a15.88,15.88,0,0,0-15.36,0l-88,48.17a16,16,0,0,0-8.32,14v95.64a16,16,0,0,0,8.32,14l88,48.17a15.88,15.88,0,0,0,15.36,0l88-48.17a16,16,0,0,0,8.32-14V80.18A16,16,0,0,0,223.68,66.15ZM128,32l80.34,44-29.77,16.3-80.35-44ZM128,120,47.66,76l33.9-18.56,80.34,44ZM40,90l80,43.78v85.79L40,175.82Zm176,85.78h0l-80,43.79V133.82l32-17.51V152a8,8,0,0,0,16,0V107.55L216,90v85.77Z\"></path></svg>", | |
| 45 | + upload: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M224,144v64a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V144a8,8,0,0,1,16,0v56H208V144a8,8,0,0,1,16,0ZM93.66,77.66,120,51.31V144a8,8,0,0,0,16,0V51.31l26.34,26.35a8,8,0,0,0,11.32-11.32l-40-40a8,8,0,0,0-11.32,0l-40,40A8,8,0,0,0,93.66,77.66Z\"></path></svg>", | |
| 46 | + trash: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z\"></path></svg>", | |
| 47 | + userPlus: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M256,136a8,8,0,0,1-8,8H232v16a8,8,0,0,1-16,0V144H200a8,8,0,0,1,0-16h16V112a8,8,0,0,1,16,0v16h16A8,8,0,0,1,256,136Zm-57.87,58.85a8,8,0,0,1-12.26,10.3C165.75,181.19,138.09,168,108,168s-57.75,13.19-77.87,37.15a8,8,0,0,1-12.25-10.3c14.94-17.78,33.52-30.41,54.17-37.17a68,68,0,1,1,71.9,0C164.6,164.44,183.18,177.07,198.13,194.85ZM108,152a52,52,0,1,0-52-52A52.06,52.06,0,0,0,108,152Z\"></path></svg>", | |
| 48 | + envelope: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z\"></path></svg>", | |
| 49 | + sun: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M120,40V16a8,8,0,0,1,16,0V40a8,8,0,0,1-16,0Zm72,88a64,64,0,1,1-64-64A64.07,64.07,0,0,1,192,128Zm-16,0a48,48,0,1,0-48,48A48.05,48.05,0,0,0,176,128ZM58.34,69.66A8,8,0,0,0,69.66,58.34l-16-16A8,8,0,0,0,42.34,53.66Zm0,116.68-16,16a8,8,0,0,0,11.32,11.32l16-16a8,8,0,0,0-11.32-11.32ZM192,72a8,8,0,0,0,5.66-2.34l16-16a8,8,0,0,0-11.32-11.32l-16,16A8,8,0,0,0,192,72Zm5.66,114.34a8,8,0,0,0-11.32,11.32l16,16a8,8,0,0,0,11.32-11.32ZM48,128a8,8,0,0,0-8-8H16a8,8,0,0,0,0,16H40A8,8,0,0,0,48,128Zm80,80a8,8,0,0,0-8,8v24a8,8,0,0,0,16,0V216A8,8,0,0,0,128,208Zm112-88H216a8,8,0,0,0,0,16h24a8,8,0,0,0,0-16Z\"></path></svg>", | |
| 50 | + moon: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M233.54,142.23a8,8,0,0,0-8-2,88.08,88.08,0,0,1-109.8-109.8,8,8,0,0,0-10-10,104.84,104.84,0,0,0-52.91,37A104,104,0,0,0,136,224a103.09,103.09,0,0,0,62.52-20.88,104.84,104.84,0,0,0,37-52.91A8,8,0,0,0,233.54,142.23ZM188.9,190.34A88,88,0,0,1,65.66,67.11a89,89,0,0,1,31.4-26A106,106,0,0,0,96,56,104.11,104.11,0,0,0,200,160a106,106,0,0,0,14.92-1.06A89,89,0,0,1,188.9,190.34Z\"></path></svg>", | |
| 51 | + signOut: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M120,216a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V40a8,8,0,0,1,8-8h64a8,8,0,0,1,0,16H56V208h56A8,8,0,0,1,120,216Zm109.66-93.66-40-40a8,8,0,0,0-11.32,11.32L204.69,120H112a8,8,0,0,0,0,16h92.69l-26.35,26.34a8,8,0,0,0,11.32,11.32l40-40A8,8,0,0,0,229.66,122.34Z\"></path></svg>", | |
| 52 | + caret: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z\"></path></svg>", | |
| 53 | + link: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M165.66,90.34a8,8,0,0,1,0,11.32l-64,64a8,8,0,0,1-11.32-11.32l64-64A8,8,0,0,1,165.66,90.34ZM215.6,40.4a56,56,0,0,0-79.2,0L106.34,70.45a8,8,0,0,0,11.32,11.32l30.06-30a40,40,0,0,1,56.57,56.56l-30.07,30.06a8,8,0,0,0,11.31,11.32L215.6,119.6a56,56,0,0,0,0-79.2ZM138.34,174.22l-30.06,30.06a40,40,0,1,1-56.56-56.57l30.05-30.05a8,8,0,0,0-11.32-11.32L40.4,136.4a56,56,0,0,0,79.2,79.2l30.06-30.07a8,8,0,0,0-11.32-11.31Z\"></path></svg>", | |
| 54 | + rocket: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M223.85,47.12a16,16,0,0,0-15-15c-12.58-.75-44.73.4-71.41,27.07L132.69,64H74.36A15.91,15.91,0,0,0,63,68.68L28.7,103a16,16,0,0,0,9.07,27.16l38.47,5.37,44.21,44.21,5.37,38.49a15.94,15.94,0,0,0,10.78,12.92,16.11,16.11,0,0,0,5.1.83A15.91,15.91,0,0,0,153,227.3L187.32,193A15.91,15.91,0,0,0,192,181.64V123.31l4.77-4.77C223.45,91.86,224.6,59.71,223.85,47.12ZM74.36,80h42.33L77.16,119.52,40,114.34Zm74.41-9.45a76.65,76.65,0,0,1,59.11-22.47,76.46,76.46,0,0,1-22.42,59.16L128,164.68,91.32,128ZM176,181.64,141.67,216l-5.19-37.17L176,139.31Zm-74.16,9.5C97.34,201,82.29,224,40,224a8,8,0,0,1-8-8c0-42.29,23-57.34,32.86-61.85a8,8,0,0,1,6.64,14.56c-6.43,2.93-20.62,12.36-23.12,38.91,26.55-2.5,36-16.69,38.91-23.12a8,8,0,1,1,14.56,6.64Z\"></path></svg>", | |
| 55 | +} as const; |
modifiedapps/diffinery/src/web/markdown.ts+17−2View file
| @@ -20,6 +20,8 @@ | ||
| 20 | 20 | |
| 21 | 21 | export interface MarkdownOptions { |
| 22 | 22 | base?: MarkdownBase; |
| 23 | + /** Syntax highlighter for fenced code: HTML for the code, or null for plain text. */ | |
| 24 | + highlight?: (code: string, lang: string) => string | null; | |
| 23 | 25 | } |
| 24 | 26 | |
| 25 | 27 | const MAX_SOURCE = 512 * 1024; |
| @@ -393,8 +395,10 @@ | ||
| 393 | 395 | i++; |
| 394 | 396 | } |
| 395 | 397 | i++; |
| 396 | - const cls = lang && /^[\w+#.-]{1,30}$/.test(lang) ? ` class="language-${escapeHtml(lang)}"` : ""; | |
| 397 | - parts.push(`<pre><code${cls}>${escapeHtml(body.join("\n"))}</code></pre>`); | |
| 398 | + const named = /^[\w+#.-]{1,30}$/.test(lang) ? lang : ""; | |
| 399 | + const hl = named && opts.highlight ? opts.highlight(body.join("\n"), named) : null; | |
| 400 | + const cls = named ? ` class="language-${escapeHtml(named)}${hl !== null ? " sh" : ""}"` : ""; | |
| 401 | + parts.push(`<pre><code${cls}>${hl ?? escapeHtml(body.join("\n"))}</code></pre>`); | |
| 398 | 402 | continue; |
| 399 | 403 | } |
| 400 | 404 | // Indented code (4 spaces), only outside lists. |
| @@ -536,6 +540,17 @@ | ||
| 536 | 540 | return parts.join("\n"); |
| 537 | 541 | } |
| 538 | 542 | |
| 543 | +/** Languages named by the document's code fences, to load grammars before rendering. */ | |
| 544 | +export function fenceLangs(source: string): string[] { | |
| 545 | + if (source.length > MAX_SOURCE) return []; | |
| 546 | + const out = new Set<string>(); | |
| 547 | + for (const line of source.split("\n")) { | |
| 548 | + const lang = FENCE.exec(line)?.[2]; | |
| 549 | + if (lang && /^[\w+#.-]{1,30}$/.test(lang)) out.add(lang); | |
| 550 | + } | |
| 551 | + return [...out]; | |
| 552 | +} | |
| 553 | + | |
| 539 | 554 | /** Render Markdown to sanitized HTML. */ |
| 540 | 555 | export function renderMarkdown(source: string, opts: MarkdownOptions = {}): string { |
| 541 | 556 | if (source.length > MAX_SOURCE) return `<pre>${escapeHtml(source)}</pre>`; |
modifiedapps/diffinery/src/web/packages.tsx+5−5View file
| @@ -92,16 +92,16 @@ | ||
| 92 | 92 | <div class="body"> |
| 93 | 93 | <p class="muted small m0">Sign in with any username and a token from Settings → Tokens as the password (the read scope pulls, write pushes).</p> |
| 94 | 94 | <div class="row mt"> |
| 95 | - <pre class="secret grow m0 mono">{login}</pre> | |
| 95 | + <pre class="cmd one grow m0 mono">{login}</pre> | |
| 96 | 96 | </div> |
| 97 | 97 | <div class="row mt"> |
| 98 | - <pre class="secret grow m0 mono">{pull}</pre> | |
| 99 | - <CopyButton text={pull} /> | |
| 98 | + <pre class="cmd one grow m0 mono">{pull}</pre> | |
| 99 | + <CopyButton text={pull} size="base" /> | |
| 100 | 100 | </div> |
| 101 | 101 | <p class="muted small mt"> |
| 102 | 102 | In a workflow, the job's <code>GITHUB_TOKEN</code> signs in. It can push to packages of its own repository (with <code>permissions: packages: write</code> when the workflow sets permissions) and pull its owner's packages. |
| 103 | 103 | </p> |
| 104 | - <pre class="secret m0 mono small">{workflow}</pre> | |
| 104 | + <pre class="cmd m0 mono small">{workflow}</pre> | |
| 105 | 105 | </div> |
| 106 | 106 | </div> |
| 107 | 107 | ); |
| @@ -118,7 +118,7 @@ | ||
| 118 | 118 | { title: `${owner.login} packages` }, |
| 119 | 119 | <> |
| 120 | 120 | <div class="row mb" style="gap:12px"> |
| 121 | - <Avatar login={owner.login} size="lg" /> | |
| 121 | + <Avatar login={owner.login} size="lg" version={owner.avatar_version} /> | |
| 122 | 122 | <div> |
| 123 | 123 | <h1 class="m0"> |
| 124 | 124 | <a href={`/${owner.login}`}>{owner.login}</a> <span class="faint">/</span> Packages |
modifiedapps/diffinery/src/web/releases.tsx+4−4View file
| @@ -118,7 +118,7 @@ | ||
| 118 | 118 | <Badges r={r} /> |
| 119 | 119 | </h2> |
| 120 | 120 | <div class="muted small" style="margin-top:4px"> |
| 121 | - <span class="st faint">{raw(ICONS.tag)}</span>{" "} | |
| 121 | + <span class="faint">{raw(ICONS.tag)}</span>{" "} | |
| 122 | 122 | <a class="mono" href={`${base}/tree/${encPath(r.tagName)}`}> |
| 123 | 123 | {r.tagName} |
| 124 | 124 | </a>{" "} |
| @@ -200,11 +200,11 @@ | ||
| 200 | 200 | <div class="row between mb"> |
| 201 | 201 | <h1 class="m0">Releases</h1> |
| 202 | 202 | <div class="row"> |
| 203 | - <a class="btn sm" href={`${base}/tags`}> | |
| 203 | + <a class="btn" href={`${base}/tags`}> | |
| 204 | 204 | {raw(ICONS.tag)} Tags |
| 205 | 205 | </a> |
| 206 | 206 | {canWrite ? ( |
| 207 | - <a class="btn sm green" href={`${base}/releases/new`}> | |
| 207 | + <a class="btn green" href={`${base}/releases/new`}> | |
| 208 | 208 | Draft a new release |
| 209 | 209 | </a> |
| 210 | 210 | ) : null} |
| @@ -295,7 +295,7 @@ | ||
| 295 | 295 | Attached: {r.assets.map((a) => a.name).join(", ")}. Remove files from the <a href={tagHref(base, r.tagName)}>release page</a>. |
| 296 | 296 | </p> |
| 297 | 297 | ) : null} |
| 298 | - <div class="field"> | |
| 298 | + <div class="field checklist"> | |
| 299 | 299 | <KCheckbox name="prerelease" checked={v.prerelease} label="Set as a pre-release (not production ready)" /> |
| 300 | 300 | <KCheckbox name="latest" checked={v.latest} label="Set as the latest release" /> |
| 301 | 301 | {r ? null : <KCheckbox name="generate" checked={v.generate} label="Generate release notes from merged pull requests (when the notes are empty)" />} |
modifiedapps/diffinery/src/web/repo-settings.tsx+39−47View file
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | import { ICONS } from "./assets.js"; |
| 9 | 9 | import { KCheckbox, KRadio, KSelect } from "./kumo.js"; |
| 10 | 10 | import { csrfToken, type FormData } from "./csrf.js"; |
| 11 | -import { Actor, CopyButton, Form, ROLES, Time, act, ago, post, refNames, render, repoUrl, statusOf, type WebApp, prm } from "./ui.js"; | |
| 11 | +import { Actor, CopyButton, Form, ROLES, Time, cap, act, ago, flash, post, refNames, render, repoUrl, statusOf, type WebApp, prm } from "./ui.js"; | |
| 12 | 12 | |
| 13 | 13 | type Loaded = Awaited<ReturnType<typeof loadRepo>>; |
| 14 | 14 | type Tab = "general" | "access" | "branches" | "secrets" | "webhooks"; |
| @@ -64,7 +64,7 @@ | ||
| 64 | 64 | <p class="muted">{note}</p> |
| 65 | 65 | <div class="row"> |
| 66 | 66 | <div class="secret grow">{value}</div> |
| 67 | - <CopyButton text={value} /> | |
| 67 | + <CopyButton text={value} size="base" /> | |
| 68 | 68 | </div> |
| 69 | 69 | </div> |
| 70 | 70 | </div> |
| @@ -222,8 +222,8 @@ | ||
| 222 | 222 | </div> |
| 223 | 223 | <div class="field"> |
| 224 | 224 | <label>Visibility</label> |
| 225 | - <KRadio name="visibility" label="Visibility" orientation="horizontal" value={repo.visibility} items={(["private", "internal", "public"] as const).map((v) => ({ value: v, label: v }))} /> | |
| 226 | - <div class="hint">Internal repositories are visible to everyone signed in to this instance; public ones to anyone.</div> | |
| 225 | + <KRadio name="visibility" label="Visibility" orientation="horizontal" value={repo.visibility} items={(["private", "public"] as const).map((v) => ({ value: v, label: v === "public" ? "Public" : "Private" }))} /> | |
| 226 | + <div class="hint">Private repositories are visible to you, collaborators and organization members; public ones to anyone.</div> | |
| 227 | 227 | </div> |
| 228 | 228 | <button class="btn primary">Save changes</button> |
| 229 | 229 | </div> |
| @@ -280,7 +280,26 @@ | ||
| 280 | 280 | </> |
| 281 | 281 | )} |
| 282 | 282 | </p> |
| 283 | - {invite ? <Once title={`Invite created for ${invite.email}`} value={invite.url} note="Send this link. It expires in 14 days. Opening it lets them create a passkey and join." /> : null} | |
| 283 | + {invite ? <Once title={`Invitation sent to ${invite.email}`} value={invite.url} note="They don't have an account yet, so we emailed them an invitation. You can also send them this link yourself. It works once and expires in 14 days: opening it creates their account and passkey and gives them access." /> : null} | |
| 284 | + <Form action={`${base}/settings/access/people`} t={t} class="box"> | |
| 285 | + <div class="head"> | |
| 286 | + <h3>{raw(ICONS.userPlus)} Add people</h3> | |
| 287 | + </div> | |
| 288 | + <div class="body"> | |
| 289 | + <div class="formrow"> | |
| 290 | + <div class="field grow"> | |
| 291 | + <label for="who">Username or email</label> | |
| 292 | + <input id="who" name="who" type="text" required autocomplete="off" spellcheck={false} placeholder="octocat, review-bot or name@example.com" /> | |
| 293 | + </div> | |
| 294 | + <div class="field"> | |
| 295 | + <label for="role">Role</label> | |
| 296 | + <KSelect id="role" name="role" label="Role" value="write" options={ROLES.map((r) => ({ value: r, label: cap(r) }))} /> | |
| 297 | + </div> | |
| 298 | + <button class="btn primary">Add</button> | |
| 299 | + </div> | |
| 300 | + <div class="hint">People and agents with an account get access right away and a heads-up. Anyone else gets an email invitation: opening it creates their account with access to this repository.</div> | |
| 301 | + </div> | |
| 302 | + </Form> | |
| 284 | 303 | <div class="box"> |
| 285 | 304 | {collaborators.length ? ( |
| 286 | 305 | <table class="tbl"> |
| @@ -300,7 +319,7 @@ | ||
| 300 | 319 | <td> |
| 301 | 320 | <Form action={`${base}/settings/access`} t={t} class="row" style="gap:6px;flex-wrap:nowrap"> |
| 302 | 321 | <input type="hidden" name="login" value={x.account.login} /> |
| 303 | - <KSelect name="role" size="sm" label={`Role of ${x.account.login}`} value={x.role} options={ROLES.map((r) => ({ value: r, label: r }))} /> | |
| 322 | + <KSelect name="role" size="sm" label={`Role of ${x.account.login}`} value={x.role} options={ROLES.map((r) => ({ value: r, label: cap(r) }))} /> | |
| 304 | 323 | <button class="btn sm">Save</button> |
| 305 | 324 | </Form> |
| 306 | 325 | </td> |
| @@ -319,40 +338,6 @@ | ||
| 319 | 338 | <div class="empty">No direct collaborators.</div> |
| 320 | 339 | )} |
| 321 | 340 | </div> |
| 322 | - <div class="row" style="align-items:stretch"> | |
| 323 | - <Form action={`${base}/settings/access`} t={t} class="box grow"> | |
| 324 | - <div class="head"> | |
| 325 | - <h3>Add by username</h3> | |
| 326 | - </div> | |
| 327 | - <div class="body"> | |
| 328 | - <div class="field"> | |
| 329 | - <label>Username (person or agent)</label> | |
| 330 | - <input name="login" type="text" required /> | |
| 331 | - </div> | |
| 332 | - <div class="field"> | |
| 333 | - <label>Role</label> | |
| 334 | - <KSelect name="role" block label="Role" value="write" options={ROLES.map((r) => ({ value: r, label: r }))} /> | |
| 335 | - </div> | |
| 336 | - <button class="btn primary">Add collaborator</button> | |
| 337 | - </div> | |
| 338 | - </Form> | |
| 339 | - <Form action={`${base}/settings/access/invite`} t={t} class="box grow"> | |
| 340 | - <div class="head"> | |
| 341 | - <h3>Invite by email</h3> | |
| 342 | - </div> | |
| 343 | - <div class="body"> | |
| 344 | - <div class="field"> | |
| 345 | - <label>Email address</label> | |
| 346 | - <input name="email" type="email" required placeholder="friend@example.com" /> | |
| 347 | - </div> | |
| 348 | - <div class="field"> | |
| 349 | - <label>Role</label> | |
| 350 | - <KSelect name="role" block label="Role" value="write" options={ROLES.map((r) => ({ value: r, label: r }))} /> | |
| 351 | - </div> | |
| 352 | - <button class="btn primary">Create invite link</button> | |
| 353 | - </div> | |
| 354 | - </Form> | |
| 355 | - </div> | |
| 356 | 341 | <div class="box"> |
| 357 | 342 | <div class="body small muted"> |
| 358 | 343 | <b>Roles:</b> read — view and clone · triage — manage issues and labels · write — push, open and merge pull requests, run workflows · maintain — also move tags · admin — settings, secrets and access. |
| @@ -599,7 +584,7 @@ | ||
| 599 | 584 | `${repoUrl(l.repo)}/settings`, |
| 600 | 585 | async () => { |
| 601 | 586 | const name = f.s("name").trim(); |
| 602 | - const visibility = (["private", "internal", "public"] as const).find((v) => v === f.s("visibility")); | |
| 587 | + const visibility = (["private", "public"] as const).find((v) => v === f.s("visibility")); | |
| 603 | 588 | const defaultBranch = f.s("default_branch").trim(); |
| 604 | 589 | if (name && name !== l.repo.name && (await dir.repo(l.repo.owner_login, name))) throw new HttpError(422, "a repository with that name already exists"); |
| 605 | 590 | const updated = await dir.updateRepo(l.repo, { |
| @@ -660,15 +645,22 @@ | ||
| 660 | 645 | }); |
| 661 | 646 | }); |
| 662 | 647 | |
| 663 | - post(web, "/:owner/:repo/settings/access/invite", async (c, f) => { | |
| 648 | + post(web, "/:owner/:repo/settings/access/people", async (c, f) => { | |
| 664 | 649 | const l = await load(c); |
| 665 | 650 | const me = requireAccount(c); |
| 666 | - const email = f.s("email").trim(); | |
| 667 | 651 | const role = ROLES.find((r) => r === f.s("role")) ?? "write"; |
| 668 | - if (!/^[^\s@]+@[^\s@]+$/.test(email)) throw new HttpError(422, "enter a valid email address"); | |
| 669 | - const inv = await c.get("dir").createInvite(me, { email, repoId: l.repo.id, role }); | |
| 670 | - audit(c, l, "repo.invite", { email, role }); | |
| 671 | - return accessPage(c, l, { url: inv.url, email }); | |
| 652 | + let invited: { email: string; url: string } | undefined; | |
| 653 | + const res = await act(c, `${repoUrl(l.repo)}/settings/access`, async () => { | |
| 654 | + const r = await c.get("dir").addRepoPerson(l.repo, me, f.s("who"), role); | |
| 655 | + if ("added" in r) { | |
| 656 | + flash(c, "notice", `${r.added.login} now has ${role} access. We let them know.`); | |
| 657 | + audit(c, l, "repo.collaborator.set", { login: r.added.login, role }); | |
| 658 | + } else { | |
| 659 | + invited = r.invited; | |
| 660 | + audit(c, l, "repo.invite", { email: r.invited.email, role }); | |
| 661 | + } | |
| 662 | + }); | |
| 663 | + return invited ? accessPage(c, l, invited) : res; | |
| 672 | 664 | }); |
| 673 | 665 | |
| 674 | 666 | post(web, "/:owner/:repo/settings/branches", async (c, f) => { |
modifiedapps/diffinery/src/web/ui.tsx+97−42View file
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** @jsxImportSource hono/jsx */ |
| 2 | 2 | import type { Hono } from "hono"; |
| 3 | 3 | import { deleteCookie, getCookie, setCookie } from "hono/cookie"; |
| 4 | -import { raw } from "hono/html"; | |
| 4 | +import { html, raw } from "hono/html"; | |
| 5 | 5 | import type { Child, FC, PropsWithChildren } from "hono/jsx"; |
| 6 | 6 | import type { Account, Repo } from "../lib/directory.js"; |
| 7 | 7 | import type { Env } from "../env.js"; |
| @@ -11,10 +11,12 @@ | ||
| 11 | 11 | import type { repoStub } from "../http/common.js"; |
| 12 | 12 | import { CSS, ICONS, JS, type IconName } from "./assets.js"; |
| 13 | 13 | import { CSRF_FIELD, csrfToken, verifyCsrf, type FormData } from "./csrf.js"; |
| 14 | -import { renderMarkdown, type MarkdownBase } from "./markdown.js"; | |
| 14 | +import { fenceLangs, renderMarkdown, type MarkdownBase } from "./markdown.js"; | |
| 15 | +import { diffLineTokens, fenceHighlighter, langForPath, LIMITS, ready, tokenize, type Lang, type TokLine } from "./highlight.js"; | |
| 15 | 16 | import { KSelect, kumoAssets } from "./kumo.js"; |
| 16 | 17 | import { MailStore, type WatchLevel } from "../mail/prefs.js"; |
| 17 | 18 | import { SeoHead, SITE } from "./seo.js"; |
| 19 | +import { avatarPath } from "../http/avatars.js"; | |
| 18 | 20 | |
| 19 | 21 | export type WebApp = Hono<{ Bindings: Env; Variables: AppVars }>; |
| 20 | 22 | export type Stub = ReturnType<typeof repoStub>; |
| @@ -103,22 +105,19 @@ | ||
| 103 | 105 | } |
| 104 | 106 | }; |
| 105 | 107 | |
| 106 | -function hue(s: string): number { | |
| 107 | - let h = 0; | |
| 108 | - for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; | |
| 109 | - return h % 360; | |
| 110 | -} | |
| 108 | +/** CSS pixels of each avatar size, and the stored square (2×) served for it. */ | |
| 109 | +const AVATAR_PX = { sm: [20, 64], lg: [40, 128], xl: [96, 256] } as const; | |
| 111 | 110 | |
| 112 | -export const Avatar: FC<{ login: string; kind?: string; size?: "lg" | "xl" }> = ({ login, kind, size }) => ( | |
| 113 | - <span class={`av ${size ?? ""} ${kind === "agent" || kind === "system" ? "bot" : ""}`} style={`--h:${hue(login)}`} aria-hidden="true"> | |
| 114 | - {(login.replace(/[^A-Za-z0-9]/g, "")[0] ?? "?").toUpperCase()} | |
| 115 | - </span> | |
| 116 | -); | |
| 111 | +/** Profile picture, or generated initials. Pass the account's avatar_version when it is at hand, so a new upload shows at once. */ | |
| 112 | +export const Avatar: FC<{ login: string; kind?: string; size?: "lg" | "xl"; version?: number | null }> = ({ login, kind, size, version }) => { | |
| 113 | + const [css, px] = AVATAR_PX[size ?? "sm"]; | |
| 114 | + return <img class={`av ${size ?? ""} ${kind === "agent" || kind === "system" ? "bot" : ""}`} src={avatarPath(login, px, version)} width={css} height={css} alt="" loading="lazy" decoding="async" />; | |
| 115 | +}; | |
| 117 | 116 | |
| 118 | 117 | /** Linked actor name with an "agent" badge and the human it acts for. */ |
| 119 | -export const Actor: FC<{ login: string; kind?: string; onBehalfOf?: string | null; avatar?: boolean }> = ({ login, kind, onBehalfOf, avatar = true }) => ( | |
| 118 | +export const Actor: FC<{ login: string; kind?: string; onBehalfOf?: string | null; avatar?: boolean; version?: number | null }> = ({ login, kind, onBehalfOf, avatar = true, version }) => ( | |
| 120 | 119 | <span class="who"> |
| 121 | - {avatar ? <Avatar login={login} kind={kind} /> : null} | |
| 120 | + {avatar ? <Avatar login={login} kind={kind} version={version} /> : null} | |
| 122 | 121 | {kind === "system" ? <span>{login}</span> : <a href={`/${encodeURIComponent(login)}`} style="color:inherit">{login}</a>} |
| 123 | 122 | {kind === "agent" ? <span class="badge agent">agent</span> : null} |
| 124 | 123 | {kind === "system" ? <span class="badge">bot</span> : null} |
| @@ -141,20 +140,30 @@ | ||
| 141 | 140 | ); |
| 142 | 141 | }; |
| 143 | 142 | |
| 144 | -export const Markdown: FC<{ text: string; base?: MarkdownBase; empty?: string }> = ({ text, base, empty }) => | |
| 145 | - text.trim() ? <div class="markdown">{raw(renderMarkdown(text, { base }))}</div> : <p class="muted m0"><em>{empty ?? "No description provided."}</em></p>; | |
| 143 | +export const Markdown: FC<{ text: string; base?: MarkdownBase; empty?: string }> = async ({ text, base, empty }) => | |
| 144 | + text.trim() ? ( | |
| 145 | + <div class="markdown">{raw(renderMarkdown(text, { base, highlight: await fenceHighlighter(fenceLangs(text)) }))}</div> | |
| 146 | + ) : ( | |
| 147 | + <p class="muted m0"> | |
| 148 | + <em>{empty ?? "No description provided."}</em> | |
| 149 | + </p> | |
| 150 | + ); | |
| 146 | 151 | |
| 147 | -/** CSRF-protected POST form. */ | |
| 148 | -export const Form: FC<PropsWithChildren<{ action: string; t: string; class?: string; confirm?: string; match?: string; id?: string; style?: string }>> = (p) => ( | |
| 149 | - <form method="post" action={p.action} class={p.class} data-confirm={p.confirm} data-match={p.match} id={p.id} style={p.style}> | |
| 152 | +/** Highlighted code tokens (children of an element with class "sh"). */ | |
| 153 | +export const tokens = (line: TokLine): Child[] => line.map((t) => (t.style ? <span style={t.style}>{t.text}</span> : t.text)); | |
| 154 | + | |
| 155 | +/** CSRF-protected POST form. `avatar` makes it a multipart picture upload (shrunk in the browser first, see assets.ts). */ | |
| 156 | +export const Form: FC<PropsWithChildren<{ action: string; t: string; class?: string; confirm?: string; match?: string; id?: string; style?: string; avatar?: boolean }>> = (p) => ( | |
| 157 | + <form method="post" action={p.action} class={p.class} data-confirm={p.confirm} data-match={p.match} id={p.id} style={p.style} enctype={p.avatar ? "multipart/form-data" : undefined} data-avatar={p.avatar ? "1" : undefined}> | |
| 150 | 158 | <input type="hidden" name={CSRF_FIELD} value={p.t} /> |
| 151 | 159 | {p.children} |
| 152 | 160 | </form> |
| 153 | 161 | ); |
| 154 | 162 | |
| 155 | -export const CopyButton: FC<{ text: string; label?: string; class?: string }> = ({ text, label, class: cls }) => ( | |
| 156 | - <button type="button" class={`btn sm ${cls ?? ""}`} data-copy={text} title="Copy to clipboard"> | |
| 157 | - {label ?? "Copy"} | |
| 163 | +/** Copy-to-clipboard button: small by default; `size="base"` next to 36px fields, `icon` for an icon-only button. */ | |
| 164 | +export const CopyButton: FC<{ text: string; label?: string; class?: string; size?: "sm" | "base"; icon?: boolean }> = ({ text, label, class: cls, size = "sm", icon }) => ( | |
| 165 | + <button type="button" class={`btn ${size === "sm" ? "sm" : ""} ${icon ? "icon" : ""} ${cls ?? ""}`} data-copy={text} data-copy-icon={icon ? "1" : undefined} title="Copy to clipboard" aria-label={icon ? "Copy to clipboard" : undefined}> | |
| 166 | + {icon ? raw(ICONS.copy) : (label ?? "Copy")} | |
| 158 | 167 | </button> |
| 159 | 168 | ); |
| 160 | 169 | |
| @@ -290,7 +299,7 @@ | ||
| 290 | 299 | </div> |
| 291 | 300 | ) : null} |
| 292 | 301 | <h1> |
| 293 | - <span class="st faint">{raw(vis === "public" ? ICONS.globe : vis === "internal" ? ICONS.org : ICONS.lock)}</span> | |
| 302 | + <span class="st faint">{raw(vis === "public" ? ICONS.globe : ICONS.lock)}</span> | |
| 294 | 303 | <a href={`/${r.repo.owner_login}`}>{r.repo.owner_login}</a> |
| 295 | 304 | <span class="faint">/</span> |
| 296 | 305 | <strong> |
| @@ -333,7 +342,8 @@ | ||
| 333 | 342 | "default-src 'self'", |
| 334 | 343 | `script-src 'nonce-${nonce}'`, |
| 335 | 344 | "style-src 'self' 'unsafe-inline'", |
| 336 | - "img-src 'self' https: data:", | |
| 345 | + // blob: previews a picture before it is uploaded. | |
| 346 | + "img-src 'self' https: data: blob:", | |
| 337 | 347 | `connect-src 'self' wss://${host} ws://${host}`, |
| 338 | 348 | "font-src 'self'", |
| 339 | 349 | "object-src 'none'", |
| @@ -356,6 +366,16 @@ | ||
| 356 | 366 | return renderPage(c, opts, body); |
| 357 | 367 | } |
| 358 | 368 | |
| 369 | +export type Appearance = "light" | "dark"; | |
| 370 | +export const APPEARANCE_COOKIE = "dfy_theme"; | |
| 371 | + | |
| 372 | +/** The chosen theme, or null to follow the system. Signed-out visitors keep the last choice made in this browser. */ | |
| 373 | +export function appearanceOf(c: AppContext): Appearance | null { | |
| 374 | + const p = c.get("principal"); | |
| 375 | + const v = p?.type === "account" ? p.account.appearance : getCookie(c, APPEARANCE_COOKIE); | |
| 376 | + return v === "light" || v === "dark" ? v : null; | |
| 377 | +} | |
| 378 | + | |
| 359 | 379 | function renderPage(c: AppContext, opts: PageOpts, body: Child, watch?: WatchLevel): Response | Promise<Response> { |
| 360 | 380 | csrfToken(c); |
| 361 | 381 | const nonce = randomToken(16); |
| @@ -370,12 +390,13 @@ | ||
| 370 | 390 | c.header("cache-control", "private, no-store"); |
| 371 | 391 | const r = opts.repo; |
| 372 | 392 | const title = opts.home ? `${SITE.name}: ${SITE.tagline}` : opts.title ? `${opts.title} · ${SITE.name}` : SITE.name; |
| 393 | + const theme = appearanceOf(c); | |
| 373 | 394 | const page = ( |
| 374 | - <html lang="en"> | |
| 395 | + <html lang="en" data-theme={theme ?? undefined} data-mode={theme ?? undefined}> | |
| 375 | 396 | <head> |
| 376 | 397 | <meta charset="utf-8" /> |
| 377 | 398 | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 378 | - <meta name="color-scheme" content="light dark" /> | |
| 399 | + <meta name="color-scheme" content={theme ?? "light dark"} /> | |
| 379 | 400 | <title>{title}</title> |
| 380 | 401 | {SeoHead(c, { |
| 381 | 402 | title, |
| @@ -416,7 +437,7 @@ | ||
| 416 | 437 | {raw(ICONS.bell)} |
| 417 | 438 | </a> |
| 418 | 439 | <a href="/settings" title="Settings" class="who"> |
| 419 | - <Avatar login={me.login} /> <span>{me.login}</span> | |
| 440 | + <Avatar login={me.login} version={me.avatar_version} /> <span>{me.login}</span> | |
| 420 | 441 | </a> |
| 421 | 442 | </> |
| 422 | 443 | ) : null} |
| @@ -463,7 +484,8 @@ | ||
| 463 | 484 | </body> |
| 464 | 485 | </html> |
| 465 | 486 | ); |
| 466 | - return c.html(`<!doctype html>${page}`, (opts.status ?? 200) as 200); | |
| 487 | + // `html` waits for async components (highlighted Markdown and diffs). | |
| 488 | + return c.html(html`<!doctype html>${page}`, (opts.status ?? 200) as 200); | |
| 467 | 489 | } |
| 468 | 490 | |
| 469 | 491 | /** Friendly error page for HttpErrors (and DO errors carrying a status prefix). */ |
| @@ -549,7 +571,8 @@ | ||
| 549 | 571 | <details class="menu"> |
| 550 | 572 | <summary class="btn sm"> |
| 551 | 573 | {raw(/^[0-9a-f]{40}$/.test(current) ? ICONS.commit : tags.includes(current) && !branches.includes(current) ? ICONS.tag : ICONS.branch)} |
| 552 | - <span class="mono">{/^[0-9a-f]{40}$/.test(current) ? shortSha(current) : current}</span> ▾ | |
| 574 | + <span class="mono">{/^[0-9a-f]{40}$/.test(current) ? shortSha(current) : current}</span> | |
| 575 | + {raw(ICONS.caret)} | |
| 553 | 576 | </summary> |
| 554 | 577 | <div class="pop"> |
| 555 | 578 | <h4>Branches</h4> |
| @@ -641,15 +664,40 @@ | ||
| 641 | 664 | } |
| 642 | 665 | |
| 643 | 666 | const LINE_BUDGET = 20000; |
| 667 | +const HIGHLIGHT_BUDGET = 3000; | |
| 644 | 668 | |
| 669 | +const diffLines = (f: DiffFile): number => f.hunks.reduce((n, h) => n + h.lines.length, 0); | |
| 670 | + | |
| 671 | +/** Syntax tokens per file, hunk and line, for the rendered files that fit the highlighting budget. */ | |
| 672 | +async function diffTokens(files: DiffFile[]): Promise<Map<DiffFile, Array<Array<TokLine | null>>>> { | |
| 673 | + const out = new Map<DiffFile, Array<Array<TokLine | null>>>(); | |
| 674 | + const picked: Array<[DiffFile, Lang]> = []; | |
| 675 | + let budget = LINE_BUDGET; | |
| 676 | + let hlBudget = HIGHLIGHT_BUDGET; | |
| 677 | + for (const f of files) { | |
| 678 | + const lines = diffLines(f); | |
| 679 | + const rendered = budget > 0 && !f.binary && !f.truncated; | |
| 680 | + budget -= lines; | |
| 681 | + const lang = langForPath(f.path); | |
| 682 | + if (!rendered || !lang || lines > LIMITS.lines || lines > hlBudget) continue; | |
| 683 | + hlBudget -= lines; | |
| 684 | + picked.push([f, lang]); | |
| 685 | + } | |
| 686 | + const hl = picked.length ? await ready(picked.map(([, l]) => l)) : null; | |
| 687 | + if (hl) for (const [f, lang] of picked) out.set(f, f.hunks.map((h) => diffLineTokens(h.lines, (code) => tokenize(hl, code, lang)))); | |
| 688 | + return out; | |
| 689 | +} | |
| 690 | + | |
| 645 | 691 | /** Unified diff with line numbers; each file is collapsible. */ |
| 646 | -export const DiffView: FC<{ files: DiffFile[]; opts?: DiffOptions }> = ({ files, opts = {} }) => { | |
| 692 | +export const DiffView: FC<{ files: DiffFile[]; opts?: DiffOptions }> = async ({ files, opts = {} }) => { | |
| 693 | + const toks = await diffTokens(files); | |
| 647 | 694 | let budget = LINE_BUDGET; |
| 648 | 695 | return ( |
| 649 | 696 | <div> |
| 650 | 697 | {files.map((f) => { |
| 651 | - const lines = f.hunks.reduce((n, h) => n + h.lines.length, 0); | |
| 698 | + const lines = diffLines(f); | |
| 652 | 699 | const big = lines > 800; |
| 700 | + const ft = toks.get(f); | |
| 653 | 701 | const render = budget > 0 && !f.binary && !f.truncated; |
| 654 | 702 | budget -= lines; |
| 655 | 703 | const threads = opts.threads?.get(f.path) ?? []; |
| @@ -678,7 +726,8 @@ | ||
| 678 | 726 | ) : ( |
| 679 | 727 | <div class="code-wrap"> |
| 680 | 728 | <table class="diff"> |
| 681 | - {f.hunks.map((h) => { | |
| 729 | + {f.hunks.map((h, hi) => { | |
| 730 | + const ht = ft?.[hi]; | |
| 682 | 731 | let oldN = h.oldStart; |
| 683 | 732 | let newN = h.newStart; |
| 684 | 733 | return ( |
| @@ -691,8 +740,9 @@ | ||
| 691 | 740 | @@ -{h.oldStart},{h.oldLines} +{h.newStart},{h.newLines} @@ |
| 692 | 741 | </td> |
| 693 | 742 | </tr> |
| 694 | - {h.lines.map((l) => { | |
| 743 | + {h.lines.map((l, li) => { | |
| 695 | 744 | const k = l[0]; |
| 745 | + const lt = ht?.[li]; | |
| 696 | 746 | if (k === "\\") { |
| 697 | 747 | return ( |
| 698 | 748 | <tr class="meta"> |
| @@ -721,9 +771,9 @@ | ||
| 721 | 771 | ) : null} |
| 722 | 772 | <td class="ln">{o ?? ""}</td> |
| 723 | 773 | <td class="ln">{n ?? ""}</td> |
| 724 | - <td class="code"> | |
| 774 | + <td class={lt ? "code sh" : "code"}> | |
| 725 | 775 | {k === "+" || k === "-" ? k : " "} |
| 726 | - {l.slice(1)} | |
| 776 | + {lt ? tokens(lt) : l.slice(1)} | |
| 727 | 777 | </td> |
| 728 | 778 | </tr> |
| 729 | 779 | {here.length ? ( |
| @@ -781,13 +831,15 @@ | ||
| 781 | 831 | </div> |
| 782 | 832 | </div> |
| 783 | 833 | {extra} |
| 784 | - <CopyButton text={sha} label="Copy" /> | |
| 785 | - <a class="sha" href={`${base}/commit/${sha}`}> | |
| 786 | - {shortSha(sha)} | |
| 787 | - </a> | |
| 788 | - <a class="btn sm" href={`${base}/tree/${sha}`} title="Browse the repository at this point"> | |
| 789 | - {raw(ICONS.code)} | |
| 790 | - </a> | |
| 834 | + <span class="btngroup"> | |
| 835 | + <CopyButton text={sha} icon /> | |
| 836 | + <a class="btn sm mono" href={`${base}/commit/${sha}`} title="View commit"> | |
| 837 | + {shortSha(sha)} | |
| 838 | + </a> | |
| 839 | + <a class="btn sm icon" href={`${base}/tree/${sha}`} title="Browse the repository at this point" aria-label="Browse the repository at this point"> | |
| 840 | + {raw(ICONS.code)} | |
| 841 | + </a> | |
| 842 | + </span> | |
| 791 | 843 | </li> |
| 792 | 844 | ); |
| 793 | 845 | |
| @@ -809,3 +861,6 @@ | ||
| 809 | 861 | } |
| 810 | 862 | |
| 811 | 863 | export const ROLES = ["read", "triage", "write", "maintain", "admin"] as const; |
| 864 | + | |
| 865 | +/** "write" → "Write", for showing a stored value as a label. */ | |
| 866 | +export const cap = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1); |
modifiedapps/diffinery/test/e2e/e2e.test.mjs+103−1View file
| @@ -6,7 +6,8 @@ | ||
| 6 | 6 | // protection, workflows triggered by push and pull_request and executed by a |
| 7 | 7 | // self-hosted runner (the same agent that runs in Cloudflare Containers), |
| 8 | 8 | // check runs, branch protection with required checks and human review for |
| 9 | -// agent-authored PRs, merging, the REST contents API and the MCP server. | |
| 9 | +// agent-authored PRs, merging, the REST contents API, the MCP server and | |
| 10 | +// profile pictures (WASM image processing in the Worker). | |
| 10 | 11 | import { spawn, spawnSync, execFileSync } from "node:child_process"; |
| 11 | 12 | import { mkdtempSync, writeFileSync, mkdirSync, readFileSync, existsSync, rmSync } from "node:fs"; |
| 12 | 13 | import { tmpdir } from "node:os"; |
| @@ -14,6 +15,7 @@ | ||
| 14 | 15 | import { join, dirname, resolve } from "node:path"; |
| 15 | 16 | import { fileURLToPath } from "node:url"; |
| 16 | 17 | import { createServer } from "node:net"; |
| 18 | +import { crc32, deflateSync } from "node:zlib"; | |
| 17 | 19 | import { test, before, after } from "node:test"; |
| 18 | 20 | import assert from "node:assert/strict"; |
| 19 | 21 | |
| @@ -1042,3 +1044,103 @@ | ||
| 1042 | 1044 | await gh.rest.actions.updateRepoVariable({ ...R, name: "COLOR", value: "green" }); |
| 1043 | 1045 | assert.equal((await gh.rest.actions.getRepoVariable({ ...R, name: "COLOR" })).data.value, "green"); |
| 1044 | 1046 | }); |
| 1047 | + | |
| 1048 | +/** A small opaque RGBA PNG (a gradient), built by hand so the test needs no image tools. */ | |
| 1049 | +function pngGradient(width, height) { | |
| 1050 | + const chunk = (type, body) => { | |
| 1051 | + const out = Buffer.alloc(12 + body.length); | |
| 1052 | + out.writeUInt32BE(body.length, 0); | |
| 1053 | + out.write(type, 4, "latin1"); | |
| 1054 | + body.copy(out, 8); | |
| 1055 | + out.writeUInt32BE(crc32(out.subarray(4, 8 + body.length)), 8 + body.length); | |
| 1056 | + return out; | |
| 1057 | + }; | |
| 1058 | + const ihdr = Buffer.alloc(13); | |
| 1059 | + ihdr.writeUInt32BE(width, 0); | |
| 1060 | + ihdr.writeUInt32BE(height, 4); | |
| 1061 | + ihdr.set([8, 6, 0, 0, 0], 8); | |
| 1062 | + const raw = Buffer.alloc((width * 4 + 1) * height); | |
| 1063 | + for (let y = 0; y < height; y++) | |
| 1064 | + for (let x = 0; x < width; x++) raw.set([(x * 255) / width, (y * 255) / height, 128, 255], y * (width * 4 + 1) + 1 + x * 4); | |
| 1065 | + return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]); | |
| 1066 | +} | |
| 1067 | + | |
| 1068 | +/** Width and height of a lossy or extended WebP, or null when it is not one. */ | |
| 1069 | +function webpSize(b) { | |
| 1070 | + if (b.toString("latin1", 0, 4) !== "RIFF" || b.toString("latin1", 8, 12) !== "WEBP") return null; | |
| 1071 | + const chunk = b.toString("latin1", 12, 16); | |
| 1072 | + if (chunk === "VP8 ") return { width: b.readUInt16LE(26) & 0x3fff, height: b.readUInt16LE(28) & 0x3fff }; | |
| 1073 | + if (chunk === "VP8X") return { width: b.readUIntLE(24, 3) + 1, height: b.readUIntLE(27, 3) + 1 }; | |
| 1074 | + return null; | |
| 1075 | +} | |
| 1076 | + | |
| 1077 | +test("profile pictures: upload, resize, serve, cache headers and removal", async () => { | |
| 1078 | + const me = await ok("GET", "/user"); | |
| 1079 | + const avatar = async (login, query = "") => { | |
| 1080 | + const r = await fetch(`${base}/api/avatars/${login}${query}`); | |
| 1081 | + assert.equal(r.status, 200); | |
| 1082 | + return { type: r.headers.get("content-type"), cache: r.headers.get("cache-control"), csp: r.headers.get("content-security-policy"), body: Buffer.from(await r.arrayBuffer()) }; | |
| 1083 | + }; | |
| 1084 | + const put = (path, body, tok = token) => fetch(`${base}/api/v3${path}`, { method: "PUT", headers: { authorization: `Bearer ${tok}`, "content-type": "image/png" }, body }); | |
| 1085 | + | |
| 1086 | + // Before any upload: generated initials, also for logins that don't exist. | |
| 1087 | + const initials = await avatar(me.login, "?s=64"); | |
| 1088 | + assert.match(initials.type, /^image\/svg\+xml/); | |
| 1089 | + assert.equal(initials.csp, "default-src 'none'; style-src 'unsafe-inline'"); | |
| 1090 | + assert.match((await avatar("no-such-user-here")).type, /^image\/svg\+xml/); | |
| 1091 | + | |
| 1092 | + // REST upload: a 600×400 PNG becomes centred square WebPs. | |
| 1093 | + const up = await put("/user/avatar", pngGradient(600, 400)); | |
| 1094 | + assert.equal(up.status, 200, await up.clone().text()); | |
| 1095 | + const { avatar_url } = await up.json(); | |
| 1096 | + const version = new URL(avatar_url).searchParams.get("v"); | |
| 1097 | + assert.match(version ?? "", /^\d+$/); | |
| 1098 | + for (const s of [64, 128, 256]) { | |
| 1099 | + const a = await avatar(me.login, `?s=${s}&v=${version}`); | |
| 1100 | + assert.equal(a.type, "image/webp"); | |
| 1101 | + assert.deepEqual(webpSize(a.body), { width: s, height: s }); | |
| 1102 | + assert.ok(a.body.length < 10_000, `${s}px WebP is ${a.body.length} bytes`); | |
| 1103 | + assert.match(a.cache, /immutable/); | |
| 1104 | + } | |
| 1105 | + const current = await avatar(me.login, "?s=64"); | |
| 1106 | + assert.equal(current.type, "image/webp"); | |
| 1107 | + assert.match(current.cache, /max-age=600/); | |
| 1108 | + | |
| 1109 | + // Only JPEG, PNG and WebP, sniffed from the bytes. | |
| 1110 | + const gif = await put("/user/avatar", Buffer.from("GIF89a\x01\x00\x01\x00\x00\x00\x00;", "latin1")); | |
| 1111 | + assert.equal(gif.status, 422); | |
| 1112 | + assert.equal((await gif.json()).message, "Use a JPEG, PNG or WebP image."); | |
| 1113 | + // Read-only tokens can't change a profile. | |
| 1114 | + const ro = (await ok("POST", "/diffinery/tokens", { name: "ro", scopes: ["read"] })).token; | |
| 1115 | + assert.equal((await put("/user/avatar", pngGradient(8, 8), ro)).status, 403); | |
| 1116 | + | |
| 1117 | + // The web form (multipart, CSRF double-submit) takes the original file and the server resizes it. | |
| 1118 | + const page = await fetch(`${base}/settings`); | |
| 1119 | + const csrf = /name="_csrf" value="([^"]+)"/.exec(await page.text())?.[1]; | |
| 1120 | + const cookie = /dfy_csrf=([^;]+)/.exec(page.headers.get("set-cookie") ?? "")?.[1]; | |
| 1121 | + assert.ok(csrf && cookie); | |
| 1122 | + const form = new FormData(); | |
| 1123 | + form.set("_csrf", csrf); | |
| 1124 | + form.set("avatar", new Blob([pngGradient(300, 500)], { type: "image/png" }), "me.png"); | |
| 1125 | + const posted = await fetch(`${base}/settings/avatar`, { method: "POST", body: form, redirect: "manual", headers: { cookie: `dfy_csrf=${cookie}`, "sec-fetch-site": "same-origin" } }); | |
| 1126 | + assert.equal(posted.status, 303); | |
| 1127 | + assert.match(decodeURIComponent(decodeURIComponent(posted.headers.get("set-cookie") ?? "")), /"kind":"notice","message":"Profile picture updated\."/); | |
| 1128 | + const settings = await (await fetch(`${base}/settings`)).text(); | |
| 1129 | + const newVersion = /\/api\/avatars\/[^"?]+\?s=256&v=(\d+)/.exec(settings)?.[1]; | |
| 1130 | + assert.ok(newVersion && newVersion !== version, "the settings page links the new version"); | |
| 1131 | + // An old version's URL gets the current picture, but not as an immutable response. | |
| 1132 | + assert.match((await avatar(me.login, `?s=64&v=${version}`)).cache, /max-age=600/); | |
| 1133 | + | |
| 1134 | + // Organizations: owners and admins. | |
| 1135 | + await ok("POST", "/user/orgs", { login: "picture-org" }); | |
| 1136 | + const org = await put("/orgs/picture-org/avatar", pngGradient(64, 64)); | |
| 1137 | + assert.equal(org.status, 200, await org.clone().text()); | |
| 1138 | + assert.equal((await avatar("picture-org", "?s=128")).type, "image/webp"); | |
| 1139 | + assert.equal((await fetch(`${base}/api/v3/orgs/picture-org/avatar`, { method: "DELETE", headers: { authorization: `Bearer ${token}` } })).status, 200); | |
| 1140 | + assert.match((await avatar("picture-org", "?s=128")).type, /^image\/svg\+xml/); | |
| 1141 | + | |
| 1142 | + // Removal falls back to initials straight away (the cached copies are purged). | |
| 1143 | + const del = await fetch(`${base}/api/v3/user/avatar`, { method: "DELETE", headers: { authorization: `Bearer ${token}` } }); | |
| 1144 | + assert.deepEqual(await del.json(), { avatar_url: `${base}/api/avatars/${me.login}` }); | |
| 1145 | + assert.match((await avatar(me.login, "?s=64")).type, /^image\/svg\+xml/); | |
| 1146 | +}); |
modifiedapps/diffinery/test/e2e/passkeys.test.mjs+61−4View file
| @@ -148,7 +148,8 @@ | ||
| 148 | 148 | assert.match(await text(page), /invited as pal@example\.com/); |
| 149 | 149 | await page.fill("#login", "pal"); |
| 150 | 150 | await page.fill("#name", "Pal Friend"); |
| 151 | - await Promise.all([page.waitForURL(`${base}/`), page.click("text=Create a passkey and join")]); | |
| 151 | + // New people land in the organization they were invited to. | |
| 152 | + await Promise.all([page.waitForURL(`${base}/crew`), page.click("text=Create a passkey and join")]); | |
| 152 | 153 | assert.match(await text(page), /Welcome to Diffinery, pal/); |
| 153 | 154 | assert.equal((await pal.credentials()).length, 1); |
| 154 | 155 | const cookies = await pal.context.cookies(base); |
| @@ -230,12 +231,12 @@ | ||
| 230 | 231 | await page.waitForSelector(".dfy-k[data-mounted]"); |
| 231 | 232 | assert.equal(await nativeControls(page), 0); |
| 232 | 233 | await page.fill("#name", "kumo"); |
| 233 | - await page.getByRole("radio", { name: /Internal/ }).click(); | |
| 234 | + await page.getByRole("radio", { name: /Public/ }).click(); | |
| 234 | 235 | await Promise.all([page.waitForURL(`${base}/pal/kumo`), page.click("button:has-text('Create repository')")]); |
| 235 | 236 | await page.goto(`${base}/pal/kumo/settings`); |
| 236 | 237 | await page.waitForSelector(".dfy-k[data-mounted]"); |
| 237 | - assert.equal(await page.locator("input[type=hidden][name=visibility]").getAttribute("value"), "internal"); | |
| 238 | - assert.equal(await page.getByRole("radio", { name: "internal" }).getAttribute("aria-checked"), "true"); | |
| 238 | + assert.equal(await page.locator("input[type=hidden][name=visibility]").getAttribute("value"), "public"); | |
| 239 | + assert.equal(await page.getByRole("radio", { name: "Public" }).getAttribute("aria-checked"), "true"); | |
| 239 | 240 | await page.goto(`${base}/settings/tokens`); |
| 240 | 241 | await page.fill("#tname", "kumo"); |
| 241 | 242 | await page.click("#repos"); |
| @@ -251,6 +252,62 @@ | ||
| 251 | 252 | assert.equal(kumo.status, 200); |
| 252 | 253 | assert.match(kumo.headers.get("cache-control") ?? "", /immutable/); |
| 253 | 254 | |
| 255 | + // ------------------------------------------------------------ Add people: one form for usernames and emails | |
| 256 | + await boss.page.goto(`${base}/crew?tab=people`); | |
| 257 | + await boss.page.fill("#who", "pal"); | |
| 258 | + await Promise.all([boss.page.waitForLoadState("load"), boss.page.click("form:has(#who) button:has-text('Add')")]); | |
| 259 | + assert.match(await text(boss.page), /pal is already a member of crew/); | |
| 260 | + await boss.page.fill("#who", "newbie@example.com"); | |
| 261 | + await Promise.all([boss.page.waitForLoadState("load"), boss.page.click("form:has(#who) button:has-text('Add')")]); | |
| 262 | + assert.match(await text(boss.page), /Invitation sent to newbie@example\.com/); | |
| 263 | + assert.match(await boss.page.textContent(".secret"), new RegExp(`^${base}/invite/`)); | |
| 264 | + await boss.page.goto(`${base}/crew?tab=people`); | |
| 265 | + assert.match(await text(boss.page), /Pending invitations/); | |
| 266 | + await Promise.all([boss.page.waitForLoadState("load"), boss.page.click("tr:has-text('newbie@example.com') button:has-text('Revoke')")]); | |
| 267 | + assert.match(await text(boss.page), /Invitation revoked/); | |
| 268 | + assert.doesNotMatch(await text(boss.page), /newbie@example\.com/); | |
| 269 | + // An existing account joins right away and hears about it on Diffinery. | |
| 270 | + await page.goto(`${base}/settings/orgs`); | |
| 271 | + await Promise.all([page.waitForLoadState("load"), page.fill("#olog", "palorg")]); | |
| 272 | + await Promise.all([page.waitForURL(/\/palorg/), page.click("button:has-text('Create organization')")]); | |
| 273 | + await page.fill("#who", "boss"); | |
| 274 | + await Promise.all([page.waitForLoadState("load"), page.click("form:has(#who) button:has-text('Add')")]); | |
| 275 | + assert.match(await text(page), /Added boss to palorg as member/); | |
| 276 | + await boss.page.goto(`${base}/notifications`); | |
| 277 | + assert.match(await text(boss.page), /pal added you to palorg as a member/); | |
| 278 | + // The last owner can't step down: the organization would have nobody to manage it. | |
| 279 | + await page.goto(`${base}/palorg?tab=people`); | |
| 280 | + await Promise.all([ | |
| 281 | + page.waitForLoadState("load"), | |
| 282 | + page.evaluate(() => { | |
| 283 | + const csrf = document.querySelector("form input[type=hidden]"); | |
| 284 | + const f = document.createElement("form"); | |
| 285 | + f.method = "post"; | |
| 286 | + f.action = "/palorg/-/org/members"; | |
| 287 | + for (const [k, v] of [[csrf.name, csrf.value], ["login", "pal"], ["role", "member"]]) f.append(Object.assign(document.createElement("input"), { type: "hidden", name: k, value: v })); | |
| 288 | + document.body.append(f); | |
| 289 | + f.submit(); | |
| 290 | + }), | |
| 291 | + ]); | |
| 292 | + assert.match(await text(page), /needs at least one owner/); | |
| 293 | + await page.goto(`${base}/palorg?tab=people`); | |
| 294 | + assert.match(await page.textContent("tr:has-text('pal')"), /Owner/); | |
| 295 | + | |
| 296 | + // ------------------------------------------------------------ Appearance | |
| 297 | + await page.goto(`${base}/settings/appearance`); | |
| 298 | + await page.waitForSelector(".dfy-k[data-mounted]"); | |
| 299 | + await page.getByRole("radio", { name: /Dark/ }).click(); | |
| 300 | + await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Save appearance')")]); | |
| 301 | + assert.equal(await page.getAttribute("html", "data-theme"), "dark"); | |
| 302 | + assert.equal(await page.getAttribute("html", "data-mode"), "dark"); | |
| 303 | + await page.goto(`${base}/`); | |
| 304 | + assert.equal(await page.getAttribute("html", "data-theme"), "dark"); | |
| 305 | + await page.goto(`${base}/settings/appearance`); | |
| 306 | + await page.waitForSelector(".dfy-k[data-mounted]"); | |
| 307 | + await page.getByRole("radio", { name: /Sync with system/ }).click(); | |
| 308 | + await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Save appearance')")]); | |
| 309 | + assert.equal(await page.getAttribute("html", "data-theme"), null); | |
| 310 | + | |
| 254 | 311 | // Screenshots for review (kept with E2E_KEEP=1). |
| 255 | 312 | if (process.env.E2E_SCREENSHOTS) { |
| 256 | 313 | const out = process.env.E2E_SCREENSHOTS; |
modifiedapps/diffinery/test/e2e/security.test.mjs+10−0View file
| @@ -105,6 +105,8 @@ | ||
| 105 | 105 | token("t3", "u-rita", T.rita, ["write"]), |
| 106 | 106 | token("t4", "u-dora", T.dora, ["write"]), |
| 107 | 107 | token("t5", "a-orphan", T.orphan, ["write"]), |
| 108 | + account("o-guild", "guild", "org", "active"), | |
| 109 | + `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-guild', 'u-olive', 'owner', '${at}'), ('o-guild', 'u-rita', 'admin', '${at}')`, | |
| 108 | 110 | session("s1", "u-penny", S.penny), |
| 109 | 111 | session("s2", "u-dora", S.dora), |
| 110 | 112 | ].join("; "), |
| @@ -299,3 +301,11 @@ | ||
| 299 | 301 | ws.close(); |
| 300 | 302 | assert.ok(!seen.some((m) => m.includes("Secret plans") || m.includes(`"id":${draft.id}`)), `draft leaked: ${seen.join("\n")}`); |
| 301 | 303 | }); |
| 304 | + | |
| 305 | +test("security: organization admins can't hand out ownership", async () => { | |
| 306 | + // rita is an admin of guild, olive its owner. | |
| 307 | + await status(403, "POST", "/orgs/guild/invitations", { email: "crown@example.com", role: "owner" }, T.rita); | |
| 308 | + await status(422, "POST", "/orgs/guild/invitations", { email: "crown@example.com", role: "superuser" }, T.rita); | |
| 309 | + await status(201, "POST", "/orgs/guild/invitations", { email: "helper@example.com", role: "admin" }, T.rita); | |
| 310 | + await status(201, "POST", "/orgs/guild/invitations", { email: "crown@example.com", role: "owner" }, T.olive); | |
| 311 | +}); |
addedapps/diffinery/test/unit/avatars.test.ts+30−0View file
| @@ -1,0 +1,30 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { avatarHue, avatarInitial, avatarPath, avatarSize, initialsSvg } from "../../src/http/avatars.js"; | |
| 3 | + | |
| 4 | +describe("avatar URLs and initials", () => { | |
| 5 | + it("snaps requested sizes to the stored ones", () => { | |
| 6 | + expect([1, 20, 64, 65, 128, 129, 256, 4000].map(avatarSize)).toEqual([64, 64, 64, 128, 128, 256, 256, 256]); | |
| 7 | + }); | |
| 8 | + | |
| 9 | + it("builds image paths with an optional size and version", () => { | |
| 10 | + expect(avatarPath("dev")).toBe("/api/avatars/dev"); | |
| 11 | + expect(avatarPath("dev", 128)).toBe("/api/avatars/dev"); | |
| 12 | + expect(avatarPath("dev", 64, 1791892043)).toBe("/api/avatars/dev?s=64&v=1791892043"); | |
| 13 | + expect(avatarPath("a.b", 256, null)).toBe("/api/avatars/a.b?s=256"); | |
| 14 | + }); | |
| 15 | + | |
| 16 | + it("draws initials like the old .av span: same hue hash, first letter or digit", () => { | |
| 17 | + // The hash the web UI used before pictures (h * 31 + code, mod 360). | |
| 18 | + const old = (s: string) => [...s].reduce((h, ch) => (h * 31 + ch.charCodeAt(0)) >>> 0, 0) % 360; | |
| 19 | + for (const login of ["dev", "acme", "Claude-bot", "x"]) expect(avatarHue(login)).toBe(old(login)); | |
| 20 | + expect(avatarInitial("dev")).toBe("D"); | |
| 21 | + expect(avatarInitial("-9lives")).toBe("9"); | |
| 22 | + expect(avatarInitial("---")).toBe("?"); | |
| 23 | + const svg = initialsSvg("dev", 64); | |
| 24 | + expect(svg).toContain(`fill="hsl(${avatarHue("dev")},55%,55%)"`); | |
| 25 | + expect(svg).toContain('width="64" height="64"'); | |
| 26 | + expect(svg).toMatch(/>D<\/text><\/svg>$/); | |
| 27 | + // Logins are restricted, but the SVG never carries more than one [A-Za-z0-9?] character of them. | |
| 28 | + expect(initialsSvg("<script>", 128)).toMatch(/>S<\/text>/); | |
| 29 | + }); | |
| 30 | +}); |
addedapps/diffinery/test/unit/highlight.test.ts+202−0View file
| @@ -1,0 +1,202 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { diffLineTokens, fenceHighlighter, highlightLines, langForFence, langForPath, LANGS, LIMITS, ready, tokenize, type Lang, type TokLine } from "../../src/web/highlight.js"; | |
| 3 | +import { fenceLangs, renderMarkdown } from "../../src/web/markdown.js"; | |
| 4 | + | |
| 5 | +const text = (line: TokLine | null | undefined) => line?.map((t) => t.text).join("") ?? null; | |
| 6 | + | |
| 7 | +describe("language detection", () => { | |
| 8 | + it("maps file names and extensions", () => { | |
| 9 | + const cases: Record<string, string | null> = { | |
| 10 | + "src/app.ts": "typescript", | |
| 11 | + "src/App.tsx": "tsx", | |
| 12 | + "lib/x.mjs": "javascript", | |
| 13 | + "lib/x.cjs": "javascript", | |
| 14 | + "a/b.jsx": "jsx", | |
| 15 | + "Dockerfile": "docker", | |
| 16 | + "deploy/Dockerfile.prod": "docker", | |
| 17 | + "web.dockerfile": "docker", | |
| 18 | + "Makefile": "make", | |
| 19 | + "GNUmakefile": "make", | |
| 20 | + ".github/workflows/ci.yml": "yaml", | |
| 21 | + "config.yaml": "yaml", | |
| 22 | + "inc/x.h": "c", | |
| 23 | + "inc/x.hpp": "cpp", | |
| 24 | + "src/x.cc": "cpp", | |
| 25 | + "run.sh": "shellscript", | |
| 26 | + "x.bash": "shellscript", | |
| 27 | + ".zshrc": "shellscript", | |
| 28 | + ".env": "dotenv", | |
| 29 | + ".env.local": "dotenv", | |
| 30 | + ".gitignore": null, | |
| 31 | + "tsconfig.json": "jsonc", | |
| 32 | + "tsconfig.build.json": "jsonc", | |
| 33 | + "package.json": "json", | |
| 34 | + "Cargo.toml": "toml", | |
| 35 | + "main.go": "go", | |
| 36 | + "lib.rs": "rust", | |
| 37 | + "App.vue": "vue", | |
| 38 | + "Page.svelte": "svelte", | |
| 39 | + "main.tf": "terraform", | |
| 40 | + "nginx.conf": "nginx", | |
| 41 | + "Gemfile": "ruby", | |
| 42 | + "build.gradle": "groovy", | |
| 43 | + "api.proto": "proto", | |
| 44 | + "fix.patch": "diff", | |
| 45 | + "x.m": "objective-c", | |
| 46 | + "core.clj": "clojure", | |
| 47 | + "README": null, | |
| 48 | + "LICENSE": null, | |
| 49 | + "notes.txt": null, | |
| 50 | + "image.png": null, | |
| 51 | + }; | |
| 52 | + for (const [path, lang] of Object.entries(cases)) expect([path, langForPath(path)]).toEqual([path, lang]); | |
| 53 | + }); | |
| 54 | + | |
| 55 | + it("maps fence names and aliases", () => { | |
| 56 | + expect(langForFence("ts")).toBe("typescript"); | |
| 57 | + expect(langForFence("TypeScript")).toBe("typescript"); | |
| 58 | + expect(langForFence("yml")).toBe("yaml"); | |
| 59 | + expect(langForFence("bash")).toBe("shellscript"); | |
| 60 | + expect(langForFence("sh")).toBe("shellscript"); | |
| 61 | + expect(langForFence("console")).toBe("shellscript"); | |
| 62 | + expect(langForFence("c++")).toBe("cpp"); | |
| 63 | + expect(langForFence("c#")).toBe("csharp"); | |
| 64 | + expect(langForFence("golang")).toBe("go"); | |
| 65 | + expect(langForFence("dockerfile")).toBe("docker"); | |
| 66 | + expect(langForFence("hcl")).toBe("hcl"); | |
| 67 | + expect(langForFence("{.python}")).toBe("python"); | |
| 68 | + expect(langForFence("text")).toBeNull(); | |
| 69 | + expect(langForFence("mermaid")).toBeNull(); | |
| 70 | + }); | |
| 71 | +}); | |
| 72 | + | |
| 73 | +describe("tokens", () => { | |
| 74 | + it("tokenizes with both themes as CSS variables and leaves default text bare", async () => { | |
| 75 | + const out = await highlightLines(["const a = 1; // one", "", "export {};"], "typescript"); | |
| 76 | + expect(out).toHaveLength(3); | |
| 77 | + expect(out!.map(text)).toEqual(["const a = 1; // one", "", "export {};"]); | |
| 78 | + const kw = out![0]!.find((t) => t.text === "const")!; | |
| 79 | + expect(kw.style).toMatch(/^--shiki-light:#[0-9A-F]{6};--shiki-dark:#[0-9A-F]{6}$/i); | |
| 80 | + expect(out![0]!.some((t) => !t.style)).toBe(true); | |
| 81 | + expect(out![0]!.every((t) => !t.style || /^(--shiki-[a-z-]+:[^;"<>]+;?)+$/.test(t.style))).toBe(true); | |
| 82 | + }); | |
| 83 | + | |
| 84 | + it("loads every grammar on the JavaScript regex engine", async () => { | |
| 85 | + for (const lang of LANGS) { | |
| 86 | + const hl = await ready([lang]); | |
| 87 | + expect([lang, hl !== null]).toEqual([lang, true]); | |
| 88 | + const out = tokenize(hl!, "x = 1 # a\n// b\n<a href='c'>d</a>", lang); | |
| 89 | + expect([lang, out?.length]).toEqual([lang, 3]); | |
| 90 | + } | |
| 91 | + }, 60_000); | |
| 92 | + | |
| 93 | + it("falls back to plain text for unknown languages and oversized input", async () => { | |
| 94 | + expect(await highlightLines(["x"], null)).toBeNull(); | |
| 95 | + expect(await highlightLines(Array.from({ length: LIMITS.lines + 1 }, () => "x"), "typescript")).toBeNull(); | |
| 96 | + expect(await highlightLines(["x".repeat(LIMITS.bytes + 1)], "typescript")).toBeNull(); | |
| 97 | + }); | |
| 98 | + | |
| 99 | + it("does not tokenize very long lines", async () => { | |
| 100 | + const long = `const s = "${"a".repeat(LIMITS.lineLength + 10)}";`; | |
| 101 | + const out = await highlightLines([long, "let b = 2;"], "typescript"); | |
| 102 | + expect(out!.map(text)).toEqual([long, "let b = 2;"]); | |
| 103 | + expect(out![0]).toEqual([{ text: long }]); | |
| 104 | + expect(out![1]!.find((t) => t.text === "let")?.style).toBeTruthy(); | |
| 105 | + }); | |
| 106 | + | |
| 107 | + it("highlights a 1,500-line TypeScript file quickly", async () => { | |
| 108 | + const chunk = [ | |
| 109 | + "import { readFile } from \"node:fs/promises\";", | |
| 110 | + "/** A documented interface. */", | |
| 111 | + "export interface Item<T extends object = {}> { id: number; name: string; meta?: T }", | |
| 112 | + "export async function load(path: string): Promise<Item[]> {", | |
| 113 | + " const raw = await readFile(path, \"utf8\"); // read", | |
| 114 | + " return JSON.parse(raw).map((x: any, i: number) => ({ id: i, name: `item ${x.name}` }));", | |
| 115 | + "}", | |
| 116 | + "class Store { #items = new Map<number, Item>(); get size() { return this.#items.size; } }", | |
| 117 | + "const re = /^(?:[a-z]+)\\d*$/gi;", | |
| 118 | + "", | |
| 119 | + ]; | |
| 120 | + const lines = Array.from({ length: 1500 }, (_, i) => chunk[i % chunk.length]!); | |
| 121 | + await ready(["typescript"]); | |
| 122 | + const t0 = performance.now(); | |
| 123 | + const out = await highlightLines(lines, "typescript"); | |
| 124 | + const ms = performance.now() - t0; | |
| 125 | + console.log(`highlight: 1,500-line TypeScript file in ${ms.toFixed(0)} ms`); | |
| 126 | + expect(out).toHaveLength(1500); | |
| 127 | + expect(ms).toBeLessThan(5000); | |
| 128 | + }); | |
| 129 | +}); | |
| 130 | + | |
| 131 | +describe("paired GitHub themes", () => { | |
| 132 | + it("match Shiki's own dual-theme output", async () => { | |
| 133 | + const { createHighlighterCore } = await import("shiki/core"); | |
| 134 | + const { createJavaScriptRegexEngine } = await import("shiki/engine/javascript"); | |
| 135 | + const samples: Array<[Lang, string]> = [ | |
| 136 | + ["typescript", 'import { a } from "b";\n/** doc @param x */\nexport class C<T> extends D { #x = 1; get y(): T { return `v${this.#x}` as T } }\nconst re = /a+/g;'], | |
| 137 | + ["markdown", "# Title\n\n*em* **bold** ~~gone~~ [link](https://x.y)\n\n> quote\n\n- item"], | |
| 138 | + ["python", "@dataclass\nclass A:\n def f(self, x: int = 2) -> str:\n return f\"{x!r}\" # c"], | |
| 139 | + ["diff", "--- a\n+++ b\n@@ -1 +1 @@\n-old\n+new"], | |
| 140 | + ["yaml", "on:\n push:\n branches: [main] # c\njobs: { a: 1 }"], | |
| 141 | + ]; | |
| 142 | + const ref = await createHighlighterCore({ themes: [import("shiki/themes/github-light-default.mjs"), import("shiki/themes/github-dark-default.mjs")], langs: samples.map(([l]) => import(`shiki/langs/${l}.mjs`)), engine: createJavaScriptRegexEngine({ forgiving: true }) }); | |
| 143 | + const hl = await ready(samples.map(([l]) => l)); | |
| 144 | + for (const [lang, code] of samples) { | |
| 145 | + const { tokens: theirs, fg } = ref.codeToTokens(code, { lang, themes: { light: "github-light-default", dark: "github-dark-default" }, defaultColor: false }); | |
| 146 | + const ours = tokenize(hl!, code, lang)!; | |
| 147 | + const flat = (lines: Array<Array<{ text: string; style?: string }>>) => lines.map((l) => l.flatMap((t) => [...t.text].map((ch) => `${ch}\t${t.style ?? ""}`))); | |
| 148 | + // Shiki spells out the default foreground and token backgrounds; ours leaves both to the site. | |
| 149 | + const style = (s: Record<string, string> = {}) => Object.entries(s).filter(([k]) => !k.endsWith("-bg")).map(([k, v]) => `${k}:${v}`).join(";"); | |
| 150 | + const expected = theirs.map((l) => l.map((t) => ({ text: t.content, style: style(t.htmlStyle).toLowerCase() === fg!.toLowerCase() ? undefined : style(t.htmlStyle) }))); | |
| 151 | + expect([lang, flat(ours)]).toEqual([lang, flat(expected)]); | |
| 152 | + } | |
| 153 | + }); | |
| 154 | +}); | |
| 155 | + | |
| 156 | +describe("diff mapping", () => { | |
| 157 | + // Each token records the snippet it was tokenized from. | |
| 158 | + const fake = (code: string): TokLine[] => code.split("\n").map((l) => [{ text: l, style: code.replace(/\n/g, "|") }]); | |
| 159 | + | |
| 160 | + it("maps old and new side tokens back to rows", () => { | |
| 161 | + const hunk = [" a", "-b", "-c", "+B", " d", "\\ No newline at end of file", "+e"]; | |
| 162 | + const rows = diffLineTokens(hunk, fake); | |
| 163 | + expect(rows.map(text)).toEqual(["a", "b", "c", "B", "d", null, "e"]); | |
| 164 | + expect(rows.map((r) => r?.[0]?.style ?? null)).toEqual(["a|B|d|e", "a|b|c|d", "a|b|c|d", "a|B|d|e", "a|B|d|e", null, "a|B|d|e"]); | |
| 165 | + }); | |
| 166 | + | |
| 167 | + it("treats empty lines as context and fails soft", () => { | |
| 168 | + expect(diffLineTokens(["", "+x"], fake).map(text)).toEqual(["", "x"]); | |
| 169 | + expect(diffLineTokens([" a", "+b"], () => null)).toEqual([null, null]); | |
| 170 | + expect(diffLineTokens([" a", "+b"], () => [])).toEqual([null, null]); | |
| 171 | + }); | |
| 172 | + | |
| 173 | + it("carries grammar state across rows of a hunk", async () => { | |
| 174 | + const hl = await ready(["typescript"]); | |
| 175 | + const hunk = [" /* start of a", "-old comment", "+new comment", " end */ const x = 1;"]; | |
| 176 | + const rows = diffLineTokens(hunk, (code) => tokenize(hl!, code, "typescript")); | |
| 177 | + expect(rows.map(text)).toEqual(["/* start of a", "old comment", "new comment", "end */ const x = 1;"]); | |
| 178 | + const comment = rows[0]![0]!.style; | |
| 179 | + expect(rows[1]![0]!.style).toBe(comment); | |
| 180 | + expect(rows[2]![0]!.style).toBe(comment); | |
| 181 | + expect(rows[3]!.find((t) => t.text.includes("const"))?.style).not.toBe(comment); | |
| 182 | + }); | |
| 183 | +}); | |
| 184 | + | |
| 185 | +describe("markdown fences", () => { | |
| 186 | + it("highlights named fences and escapes code", async () => { | |
| 187 | + const md = "# T\n\n```ts\nconst a = \"<b>\" && 1;\n```\n\n```\nplain <i>\n```\n\n```mermaid\ngraph\n```\n"; | |
| 188 | + expect(fenceLangs(md)).toEqual(["ts", "mermaid"]); | |
| 189 | + const html = renderMarkdown(md, { highlight: await fenceHighlighter(fenceLangs(md)) }); | |
| 190 | + expect(html).toContain('<code class="language-ts sh">'); | |
| 191 | + expect(html).toMatch(/<span style="--shiki-light:#[0-9A-F]{6};--shiki-dark:#[0-9A-F]{6}">const<\/span>/i); | |
| 192 | + expect(html).toContain(""<b>""); | |
| 193 | + expect(html).not.toContain("<b>"); | |
| 194 | + expect(html).toContain("<pre><code>plain <i></code></pre>"); | |
| 195 | + expect(html).toContain('<code class="language-mermaid">graph</code>'); | |
| 196 | + }); | |
| 197 | + | |
| 198 | + it("renders plain code without a highlighter", async () => { | |
| 199 | + expect(await fenceHighlighter(["text", "mermaid"])).toBeUndefined(); | |
| 200 | + expect(renderMarkdown("```ts\nlet x\n```")).toBe('<pre><code class="language-ts">let x</code></pre>'); | |
| 201 | + }); | |
| 202 | +}); |
addedapps/diffinery/test/unit/image.test.ts+242−0View file
| @@ -1,0 +1,242 @@ | ||
| 1 | +import { crc32, deflateSync } from "node:zlib"; | |
| 2 | +import { describe, expect, it } from "vitest"; | |
| 3 | +import encodeJpeg, { init as initJpegEncode } from "@jsquash/jpeg/encode.js"; | |
| 4 | +import decodeWebpWasm, { init as initWebpDecode } from "@jsquash/webp/decode.js"; | |
| 5 | +import encodeWebp from "@jsquash/webp/encode.js"; | |
| 6 | +import JPEG_ENC_WASM from "@jsquash/jpeg/codec/enc/mozjpeg_enc.wasm?module"; | |
| 7 | +import WEBP_DEC_WASM from "@jsquash/webp/codec/dec/webp_dec.wasm?module"; | |
| 8 | +import { AVATAR_SIZES, MAX_AVATAR_BYTES, cropAndShrink, imageSize, jpegOrientation, makeAvatarImages, orientSquare, shrinkFactor, sniffImage, squareCrop, type Pixels } from "../../src/lib/image.js"; | |
| 9 | +import { HttpError } from "../../src/lib/util.js"; | |
| 10 | + | |
| 11 | +// ---------------------------------------------------------------- fixtures, generated here | |
| 12 | + | |
| 13 | +type Paint = (x: number, y: number) => [number, number, number, number?]; | |
| 14 | + | |
| 15 | +function pixels(width: number, height: number, paint: Paint): Pixels { | |
| 16 | + const data = new Uint8ClampedArray(width * height * 4); | |
| 17 | + for (let y = 0; y < height; y++) | |
| 18 | + for (let x = 0; x < width; x++) { | |
| 19 | + const [r, g, b, a = 255] = paint(x, y); | |
| 20 | + data.set([r, g, b, a], (y * width + x) * 4); | |
| 21 | + } | |
| 22 | + return { data, width, height }; | |
| 23 | +} | |
| 24 | + | |
| 25 | +/** A minimal RGBA PNG: IHDR, one IDAT (filter 0 rows), IEND. */ | |
| 26 | +function png(img: Pixels): Uint8Array { | |
| 27 | + const chunk = (type: string, body: Uint8Array) => { | |
| 28 | + const out = new Uint8Array(12 + body.length); | |
| 29 | + const v = new DataView(out.buffer); | |
| 30 | + v.setUint32(0, body.length); | |
| 31 | + out.set(new TextEncoder().encode(type), 4); | |
| 32 | + out.set(body, 8); | |
| 33 | + v.setUint32(8 + body.length, crc32(out.subarray(4, 8 + body.length))); | |
| 34 | + return out; | |
| 35 | + }; | |
| 36 | + const ihdr = new Uint8Array(13); | |
| 37 | + new DataView(ihdr.buffer).setUint32(0, img.width); | |
| 38 | + new DataView(ihdr.buffer).setUint32(4, img.height); | |
| 39 | + ihdr.set([8, 6, 0, 0, 0], 8); // 8-bit RGBA | |
| 40 | + const raw = new Uint8Array((img.width * 4 + 1) * img.height); | |
| 41 | + for (let y = 0; y < img.height; y++) raw.set(img.data.subarray(y * img.width * 4, (y + 1) * img.width * 4), y * (img.width * 4 + 1) + 1); | |
| 42 | + return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw)), chunk("IEND", new Uint8Array())]); | |
| 43 | +} | |
| 44 | + | |
| 45 | +let jpegReady: Promise<unknown> | undefined; | |
| 46 | +async function jpeg(img: Pixels, quality = 85): Promise<Uint8Array> { | |
| 47 | + jpegReady ??= (initJpegEncode as unknown as (m: WebAssembly.Module) => Promise<unknown>)(JPEG_ENC_WASM); | |
| 48 | + await jpegReady; | |
| 49 | + return new Uint8Array(await encodeJpeg(img as Parameters<typeof encodeJpeg>[0], { quality })); | |
| 50 | +} | |
| 51 | + | |
| 52 | +async function decodeWebp(bytes: ArrayBuffer): Promise<Pixels> { | |
| 53 | + await (initWebpDecode as unknown as (m: WebAssembly.Module) => Promise<unknown>)(WEBP_DEC_WASM); | |
| 54 | + return (await decodeWebpWasm(bytes)) as Pixels; | |
| 55 | +} | |
| 56 | + | |
| 57 | +async function webp(img: Pixels, lossless = false): Promise<Uint8Array> { | |
| 58 | + await makeAvatarImages(png(pixels(1, 1, () => [0, 0, 0]))); // initialises the shared encoder | |
| 59 | + return new Uint8Array(await encodeWebp(img as Parameters<typeof encodeWebp>[0], { quality: 80, lossless: lossless ? 1 : 0 })); | |
| 60 | +} | |
| 61 | + | |
| 62 | +/** Insert an EXIF APP1 segment carrying only an orientation tag right after SOI. */ | |
| 63 | +function withOrientation(jpg: Uint8Array, orientation: number, le = true): Uint8Array { | |
| 64 | + const tiff = new Uint8Array(26); | |
| 65 | + const v = new DataView(tiff.buffer); | |
| 66 | + tiff.set(new TextEncoder().encode(le ? "II" : "MM"), 0); | |
| 67 | + v.setUint16(2, 42, le); | |
| 68 | + v.setUint32(4, 8, le); // IFD0 offset | |
| 69 | + v.setUint16(8, 1, le); // one entry | |
| 70 | + v.setUint16(10, 0x0112, le); // Orientation | |
| 71 | + v.setUint16(12, 3, le); // SHORT | |
| 72 | + v.setUint32(14, 1, le); // count | |
| 73 | + v.setUint16(18, orientation, le); | |
| 74 | + const body = Buffer.concat([Buffer.from("Exif\0\0", "binary"), tiff]); | |
| 75 | + const seg = Buffer.concat([Buffer.from([0xff, 0xe1, (body.length + 2) >> 8, (body.length + 2) & 0xff]), body]); | |
| 76 | + return Buffer.concat([jpg.subarray(0, 2), seg, jpg.subarray(2)]); | |
| 77 | +} | |
| 78 | + | |
| 79 | +/** A photo-like test card: smooth gradients plus fine noise, which is what makes real photos expensive to encode. */ | |
| 80 | +const photo: Paint = (x, y) => { | |
| 81 | + const n = ((x * 7919 + y * 104729) ^ (x * y)) % 23; | |
| 82 | + return [(x * 0.21 + Math.sin(y / 37) * 40 + 90 + n) & 255, (y * 0.17 + Math.cos(x / 53) * 50 + 100 + n) & 255, (150 + Math.sin((x + y) / 71) * 60 + n) & 255]; | |
| 83 | +}; | |
| 84 | + | |
| 85 | +const px = (img: Pixels, x: number, y: number) => [...img.data.subarray((y * img.width + x) * 4, (y * img.width + x) * 4 + 4)]; | |
| 86 | + | |
| 87 | +async function status(p: Promise<unknown>): Promise<{ status: number; message: string }> { | |
| 88 | + try { | |
| 89 | + await p; | |
| 90 | + } catch (e) { | |
| 91 | + if (e instanceof HttpError) return { status: e.status, message: e.detail }; | |
| 92 | + throw e; | |
| 93 | + } | |
| 94 | + throw new Error("expected a rejection"); | |
| 95 | +} | |
| 96 | + | |
| 97 | +// ---------------------------------------------------------------- tests | |
| 98 | + | |
| 99 | +describe("image format sniffing", () => { | |
| 100 | + it("recognises JPEG, PNG and WebP by their magic bytes only", async () => { | |
| 101 | + const img = pixels(3, 2, () => [10, 20, 30]); | |
| 102 | + expect(sniffImage(await jpeg(img))).toBe("jpeg"); | |
| 103 | + expect(sniffImage(png(img))).toBe("png"); | |
| 104 | + expect(sniffImage(await webp(img))).toBe("webp"); | |
| 105 | + expect(sniffImage(new TextEncoder().encode("GIF89a\x01\x00\x01\x00"))).toBeNull(); | |
| 106 | + expect(sniffImage(new TextEncoder().encode("\0\0\0\x18ftypheic"))).toBeNull(); | |
| 107 | + expect(sniffImage(new TextEncoder().encode("<svg xmlns='http://www.w3.org/2000/svg'/>"))).toBeNull(); | |
| 108 | + expect(sniffImage(new TextEncoder().encode("RIFF\0\0\0\0WAVEfmt "))).toBeNull(); | |
| 109 | + expect(sniffImage(new Uint8Array())).toBeNull(); | |
| 110 | + }); | |
| 111 | + | |
| 112 | + it("reads dimensions from headers without decoding", async () => { | |
| 113 | + const img = pixels(37, 21, photo); | |
| 114 | + expect(imageSize(await jpeg(img), "jpeg")).toEqual({ width: 37, height: 21 }); | |
| 115 | + expect(imageSize(withOrientation(await jpeg(img), 6), "jpeg")).toEqual({ width: 37, height: 21 }); | |
| 116 | + expect(imageSize(png(img), "png")).toEqual({ width: 37, height: 21 }); | |
| 117 | + expect(imageSize(await webp(img), "webp")).toEqual({ width: 37, height: 21 }); // VP8 | |
| 118 | + expect(imageSize(await webp(img, true), "webp")).toEqual({ width: 37, height: 21 }); // VP8L | |
| 119 | + expect(imageSize(await webp(pixels(37, 21, (x) => [1, 2, 3, x * 6])), "webp")).toEqual({ width: 37, height: 21 }); // VP8X (alpha) | |
| 120 | + expect(imageSize(new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0, 2]), "jpeg")).toBeNull(); | |
| 121 | + }); | |
| 122 | + | |
| 123 | + it("reads the EXIF orientation of a JPEG", async () => { | |
| 124 | + const jpg = await jpeg(pixels(8, 8, photo)); | |
| 125 | + expect(jpegOrientation(jpg)).toBe(1); | |
| 126 | + expect(jpegOrientation(withOrientation(jpg, 6))).toBe(6); | |
| 127 | + expect(jpegOrientation(withOrientation(jpg, 8, false))).toBe(8); | |
| 128 | + expect(jpegOrientation(withOrientation(jpg, 42))).toBe(1); | |
| 129 | + }); | |
| 130 | +}); | |
| 131 | + | |
| 132 | +describe("crop and scale math", () => { | |
| 133 | + it("takes the centred square", () => { | |
| 134 | + expect(squareCrop(3000, 2000)).toEqual({ x: 500, y: 0, side: 2000 }); | |
| 135 | + expect(squareCrop(2000, 3001)).toEqual({ x: 0, y: 500, side: 2000 }); | |
| 136 | + expect(squareCrop(64, 64)).toEqual({ x: 0, y: 0, side: 64 }); | |
| 137 | + }); | |
| 138 | + | |
| 139 | + it("box-filters by an integer factor, keeping at least the minimum", () => { | |
| 140 | + expect(shrinkFactor(4096, 512)).toBe(8); | |
| 141 | + expect(shrinkFactor(2000, 512)).toBe(3); // 666 px | |
| 142 | + expect(shrinkFactor(1023, 512)).toBe(1); | |
| 143 | + expect(shrinkFactor(100, 512)).toBe(1); | |
| 144 | + expect(cropAndShrink(pixels(3000, 2000, () => [1, 2, 3])).width).toBe(666); | |
| 145 | + expect(cropAndShrink(pixels(300, 200, () => [1, 2, 3])).width).toBe(200); | |
| 146 | + }); | |
| 147 | + | |
| 148 | + it("averages colour weighted by alpha and crops off the sides", () => { | |
| 149 | + // 4×2: red | green green | blue. The centre square is the two green columns. | |
| 150 | + const img = pixels(4, 2, (x) => (x === 0 ? [255, 0, 0] : x === 3 ? [0, 0, 255] : [0, 200, 0])); | |
| 151 | + const out = cropAndShrink(img, 1); | |
| 152 | + expect(out.width).toBe(1); | |
| 153 | + expect(px(out, 0, 0)).toEqual([0, 200, 0, 255]); | |
| 154 | + // A transparent black pixel next to opaque white: still white, half transparent. | |
| 155 | + const half = cropAndShrink(pixels(2, 1, (x) => (x ? [255, 255, 255, 255] : [0, 0, 0, 0])), 1); | |
| 156 | + expect(px(half, 0, 0)).toEqual([0, 0, 0, 0]); // 2×1 crops to the centre 1×1 (x = 0) | |
| 157 | + const quad = cropAndShrink(pixels(2, 2, (x) => (x ? [255, 255, 255, 255] : [0, 0, 0, 0])), 1); | |
| 158 | + expect(px(quad, 0, 0)).toEqual([255, 255, 255, 128]); | |
| 159 | + }); | |
| 160 | + | |
| 161 | + it("applies EXIF orientations to a square", () => { | |
| 162 | + const A = [1, 0, 0, 255], | |
| 163 | + B = [2, 0, 0, 255], | |
| 164 | + C = [3, 0, 0, 255], | |
| 165 | + D = [4, 0, 0, 255]; | |
| 166 | + const img = pixels(2, 2, (x, y) => [[A, B], [C, D]][y]![x]! as [number, number, number, number]); | |
| 167 | + const grid = (o: number) => { | |
| 168 | + const r = orientSquare(img, o); | |
| 169 | + return [px(r, 0, 0)[0], px(r, 1, 0)[0], px(r, 0, 1)[0], px(r, 1, 1)[0]]; | |
| 170 | + }; | |
| 171 | + expect(grid(1)).toEqual([1, 2, 3, 4]); | |
| 172 | + expect(grid(2)).toEqual([2, 1, 4, 3]); // mirrored | |
| 173 | + expect(grid(3)).toEqual([4, 3, 2, 1]); // 180° | |
| 174 | + expect(grid(4)).toEqual([3, 4, 1, 2]); // flipped | |
| 175 | + expect(grid(5)).toEqual([1, 3, 2, 4]); // transposed | |
| 176 | + expect(grid(6)).toEqual([3, 1, 4, 2]); // 90° clockwise | |
| 177 | + expect(grid(7)).toEqual([4, 2, 3, 1]); // transversed | |
| 178 | + expect(grid(8)).toEqual([2, 4, 1, 3]); // 90° counter-clockwise | |
| 179 | + }); | |
| 180 | +}); | |
| 181 | + | |
| 182 | +describe("decode → crop → resize → encode", () => { | |
| 183 | + it("turns a landscape photo into small square WebPs of the centre", async () => { | |
| 184 | + // Red and blue bars outside the centre square; the avatar must be all photo, no bars. | |
| 185 | + const jpg = await jpeg(pixels(1200, 800, (x, y) => (x < 200 ? [255, 0, 0] : x >= 1000 ? [0, 0, 255] : photo(x, y)))); | |
| 186 | + const out = await makeAvatarImages(jpg); | |
| 187 | + expect([...out.keys()]).toEqual([...AVATAR_SIZES]); | |
| 188 | + const sizes: Record<number, number> = {}; | |
| 189 | + for (const [size, bytes] of out) { | |
| 190 | + sizes[size] = bytes.byteLength; | |
| 191 | + expect(sniffImage(bytes)).toBe("webp"); | |
| 192 | + expect(imageSize(bytes, "webp")).toEqual({ width: size, height: size }); | |
| 193 | + const back = await decodeWebp(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer); | |
| 194 | + for (const [x, y] of [ | |
| 195 | + [0, 0], | |
| 196 | + [size - 1, size / 2], | |
| 197 | + ]) { | |
| 198 | + const [r, , b] = px(back as Pixels, x!, y!); | |
| 199 | + expect(r! < 200 || b! < 200).toBe(true); // neither pure red nor pure blue survived the crop | |
| 200 | + } | |
| 201 | + } | |
| 202 | + // A few KB each. | |
| 203 | + console.log("avatar WebP bytes (1200×800 synthetic photo):", sizes); | |
| 204 | + expect(sizes[256]!).toBeLessThan(20_000); | |
| 205 | + expect(sizes[64]!).toBeLessThan(sizes[256]!); | |
| 206 | + }); | |
| 207 | + | |
| 208 | + it("keeps transparency from a PNG and accepts WebP input", async () => { | |
| 209 | + const pic = pixels(300, 300, (x, y) => ((x - 150) ** 2 + (y - 150) ** 2 < 120 ** 2 ? [30, 120, 220] : [0, 0, 0, 0])); | |
| 210 | + const fromPng = await makeAvatarImages(png(pic)); | |
| 211 | + const corner = px((await decodeWebp(fromPng.get(64)!.slice().buffer)) as Pixels, 0, 0); | |
| 212 | + const centre = px((await decodeWebp(fromPng.get(64)!.slice().buffer)) as Pixels, 32, 32); | |
| 213 | + expect(corner[3]).toBe(0); | |
| 214 | + expect(centre[3]).toBe(255); | |
| 215 | + const fromWebp = await makeAvatarImages(await webp(pixels(100, 50, photo))); | |
| 216 | + expect(imageSize(fromWebp.get(256)!, "webp")).toEqual({ width: 256, height: 256 }); | |
| 217 | + }); | |
| 218 | + | |
| 219 | + it("rotates a JPEG by its EXIF orientation", async () => { | |
| 220 | + // Stored: red top half, blue bottom half. Orientation 6 (rotate 90° clockwise) shows red on the right. | |
| 221 | + const jpg = withOrientation(await jpeg(pixels(300, 300, (_x, y) => (y < 150 ? [230, 20, 20] : [20, 20, 230])), 95), 6); | |
| 222 | + const out = await makeAvatarImages(jpg); | |
| 223 | + const img = (await decodeWebp(out.get(128)!.slice().buffer)) as Pixels; | |
| 224 | + const [lr, , lb] = px(img, 10, 64); | |
| 225 | + const [rr, , rb] = px(img, 117, 64); | |
| 226 | + expect(lb!).toBeGreaterThan(lr!); | |
| 227 | + expect(rr!).toBeGreaterThan(rb!); | |
| 228 | + }); | |
| 229 | + | |
| 230 | + it("rejects what it cannot or should not handle", async () => { | |
| 231 | + expect(await status(makeAvatarImages(new TextEncoder().encode("GIF89a\x01\x00\x01\x00")))).toEqual({ status: 422, message: "Use a JPEG, PNG or WebP image." }); | |
| 232 | + expect((await status(makeAvatarImages(new Uint8Array(MAX_AVATAR_BYTES + 1)))).status).toBe(413); | |
| 233 | + // A header claiming 5000×10: refused before anything is decoded. | |
| 234 | + const wide = png(pixels(1, 1, () => [0, 0, 0])); | |
| 235 | + new DataView(wide.buffer, wide.byteOffset).setUint32(16, 5000); | |
| 236 | + expect(await status(makeAvatarImages(wide))).toMatchObject({ status: 422, message: expect.stringContaining("3072") }); | |
| 237 | + // A plausible JPEG header (64×64 baseline frame) followed by junk. | |
| 238 | + const junk = Uint8Array.from([0xff, 0xd8, 0xff, 0xc0, 0, 17, 8, 0, 64, 0, 64, 3, 1, 0x22, 0, 2, 0x11, 1, 3, 0x11, 1, ...Array.from({ length: 200 }, (_, i) => (i * 37) & 0xff)]); | |
| 239 | + expect(imageSize(junk, "jpeg")).toEqual({ width: 64, height: 64 }); | |
| 240 | + expect(await status(makeAvatarImages(junk))).toMatchObject({ status: 422, message: expect.stringContaining("could not be read") }); | |
| 241 | + }); | |
| 242 | +}); |
addedapps/diffinery/vitest.config.ts+23−0View file
| @@ -1,0 +1,23 @@ | ||
| 1 | +import { defineConfig } from "vitest/config"; | |
| 2 | + | |
| 3 | +// The path is base64-encoded so the id does not look like a node_modules file, which vitest would hand to Node unprocessed. | |
| 4 | +const PREFIX = "virtual:wasm-module/"; | |
| 5 | + | |
| 6 | +export default defineConfig({ | |
| 7 | + plugins: [ | |
| 8 | + { | |
| 9 | + // `import mod from "x.wasm?module"` gives a compiled WebAssembly.Module, like wrangler's CompiledWasm rule in the Worker. | |
| 10 | + name: "diffinery:wasm-module", | |
| 11 | + enforce: "pre", | |
| 12 | + async resolveId(source, importer) { | |
| 13 | + if (!source.endsWith(".wasm?module")) return null; | |
| 14 | + const file = await this.resolve(source.slice(0, -"?module".length), importer, { skipSelf: true }); | |
| 15 | + return file ? PREFIX + Buffer.from(file.id).toString("base64url") : null; | |
| 16 | + }, | |
| 17 | + load(id) { | |
| 18 | + if (!id.startsWith(PREFIX)) return null; | |
| 19 | + return `import { readFileSync } from "node:fs";\nexport default new WebAssembly.Module(readFileSync(${JSON.stringify(Buffer.from(id.slice(PREFIX.length), "base64url").toString())}));`; | |
| 20 | + }, | |
| 21 | + }, | |
| 22 | + ], | |
| 23 | +}); |
modifiedpnpm-lock.yaml+42−0View file
| @@ -48,12 +48,27 @@ | ||
| 48 | 48 | '@diffinery/workflow-yaml': |
| 49 | 49 | specifier: workspace:* |
| 50 | 50 | version: link:../../packages/workflow-yaml |
| 51 | + '@jsquash/jpeg': | |
| 52 | + specifier: 1.6.0 | |
| 53 | + version: 1.6.0 | |
| 54 | + '@jsquash/png': | |
| 55 | + specifier: 3.1.1 | |
| 56 | + version: 3.1.1 | |
| 57 | + '@jsquash/resize': | |
| 58 | + specifier: 2.1.1 | |
| 59 | + version: 2.1.1 | |
| 60 | + '@jsquash/webp': | |
| 61 | + specifier: 1.5.0 | |
| 62 | + version: 1.5.0 | |
| 51 | 63 | '@simplewebauthn/server': |
| 52 | 64 | specifier: 14.0.3 |
| 53 | 65 | version: 14.0.3 |
| 54 | 66 | hono: |
| 55 | 67 | specifier: ^4.13.0 |
| 56 | 68 | version: 4.13.13 |
| 69 | + shiki: | |
| 70 | + specifier: 4.5.0 | |
| 71 | + version: 4.5.0 | |
| 57 | 72 | yaml: |
| 58 | 73 | specifier: ^2.9.1 |
| 59 | 74 | version: 2.9.1 |
| @@ -1260,6 +1275,18 @@ | ||
| 1260 | 1275 | '@jridgewell/trace-mapping@0.3.9': |
| 1261 | 1276 | resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} |
| 1262 | 1277 | |
| 1278 | + '@jsquash/jpeg@1.6.0': | |
| 1279 | + resolution: {integrity: sha512-zwN46Awh1VM6gXlIcALwb5WzqK5H2e6+Awcs1QP8AvS8ohsK/sbE4esvmH4jhlhW7+CgiUUww66vg0aTnlSIMA==} | |
| 1280 | + | |
| 1281 | + '@jsquash/png@3.1.1': | |
| 1282 | + resolution: {integrity: sha512-C10pc+0H6j0h8fENOfnGOvkXCmvpSQTDGlfGd0sHphZhPSGTyLjIrHba0FaZZdsKqA/wlmhYicUHb92vfZphaw==} | |
| 1283 | + | |
| 1284 | + '@jsquash/resize@2.1.1': | |
| 1285 | + resolution: {integrity: sha512-0R5UL1ZLHUT+carjVikcE1QfA+kfNQ2YamYyGVRmhfh4zttU5EY3bQBGxPIPtY2xIAw1P4Kgyxm2xrceRw1r2w==} | |
| 1286 | + | |
| 1287 | + '@jsquash/webp@1.5.0': | |
| 1288 | + resolution: {integrity: sha512-KggLoj2MnRSfIqTeKe1EmbljTX2vuV7mh79k89PCL1pyqiDULcPM1L47twxXt0hkb68F70bXiL31MxsuoZtKFw==} | |
| 1289 | + | |
| 1263 | 1290 | '@levischuck/tiny-cbor@0.2.11': |
| 1264 | 1291 | resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} |
| 1265 | 1292 | |
| @@ -2230,6 +2257,9 @@ | ||
| 2230 | 2257 | jsdom: |
| 2231 | 2258 | optional: true |
| 2232 | 2259 | |
| 2260 | + wasm-feature-detect@1.9.0: | |
| 2261 | + resolution: {integrity: sha512-zonE+xlIIYtxPy++L24ow0hAD8CICb4+FgPyROd3buyXIqsJvUEDkBgfCCoXOd1Hu3DUr0GOfnPIdcGV+YpNaA==} | |
| 2262 | + | |
| 2233 | 2263 | why-is-node-running@2.3.0: |
| 2234 | 2264 | resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} |
| 2235 | 2265 | engines: {node: '>=8'} |
| @@ -2969,6 +2999,16 @@ | ||
| 2969 | 2999 | '@jridgewell/resolve-uri': 3.1.2 |
| 2970 | 3000 | '@jridgewell/sourcemap-codec': 1.6.0 |
| 2971 | 3001 | |
| 3002 | + '@jsquash/jpeg@1.6.0': {} | |
| 3003 | + | |
| 3004 | + '@jsquash/png@3.1.1': {} | |
| 3005 | + | |
| 3006 | + '@jsquash/resize@2.1.1': {} | |
| 3007 | + | |
| 3008 | + '@jsquash/webp@1.5.0': | |
| 3009 | + dependencies: | |
| 3010 | + wasm-feature-detect: 1.9.0 | |
| 3011 | + | |
| 2972 | 3012 | '@levischuck/tiny-cbor@0.2.11': {} |
| 2973 | 3013 | |
| 2974 | 3014 | '@octokit/auth-token@6.0.0': {} |
| @@ -4141,6 +4181,8 @@ | ||
| 4141 | 4181 | transitivePeerDependencies: |
| 4142 | 4182 | - msw |
| 4143 | 4183 | |
| 4184 | + wasm-feature-detect@1.9.0: {} | |
| 4185 | + | |
| 4144 | 4186 | why-is-node-running@2.3.0: |
| 4145 | 4187 | dependencies: |
| 4146 | 4188 | siginfo: 2.0.0 |