| M | DESIGN.md | 6 | +- |
| M | README.md | 34 | ++++++- |
| M | bin/ | 62 | +++++++++-- |
| M | lib/ | 47 | ++++++++- |
| A | lib/ | 49 | ++++++++ |
| M | lib/ | 2 | +- |
| A | lib/ | 175 | ++++++++++++++++++++++++++++++ |
| M | lib/ | 5 | +- |
| M | lib/ | 5 | +- |
| M | site/ | 44 | ++++---- |
| M | site/ | 2 | +- |
| M | site/ | 3 | +- |
| M | site/ | 7 | + |
| M | site/ | 6 | +- |
| M | site/ | 95 | ++++++++++++++++- |
| A | site/ | 16 | +++ |
| A | test/ | 35 | ++++++ |
| A | test/ | 58 | ++++++++++ |
| A | test/ | 37 | ++++++ |
| M | test/ | 2 | +- |
| M | test/ | 2 | +- |
| A | test/ | 33 | ++++++ |
| A | test/ | 36 | ++++++ |
| A | test/ | 29 | +++++ |
| A | test/ | 17 | +++ |
| M | test/ | 4 | +- |
| M | test/ | 4 | +- |
| M | test/ | 4 | +- |
@@ -60,7 +60,9 @@ it starts a group: the style guide titles groups by those comments).
6060 One accent, `#3148F6`: `--link` for links (lighter in dark mode),6161 `--accent` for the current tab's bar, focus, primary fills, `--deep` on6262 hover. Meaning: `--add-*`, `--del-*`, `--pass`, `--fail`, `--warn`,63- `--status-*` fills.63+ `--status-*` fills. The home page's letter avatars are white on64+ `--avatar-1` to `--avatar-5`, seeded from the name, or the accent for65+ the forge's own owner.6466 - Type: `--text-xs` 12, `--text-code` 12.5, `--text-sm` 13.5, `--text-md`6567 15 (body), `--text-prose` 16, `--text-lg` 18, `--text-xl` 22,6668 `--text-2xl` 28. Weights `--fw-regular` 400 (body), `--fw-medium` 500@@ -101,7 +103,7 @@ Self-hosted variable woff2 in `site/assets/fonts/` (how they were cut:
101103 Words for where you are, symbols for how you look at it.102104 103105 - Tabs say where you are (`ui.tabs`): the repo's sections, the shelves on104- the index (with an "all" tab).106+ an owner's page and `/topics/` (with an "all" tab).105107 - A checkbox turns one thing on or off (`ui.checkbox`): wrap lines,106108 viewed.107109 - An icon toggle is a checkbox drawn as a symbol (`ui.iconToggle`): a@@ -197,6 +197,19 @@ ssh git@git.example.com invites [rm fox]
197197 ssh git@git.example.com users198198 ```199199 200+They also set the home page's motd, the quiet line under its header201+(the server's form is `forge motd`):202+203+```sh204+ssh git@git.example.com motd "notes, tools and games" # one line of plain text, 72 characters at most205+ssh git@git.example.com motd # print it206+ssh git@git.example.com motd --clear # no line at all207+ssh git@git.example.com motd --default # back to "a personal forge on plain git"208+```209+210+It is kept in `.forge/motd` (no file is the default, an empty one no line),211+which backups carry, and setting it rebuilds the home page.212+200213 An admin's ssh session finds them in a `git-shell-commands` of their own,201214 `.forge/admin/git-shell-commands` (`forge server init` writes it), so to202215 anyone else they don't exist: git-shell answers `fatal: unrecognized@@ -1326,7 +1339,26 @@ first build takes minutes.
13261339 13271340 ## Reading pages13281341 1329-The index and each owner's page show repos as rows on shelves by category,1342+The home page has the motd (above), then three panels. **profiles**: each1343+owner with public repos, how many and when they were last active.1344+**recent**: the last eight things that happened on public repos, newest1345+first: landed reviews, pushes to a default branch, releases (a tag or1346+release pushed) and site deploys, each linking to what it names1347+(`lib/recent.ts`). Landed reviews come from the review logs; the rest1348+from `.forge/events.jsonl`, whose push lines carry the default branch's new1349+tip as `head`. A push whose head is a review's tip is that review landing,1350+and shows once, as landed. Every build redoes the home page, and open1351+copies of it swap in the new one when the build is done, so the list is1352+as fresh as the last push; its times stay relative in the browser. The1353+events log is cut and left out of backups, so after a restore the list has1354+only landed reviews until the next pushes. **topics**: the shelves (below)1355+across every public repo; one owner's links to that shelf on their page1356+(`/dami/#games`, which opens on its tab), and one that several owners1357+share to `/topics/#games`, every public repo on its shelf, each naming its1358+owner. Private repos, and owners with only private ones, are never on1359+any of it.1360+1361+Each owner's page, and `/topics/`, show repos as rows on shelves by category,13301362 newest first. A repo's category is one line in the bare repo's `category`13311363 file, next to `description` (`printf 'games\n' > /srv/git/dami/greet.git/category`).13321364 A repo without one goes on the shelf for what Jev says it is (a web app, a@@ -24,6 +24,7 @@
2424 # forge user invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator] a one-time link to join2525 # forge user invites [rm <name>] invites not taken up yet2626 # forge users everyone: keys, passkeys; and who is invited27+# forge motd [<text>|--clear|--default] the line under the header on /2728 # forge readers|collaborators <owner>/<repo> [add|rm <user>] who may read or push a repo2829 # forge keys sync2930 # forge repo new [--private] <owner>/<name> [description]@@ -57,8 +58,8 @@ NS=forge-ci
5758 RNS=forge-release5859 ZERO=00000000000000000000000000000000000000005960 # ssh commands for the forge's admins only (is_admin): the operator's60-# forge user invite, user invites and users.61-ADMIN_CMDS='invite invites users'61+# forge user invite, user invites and users, and motd.62+ADMIN_CMDS='invite invites users motd'6263 CI_SCRIPT=.forge/ci6364 6465 die() { printf 'forge: %s\n' "$*" >&2; exit 1; }@@ -1020,7 +1021,7 @@ load_config() {
10201021 10211022 valid_name() {10221023 case $1 in1023- '' | .* | *[!A-Za-z0-9._-]* | *.git | assets | sites | previews | hosts | insights | design | git-shell-commands) return 1 ;;1024+ '' | .* | *[!A-Za-z0-9._-]* | *.git | assets | sites | previews | hosts | insights | design | topics | git-shell-commands) return 1 ;;10241025 esac10251026 }10261027 @@ -1508,6 +1509,42 @@ can_read() {
15081509 return 115091510 }15101511 1512+# forge motd [<text>|--clear|--default] (and ssh motd, for admins): the1513+# quiet line under the header on the home page. Without arguments it prints1514+# it. Text is one line of plain text, at most 72 characters, trimmed; the1515+# page escapes it (lib/motd.ts checks it). --clear shows no line, --default1516+# the default one. It is kept in .forge/motd, which backups carry: no file1517+# is the default, an empty one no line. A change rebuilds the index pages.1518+cmd_motd() {1519+ if [ -n "${FORGE_USER:-}" ]; then mu="usage: motd"; else mu="usage: forge motd"; fi1520+ f=$FORGE_STATE/motd1521+ if [ $# -eq 0 ]; then1522+ if [ ! -f "$f" ]; then1523+ node "$FORGE_HOME/lib/motd.ts" default1524+ say "(the default; motd <text> sets another)"1525+ elif [ -s "$f" ]; then cat "$f"1526+ else say "(none: the home page shows no line; motd <text> sets one)"; fi1527+ return 01528+ fi1529+ case $1 in1530+ --clear | --default)1531+ [ $# -eq 1 ] || die "$mu [<text>|--clear|--default]"1532+ if [ "$1" = --clear ]; then : >"$f"; else rm -f "$f"; fi1533+ ;;1534+ -*) die "$mu [<text>|--clear|--default]" ;;1535+ *)1536+ text=$(node "$FORGE_HOME/lib/motd.ts" check "$*") || exit 11537+ printf '%s\n' "$text" >"$f.tmp" && mv "$f.tmp" "$f"1538+ ;;1539+ esac1540+ rebuild - motd1541+ case $1 in1542+ --clear) say "the home page shows no line now" ;;1543+ --default) say "the home page shows the default line now" ;;1544+ *) say "the home page says: $text" ;;1545+ esac1546+}1547+15111548 # is_admin <user>: is user one of the forge's admins? They are the values15121549 # of forge.admins in the server's git config, $FORGE_ROOT/.gitconfig (the15131550 # git user's own; several values, or names split by spaces or commas).@@ -2184,10 +2221,11 @@ cmd_shell_cmd() {
21842221 release) sc_release "$@" ;;21852222 rm) sc_rm "$@" ;;21862223 passkey | passkeys | sessions | tokens) credentials "$c" "$@" ;;2187- invite | invites | users)2224+ invite | invites | users | motd)21882225 # Reached only through the admins' git-shell-commands (cmd_shell).21892226 is_admin "$user" || die "unknown command $c"21902227 case $c in2228+ motd) cmd_motd "$@" ;;21912229 invite) user_invite "$@" ;;21922230 invites) node "$FORGE_HOME/lib/identity.ts" invites "$@" ;;21932231 users) [ $# -eq 0 ] || die "usage: users"; node "$FORGE_HOME/lib/identity.ts" users ;;@@ -2206,7 +2244,7 @@ cmd_shell_cmd() {
22062244 ;;22072245 *) printf 'commands:\n new [--private] <name> [description] create %s/<name>\n ls list your repos, and private ones you may read\n signers keys that may sign CI results (for forge verify)\n site-url <owner>/<repo> <site> [branch] where a site deploy is served\n reviews <owner>/<repo> open and draft reviews (forge reviews)\n land <owner>/<repo> <id|branch> land a review here, with the same gates as a push\n comment, approve, approvals used by forge comment, approve and land\n private <owner>/<repo> only its owner, collaborators and readers can see it\n public <owner>/<repo> [--force] anyone can, once its whole history passes the secret check\n readers <owner>/<repo> [add|rm <user>] who may read it while it is private\n collaborators <owner>/<repo> [add|rm <user>] who may push anything to it; only its owner changes the list\n who <owner>/<repo> its owner, collaborators and readers\n release ls <owner>/<repo> its releases: tag, file, size, sha256\n release get <owner>/<repo> <tag> <file> a release file, on stdout\n rm <owner>/<repo> --yes remove a repo of yours (the admin can restore it for 14 days)\n passkey [name] a one-time link that adds a passkey, to sign in on the web\n passkeys [rm <id>] your passkeys\n sessions [rm <id>|--all] where you are signed in on the web; rm signs one out, --all all\n token [--repo <owner>/<repo>] [--ttl 30d] [name] a token for curl and HTTPS clones, shown once\n tokens [rm <id>] your tokens\na push to %s/<name>.git creates that repo when it is missing\n' "$user" "$user"22082246 if is_admin "$user"; then2209- printf 'for admins of the forge:\n invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator] a one-time link that makes someone a user\n invites [rm <name>] invites not taken up yet; rm makes a link dead\n users everyone here: keys, passkeys, tokens; then the invites\n'2247+ printf 'for admins of the forge:\n invite <name> [--ttl 7d] [--repo <owner>/<repo> --as reader|collaborator] a one-time link that makes someone a user\n invites [rm <name>] invites not taken up yet; rm makes a link dead\n users everyone here: keys, passkeys, tokens; then the invites\n motd [<text>|--clear|--default] the line under the header on the home page: print it, set it (72 characters at most), hide it, or go back to the default\n'22102248 fi22112249 ;;22122250 esac@@ -2636,13 +2674,13 @@ noreview
26362674 DEFAULT=$first26372675 fi26382676 fi2639- PUSHED= rel=02677+ PUSHED= rel=0 head=26402678 while read -r old new ref; do26412679 case $ref in refs/heads/* | refs/tags/* | refs/sites/* | refs/releases/*) PUSHED="$PUSHED $ref" ;; esac26422680 case $ref in refs/tags/* | refs/releases/*) rel=1 ;; esac26432681 [ "$new" != "$ZERO" ] || case $ref in refs/heads/*) drop_previews "${ref#refs/heads/}" ;; esac26442682 case $ref in2645- "refs/heads/$DEFAULT") ;;2683+ "refs/heads/$DEFAULT") [ "$new" = "$ZERO" ] || head=$new ;;26462684 refs/heads/*) [ "$noreview" = 1 ] || record_review "${ref#refs/heads/}" "$new" ;;26472685 refs/sites/*) push_site "sites/$OWNER/$NAME/${ref#refs/sites/}" "$new" ;;26482686 refs/site-previews/*)@@ -2658,7 +2696,10 @@ noreview
26582696 if [ "$rel" = 1 ]; then release_latest "$(pages_root "$GIT_DIR")"; fi26592697 git update-server-info26602698 refs=$(printf '%s' "$PUSHED" | tr -d '"\\')2661- printf '{"t":%s000,"repo":"%s","type":"push","user":"%s","refs":"%s"%s}\n' "${FORGE_NOW:-$(date +%s)}" "$OWNER/$NAME" "${USER_:-admin}" "$refs" "$(ev_private "$GIT_DIR")" >>"$FORGE_STATE/events.jsonl"2699+ # head: the default branch's new tip, for the index's recent list (lib/recent.ts).2700+ hf=2701+ [ -z "$head" ] || hf=",\"head\":\"$head\""2702+ printf '{"t":%s000,"repo":"%s","type":"push","user":"%s"%s,"refs":"%s"%s}\n' "${FORGE_NOW:-$(date +%s)}" "$OWNER/$NAME" "${USER_:-admin}" "$hf" "$refs" "$(ev_private "$GIT_DIR")" >>"$FORGE_STATE/events.jsonl"26622703 rebuild "$OWNER/$NAME" push26632704 }26642705 @@ -2695,7 +2736,7 @@ build_all() {
26952736 [ -d "$w" ] || continue26962737 slug=${w#"$FORGE_WWW"/}26972738 slug=${slug%/}2698- case $slug in assets/* | sites/* | previews/* | hosts/* | insights/* | design/*) continue ;; esac2739+ case $slug in assets/* | sites/* | previews/* | hosts/* | insights/* | design/* | topics/*) continue ;; esac26992740 [ -f "$FORGE_ROOT/$slug.git/HEAD" ] && [ -f "$FORGE_ROOT/$slug.git/git-daemon-export-ok" ] || rm -rf "$w"27002741 done27012742 for w in "$FORGE_WWW"/sites/*/*/ "$FORGE_WWW"/previews/*/*/; do@@ -2706,7 +2747,7 @@ build_all() {
27062747 done27072748 for w in "$FORGE_WWW"/*/; do27082749 b=$(basename "$w")2709- case $b in assets | sites | previews | hosts | insights | design) continue ;; esac2750+ case $b in assets | sites | previews | hosts | insights | design | topics) continue ;; esac27102751 [ -d "$FORGE_ROOT/$b" ] || rm -rf "$w"27112752 done27122753 if [ -d "$FORGE_WWW/hosts" ]; then node "$FORGE_HOME/lib/hosts.ts" prune; fi@@ -3766,6 +3807,7 @@ server)
37663807 ;;37673808 user) cmd_user "$@" ;;37683809 users) [ $# -eq 0 ] || die "usage: forge users"; load_config; node "$FORGE_HOME/lib/identity.ts" users ;;3810+motd) load_config; cmd_motd "$@" ;;37693811 readers | collaborators) cmd_names "$cmd" "$@" ;;37703812 keys) [ "${1:-}" = sync ] || die "usage: forge keys sync"; cmd_keys_sync ;;37713813 repo) cmd_repo "$@" ;;@@ -22,6 +22,8 @@ import { labelCommits, labelKey, releases, repoTopic } from './jev-commits.ts';
2222 import { annotateReviews, answersStamp, isMechanical, pairChanges } from './jev-reviews.ts';2323 import { HighlightCache } from './highlight.ts';2424 import { assets } from './assets.ts';25+import { recent, type RecentItem } from './recent.ts';26+import { motd } from './motd.ts';2527 import type { CiStatus, Commit, Config, DiffFile, Repo, RepoCounts, RepoSummary, RepoUrls, Review, ReviewComment, SiteLink } from './types.ts';2628 2729 export function config(env: NodeJS.ProcessEnv = process.env): Config {@@ -52,7 +54,7 @@ export function config(env: NodeJS.ProcessEnv = process.env): Config {
5254 const caseless = (a: string, b: string): number => a.localeCompare(b, 'en', { sensitivity: 'base' }) || (a < b ? -1 : a > b ? 1 : 0);5355 5456 // Top-level names used by the forge itself; never owners.55-const RESERVED = new Set(['assets', 'sites', 'previews', 'hosts', 'insights', 'index.html', 'git-shell-commands', 'lost+found', 'login', 'private']);57+const RESERVED = new Set(['assets', 'sites', 'previews', 'hosts', 'insights', 'topics', 'index.html', 'git-shell-commands', 'lost+found', 'login', 'private']);5658 5759 // Every repo, public or not (RepoSummary.private); builds filter.5860 export function discover(cfg: Config): { repos: RepoSummary[]; owners: string[] } {@@ -117,6 +119,12 @@ export interface Forge {
117119 config: Config;118120 repos: RepoSummary[];119121 owners: { owner: string; repos: RepoSummary[] }[];122+ // The index (site/index.njk): its line under the header (null: none),123+ // the public owners, the shelves across them, and what happened lately.124+ motd: string | null;125+ profiles: Profile[];126+ topics: Topic[];127+ recent: RecentItem[];120128 pages: Record<string, Page[]>;121129 raw: RawFile[];122130 // Incremental builds: the live site, and the pages taken from it unchanged.@@ -126,6 +134,32 @@ export interface Forge {
126134 pageCaches: { cache: RepoCache; fps: Record<string, string> }[];127135 }128136 137+export interface Profile {138+ owner: string;139+ repos: number;140+ // When they last did something public (lib/recent.ts), ISO.141+ active: string;142+ // Their letter avatar's colour: avatar-1 to avatar-5, seeded from the143+ // name, or the accent for the forge's own owner (FORGE_OWNER).144+ colour: string;145+}146+147+export interface Topic {148+ id: string;149+ title: string;150+ repos: number;151+ // One owner's: that shelf on their page; several owners': on /topics/.152+ href: string;153+}154+155+const AVATARS = 5;156+function colour(owner: string, env: NodeJS.ProcessEnv): string {157+ if (owner === env.FORGE_OWNER) return 'accent';158+ let h = 0;159+ for (const ch of owner) h = (h * 31 + ch.codePointAt(0)!) >>> 0;160+ return `avatar-${(h % AVATARS) + 1}`;161+}162+129163 let last: Forge | null = null;130164 export const current = (): Forge | null => last;131165 @@ -385,10 +419,17 @@ export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {
385419 fs.writeFileSync(summaryCache, JSON.stringify(Object.fromEntries(summaries.map((s) => [s.slug, cached[s.slug] ?? { counts: {}, sites: [] }]))));386420 387421 const owners = ownerNames.map((owner) => ({ owner, repos: summaries.filter((s) => s.owner === owner) }));422+ // The index's panels; a private build has no index.423+ const lately = priv ? { items: [], active: {} } : recent(cfg, summaries);424+ const profiles = owners.filter((o) => o.repos.length).map((o) => ({ owner: o.owner, repos: o.repos.length, active: lately.active[o.owner] ?? '', colour: colour(o.owner, env) }));425+ const topics = filters.shelves(summaries).map((sh) => {426+ const who = [...new Set(sh.repos.map((r) => r.owner))];427+ return { id: sh.id, title: sh.title, repos: sh.repos.length, href: who.length === 1 ? `/${who[0]}/#${sh.id}` : `/topics/#${sh.id}` };428+ });388429 // Repo search on the index and owner pages.389430 raw.push({ path: '/repos.json', data: Buffer.from(reposIndex(summaries)) });390431 if (!cfg.only || cfg.only === '-') raw.push(...insightFiles(cfg, 'insights', '/insights/'));391- last = { config: cfg, repos: summaries, owners, pages, raw: raw.filter((r) => r.data || r.file), live, code, skipped, pageCaches };432+ last = { config: cfg, repos: summaries, owners, motd: motd(cfg.state), profiles, topics, recent: lately.items, pages, raw: raw.filter((r) => r.data || r.file), live, code, skipped, pageCaches };392433 return last;393434 }394435 @@ -529,7 +570,7 @@ export const filters = {
529570 return html.ci(status, { long });530571 },531572 date: html.date,532- // The index and owner pages: repos on shelves, newest first. A shelf is573+ // Owner pages, /topics/ and the home page's topics: repos on shelves, newest first. A shelf is533574 // the category the repo's owner gave it, else Jev's topic, else "other".534575 // Fuller shelves come first, "other" last.535576 shelves(repos: RepoSummary[]): { id: string; title: string; repos: RepoSummary[] }[] {@@ -0,0 +1,49 @@
1+// The motd: the quiet line under the header on the home page, which an2+// admin sets (bin/forge motd). It is kept in .forge/motd: no file is the3+// default, an empty one no line at all.4+//5+// node lib/motd.ts default the default line6+// node lib/motd.ts check <text> the text as stored (trimmed), or why not7+import fs from 'node:fs';8+import path from 'node:path';9+import { fileURLToPath } from 'node:url';10+11+export const MOTD_DEFAULT = 'a personal forge on plain git';12+export const MOTD_MAX = 72;13+14+// What the index shows: null for no line.15+export function motd(state: string): string | null {16+ let text: string;17+ try {18+ text = fs.readFileSync(path.join(state, 'motd'), 'utf8');19+ } catch {20+ return MOTD_DEFAULT;21+ }22+ return text.split('\n')[0].trim() || null;23+}24+25+// Plain text on one line, counted in characters; the page escapes it.26+export function check(text: string): { text: string } | { error: string } {27+ const s = text.trim();28+ const n = [...s].length;29+ if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/.test(s)) return { error: 'one line of plain text, without control characters' };30+ if (!n) return { error: 'empty; motd --clear shows no line' };31+ if (n > MOTD_MAX) return { error: `${n} characters; it can have ${MOTD_MAX} at most` };32+ return { text: s };33+}34+35+if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {36+ const [cmd, text = ''] = process.argv.slice(2);37+ if (cmd === 'default') console.log(MOTD_DEFAULT);38+ else if (cmd === 'check') {39+ const r = check(text);40+ if ('error' in r) {41+ console.error(`forge: motd: ${r.error}`);42+ process.exit(1);43+ }44+ console.log(r.text);45+ } else {46+ console.error('usage: node lib/motd.ts default | check <text>');47+ process.exit(2);48+ }49+}@@ -280,7 +280,7 @@ export function privateHandler(cfg: PrivateConfig): http.RequestListener {
280280 const who = gate.bySession(cookieValue(req.headers.cookie));281281 const html = render('login.njk', {282282 p: { title: `Sign in - ${cfg.name ?? 'forge'}` }, user: who?.user ?? '', csrf: who?.secret ? csrfToken(who.secret) : '',283- signal: who ? signalJson(who.user) : '', next: safeNext(url.searchParams.get('next')), ready: !!login, host: rp?.id ?? '',283+ signal: who ? signalJson(who.user) : '', next: safeNext(url.searchParams.get('next')), ready: !!login,284284 });285285 res.writeHead(200, { ...PAGE_HEADERS, ...cookie(who) }).end(html);286286 }@@ -0,0 +1,175 @@
1+// What happened lately on the forge's public repos, for the index's recent2+// list and its profiles: landed reviews, pushes to a default branch,3+// releases and site deploys, newest first.4+//5+// Pushes, releases and site deploys come from the events log6+// (.forge/events.jsonl): post-receive writes a line for each push with the7+// refs it moved, and the default branch's new tip as head. Landed reviews8+// come from the review logs, which last: a review whose branch was deleted9+// with its tip in the default branch, at the time it was deleted. A push10+// whose head is a review's tip is that landing, and shows as landed. The11+// events log is cut to its last 1000 lines and left out of backups, so12+// after a restore only landed reviews show until the next pushes.13+//14+// Private repos are never read: the list is made from the public repos the15+// build was given, and their events, which carry no "private" mark.16+import fs from 'node:fs';17+import path from 'node:path';18+import { isAncestor, tryRun } from './git.ts';19+import { readReviewLogs } from './reviews.ts';20+import type { Config, RepoSummary } from './types.ts';21+22+export type RecentKind = 'landed' | 'pushed' | 'release' | 'site';23+24+export interface RecentItem {25+ kind: RecentKind;26+ // ISO time, for <time datetime>.27+ at: string;28+ repo: { slug: string; name: string; owner: string };29+ href: string;30+ // The review's #id or the tag, before the text (a release's is its tag only).31+ ref: string;32+ text: string;33+}34+35+// When each owner last did something public: their newest event, landed36+// review or commit. ISO, '' for never.37+export type Active = Record<string, string>;38+39+interface Candidate {40+ kind: RecentKind;41+ t: number;42+ repo: RepoSummary;43+ // pushed: the new head; landed: the review's tip; release, site: the name.44+ key: string;45+ // landed: the review.46+ id?: number;47+ title?: string;48+ base?: string | null;49+}50+51+interface Event {52+ t?: number;53+ repo?: string;54+ type?: string;55+ refs?: string;56+ head?: string;57+ private?: boolean;58+}59+60+function events(state: string): Event[] {61+ let text = '';62+ try {63+ text = fs.readFileSync(path.join(state, 'events.jsonl'), 'utf8');64+ } catch {65+ return [];66+ }67+ const out: Event[] = [];68+ for (const line of text.split('\n')) {69+ if (!line.includes('"type":"push"')) continue;70+ try {71+ const e = JSON.parse(line);72+ if (e && e.type === 'push' && !e.private && typeof e.repo === 'string' && typeof e.t === 'number') out.push(e);73+ } catch {74+ // A line cut short by a crash.75+ }76+ }77+ return out;78+}79+80+const iso = (ms: number): string => new Date(ms).toISOString().replace(/\.\d+Z$/, 'Z');81+82+export function recent(cfg: Config, repos: RepoSummary[], n = 8): { items: RecentItem[]; active: Active } {83+ const bySlug = new Map(repos.map((r) => [r.slug, r]));84+ const latest: Record<string, number> = {};85+ const seen = (owner: string, t: number): void => {86+ if (t > (latest[owner] ?? 0)) latest[owner] = t;87+ };88+ for (const r of repos) if (r.lastCommit) seen(r.owner, Date.parse(r.lastCommit));89+90+ const list: Candidate[] = [];91+ for (const e of events(cfg.state)) {92+ const repo = bySlug.get(e.repo!);93+ if (!repo) continue;94+ seen(repo.owner, e.t!);95+ const named = new Set<string>();96+ for (const ref of (e.refs ?? '').split(/\s+/).filter(Boolean)) {97+ if (ref === `refs/heads/${repo.defaultBranch}`) {98+ if (e.head && /^[0-9a-f]{40,64}$/.test(e.head)) list.push({ kind: 'pushed', t: e.t!, repo, key: e.head });99+ } else {100+ const m = /^refs\/(tags|releases|sites)\/(.+)$/.exec(ref);101+ if (!m) continue;102+ const kind = m[1] === 'sites' ? 'site' : 'release';103+ // A tag and its release in one push are one release.104+ if (named.has(kind + ' ' + m[2])) continue;105+ named.add(kind + ' ' + m[2]);106+ list.push({ kind, t: e.t!, repo, key: m[2] });107+ }108+ }109+ }110+111+ // Every review's revisions, by repo: a push of one to the default branch landed it.112+ const revs = new Map<string, Set<string>>();113+ for (const repo of repos) {114+ const shas = new Set<string>();115+ for (const r of readReviewLogs(repo.dir, false)) {116+ for (const v of r.revs) shas.add(v.sha);117+ const tip = r.revs.at(-1);118+ if (!r.deleted || !tip) continue;119+ seen(repo.owner, r.deleted.ts * 1000);120+ list.push({ kind: 'landed', t: r.deleted.ts * 1000, repo, key: tip.sha, id: r.id, title: r.opts.title, base: tip.base });121+ }122+ revs.set(repo.slug, shas);123+ }124+125+ // The refs each repo has now: a deleted tag or site isn't news.126+ const refs = new Map<string, Set<string>>();127+ const refsOf = (repo: RepoSummary): Set<string> => {128+ let s = refs.get(repo.slug);129+ if (!s) {130+ s = new Set((tryRun(repo.dir, ['for-each-ref', '--format=%(refname)', 'refs/tags', 'refs/releases', 'refs/sites']) ?? '').split('\n').filter(Boolean));131+ refs.set(repo.slug, s);132+ }133+ return s;134+ };135+ const subject = (repo: RepoSummary, rev: string): string | null => tryRun(repo.dir, ['log', '-1', '--format=%s', rev, '--'])?.trim() ?? null;136+137+ // Newest first; checked one by one (git), until there are n.138+ list.sort((a, b) => b.t - a.t || Number(b.kind === 'landed') - Number(a.kind === 'landed'));139+ const items: RecentItem[] = [];140+ const done = new Set<string>();141+ for (const c of list) {142+ if (items.length >= n) break;143+ const { repo } = c;144+ const once = `${c.kind} ${repo.slug} ${c.key}`;145+ if (done.has(once)) continue;146+ let href = '', ref = '', text = '';147+ if (c.kind === 'pushed') {148+ if (revs.get(repo.slug)?.has(c.key)) continue;149+ const s = subject(repo, c.key);150+ if (s == null) continue;151+ href = repo.urls.commit(c.key).href;152+ text = s;153+ } else if (c.kind === 'landed') {154+ if (!isAncestor(repo.dir, c.key, `refs/heads/${repo.defaultBranch}`)) continue;155+ // Untitled, a review is named for its first commit (as lib/repo.ts names it).156+ const first = c.title ? null : tryRun(repo.dir, ['log', '--reverse', '--format=%s', c.base ? `${c.base}..${c.key}` : c.key, '--'])?.split('\n')[0];157+ href = repo.urls.review(c.id!).href;158+ ref = `#${c.id}`;159+ text = c.title || first || '';160+ } else if (c.kind === 'release') {161+ const have = refsOf(repo);162+ if (!have.has(`refs/tags/${c.key}`) && !have.has(`refs/releases/${c.key}`)) continue;163+ href = `${repo.urls.releases.href}#${encodeURIComponent(c.key)}`;164+ ref = c.key;165+ } else {166+ if (!refsOf(repo).has(`refs/sites/${c.key}`)) continue;167+ const sites = repo.sites ?? [];168+ href = sites.find((s) => s.name === c.key)?.url ?? repo.urls.site(c.key);169+ text = sites.length > 1 ? `deployed its site ${c.key}` : 'deployed its site';170+ }171+ done.add(once);172+ items.push({ kind: c.kind, at: iso(c.t), repo: { slug: repo.slug, name: repo.name, owner: repo.owner }, href, ref, text });173+ }174+ return { items, active: Object.fromEntries(Object.entries(latest).map(([o, t]) => [o, iso(t)])) };175+}@@ -161,12 +161,13 @@ if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import
161161 const forge = loadForge();162162 const render = pageRenderer();163163 const templates = listTemplates();164- // Every page, unless lists are named: the index, owners, the forge's insights, the style guide, and forge.pages.<list>.164+ // Every page, unless lists are named: the index, owners, the forge's insights, the style guide, topics, and forge.pages.<list>.165165 const jobs: { file: string; template: string; data: Record<string, unknown> }[] = [];166- for (const list of lists.length ? lists : ['index', 'owners', 'forgeInsights', 'design', ...templates.keys()]) {166+ for (const list of lists.length ? lists : ['index', 'owners', 'forgeInsights', 'design', 'topics', ...templates.keys()]) {167167 if (list === 'index') jobs.push({ file: '/index.html', template: 'index.njk', data: { forge } });168168 else if (list === 'forgeInsights') jobs.push({ file: '/insights/index.html', template: 'insights.njk', data: { forge } });169169 else if (list === 'design') jobs.push({ file: '/design/index.html', template: 'design.njk', data: { forge } });170+ else if (list === 'topics') jobs.push({ file: '/topics/index.html', template: 'topics.njk', data: { forge } });170171 else if (list === 'owners') for (const o of forge.owners) jobs.push({ file: `/${o.owner}/index.html`, template: 'owner.njk', data: { forge, o } });171172 else {172173 const template = templates.get(list);@@ -7,11 +7,12 @@ import { revList, isAncestor, tryRun } from './git.ts';
77 import { readComments, parseApproval } from './discussion.ts';88 import type { Review, ReviewLog, Revision } from './types.ts';99 10-export function readReviewLogs(gitDir: string): ReviewLog[] {10+// comments: false leaves them out (the index's recent list, lib/recent.ts).11+export function readReviewLogs(gitDir: string, comments = true): ReviewLog[] {1112 const dir = path.join(gitDir, 'forge', 'reviews');1213 if (!fs.existsSync(dir)) return [];1314 return fs.readdirSync(dir).filter((f) => /^\d+$/.test(f)).map((f): ReviewLog => {14- const r: ReviewLog = { id: Number(f), branch: '', created: null, opts: {}, draft: false, revs: [], deleted: null, approvals: [], comments: readComments(gitDir, Number(f)) };15+ const r: ReviewLog = { id: Number(f), branch: '', created: null, opts: {}, draft: false, revs: [], deleted: null, approvals: [], comments: comments ? readComments(gitDir, Number(f)) : [] };1516 for (const line of fs.readFileSync(path.join(dir, f), 'utf8').split('\n')) {1617 const sp = line.indexOf(' ');1718 const k = sp < 0 ? line : line.slice(0, sp);@@ -2,8 +2,10 @@
22 sign in with a passkey, add one from the link that ssh <host> passkey33 prints (/login/#enroll=<code>; login.ts does both), join from the link44 forge user invite prints (/login/#invite=<code>: an SSH key, then a5- passkey), and, signed in, sign out. Data: forge, p (title), user (or ''), csrf, signal (JSON or ''),6- next, ready (sign-in is set up: FORGE_BASE_URL), host. -#}5+ passkey), and, signed in, sign out. Each step is a panel named in6+ lowercase for the forge (FORGE_NAME), not its domain; ssh commands show7+ the real host, to copy. Data: forge, p (title), user (or ''), csrf,8+ signal (JSON or ''), next, ready (sign-in is set up: FORGE_BASE_URL). -#}79 {% extends "base.njk" %}810 {% import "ui.njk" as ui %}911 {% block scripts %}@@ -14,44 +16,42 @@
1416 {% endblock %}1517 {% block content %}1618 {% css %}17-/* the sign-in page */19+/* the sign-in page: a panel for each step, the status and the help under them */1820 .login { max-width: 40em; }19-.login h1 { font-size: var(--text-xl); margin: 0 0 var(--space-3); }20-.login-status { min-height: 1.4em; }21+.login-status { min-height: 1.4em; margin: var(--space-3) 0 0; }2122 .login-status.bad { color: var(--fail); }2223 .login form { display: inline; }2324 .login .input { max-width: 36em; }2425 {% endcss %}25-<div class="panel login" id="login" data-next="{{ next }}"{% if signal %} data-signal="{{ signal }}"{% endif %}><div class="panel-body">26+<div class="login" id="login" data-next="{{ next }}"{% if signal %} data-signal="{{ signal }}"{% endif %}>2627 {% if user %}27-<h1>Signed in as {{ user }}</h1>28+{% set head %}<h2>signed in as <b>{{ user }}</b></h2>{% endset %}29+{% call ui.panel(head) %}<div class="panel-body">2830 <p>You see the pages of the private repos you can read: <a href="/private/">your private repos</a>.</p>2931 <form method="post" action="/login/logout"><input type="hidden" name="csrf" value="{{ csrf }}">{{ ui.button('sign out', false, 'id="signout"', 'submit') }}</form>32+</div>{% endcall %}3033 {% elif not ready %}31-<h1>Sign in</h1>34+{% call ui.panel('<h2>sign in</h2>') %}<div class="panel-body">3235 <p>Sign-in isn't set up here: the forge needs <code>FORGE_BASE_URL</code>, the origin passkeys belong to.</p>36+</div>{% endcall %}3337 {% else %}34-<div id="signin-part">35-<h1>Sign in</h1>38+{% call ui.panel('<h2>sign in</h2>', '', 'id="signin-part"') %}<div class="panel-body">3639 <p>Private repos show their pages to their readers. Sign in with a passkey you have added to see them.</p>3740 <p>{{ ui.button('sign in with a passkey', true, 'id="signin"') }}</p>38-</div>39-<div id="invite-part" hidden>40-<h1>Join {{ host }}</h1>41-<div id="invite-form">41+</div>{% endcall %}42+{% set head %}<h2>join {{ forge.config.name }}</h2>{% endset %}43+{% call ui.panel(head, '', 'id="invite-part" hidden') %}<div class="panel-body" id="invite-form">4244 <p>You are invited as <b id="invite-user">you</b><span id="invite-grant"></span>. Your SSH key is how you get in: paste the public half, the one line of a <code>.pub</code> file such as <code>~/.ssh/id_ed25519.pub</code>. None yet? <code>ssh-keygen -t ed25519</code> makes one.</p>4345 <p>{{ ui.textarea('ssh public key', 'ssh-ed25519 AAAA... you@laptop', 'id="invite-key" rows="3" spellcheck="false" autocomplete="off" autocapitalize="off" maxlength="8192"', true) }}</p>4446 <p>{{ ui.button('join', true, 'id="invite-join"') }}</p>45-</div>46-</div>47-<div id="enroll-part" hidden>48-<h1>Add a passkey</h1>49-<p id="joined" hidden>You are <b id="joined-user">in</b> on {{ host }}: <code>ssh {{ forge.config.ssh }} help</code> says what you can do over ssh. A passkey signs you in on the web as well, to see the pages of private repos you may read. Your browser, phone or password manager keeps it. Not now? <code>ssh {{ forge.config.ssh }} passkey</code> prints a link for one later.</p>50-<p id="enroll-for">For <b id="enroll-user">you</b> on {{ host }}. Your browser, phone or password manager keeps it, and signs you in with it from now on.</p>47+</div>{% endcall %}48+{% call ui.panel('<h2>add a passkey for <b id="enroll-user">you</b></h2>', '', 'id="enroll-part" hidden') %}<div class="panel-body">49+<p id="joined" hidden>You are in: <code>ssh {{ forge.config.ssh }} help</code> says what you can do over ssh. A passkey signs you in on the web as well, to see the pages of private repos you may read. Not now? <code>ssh {{ forge.config.ssh }} passkey</code> prints a link for one later.</p>50+<p>Your browser, phone or password manager keeps it, and signs you in with it from now on.</p>5151 <p>{{ ui.button('add a passkey', true, 'id="enroll"') }}</p>52-</div>52+</div>{% endcall %}5353 <p class="login-status" id="login-status" role="status"></p>5454 <p class="desc" id="login-help">No passkey yet, or lost them all? <code>ssh {{ forge.config.ssh }} passkey</code> prints a link that adds one: your ssh key is how you get in.</p>5555 {% endif %}56-</div></div>56+</div>5757 {% endblock %}@@ -138,7 +138,7 @@
138138 </table>139139 {% endmacro %}140140 141-{# The index and owner pages: repos as rows on shelves by kind. Tabs above141+{# Owner pages and /topics/: repos as rows on shelves by kind. Tabs above142142 them name the shelves, "all" first: without JS they jump to a shelf,143143 with it (ui.ts) one shows that shelf alone. Beside them, how to look at144144 the list, which needs JS and shows with it: the globe keeps only repos@@ -141,9 +141,8 @@ interface WebAuthn {
141141 history.replaceState(null, '', '/login/');142142 say('');143143 document.getElementById('invite-part')!.hidden = true;144- document.getElementById('joined-user')!.textContent = String(done.user);144+ document.getElementById('enroll-user')!.textContent = String(done.user);145145 document.getElementById('joined')!.hidden = false;146- document.getElementById('enroll-for')!.hidden = true;147146 if (wa.browserSupportsWebAuthn()) startEnroll(String(done.enroll));148147 else {149148 document.getElementById('enroll-part')!.hidden = false;@@ -55,6 +55,13 @@
5555 --status-merged: #3148f6;5656 --status-closed: #c7162b;5757 --status-other: #5b6169;58+ /* letter avatars on the home page: white on a fill seeded from the59+ name (the forge's owner has the accent), the same in both modes */60+ --avatar-1: #00796b;61+ --avatar-2: #7a3fc4;62+ --avatar-3: #a8461d;63+ --avatar-4: #b02a66;64+ --avatar-5: #2f7a1f;5865 /* a linked line, floating panels, dialogs' backdrop, masks */5966 --target-bg: rgba(255, 213, 79, 0.25);6067 --target-gut: rgba(255, 213, 79, 0.4);@@ -72,7 +72,7 @@
7272 if (over > 0) tabs.scrollLeft += over + 24;7373 }7474 75- // The index and owner pages' shelves (shelves in macros.njk). Without JS75+ // The owner pages' and /topics/' shelves (shelves in macros.njk). Without JS7676 // the tabs jump to a shelf; here one shows that shelf alone ("all" shows7777 // every one), the globe hides repos without a site, and the sort is7878 // newest first or by name. The choices outlive live.ts swapping in a@@ -124,6 +124,10 @@
124124 else b.setAttribute('aria-pressed', String(b.getAttribute('aria-pressed') !== 'true'));125125 });126126 127+ // A link to one shelf (/cat/#games, from the home page's topics) opens on it.128+ const at = decodeURIComponent(location.hash.slice(1));129+ if (at && document.querySelector(`[data-shelves] a[data-shelf="${CSS.escape(at)}"]`)) pick.shelf = at;130+127131 const content = (): void => {128132 wrap(wrapped());129133 times();@@ -1,10 +1,101 @@
11 ---22 permalink: /index.html33 ---4+{#- The home page: the motd an admin sets (bin/forge motd), then who is5+ here (profiles), the shelves across them (topics) and what happened6+ lately on public repos (recent, lib/recent.ts). Every build redoes it,7+ so it is as fresh as the last push; ui.ts keeps its times relative, and8+ live.ts swaps it in when a build finishes. Each owner's page has their9+ repos on shelves. -#}410 {% extends "base.njk" %}511 {% import "macros.njk" as m with context %}612 {% import "ui.njk" as ui %}713 {% block content %}8-{{ m.shelves(forge.repos, forge.owners.length > 1) }}9-<p class="desc"><a href="/insights/">Insights</a>: builds, pushes, reviews and CI across these repos, over time.</p>14+{% css %}15+main.wrap > p.motd { margin: calc(-1 * var(--space-5)) 0 0; padding: var(--space-3) 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-sm); color: var(--muted); }16+/* Two columns: profiles and topics, then what happened; on phones one,17+ recent after profiles. */18+.home { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr); grid-template-areas: "profiles recent" "topics recent"; grid-template-rows: auto 1fr; gap: var(--space-4); align-items: start; }19+.home > .panel { margin: 0; }20+.home .profiles { grid-area: profiles; }21+.home .topics { grid-area: topics; }22+.home .recent { grid-area: recent; }23+@media (max-width: 720px) {24+ .home { grid-template-columns: minmax(0, 1fr); grid-template-areas: "profiles" "recent" "topics"; grid-template-rows: none; }25+}26+.home .panel-head .end a { font-weight: var(--fw-medium); }27+/* A profile: a letter on a colour, the name, how many repos and when. */28+.profile { position: relative; display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-3); }29+.profile:hover, .profile:focus-within { background: var(--hover); }30+.avatar { display: grid; place-items: center; flex: none; width: 36px; height: 36px; font: var(--fw-strong) var(--text-lg)/1 var(--mono); color: var(--on-accent); background: var(--avatar-1); }31+.avatar.accent { background: var(--accent); }32+.avatar.avatar-2 { background: var(--avatar-2); }33+.avatar.avatar-3 { background: var(--avatar-3); }34+.avatar.avatar-4 { background: var(--avatar-4); }35+.avatar.avatar-5 { background: var(--avatar-5); }36+.profile-text { display: flex; flex-direction: column; min-width: 0; }37+.profile-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-prose); font-weight: var(--fw-strong); color: var(--fg); }38+.profile-name:hover { color: var(--fg); }39+.profile-name::after { content: ''; position: absolute; inset: 0; }40+.profile-meta { font-size: var(--text-sm); color: var(--muted); }41+.profile-meta time { font: inherit; }42+/* Topics: tiles with hairlines between them; the panel's edge is the rule43+ round them, so the last column's and row's own rules are cut off. */44+.topics { overflow: hidden; }45+.topic-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0 calc(-1 * var(--hairline)) calc(-1 * var(--hairline)) 0; padding: 0; list-style: none; }46+.topic-grid > li { position: relative; border-right: var(--hairline) solid var(--border); border-bottom: var(--hairline) solid var(--border); }47+.topic-grid > li:hover, .topic-grid > li:focus-within { background: var(--hover); }48+.topic-grid a { display: flex; flex-direction: column; padding: var(--space-2) var(--space-3); color: var(--fg); font-weight: var(--fw-medium); overflow-wrap: anywhere; }49+.topic-grid a:hover { color: var(--fg); }50+.topic-grid .n { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--faint); }51+@media (max-width: 720px) {52+ .topic-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }53+}54+/* What happened: its kind, the repo and what, when. */55+.ev { position: relative; display: grid; grid-template-columns: 3.6em minmax(0, 1fr) auto; column-gap: var(--space-3); align-items: baseline; padding: var(--space-2) var(--space-3); }56+.ev:hover, .ev:focus-within { background: var(--hover); }57+.ev-kind { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--muted); }58+.ev-what { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg); }59+.ev-what:hover { color: var(--fg); }60+.ev-what::after { content: ''; position: absolute; inset: 0; }61+.ev-what b { font-weight: var(--fw-strong); }62+.ev-what .tag { font-family: var(--mono); font-size: var(--text-inline-mono); color: var(--link); }63+.ev time { font-size: var(--text-xs); color: var(--faint); white-space: nowrap; }64+{% endcss %}65+{% if forge.motd %}<p class="motd">{{ forge.motd }}</p>66+{% endif %}67+<div class="home">68+{% set head %}<h2>profiles</h2><span class="n">{{ forge.profiles.length }}</span>{% endset %}69+{% call ui.panel(head, 'profiles') %}70+{% if forge.profiles.length %}71+<ul class="rows">72+{% for pr in forge.profiles %}73+<li class="profile"><span class="avatar {{ pr.colour }}" aria-hidden="true">{{ pr.owner | first }}</span><span class="profile-text"><a class="profile-name" href="/{{ pr.owner }}/">{{ pr.owner }}</a><span class="profile-meta">{{ pr.repos | plural("repo") }}{% if pr.active %}, active {{ pr.active | date }}{% endif %}</span></span></li>74+{% endfor %}75+</ul>76+{% else %}<p class="empty">No public repos here yet.</p>77+{% endif %}78+{% endcall %}79+{% set head %}<h2>recent</h2><span class="end"><a href="/insights/">insights</a></span>{% endset %}80+{% call ui.panel(head, 'recent') %}81+{% if forge.recent.length %}82+<ul class="rows">83+{% for e in forge.recent %}84+<li class="ev"><span class="ev-kind">{{ e.kind }}</span><a class="ev-what" href="{{ e.href }}"><b>{{ e.repo.name }}</b> {% if e.kind == 'release' %}<span class="tag">{{ e.ref }}</span>{% else %}{{ (e.ref + ' ') if e.ref }}{{ e.text }}{% endif %}</a>{{ e.at | date }}</li>85+{% endfor %}86+</ul>87+{% else %}<p class="empty">Nothing has happened here yet.</p>88+{% endif %}89+{% endcall %}90+{% if forge.topics.length %}91+{% set head %}<h2>topics</h2>{% endset %}92+{% call ui.panel(head, 'topics') %}93+<ul class="topic-grid">94+{% for t in forge.topics %}95+<li><a href="{{ t.href }}">{{ t.title }}<span class="n">{{ t.repos | plural('repo') }}</span></a></li>96+{% endfor %}97+</ul>98+{% endcall %}99+{% endif %}100+</div>10101 {% endblock %}@@ -0,0 +1,16 @@
1+---2+permalink: /topics/index.html3+---4+{#- Every public repo on its shelf, across owners: where the home page's5+ topics link when more than one owner has repos on a shelf (/topics/#<id>,6+ which ui.ts opens on that shelf). One owner's go to their own page. -#}7+{% extends "base.njk" %}8+{% import "macros.njk" as m with context %}9+{% import "ui.njk" as ui %}10+{% block title %}Topics - {{ forge.config.name }}{% endblock %}11+{% block head %}{{ m.meta('Topics - ' + forge.config.name, 'Every repo on ' + forge.config.name + ', by topic.', '/topics/') }}{% endblock %}12+{% block heading %}<a href="/">{{ forge.config.name }}</a> / <b>topics</b>13+{% endblock %}14+{% block content %}15+{{ m.shelves(forge.repos, true) }}16+{% endblock %}@@ -0,0 +1,35 @@
1+# The home page has a profile for each owner with public repos: their2+# letter, their name linking to their page, how many public repos they3+# have and when they were last active. No shelves of repos: those are on4+# each owner's page. The header says the forge's name.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+user bob9+for r in dami/chess dami/go ana/notes; do10+ repo "$r"11+ clone "${r%%/*}" "$r" "$T/w/${r#*/}"12+ tick 360013+ at "$T/w/${r#*/}" commit "first"14+ push_as "${r%%/*}" "$T/w/${r#*/}" origin main15+done16+repo --private dami/secret17+repo --private bob/hidden18+export FORGE_STRICT=119+build20+i=$FORGE_WWW/index.html21+has "$i" '<div class="title">22+<b>forge</b></div>'23+has "$i" '<section class="panel profiles"><header class="panel-head"><h2>profiles</h2><span class="n">2</span></header>'24+has "$i" '<a class="profile-name" href="/ana/">ana</a><span class="profile-meta">1 repo, active <time datetime="2026-09-01T03:01:00Z">'25+has "$i" '<a class="profile-name" href="/dami/">dami</a><span class="profile-meta">2 repos, active <time datetime="2026-09-01T02:01:00Z">'26+has "$i" '<span class="avatar avatar-[1-5]" aria-hidden="true">d</span>'27+# bob has only a private repo: no profile.28+lacks "$i" 'bob'29+lacks "$i" 'data-shelves|class="card'30+lacks "$i" 'Insights</a>:'31+# The forge's owner has the accent.32+FORGE_OWNER=dami build33+has "$i" '<span class="avatar accent" aria-hidden="true">d</span>'34+# The owner page keeps its shelves.35+has "$FORGE_WWW/dami/index.html" 'data-shelves'@@ -0,0 +1,58 @@
1+# The home page's recent list: landed reviews, pushes to a default branch,2+# releases and site deploys of public repos, newest first, each linking to3+# what it names, with its time for ui.ts to say relative. A landing's push4+# shows once, as landed. Nothing of a private repo is in it.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+repo dami/greet9+repo --private dami/secret10+clone dami dami/greet "$T/w/d"11+at "$T/w/d" commit "first"12+push_as dami "$T/w/d" origin main13+first=$(git -C "$T/w/d" rev-parse HEAD)14+tick15+clone ana dami/greet "$T/w/a"16+at "$T/w/a" commit "ana's change"17+push_as ana "$T/w/a" -o 'title=Say it louder' origin HEAD:ana/change18+tick19+ok ssh_as dami land dami/greet 120+tick21+at "$T/w/d" git pull -q origin main22+at "$T/w/d" git tag v123+push_as dami "$T/w/d" origin v124+tick25+push_as dami "$T/w/d" origin "$(site_commit "$T/w/d" docs):refs/sites/docs"26+tick27+at "$T/w/d" commit "polish <b>the</b> greeting"28+push_as dami "$T/w/d" origin main29+last=$(git -C "$T/w/d" rev-parse HEAD)30+# Newest of all, but private.31+tick32+clone dami dami/secret "$T/w/s"33+at "$T/w/s" commit "hidden thing"34+at "$T/w/s" git tag v935+push_as dami "$T/w/s" origin main v9 "$(site_commit "$T/w/s" docs):refs/sites/docs"36+has "$FORGE_ROOT/.forge/events.jsonl" "\"head\":\"$last\",\"refs\":\" refs/heads/main\"}$"37+38+export FORGE_STRICT=139+build40+i=$FORGE_WWW/index.html41+has "$i" '<section class="panel recent"><header class="panel-head"><h2>recent</h2><span class="end"><a href="/insights/">insights</a></span></header>'42+eq "$(grep -o 'class="ev-kind">[a-z]*' "$i" | sed 's/.*>//' | tr '\n' ' ')" "pushed site release landed pushed "43+has "$i" "<span class=\"ev-kind\">pushed</span><a class=\"ev-what\" href=\"/dami/greet/commit/$last/\"><b>greet</b> polish <b>the</b> greeting</a><time datetime=\"2026-09-01T00:05:00Z\">"44+has "$i" '<span class="ev-kind">site</span><a class="ev-what" href="/sites/dami/greet/docs/"><b>greet</b> deployed its site</a><time datetime="2026-09-01T00:04:00Z">'45+has "$i" '<span class="ev-kind">release</span><a class="ev-what" href="/dami/greet/releases/#v1"><b>greet</b> <span class="tag">v1</span></a><time datetime="2026-09-01T00:03:00Z">'46+has "$i" '<span class="ev-kind">landed</span><a class="ev-what" href="/dami/greet/reviews/1/"><b>greet</b> #1 Say it louder</a><time datetime="2026-09-01T00:02:00Z">'47+has "$i" "<span class=\"ev-kind\">pushed</span><a class=\"ev-what\" href=\"/dami/greet/commit/$first/\"><b>greet</b> first</a>"48+lacks "$i" 'secret|hidden thing|v9'49+# The links go somewhere.50+exists "$FORGE_WWW/dami/greet/commit/$last/index.html"51+exists "$FORGE_WWW/dami/greet/reviews/1/index.html"52+has "$FORGE_WWW/dami/greet/releases/index.html" 'id="v1"'53+exists "$FORGE_WWW/sites/dami/greet/docs/index.html"54+55+# Made private, the repo leaves the list.56+ok ssh_as dami private dami/greet57+build58+has "$i" '<p class="empty">Nothing has happened here yet.</p>'@@ -0,0 +1,37 @@
1+# The home page's topics are the shelves across the public repos, each2+# with its count. One owner's shelf links to it on their page, where ui.ts3+# opens the tab the fragment names; a shelf several owners share links to4+# it on /topics/, every public repo by shelf, each naming its owner.5+. "$(dirname "$0")/../lib.sh"6+user dami7+user ana8+for r in dami/chess dami/go dami/hammer ana/cards ana/notes; do9+ repo "$r"10+ clone "${r%%/*}" "$r" "$T/w/${r#*/}"11+ at "$T/w/${r#*/}" commit "first"12+ push_as "${r%%/*}" "$T/w/${r#*/}" origin main13+done14+repo --private ana/secret15+for r in dami/chess dami/go ana/cards ana/secret; do printf 'games\n' >"$FORGE_ROOT/$r.git/category"; done16+printf 'tools\n' >"$FORGE_ROOT/dami/hammer.git/category"17+export FORGE_STRICT=118+build19+i=$FORGE_WWW/index.html20+has "$i" '<li><a href="/topics/#games">games<span class="n">3 repos</span></a></li>'21+has "$i" '<li><a href="/dami/#tools">tools<span class="n">1 repo</span></a></li>'22+has "$i" '<li><a href="/ana/#other">other<span class="n">1 repo</span></a></li>'23+# Where they go.24+has "$FORGE_WWW/dami/index.html" '<a href="#tools" data-shelf="tools">tools<span class="n">1</span></a>'25+has "$FORGE_WWW/dami/index.html" 'data-shelf="tools" id="tools"'26+t=$FORGE_WWW/topics/index.html27+has "$t" '<section class="panel shelf" data-shelf="games" id="games"><header class="panel-head"><h2>games</h2><span class="n">3</span></header>'28+has "$t" '<a href="#games" data-shelf="games">games<span class="n">3</span></a>'29+has "$t" '<span class="desc">ana / </span>cards</a>'30+lacks "$t" 'secret'31+# ui.ts opens the shelf a link names.32+has site/assets/ui.ts 'location.hash'33+# A single repo's build redoes /topics/ too.34+printf 'tools\n' >"$FORGE_ROOT/ana/notes.git/category"35+build ana/notes36+has "$t" 'data-shelf="tools" id="tools"><header class="panel-head"><h2>tools</h2><span class="n">2</span>'37+has "$i" '<li><a href="/topics/#tools">tools<span class="n">2 repos</span></a></li>'@@ -26,7 +26,7 @@ http -s -D "$T/h" -o "$T/login" -H "Cookie: __Host-forge=$c" "$PRIV/login/"
2626 lacks "$T/h" '^Set-Cookie'2727 has "$T/h" "^Content-Security-Policy: default-src 'self'"2828 has "$T/h" '^Cache-Control: private, no-store'29-has "$T/login" 'Signed in as dami'29+has "$T/login" '<h2>signed in as <b>dami</b></h2>'3030 signal=$(sed -n 's/.*data-signal="\([^"]*\)".*/\1/p' "$T/login" | sed 's/"/"/g')3131 for k in one two; do3232 id=$(node -e 'console.log(JSON.parse(require("fs").readFileSync(process.argv[1])).id)' "$T/$k.key")@@ -18,7 +18,7 @@ has "$w/dami/greet/insights/index.html" 'data-insights="/dami/greet/insights/ins
1818 has "$w/dami/greet/insights/index.html" '<script src="/assets/insights\.[A-Za-z0-9_-]{10}\.js" defer>'1919 has "$w/dami/greet/index.html" '<a href="/dami/greet/insights/" data-key="i">insights</a>'2020 has "$w/insights/index.html" 'data-insights="/insights/insights.json" data-forge="1"'21-has "$w/index.html" '<a href="/insights/">Insights</a>'21+has "$w/index.html" '<span class="end"><a href="/insights/">insights</a></span>'2222 # The charts' script is only on insights pages.2323 lacks "$w/dami/greet/index.html" 'assets/insights\.'2424 # No lake yet: no data, and the page says so when it can't fetch it.@@ -0,0 +1,33 @@
1+# /login/'s steps (sign in, join from an invite, add a passkey, signed in)2+# are panels whose head strips are lowercase chrome naming the forge by its3+# name, never its domain; ssh commands on the page show the real host, to4+# copy. The passkey step names who it is for once, in its head strip.5+. "$(dirname "$0")/../lib.sh"6+user dami7+export FORGE_BASE_URL=https://forge.test FORGE_SSH=git@forge.test8+private_server9+# heads file: the head strips of the page, one per line.10+heads() { grep -o '<header class="panel-head">.*</header>' "$1" | sed 's/<[^>]*>//g'; }11+http -s "$PRIV/login/" >"$T/page"12+eq "$(heads "$T/page")" "sign in13+join forge14+add a passkey for you"15+has "$T/page" '<section class="panel" id="invite-part" hidden><header class="panel-head"><h2>join forge</h2></header>'16+has "$T/page" '<section class="panel" id="enroll-part" hidden><header class="panel-head"><h2>add a passkey for <b id="enroll-user">you</b></h2></header>'17+has "$T/page" '<code>ssh git@forge\.test passkey</code>'18+lacks "$T/page" 'For <b|on forge\.test'19+heads "$T/page" | grep -q 'forge\.test' && fail "a heading names the domain"20+heads "$T/page" | grep -q '[A-Z]' && fail "a heading has capitals"21+22+code=$(ssh_as dami passkey | sed -n 's/^ .*#enroll=//p')23+ok node "$T_HOME/test/authenticator.ts" enroll "$PRIV" "$code" "$T/dami.key"24+c=$(node "$T_HOME/test/authenticator.ts" login "$PRIV" "$T/dami.key")25+http -s -H "Cookie: __Host-forge=$c" "$PRIV/login/" >"$T/in"26+eq "$(heads "$T/in")" "signed in as dami"27+28+# Without sign-in set up, still a panel.29+stop_servers30+unset FORGE_BASE_URL31+private_server32+http -s "$PRIV/login/" >"$T/off"33+eq "$(heads "$T/off")" "sign in"@@ -0,0 +1,36 @@
1+# An admin sets the home page's motd over ssh: motd <text> sets it (trimmed,2+# escaped on the page) and rebuilds the index, motd prints it, --clear3+# hides the line, --default brings the default back. The operator's forge4+# motd does the same, and backups carry it.5+. "$(dirname "$0")/../lib.sh"6+user cat7+repo cat/notes8+git config --file "$FORGE_ROOT/.gitconfig" forge.admins cat9+export FORGE_STRICT=110+build11+i=$FORGE_WWW/index.html12+has "$i" '<p class="motd">a personal forge on plain git</p>'13+ok ssh_as cat motd14+eq "$(output)" "a personal forge on plain git15+(the default; motd <text> sets another)"16+17+sync_builds18+ok ssh_as cat motd ' notes <b>&</b> things '19+has "$T_LOG" '^the home page says: notes <b>&</b> things$'20+has "$i" '<p class="motd">notes <b>&</b> things</p>'21+ok ssh_as cat motd22+eq "$(output)" 'notes <b>&</b> things'23+ok forge backup --dry-run24+has "$FORGE_ROOT/.forge/backup/stage/.forge/motd" '^notes <b>&</b> things$'25+26+ok ssh_as cat motd --clear27+lacks "$i" 'class="motd"'28+ok ssh_as cat motd29+eq "$(output)" "(none: the home page shows no line; motd <text> sets one)"30+31+ok forge motd "on the server"32+has "$i" '<p class="motd">on the server</p>'33+ok forge motd --default34+has "$i" '<p class="motd">a personal forge on plain git</p>'35+missing "$FORGE_ROOT/.forge/motd"36+fails_with '^forge: usage: motd \[<text>\|--clear\|--default\]$' ssh_as cat motd --clear now@@ -0,0 +1,29 @@
1+# To anyone who isn't an admin, ssh motd is an unknown command: the same2+# output and exit status as one that doesn't exist, nothing changed, and3+# not in their help. An admin's help lists it.4+. "$(dirname "$0")/../lib.sh"5+user cat6+user bob7+repo bob/stuff8+git config --file "$FORGE_ROOT/.gitconfig" forge.admins cat9+# as user word args...: output and exit status, the word written WORD.10+as() {11+ u=$1 w=$212+ shift 213+ rc=014+ ssh_as "$u" "$w" "$@" >"$T/out.$w" 2>&1 || rc=$?15+ printf 'exit %s\n' "$rc" >>"$T/out.$w"16+ sed "s/$w/WORD/g" "$T/out.$w"17+}18+for args in "motd" "motd hello there" "motd --clear" "motd --default"; do19+ set -- $args20+ shift21+ eq "$(as bob motd "$@")" "$(as bob nosuch "$@")"22+done23+eq "$(as bob motd)" "fatal: unrecognized command 'WORD'24+exit 128"25+missing "$FORGE_ROOT/.forge/motd"26+ok ssh_as bob help27+lacks "$T_LOG" 'motd'28+ok ssh_as cat help29+has "$T_LOG" '^ motd \[<text>\|--clear\|--default\] '@@ -0,0 +1,17 @@
1+# A motd is one line of plain text, 72 characters at most (characters, not2+# bytes): a longer one, an empty one or one with a control character is3+# refused, saying why, and the page keeps the one it had.4+. "$(dirname "$0")/../lib.sh"5+user cat6+git config --file "$FORGE_ROOT/.gitconfig" forge.admins cat7+long=$(printf 'x%.0s' $(seq 73))8+fails_with '^forge: motd: 73 characters; it can have 72 at most$' ssh_as cat motd "$long"9+missing "$FORGE_ROOT/.forge/motd"10+ok ssh_as cat motd "$(printf 'x%.0s' $(seq 72))"11+ok ssh_as cat motd "$(printf 'é%.0s' $(seq 72))"12+fails_with '72 at most' ssh_as cat motd "$(printf 'é%.0s' $(seq 73))"13+has "$FORGE_ROOT/.forge/motd" "^$(printf 'é%.0s' $(seq 72))\$"14+fails_with 'without control characters' forge motd "$(printf 'one\ntwo')"15+fails_with 'without control characters' forge motd "$(printf 'a\tb')"16+fails_with 'empty; motd --clear shows no line' ssh_as cat motd " "17+has "$FORGE_ROOT/.forge/motd" "^$(printf 'é%.0s' $(seq 72))\$"@@ -1,4 +1,4 @@
1-# The index's shelves are panels: each one's head strip names the shelf1+# The shelves (owner pages, /topics/) are panels: each one's head strip names the shelf22 # and counts its repos, and the repos are rows inside it.33 . "$(dirname "$0")/../lib.sh"44 user dami@@ -12,7 +12,7 @@ printf 'games\n' >"$FORGE_ROOT/dami/chess.git/category"
1212 printf 'games\n' >"$FORGE_ROOT/dami/go.git/category"1313 export FORGE_STRICT=11414 build15-i=$FORGE_WWW/index.html15+i=$FORGE_WWW/topics/index.html1616 has "$i" '<section class="panel shelf" data-shelf="games" id="games"><header class="panel-head"><h2>games</h2><span class="n">2</span></header>'1717 has "$i" '<section class="panel shelf" data-shelf="other" id="other"><header class="panel-head"><h2>other</h2><span class="n">1</span></header>'1818 has "$i" '<ul class="rows cards nav-cards">'@@ -1,4 +1,4 @@
1-# The index's "has a site" filter is a control, not words: a globe icon1+# The shelves' "has a site" filter is a control, not words: a globe icon22 # toggle (the globe repo rows show beside a site) with aria-pressed, a33 # label and a title; the sort is a segmented control of two icon toggles;44 # shelves are tabs, "all" first and current. ui.ts makes them work.@@ -13,7 +13,7 @@ done
1313 printf 'games\n' >"$FORGE_ROOT/dami/chess.git/category"1414 export FORGE_STRICT=11515 build16-i=$FORGE_WWW/index.html16+i=$FORGE_WWW/dami/index.html1717 has "$i" '<button type="button" class="icon-btn" aria-pressed="false" aria-label="only repos with a site" title="only repos with a site" data-has-site><svg class="icon"'1818 has "$i" '<span class="segmented" role="group" aria-label="sort">'1919 has "$i" 'aria-label="newest first" title="newest first" aria-pressed="true" data-sort="date"'@@ -1,4 +1,4 @@
1-# The index and owner pages show repos as cards on shelves: the category1+# Owner pages and /topics/ show repos as cards on shelves: the category22 # its owner wrote in the bare repo's category file, else "other" (Jev's33 # topic is off in scenarios); fuller shelves first, "other" last, newest44 # first on each. Private repos have no card, and backups keep the file.@@ -17,7 +17,7 @@ printf 'Games\n' >"$FORGE_ROOT/dami/chess.git/category"
1717 printf 'games\n' >"$FORGE_ROOT/dami/go.git/category"1818 export FORGE_STRICT=11919 build20-i=$FORGE_WWW/index.html20+i=$FORGE_WWW/topics/index.html2121 has "$i" '<section class="panel shelf" data-shelf="games" id="games">'2222 has "$i" '<section class="panel shelf" data-shelf="other" id="other">'2323 # games (two repos) before other (one); go was pushed last, so it leads.