@magland/mochi 0.3.1 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +7 -6
  2. package/dist/agefile.js +47 -0
  3. package/dist/agescript.js +343 -0
  4. package/dist/api/admin.js +66 -28
  5. package/dist/api/auth.js +3 -2
  6. package/dist/api/backup.js +227 -192
  7. package/dist/api/contents.js +46 -16
  8. package/dist/api/repos.js +60 -4
  9. package/dist/api/write.js +138 -8
  10. package/dist/api.js +34 -1
  11. package/dist/browse.js +171 -33
  12. package/dist/build-info.json +2 -2
  13. package/dist/ci/api.js +138 -4
  14. package/dist/ci/artifacts.js +82 -2
  15. package/dist/ci/engine.js +30 -17
  16. package/dist/ci/expr.js +14 -0
  17. package/dist/ci/runners.js +60 -1
  18. package/dist/ci/runs.js +21 -3
  19. package/dist/ci/trigger.js +6 -4
  20. package/dist/ci/views.js +24 -3
  21. package/dist/ci/web.js +48 -0
  22. package/dist/cli/admin-cmd.js +40 -3
  23. package/dist/cli/backup-cmd.js +166 -120
  24. package/dist/cli/gitrun.js +50 -0
  25. package/dist/cli/pr-cmd.js +155 -0
  26. package/dist/cli/repo-cmd.js +134 -6
  27. package/dist/cli/sync-cmd.js +128 -0
  28. package/dist/cli/target.js +7 -3
  29. package/dist/config.js +10 -0
  30. package/dist/credentials.js +9 -8
  31. package/dist/deploy-cli.js +214 -109
  32. package/dist/deploy-runner-cli.js +125 -49
  33. package/dist/domains.js +259 -0
  34. package/dist/egress.js +23 -6
  35. package/dist/forms.js +703 -32
  36. package/dist/git.js +124 -13
  37. package/dist/githttp.js +80 -23
  38. package/dist/githubauth.js +185 -0
  39. package/dist/icons.js +14 -0
  40. package/dist/import-cli.js +40 -25
  41. package/dist/index.js +110 -4
  42. package/dist/issueweb.js +10 -6
  43. package/dist/lfs.js +9 -2
  44. package/dist/limit.js +2 -2
  45. package/dist/logincodes.js +31 -0
  46. package/dist/maintenance.js +297 -0
  47. package/dist/markdown.js +193 -3
  48. package/dist/mdedit.js +29 -0
  49. package/dist/migrate.js +48 -0
  50. package/dist/naming.js +25 -0
  51. package/dist/onetime.js +112 -0
  52. package/dist/ops.js +293 -16
  53. package/dist/pagescript.js +355 -24
  54. package/dist/perms.js +1 -0
  55. package/dist/profile.js +2 -0
  56. package/dist/pulls.js +21 -1
  57. package/dist/pullweb.js +25 -6
  58. package/dist/releases.js +10 -1
  59. package/dist/runner/actions.js +12 -1
  60. package/dist/runner/client.js +40 -2
  61. package/dist/runner/docker.js +40 -28
  62. package/dist/runner/download.js +77 -0
  63. package/dist/runner/externals.js +12 -1
  64. package/dist/runner/job.js +26 -11
  65. package/dist/runner/steps.js +42 -7
  66. package/dist/runner-cli.js +89 -0
  67. package/dist/scan.js +58 -2
  68. package/dist/server.js +107 -26
  69. package/dist/session.js +82 -40
  70. package/dist/site.js +110 -27
  71. package/dist/sitepublish.js +177 -0
  72. package/dist/sitesettings.js +420 -0
  73. package/dist/siteshost.js +116 -28
  74. package/dist/source.js +20 -0
  75. package/dist/style.js +162 -3
  76. package/dist/topics.js +165 -0
  77. package/dist/vault.js +399 -6
  78. package/dist/vendor-age.js +15 -0
  79. package/dist/views.js +242 -57
  80. package/dist/web.js +11 -1
  81. package/dist/webauthn.js +347 -0
  82. package/dist/webops.js +1263 -33
  83. package/package.json +4 -4
package/dist/views.js CHANGED
@@ -3,14 +3,20 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.encPath = encPath;
4
4
  exports.repoUrl = repoUrl;
5
5
  exports.csrfField = csrfField;
6
+ exports.userLink = userLink;
7
+ exports.gitAuthor = gitAuthor;
6
8
  exports.layout = layout;
7
9
  exports.repoOpts = repoOpts;
8
10
  exports.copyButton = copyButton;
9
11
  exports.copyRow = copyRow;
12
+ exports.agePassInput = agePassInput;
10
13
  exports.repoHeader = repoHeader;
14
+ exports.topicChips = topicChips;
11
15
  exports.homePage = homePage;
12
16
  exports.aboutPage = aboutPage;
13
17
  exports.collectionPage = collectionPage;
18
+ exports.topicsIndexPage = topicsIndexPage;
19
+ exports.topicPage = topicPage;
14
20
  exports.treePage = treePage;
15
21
  exports.searchPage = searchPage;
16
22
  exports.findFilePage = findFilePage;
@@ -26,6 +32,8 @@ const render_1 = require("./render");
26
32
  const session_1 = require("./session");
27
33
  const assets_1 = require("./assets");
28
34
  const pagescript_1 = require("./pagescript");
35
+ const agescript_1 = require("./agescript");
36
+ const agefile_1 = require("./agefile");
29
37
  const themes_1 = require("./themes");
30
38
  const logo_1 = require("./logo");
31
39
  const icons_1 = require("./icons");
@@ -43,9 +51,39 @@ function repoUrl(ctx) {
43
51
  function csrfField(viewer) {
44
52
  return (0, html_1.html) `<input type="hidden" name="csrf" value="${viewer.csrf}">`;
45
53
  }
54
+ /**
55
+ * A vault username as a link to their profile page at /<name>. The link keeps
56
+ * the weight and colour of the text around it, so a name reads the same
57
+ * whether or not it is one; only hover says it goes somewhere. `face` puts
58
+ * the identicon before the name at that size, and `bold` matches the places
59
+ * that already set the name in bold.
60
+ */
61
+ function userLink(name, opts = {}) {
62
+ const label = opts.bold ? (0, html_1.html) `<b>${name}</b>` : (0, html_1.html) `${name}`;
63
+ return (0, html_1.html) `<a class="user-link" href="/${encodeURIComponent(name)}">${opts.face ? (0, avatar_1.avatar)(name, opts.face) : ''}${label}</a>`;
64
+ }
65
+ /**
66
+ * A git author identity, linked to a profile when the email resolves to a
67
+ * vault user and shown exactly as git reported it when it does not. The
68
+ * identicon is drawn from the account where there is one, so the same person
69
+ * wears the same face whatever author emails their commits carry.
70
+ */
71
+ function gitAuthor(ctx, name, email, opts = {}) {
72
+ const account = email ? (ctx.accountFor?.(email) ?? null) : null;
73
+ if (account) {
74
+ const label = opts.bold ? (0, html_1.html) `<b>${name}</b>` : (0, html_1.html) `${name}`;
75
+ return (0, html_1.html) `<a class="user-link" href="/${encodeURIComponent(account)}" title="${account}">${opts.face ? (0, avatar_1.avatar)(account, opts.face) : ''}${label}</a>`;
76
+ }
77
+ const label = opts.bold ? (0, html_1.html) `<b>${name}</b>` : (0, html_1.html) `${name}`;
78
+ return (0, html_1.html) `${opts.face ? (0, avatar_1.avatar)(email || name, opts.face) : ''}${label}`;
79
+ }
46
80
  const FOLDER_ICON = (0, icons_1.icon)('folder', 'icon');
47
81
  const FILE_ICON = (0, icons_1.icon)('file', 'icon file');
82
+ // An age-encrypted file wears the lock wherever files are listed, so what is
83
+ // encrypted can be seen without opening anything.
84
+ const AGE_ICON = (0, icons_1.icon)('lock', 'icon file');
48
85
  const REPO_ICON = (0, icons_1.icon)('repo', 'icon');
86
+ const fileIcon = (name) => ((0, agefile_1.isAgeFile)(name) ? AGE_ICON : FILE_ICON);
49
87
  function userBox(opts) {
50
88
  const viewer = opts.viewer ?? null;
51
89
  if (!viewer) {
@@ -63,6 +101,8 @@ function userBox(opts) {
63
101
  <summary aria-label="Account menu">${(0, avatar_1.avatar)(name, 24)}${(0, icons_1.icon)('chevron-down', 'caret')}</summary>
64
102
  <div class="dropdown-menu dd-right">
65
103
  <div class="dd-section">Signed in as <b>${name}</b></div>
104
+ <a class="dd-item" href="/${encodeURIComponent(name)}">${(0, icons_1.icon)('person')}<span>Your profile</span></a>
105
+ <a class="dd-item" href="/account">${(0, icons_1.icon)('lock')}<span>Your account</span></a>
66
106
  ${admin}
67
107
  <form method="post" action="/logout">${csrfField(viewer)}<button type="submit" class="dd-item">${(0, icons_1.icon)('sign-out')}<span>Sign out</span></button></form>
68
108
  </div>
@@ -96,6 +136,14 @@ function themeMenu() {
96
136
  </div>
97
137
  </details>`;
98
138
  }
139
+ /**
140
+ * The way to /about from every page. A visitor can land anywhere in a vault,
141
+ * on a file or an issue rather than the front page, so what this site is has
142
+ * to be reachable from wherever they arrived rather than from one listing.
143
+ */
144
+ function aboutLink() {
145
+ return (0, html_1.html) `<a class="topbar-icon" href="/about" aria-label="About" title="About this vault">${(0, icons_1.icon)('info')}</a>`;
146
+ }
99
147
  function jumpDialog(jump) {
100
148
  const data = jump
101
149
  ? // The payload is JSON in a script tag, where escaping entities would
@@ -140,6 +188,9 @@ function layout(title, content, opts = {}) {
140
188
  const theme = (0, themes_1.activeTheme)().name;
141
189
  const sheet = (0, assets_1.styleSheet)((0, themes_1.activeTheme)()).tag;
142
190
  const script = (0, pagescript_1.pageScript)().tag;
191
+ // Deferred: nothing on it is needed before the DOM exists, and the page's
192
+ // own script must not wait behind 300 KB of cryptography.
193
+ const age = opts.ageScript ? (0, html_1.html) `<script src="/assets/age.js?v=${(0, agescript_1.ageScript)().tag}" defer></script>` : '';
143
194
  // The theme pair rides on <html> rather than in the script, which is what
144
195
  // lets /assets/page.js be one cacheable file for every vault: the script
145
196
  // reads these two attributes instead of being generated around them.
@@ -153,10 +204,10 @@ function layout(title, content, opts = {}) {
153
204
  <link id="hl-css" rel="stylesheet" href="/assets/hl.css?t=${encodeURIComponent(theme)}">
154
205
  <link rel="stylesheet" href="/assets/katex/katex.css">
155
206
  <link rel="icon" href="/favicon.svg?t=${encodeURIComponent(theme)}" type="image/svg+xml">
156
- <script src="/assets/page.js?v=${script}"></script>
207
+ <script src="/assets/page.js?v=${script}"></script>${age}
157
208
  </head>
158
209
  <body>
159
- <header class="topbar"><div class="container"><a class="brand" href="/">${(0, html_1.raw)(logo_1.WORDMARK)}</a><span class="crumbs">${opts.crumbs}</span><div class="userbox">${jumpButton()}${themeMenu()}${userBox(opts)}</div></div></header>
210
+ <header class="topbar"><div class="container"><a class="brand" href="/">${(0, html_1.raw)(logo_1.WORDMARK)}</a><span class="crumbs">${opts.crumbs}</span><div class="userbox">${jumpButton()}${aboutLink()}${themeMenu()}${userBox(opts)}</div></div></header>
160
211
  <main class="container">
161
212
  ${content}
162
213
  </main>
@@ -219,6 +270,17 @@ function copyButton(label = '', text, title = '') {
219
270
  function copyRow(cmd) {
220
271
  return (0, html_1.html) `<div class="cmd-row"><code>${cmd}</code>${copyButton()}</div>`;
221
272
  }
273
+ /**
274
+ * A passphrase input wrapped with a show/hide toggle. The input never carries
275
+ * a name, so no form can ever post its value; /assets/age.js wires the
276
+ * toggle, and both eye glyphs are drawn here so the script draws nothing.
277
+ * With an `id` the field is labelled by its <label>; without one it carries
278
+ * its placeholder as the accessible name.
279
+ */
280
+ function agePassInput(opts) {
281
+ const label = opts.placeholder ?? 'Passphrase';
282
+ return (0, html_1.html) `<span class="age-pass-wrap"><input type="password"${opts.id ? (0, html_1.html) ` id="${opts.id}"` : ''}${opts.placeholder ? (0, html_1.html) ` placeholder="${opts.placeholder}"` : ''}${opts.id ? '' : (0, html_1.html) ` aria-label="${label}"`} autocomplete="${opts.autocomplete}" spellcheck="false"${opts.required ? (0, html_1.raw)(' required') : ''}><button type="button" class="age-eye" aria-label="Show the passphrase" aria-pressed="false">${(0, icons_1.icon)('eye', 'glyph-eye')}${(0, icons_1.icon)('eye-off', 'glyph-eye-off')}</button></span>`;
283
+ }
222
284
  /**
223
285
  * The branch and tag picker: a button carrying the current ref, opening a
224
286
  * menu of every ref with a filter box, as on GitHub. It is a <details>
@@ -279,7 +341,11 @@ function repoHeader(ctx, active) {
279
341
  const search = (0, html_1.html) `<form class="repo-search" method="get" action="${base}/search" role="search"><input type="hidden" name="ref" value="${ctx.ref}"><input class="search-input" type="search" name="q" placeholder="Search this repository" aria-label="Search this repository">${(0, icons_1.icon)('search', 'search-glyph')}</form>`;
280
342
  const parent = ctx.forkedFrom
281
343
  ? (0, html_1.html) `<div class="fork-note muted small">${(0, icons_1.icon)('repo-forked')}<span>forked from <a href="/${encodeURIComponent(ctx.forkedFrom.collection)}/${encodeURIComponent(ctx.forkedFrom.repo)}">${ctx.forkedFrom.collection}/${ctx.forkedFrom.repo}</a></span></div>`
282
- : '';
344
+ : ctx.upstream
345
+ ? (0, html_1.html) `<div class="fork-note muted small">${(0, icons_1.icon)('repo-forked')}<span>forked from ${ctx.upstream.web
346
+ ? (0, html_1.html) `<a href="${ctx.upstream.web}" rel="noopener">${ctx.upstream.label}</a>`
347
+ : ctx.upstream.label}${ctx.canPush ? (0, html_1.html) ` &middot; <a href="${base}/sync">Sync</a>` : ''}</span></div>`
348
+ : '';
283
349
  const badge = ctx.isPrivate ? (0, html_1.html) ` <span class="counter" title="Only collaborators, owners, and site admins can see this repository">Private</span>` : '';
284
350
  return (0, html_1.html) `<div class="repo-title">${REPO_ICON}<a href="/${encodeURIComponent(ctx.collection)}">${ctx.collection}</a> <span class="muted">/</span> <a href="${base}"><b>${ctx.repo}</b></a>${badge}${search}</div>
285
351
  ${parent}
@@ -327,6 +393,17 @@ function listFilter(target, placeholder, rowCount) {
327
393
  function noMatches(target) {
328
394
  return (0, html_1.html) `<div class="empty-state" id="${target}-empty" hidden>No match.</div>`;
329
395
  }
396
+ /**
397
+ * Topic chips, each leading to the vault-wide page for that topic. One look
398
+ * everywhere on purpose: a topic is the same topic wherever it appears, so it
399
+ * wears one colour from the theme rather than a colour of its own the way an
400
+ * issue label does.
401
+ */
402
+ function topicChips(topics) {
403
+ if (!topics || topics.length === 0)
404
+ return '';
405
+ return (0, html_1.html) `<span class="topic-chips">${(0, html_1.joinHtml)(topics.map((t) => (0, html_1.html) `<a class="chip topic" href="/topics/${encodeURIComponent(t)}">${t}</a>`))}</span>`;
406
+ }
330
407
  const CI_MARKS = {
331
408
  running: { glyph: 'dot', label: 'running' },
332
409
  success: { glyph: 'check-circle', label: 'passed' },
@@ -353,12 +430,19 @@ function repoCard(r, showCollection) {
353
430
  ? (0, html_1.html) `<a class="site-link" href="${r.siteUrl}" title="Site" aria-label="Site for ${r.name}">${(0, icons_1.icon)('globe')}</a>`
354
431
  : '';
355
432
  const ci = r.ci ? ciMark(r.ci, r.name) : '';
356
- const desc = r.description ? (0, html_1.html) `<p class="rc-desc">${r.description}</p>` : '';
433
+ // The card clips to two lines with CSS, but the clipping is visual only:
434
+ // the bytes still travel, and a listing repeats them per repository, so a
435
+ // description larger than the model now accepts (one written before the cap
436
+ // existed) is cut here rather than shipped whole to every reader.
437
+ const shown = r.description && r.description.length > 400 ? `${r.description.slice(0, 400)}…` : r.description;
438
+ const desc = shown ? (0, html_1.html) `<p class="rc-desc">${shown}</p>` : '';
439
+ const topics = topicChips(r.topics);
357
440
  const when = r.updated ? (0, html_1.html) `<span class="rc-when">${(0, render_1.timeTag)(r.updated)}</span>` : '';
358
441
  const badge = r.isPrivate ? (0, html_1.html) `<span class="counter">Private</span>` : '';
359
442
  return (0, html_1.html) `<li class="repo-card">
360
443
  <div class="rc-top"><a class="rc-name" href="${href}">${prefix}${r.name}</a>${badge}<span class="rc-marks">${site}${ci}</span></div>
361
444
  ${desc}
445
+ ${topics ? (0, html_1.html) `<div class="rc-topics">${topics}</div>` : ''}
362
446
  <div class="rc-meta">${when}</div>
363
447
  </li>`;
364
448
  }
@@ -366,7 +450,9 @@ ${desc}
366
450
  * The listing itself. Sorting is a link rather than a script, so the order a
367
451
  * reader chose is in the address they can keep, and the two orders answer the
368
452
  * two questions a listing is asked: what has been happening lately, and where
369
- * is the one I already know the name of.
453
+ * is the one I already know the name of. Narrowing by topic is a link for the
454
+ * same reason, and each link carries the rest of the choice, so picking a
455
+ * sort never loses the topic and picking a topic never loses the sort.
370
456
  */
371
457
  function repoListing(repos, opts) {
372
458
  const sorted = [...repos];
@@ -376,29 +462,54 @@ function repoListing(repos, opts) {
376
462
  else {
377
463
  sorted.sort((a, b) => (b.updated ?? '').localeCompare(a.updated ?? ''));
378
464
  }
379
- const sortLink = (id, label) => (0, html_1.html) `<a${opts.sort === id ? (0, html_1.raw)(' class="current" aria-current="true"') : ''} href="${opts.sortBase}${id === 'recent' ? '' : `?sort=${id}`}">${label}</a>`;
465
+ const topic = opts.topics?.current ?? '';
466
+ const listUrl = (sort, t) => {
467
+ const params = new URLSearchParams();
468
+ if (sort !== 'recent')
469
+ params.set('sort', sort);
470
+ if (t !== '')
471
+ params.set('topic', t);
472
+ const q = params.toString();
473
+ return q === '' ? opts.sortBase : `${opts.sortBase}?${q}`;
474
+ };
475
+ const sortLink = (id, label) => (0, html_1.html) `<a${opts.sort === id ? (0, html_1.raw)(' class="current" aria-current="true"') : ''} href="${listUrl(id, topic)}">${label}</a>`;
476
+ const inUse = opts.topics?.inUse ?? [];
477
+ const topicMenu = inUse.length !== 0 || topic !== ''
478
+ ? (0, html_1.html) `<details class="dropdown topic-menu">
479
+ <summary class="btn">${(0, icons_1.icon)('tag')}<span>${topic || 'Topics'}</span>${(0, icons_1.icon)('chevron-down', 'caret')}</summary>
480
+ <div class="dropdown-menu"><div class="dd-scroll">${(0, html_1.joinHtml)([
481
+ (0, html_1.html) `<a class="dd-item${topic === '' ? ' current' : ''}" href="${listUrl(opts.sort, '')}"><span class="dd-check"></span><span class="dd-label">Any topic</span></a>`,
482
+ ...inUse.map((t) => (0, html_1.html) `<a class="dd-item${topic === t.topic ? ' current' : ''}" href="${listUrl(opts.sort, t.topic)}">${topic === t.topic ? (0, icons_1.icon)('check', 'dd-check') : (0, html_1.raw)('<span class="dd-check"></span>')}<span class="dd-label">${t.topic}</span><span class="muted small">${t.count}</span></a>`),
483
+ ])}</div></div>
484
+ </details>`
485
+ : '';
380
486
  const controls = (0, html_1.html) `<div class="listing-controls">
381
487
  <input class="list-filter" type="text" placeholder="Filter repositories" data-target="repo-list" data-filter="cards" aria-label="Filter repositories">
488
+ ${topicMenu}
382
489
  <span class="seg">${sortLink('recent', 'Recent')}${sortLink('name', 'A–Z')}</span>
383
490
  </div>`;
384
- return (0, html_1.html) `${repos.length > 5 ? controls : ''}<ul class="repo-grid" id="repo-list">${(0, html_1.joinHtml)(sorted.map((r) => repoCard(r, opts.showCollection)), '\n')}</ul>${noMatches('repo-list')}`;
491
+ // The controls stay once a topic is chosen, whatever the count: a narrowing
492
+ // that hides the way back out would be a trap.
493
+ return (0, html_1.html) `${repos.length > 5 || topic !== '' ? controls : ''}<ul class="repo-grid" id="repo-list">${(0, html_1.joinHtml)(sorted.map((r) => repoCard(r, opts.showCollection)), '\n')}</ul>${noMatches('repo-list')}`;
385
494
  }
386
- function homePage(rootLabel, collections, repos, sort, viewer) {
495
+ function homePage(rootLabel, collections, repos, sort, viewer, topics = { current: '', inUse: [] }) {
387
496
  const total = repos.length;
388
497
  const chips = collections.length
389
498
  ? (0, html_1.html) `<nav class="collection-chips" aria-label="Collections">${collections.map((c) => (0, html_1.html) `<a class="coll-chip" href="/${encodeURIComponent(c.name)}">${(0, avatar_1.avatar)(c.name, 18, 'square')}<span>${c.name}</span><span class="coll-count">${c.repoCount}</span></a>`)}</nav>`
390
499
  : '';
391
500
  const body = total === 0
392
- ? (0, html_1.html) `<div class="empty-state">No repositories yet.${viewer ? ' Create one with the buttons above, or push to a new path.' : ''} <a href="/about">What is this?</a></div>`
393
- : repoListing(repos, { showCollection: true, sort, sortBase: '/' });
501
+ ? topics.current !== ''
502
+ ? (0, html_1.html) `<div class="empty-state">No repositories carry the topic <b>${topics.current}</b>. <a href="/">Show all</a>.</div>`
503
+ : (0, html_1.html) `<div class="empty-state">No repositories yet.${viewer ? ' Create one with the buttons above, or push to a new path.' : ''}</div>`
504
+ : repoListing(repos, { showCollection: true, sort, sortBase: '/', topics });
394
505
  const newBtn = viewer
395
506
  ? (0, html_1.html) `<a class="btn" href="/new/collection">${(0, icons_1.icon)('plus')}<span>New collection</span></a><a class="btn btn-primary" href="/new">${(0, icons_1.icon)('plus')}<span>New repository</span></a>`
396
507
  : '';
397
- // The about link is how a visitor who landed here cold finds out what they
398
- // are looking at, so it rides on the first line they read.
399
508
  const summary = total === 0
400
509
  ? ''
401
- : (0, html_1.html) `<p class="lede">${total} ${total === 1 ? 'repository' : 'repositories'} in ${collections.length} ${collections.length === 1 ? 'collection' : 'collections'}. <a class="muted" href="/about">What is this?</a></p>`;
510
+ : topics.current !== ''
511
+ ? (0, html_1.html) `<p class="lede">${total} ${total === 1 ? 'repository' : 'repositories'} with the topic <b>${topics.current}</b>.</p>`
512
+ : (0, html_1.html) `<p class="lede">${total} ${total === 1 ? 'repository' : 'repositories'} in ${collections.length} ${collections.length === 1 ? 'collection' : 'collections'}.</p>`;
402
513
  const content = (0, html_1.html) `<div class="page-head"><h1>Repositories</h1><span class="right-group">${newBtn}</span></div>
403
514
  ${summary}
404
515
  ${chips}
@@ -410,39 +521,21 @@ ${
410
521
  return layout('Mochi Forge', content, { viewer, path: '/' });
411
522
  }
412
523
  /**
413
- * What this place is and how to use it, for the visitor who landed on a
414
- * listing with no idea what they are looking at. One page, self-contained:
415
- * the vault serves no other documentation, so nothing here may point into
416
- * docs a visitor cannot reach.
524
+ * What this place is, in as few lines as answer the question. A visitor who
525
+ * wants more detail than this has the vault itself and the project on GitHub;
526
+ * a visitor who landed here cold wants one paragraph, not a manual.
417
527
  */
418
528
  function aboutPage(baseUrl, viewer) {
419
529
  const content = (0, html_1.html) `<div class="about-page">
420
530
  <h1>About this vault</h1>
421
- <p class="lede">This site is a <i>vault</i>: a self-hosted git forge run by its operator, holding git repositories
422
- grouped into collections.</p>
423
-
424
- <h2>Reading</h2>
425
- <p>Reading is anonymous. Browse files, history, and diffs; read issues and pull requests; search inside a repository
426
- (press <span class="mono">/</span> anywhere); download any ref as an archive. Cloning needs no account either:</p>
531
+ <p class="lede">This site is a <i>vault</i>: a small self-hosted git forge, holding git repositories grouped into
532
+ collections.</p>
533
+ <p>Browsing and cloning are anonymous, apart from private repositories:</p>
427
534
  ${copyRow(`git clone ${baseUrl}/<collection>/<repository>`)}
428
-
429
- <h2>Writing</h2>
430
- <p>Every write is authorized by a token. There is no sign-up: tokens are minted by this vault's administrator, so if you
431
- should be able to push here, ask whoever runs the vault. A token is used two ways, with your username: as the password
432
- when git asks for credentials, and to <a href="/login">sign in</a> on the web, which unlocks editing files, opening
433
- issues and pull requests, and drafting releases in the browser.</p>
434
- <p>Pushing to a repository that does not exist yet creates it, if your token's scope covers that path:</p>
435
- ${copyRow(`git push ${baseUrl}/<collection>/<new-repository> main`)}
436
-
437
- <h2>What repositories carry</h2>
438
- <p>Beside its files, a repository here can have issues, pull requests, releases tied to tags, GitHub-style workflows run
439
- by the operator's own runners, and a static site served from the repository at
440
- <span class="mono">/&lt;collection&gt;/&lt;repository&gt;/site/</span>. Workflows run without credentials: a vault holds
441
- no secrets, so jobs are limited to work that needs none, such as building and publishing a site.</p>
442
-
443
- <h2>The software</h2>
444
- <p>This vault runs <a href="https://github.com/magland/mochi">Mochi Forge</a>, an open-source forge: one process, no
445
- database, every repository an ordinary bare git repository on the operator's own disk.</p>
535
+ <p>Writing needs an account, which this vault's administrator creates; there is no sign-up. Your token is the password
536
+ git asks for, and it also <a href="/login">signs you in</a> here.</p>
537
+ <p>The software is <a href="https://github.com/magland/mochiforge">Mochi Forge</a>, open source under the Apache 2.0
538
+ license.</p>
446
539
  </div>`;
447
540
  return layout('About - Mochi Forge', content, { viewer, path: '/about' });
448
541
  }
@@ -453,19 +546,25 @@ canAdminCollection,
453
546
  // The collection's profile README, read from its .mochi repository; see
454
547
  // src/profile.ts. Rendered above the listing, since it is what the page is
455
548
  // for once a collection has one.
456
- profile = null) {
549
+ profile = null,
550
+ // Present when a vault user bears the collection's name: the page is then
551
+ // their profile page too, and this is what the profile part shows.
552
+ owner = null, topics = { current: '', inUse: [] }) {
457
553
  const body = repoList.length === 0
458
- ? (0, html_1.html) `<div class="empty-state">No repositories in this collection yet.${viewer ? ' Create one with the buttons above, or push to a new path.' : ''}</div>`
554
+ ? topics.current !== ''
555
+ ? (0, html_1.html) `<div class="empty-state">No repositories here carry the topic <b>${topics.current}</b>. <a href="/${encodeURIComponent(collection)}">Show all</a>.</div>`
556
+ : (0, html_1.html) `<div class="empty-state">No repositories in this collection yet.${viewer ? ' Create one with the buttons above, or push to a new path.' : ''}</div>`
459
557
  : repoListing(repoList, {
460
558
  showCollection: false,
461
559
  sort,
462
560
  sortBase: `/${encodeURIComponent(collection)}`,
561
+ topics,
463
562
  });
464
563
  const settingsBtn = canAdminCollection
465
564
  ? (0, html_1.html) `<a class="btn" href="/${encodeURIComponent(collection)}/settings">${(0, icons_1.icon)('sliders')}<span>Settings</span></a>`
466
565
  : '';
467
566
  const newBtn = viewer
468
- ? (0, html_1.html) `<a class="btn" href="/import?collection=${encodeURIComponent(collection)}">${(0, icons_1.icon)('download')}<span>Import</span></a><a class="btn btn-primary" href="/new?collection=${encodeURIComponent(collection)}">${(0, icons_1.icon)('plus')}<span>New repository</span></a>`
567
+ ? (0, html_1.html) `<a class="btn" href="/import?collection=${encodeURIComponent(collection)}">${(0, icons_1.icon)('download')}<span>Import or fork</span></a><a class="btn btn-primary" href="/new?collection=${encodeURIComponent(collection)}">${(0, icons_1.icon)('plus')}<span>New repository</span></a>`
469
568
  : '';
470
569
  // The prompt to write a profile goes only to a viewer who could administer
471
570
  // the collection, and only while there is none: a page with a profile says
@@ -475,13 +574,53 @@ profile = null) {
475
574
  : profile && canAdminCollection
476
575
  ? (0, html_1.html) `<p class="muted small profile-hint">This collection has no profile README. <a href="${profile.addUrl}">Add one</a> at <span class="mono">.mochi/profile/README.md</span> to introduce it here.</p>`
477
576
  : '';
478
- const content = (0, html_1.html) `<div class="page-head"><h1 class="with-avatar">${(0, avatar_1.avatar)(collection, 28, 'square')}${collection}</h1><span class="right-group">${settingsBtn}${newBtn}</span></div>${profileBox}${body}`;
577
+ // A user's namespace page opens with who they are; a plain collection's
578
+ // with what it is called. The avatar keeps the shape it has everywhere
579
+ // else: round for a person, square for a collection.
580
+ const editProfileBtn = owner?.isViewer
581
+ ? (0, html_1.html) `<a class="btn" href="/settings/profile">${(0, icons_1.icon)('pencil')}<span>Edit profile</span></a>`
582
+ : '';
583
+ const head = owner
584
+ ? (0, html_1.html) `<div class="page-head"><h1 class="with-avatar">${(0, avatar_1.avatar)(collection, 32)}${owner.displayName ?? collection}${owner.displayName ? (0, html_1.html) ` <span class="profile-username">${collection}</span>` : ''}${owner.siteAdmin ? (0, html_1.html) ` <span class="counter" title="Administers this vault">Admin</span>` : ''}</h1><span class="right-group">${editProfileBtn}${settingsBtn}${newBtn}</span></div>${owner.bio ? (0, html_1.html) `<p class="profile-bio">${owner.bio}</p>` : ''}${owner.links.length
585
+ ? (0, html_1.html) `<div class="profile-links">${(0, html_1.joinHtml)(owner.links.map((l) => (0, html_1.html) `<a href="${l}" rel="nofollow noopener noreferrer">${(0, icons_1.icon)('link')}<span>${l.replace(/^https?:\/\//, '')}</span></a>`))}</div>`
586
+ : ''}`
587
+ : (0, html_1.html) `<div class="page-head"><h1 class="with-avatar">${(0, avatar_1.avatar)(collection, 28, 'square')}${collection}</h1><span class="right-group">${settingsBtn}${newBtn}</span></div>`;
588
+ const content = (0, html_1.html) `${head}${profileBox}${body}`;
479
589
  return layout(collection, content, {
480
590
  crumbs: (0, html_1.html) ` / <a href="/${encodeURIComponent(collection)}">${collection}</a>`,
481
591
  viewer,
482
592
  path: `/${encodeURIComponent(collection)}`,
483
593
  });
484
594
  }
595
+ /**
596
+ * Every topic in use across the vault, as this viewer sees it: the same
597
+ * repositories the front page would list, counted rather than repeated. The
598
+ * page exists so a topic chip has somewhere vault-wide to lead, and so the
599
+ * set of topics can be surveyed without opening repositories to collect it.
600
+ */
601
+ function topicsIndexPage(topics, viewer) {
602
+ const body = topics.length === 0
603
+ ? (0, html_1.html) `<div class="empty-state">No repository carries a topic yet.${viewer ? ' Add some from a repository’s About panel or settings.' : ''}</div>`
604
+ : (0, html_1.html) `<ul class="topic-index">${(0, html_1.joinHtml)(topics.map((t) => (0, html_1.html) `<li><a class="chip topic" href="/topics/${encodeURIComponent(t.topic)}">${t.topic}</a><span class="muted small">${t.count} ${t.count === 1 ? 'repository' : 'repositories'}</span></li>`), '\n')}</ul>`;
605
+ const content = (0, html_1.html) `<div class="page-head"><h1>Topics</h1></div>
606
+ <p class="lede">What the repositories in this vault say they are about, across every collection.</p>
607
+ ${body}`;
608
+ return layout('Topics', content, { viewer, path: '/topics' });
609
+ }
610
+ /** One topic's page: every visible repository carrying it, vault-wide. */
611
+ function topicPage(topic, repos, sort, viewer) {
612
+ const body = repos.length === 0
613
+ ? (0, html_1.html) `<div class="empty-state">No repository carries this topic. <a href="/topics">All topics</a>.</div>`
614
+ : repoListing(repos, { showCollection: true, sort, sortBase: `/topics/${encodeURIComponent(topic)}` });
615
+ const content = (0, html_1.html) `<div class="page-head"><h1 class="topic-title">${(0, icons_1.icon)('tag')}${topic}</h1></div>
616
+ <p class="lede">${repos.length} ${repos.length === 1 ? 'repository' : 'repositories'} with this topic &middot; <a href="/topics">all topics</a></p>
617
+ ${body}`;
618
+ return layout(`${topic} - Topics`, content, {
619
+ crumbs: (0, html_1.html) ` / <a href="/topics">topics</a> / <a href="/topics/${encodeURIComponent(topic)}">${topic}</a>`,
620
+ viewer,
621
+ path: `/topics/${encodeURIComponent(topic)}`,
622
+ });
623
+ }
485
624
  /** "1,284" - counts in the interface are grouped, as they are on GitHub. */
486
625
  function count(n) {
487
626
  return n.toLocaleString('en-US');
@@ -516,9 +655,9 @@ function languagesBlock(languages) {
516
655
  const SHOWN_CONTRIBUTORS = 12;
517
656
  /**
518
657
  * Who wrote this repository, by commit count, as GitHub lists in its About
519
- * panel. Each face leads to that person's commits rather than to a profile
520
- * page, since a vault has no profiles: the history is what it knows about
521
- * them.
658
+ * panel. A face with an account behind it leads to that user's profile page;
659
+ * one without leads to that identity's commits here, which is all the vault
660
+ * knows about them.
522
661
  */
523
662
  function contributorsBlock(ctx, people) {
524
663
  if (people.length === 0)
@@ -529,7 +668,9 @@ function contributorsBlock(ctx, people) {
529
668
  // git author emails their commits carry.
530
669
  const faces = people
531
670
  .slice(0, SHOWN_CONTRIBUTORS)
532
- .map((p) => (0, html_1.html) `<a class="contributor" href="${base}/commits/${encPath(ctx.ref)}?author=${encodeURIComponent(p.email || p.name)}" title="${p.name} - ${count(p.commits)} commit${p.commits === 1 ? '' : 's'}">${(0, avatar_1.avatar)(p.account ?? (p.email || p.name), 28)}</a>`);
671
+ .map((p) => (0, html_1.html) `<a class="contributor" href="${p.account
672
+ ? `/${encodeURIComponent(p.account)}`
673
+ : `${base}/commits/${encPath(ctx.ref)}?author=${encodeURIComponent(p.email || p.name)}`}" title="${p.account ?? p.name} - ${count(p.commits)} commit${p.commits === 1 ? '' : 's'}">${(0, avatar_1.avatar)(p.account ?? (p.email || p.name), 28)}</a>`);
533
674
  const more = people.length > SHOWN_CONTRIBUTORS
534
675
  ? (0, html_1.html) `<span class="muted small">+${count(people.length - SHOWN_CONTRIBUTORS)} more</span>`
535
676
  : '';
@@ -557,6 +698,24 @@ function aboutPanel(ctx, view) {
557
698
  const description = view.description
558
699
  ? (0, html_1.html) `<p class="side-desc">${view.description}</p>`
559
700
  : (0, html_1.html) `<p class="side-desc muted">No description provided.</p>`;
701
+ // Topics, and for a writer the place to change them without leaving the
702
+ // page: a details element holding the one-line form, so the editor costs no
703
+ // script and appears only when opened. The form posts the whole set, which
704
+ // is what the API takes too.
705
+ const topicEditor = ctx.canPush && ctx.viewer
706
+ ? (0, html_1.html) `<details class="dropdown topic-edit"><summary class="chip topic-add" title="Edit topics" aria-label="Edit topics">${view.topics.length ? (0, icons_1.icon)('pencil') : (0, html_1.html) `${(0, icons_1.icon)('plus')}<span>Add topics</span>`}</summary>
707
+ <div class="dropdown-menu dd-right topic-edit-menu"><form method="post" action="${repoUrl(ctx)}/settings/topics">
708
+ ${csrfField(ctx.viewer)}
709
+ <input type="hidden" name="next" value="repo">
710
+ <label class="muted small" for="side-topics">Topics, separated by spaces</label>
711
+ <input type="text" id="side-topics" name="topics" value="${view.topics.join(' ')}" placeholder="webgpu numbl mri">
712
+ <p class="muted small">Lowercase letters, digits, and hyphens.</p>
713
+ <button type="submit" class="btn btn-primary">${(0, icons_1.icon)('check')}<span>Save</span></button>
714
+ </form></div></details>`
715
+ : '';
716
+ const topicsRow = view.topics.length || topicEditor
717
+ ? (0, html_1.html) `<div class="side-topics">${topicChips(view.topics)}${topicEditor}</div>`
718
+ : '';
560
719
  const facts = [
561
720
  (0, html_1.html) `<a href="${base}/commits/${encPath(ctx.ref)}">${(0, icons_1.icon)('history')}<span>${count(view.commitCount)} commit${view.commitCount === 1 ? '' : 's'}</span></a>`,
562
721
  (0, html_1.html) `<a href="${base}/branches">${(0, icons_1.icon)('git-branch')}<span>${count(ctx.branches.length)} branch${ctx.branches.length === 1 ? '' : 'es'}</span></a>`,
@@ -569,6 +728,7 @@ function aboutPanel(ctx, view) {
569
728
  <div class="side-block">
570
729
  <h3>About${settings}</h3>
571
730
  ${description}
731
+ ${topicsRow}
572
732
  ${links.length ? (0, html_1.html) `<div class="side-links">${links}</div><hr class="rule">` : ''}
573
733
  <div class="side-links">${facts}</div>
574
734
  </div>
@@ -594,7 +754,7 @@ function treePage(ctx, view) {
594
754
  name = (0, html_1.html) `${FOLDER_ICON}<a href="${refBase}/${encPath(childPath)}">${e.name}</a>`;
595
755
  }
596
756
  else if (e.type === 'blob') {
597
- name = (0, html_1.html) `${FILE_ICON}<a href="${base}/blob/${encPath(ctx.ref)}/${encPath(childPath)}">${e.name}</a>`;
757
+ name = (0, html_1.html) `${fileIcon(e.name)}<a href="${base}/blob/${encPath(ctx.ref)}/${encPath(childPath)}">${e.name}</a>`;
598
758
  }
599
759
  else {
600
760
  name = (0, html_1.html) `${FOLDER_ICON}<span>${e.name}</span> <span class="muted small mono">@ ${e.sha.slice(0, 7)}</span>`;
@@ -611,7 +771,7 @@ function treePage(ctx, view) {
611
771
  const latest = view.latest;
612
772
  const latestBar = latest
613
773
  ? (0, html_1.html) `<div class="latest-commit">
614
- <span class="lc-main"><b>${latest.author}</b> <a href="${base}/commit/${latest.sha}">${latest.subject}</a></span>
774
+ <span class="lc-main">${gitAuthor(ctx, latest.author, latest.email, { bold: true })} <a href="${base}/commit/${latest.sha}">${latest.subject}</a></span>
615
775
  <span class="lc-meta"><a class="sha" href="${base}/commit/${latest.sha}">${latest.sha.slice(0, 7)}</a> ${(0, render_1.timeTag)(latest.date)} <a class="lc-history" href="${base}/commits/${encPath(ctx.ref)}">${(0, icons_1.icon)('history')}<b>${count(view.commitCount)}</b> <span>Commits</span></a></span>
616
776
  </div>`
617
777
  : '';
@@ -640,6 +800,7 @@ function treePage(ctx, view) {
640
800
  ? (0, html_1.html) `<div class="box" id="readme"><div class="box-header">${(0, icons_1.icon)('book')}<a href="${base}/blob/${encPath(ctx.ref)}/${encPath(readmePath ?? 'README')}">${view.readmeName ?? 'README'}</a></div><div class="box-body markdown-body">${(0, html_1.raw)(view.readmeHtml)}</div></div>`
641
801
  : '';
642
802
  const content = (0, html_1.html) `${repoHeader(ctx, 'code')}
803
+ ${view.msg ? (0, html_1.html) `<div class="flash">${view.msg}</div>` : ''}
643
804
  <div class="toolbar">
644
805
  <div class="left">${refPicker(ctx, (ref) => `${base}/tree/${encPath(ref)}`)}${breadcrumb(ctx, path)}</div>
645
806
  <div class="right-group">${findButton(ctx)}${historyBtn}${forkBtn}${addFileBtn}${cloneMenu(ctx)}</div>
@@ -728,7 +889,7 @@ function findFilePage(ctx, paths, total) {
728
889
  const items = paths.map((p) => {
729
890
  const cut = p.lastIndexOf('/');
730
891
  const dir = cut === -1 ? '' : (0, html_1.html) `<span class="muted">${p.slice(0, cut + 1)}</span>`;
731
- return (0, html_1.html) `<a class="find-item" href="${base}/blob/${encPath(ctx.ref)}/${encPath(p)}">${FILE_ICON}<span>${dir}${p.slice(cut + 1)}</span></a>`;
892
+ return (0, html_1.html) `<a class="find-item" href="${base}/blob/${encPath(ctx.ref)}/${encPath(p)}">${fileIcon(p)}<span>${dir}${p.slice(cut + 1)}</span></a>`;
732
893
  });
733
894
  const capped = total > paths.length
734
895
  ? (0, html_1.html) `<p class="muted small">Showing the first ${count(paths.length)} of ${count(total)} files.</p>`
@@ -747,7 +908,7 @@ function blobPage(ctx, path, view, isMarkdown = false) {
747
908
  const base = repoUrl(ctx);
748
909
  const blobUrl = `${base}/blob/${encPath(ctx.ref)}/${encPath(path)}`;
749
910
  const rawUrl = `${base}/raw/${encPath(ctx.ref)}/${encPath(path)}`;
750
- const editable = (view.kind === 'code' || view.kind === 'markdown') && view.editable;
911
+ const editable = (view.kind === 'code' || view.kind === 'markdown' || view.kind === 'age') && view.editable;
751
912
  const editBtns = editable
752
913
  ? (0, html_1.html) `<a class="btn" href="${base}/edit/${encPath(ctx.ref)}/${encPath(path)}" title="Edit this file">${(0, icons_1.icon)('pencil')}<span>Edit</span></a><a class="btn btn-danger-outline" href="${base}/delete/${encPath(ctx.ref)}/${encPath(path)}" title="Delete this file">${(0, icons_1.icon)('trash')}<span>Delete</span></a>`
753
914
  : '';
@@ -794,6 +955,29 @@ function blobPage(ctx, path, view, isMarkdown = false) {
794
955
  <p>This file is ${(0, render_1.formatSize)(view.size)}; the repository holds a pointer to it.</p>
795
956
  <p class="muted small mono">sha256:${view.oid}</p>
796
957
  <p><a class="btn btn-primary" href="${rawUrl}">${(0, icons_1.icon)('download')}<span>Download</span></a></p>
958
+ </div>`;
959
+ }
960
+ else if (view.kind === 'age') {
961
+ // The card is inert markup; /assets/age.js (loaded by this page alone)
962
+ // wires the form, fetches the ciphertext from the raw URL, and decrypts
963
+ // in the page. The passphrase inputs never carry a name, so no form
964
+ // mishap can post one. On unlock the card gives way to a slim bar over
965
+ // the output: what happened, a copy of the exact plaintext, and the lock
966
+ // that puts the card back.
967
+ body = (0, html_1.html) `${meta((0, render_1.formatSize)(view.size))}
968
+ <div class="age-box" data-age-view data-age-raw="${rawUrl}" data-age-inner="${view.markdownInner ? 'markdown' : 'text'}">
969
+ <div class="blob-binary age-card">
970
+ <p class="age-head">${(0, icons_1.icon)('lock')}<b>Encrypted with age</b></p>
971
+ <p class="muted small">The vault stores this file as ciphertext it cannot read. The passphrase decrypts it here, in this page, and is sent nowhere.</p>
972
+ <form class="age-unlock">${agePassInput({ placeholder: 'Passphrase', autocomplete: 'off', required: true })}<button type="submit" class="btn btn-primary" data-busy-label="Decrypting&hellip;">Decrypt</button></form>
973
+ <p class="age-error form-error" hidden></p>
974
+ <noscript><p class="muted small">Decrypting in the browser needs JavaScript. Without it, take the raw file and the age CLI.</p></noscript>
975
+ </div>
976
+ <div class="age-bar" data-age-bar hidden>
977
+ <span class="age-bar-note">${(0, icons_1.icon)('lock')}<span>Decrypted in your browser; the vault still holds only the ciphertext.</span></span>
978
+ <span class="age-bar-actions"><button type="button" class="btn" data-age-copy title="Copy the decrypted text"><span class="copy-idle">${(0, icons_1.icon)('copy')}<span>Copy</span></span><span class="copy-done">${(0, icons_1.icon)('check')}<span>Copied</span></span></button><button type="button" class="btn" data-age-lock title="Clear the plaintext from the page">${(0, icons_1.icon)('lock')}<span>Lock</span></button></span>
979
+ </div>
980
+ <div class="age-output" hidden></div>
797
981
  </div>`;
798
982
  }
799
983
  else {
@@ -804,7 +988,8 @@ function blobPage(ctx, path, view, isMarkdown = false) {
804
988
  <div class="left">${refPicker(ctx, (ref) => `${base}/blob/${encPath(ref)}/${encPath(path)}`)}${breadcrumb(ctx, path)}</div>
805
989
  </div>
806
990
  ${body}`;
807
- return layout(`${path} at ${ctx.ref} - ${ctx.collection}/${ctx.repo}`, content, repoOpts(ctx, blobUrl));
991
+ const opts = view.kind === 'age' ? { ...repoOpts(ctx, blobUrl), ageScript: true } : repoOpts(ctx, blobUrl);
992
+ return layout(`${path} at ${ctx.ref} - ${ctx.collection}/${ctx.repo}`, content, opts);
808
993
  }
809
994
  /**
810
995
  * The blame view: every line of a file beside the commit that last touched
@@ -824,7 +1009,7 @@ function blamePage(ctx, path, highlighted, lines, size) {
824
1009
  ? (0, html_1.html) `<a class="blame-prior" href="${base}/blame/${encPath(l.previous.sha)}/${encPath(l.previous.path)}" title="Blame this file before this change" aria-label="Blame this file before this change">${(0, icons_1.icon)('versions')}</a>`
825
1010
  : '';
826
1011
  const about = starts
827
- ? (0, html_1.html) `<a class="sha" href="${base}/commit/${l.sha}">${l.sha.slice(0, 7)}</a><a class="blame-subject" href="${base}/commit/${l.sha}" title="${l.summary}">${l.summary}</a><span class="blame-when small muted">${l.author} ${(0, render_1.timeTag)(l.date)}</span>${prior}`
1012
+ ? (0, html_1.html) `<a class="sha" href="${base}/commit/${l.sha}">${l.sha.slice(0, 7)}</a><a class="blame-subject" href="${base}/commit/${l.sha}" title="${l.summary}">${l.summary}</a><span class="blame-when small muted">${gitAuthor(ctx, l.author, l.email)} ${(0, render_1.timeTag)(l.date)}</span>${prior}`
828
1013
  : '';
829
1014
  return (0, html_1.html) `<div class="blame-row${starts ? ' blame-start' : ''}" id="L${n}"><span class="blame-commit">${about}</span><a class="lnum" href="#L${n}" aria-label="Line ${n}">${n}</a><span class="ltext">${(0, html_1.raw)(texts[i] ?? '')}</span></div>`;
830
1015
  });
@@ -843,7 +1028,7 @@ function commitsPage(ctx, path, commits, page, totalPages, totalCount, author) {
843
1028
  const query = author ? `?author=${encodeURIComponent(author)}` : '';
844
1029
  // Each row carries what a reader might want next from that commit: to read
845
1030
  // it, to take its id, or to browse the tree as it stood then.
846
- const row = (c) => (0, html_1.html) `<div class="commit-row"><span class="commit-main">${(0, avatar_1.avatar)(c.author, 20)}<span><a class="title" href="${base}/commit/${c.sha}">${c.subject}</a><div class="muted small">${c.author} committed ${(0, render_1.timeTag)(c.date)}</div></span></span><span class="commit-actions"><a class="sha" href="${base}/commit/${c.sha}">${c.sha.slice(0, 7)}</a>${copyButton('', c.sha, 'Copy the full commit id')}<a class="btn" href="${base}/tree/${c.sha}" title="Browse the repository at this commit" aria-label="Browse the repository at this commit">${(0, icons_1.icon)('code')}</a></span></div>`;
1031
+ const row = (c) => (0, html_1.html) `<div class="commit-row"><span class="commit-main">${(0, avatar_1.avatar)((c.email && ctx.accountFor?.(c.email)) || c.email || c.author, 20)}<span><a class="title" href="${base}/commit/${c.sha}">${c.subject}</a><div class="muted small">${gitAuthor(ctx, c.author, c.email)} committed ${(0, render_1.timeTag)(c.date)}</div></span></span><span class="commit-actions"><a class="sha" href="${base}/commit/${c.sha}">${c.sha.slice(0, 7)}</a>${copyButton('', c.sha, 'Copy the full commit id')}<a class="btn" href="${base}/tree/${c.sha}" title="Browse the repository at this commit" aria-label="Browse the repository at this commit">${(0, icons_1.icon)('code')}</a></span></div>`;
847
1032
  // Commits are grouped under the day they landed, as on GitHub: a history
848
1033
  // reads as a sequence of days, and the dates stop repeating on every row.
849
1034
  const groups = [];
@@ -892,7 +1077,7 @@ function commitPage(ctx, detail, diffHtml) {
892
1077
  <div class="subject">${subject}</div>
893
1078
  ${body ? (0, html_1.html) `<div class="body">${body}</div>` : ''}
894
1079
  <div class="meta">
895
- <span><b>${detail.author}</b> &lt;${detail.email}&gt;</span>
1080
+ <span>${gitAuthor(ctx, detail.author, detail.email, { bold: true })} &lt;${detail.email}&gt;</span>
896
1081
  <span>committed ${(0, render_1.timeTag)(detail.date, '')}</span>
897
1082
  <span>commit <span class="sha">${detail.sha.slice(0, 12)}</span></span>
898
1083
  ${detail.parents.length ? (0, html_1.html) `<span>parent${detail.parents.length > 1 ? 's' : ''} ${parents}</span>` : ''}
package/dist/web.js CHANGED
@@ -58,7 +58,9 @@ const releases_1 = require("./releases");
58
58
  const perms_1 = require("./perms");
59
59
  const scan_1 = require("./scan");
60
60
  const session_1 = require("./session");
61
+ const sitesettings_1 = require("./sitesettings");
61
62
  const site_1 = require("./site");
63
+ const vault_1 = require("./vault");
62
64
  const views = __importStar(require("./views"));
63
65
  // Helpers shared by the HTML route modules.
64
66
  function ah(fn) {
@@ -153,6 +155,11 @@ async function makeCtx(root, req, loaded, ref, viewer) {
153
155
  // Where the Site tab points. A site with an origin of its own is linked there
154
156
  // directly rather than through the forge path that would only redirect.
155
157
  const siteOrigin = (0, site_1.siteHostUrl)(root, req, loaded.repo.collection, loaded.repo.name);
158
+ // Ties names on the page back to vault users: commit author emails resolve
159
+ // to accounts, and @mentions resolve to profile links. The vault is read
160
+ // once per page; a vault that failed to load just resolves nobody.
161
+ const state = (0, vault_1.loadVault)(root);
162
+ const vault = state.status === 'ok' ? state.vault : null;
156
163
  return {
157
164
  collection: loaded.repo.collection,
158
165
  repo: loaded.repo.name,
@@ -162,7 +169,7 @@ async function makeCtx(root, req, loaded, ref, viewer) {
162
169
  branches: loaded.branches,
163
170
  tags: loaded.tags,
164
171
  cloneUrl,
165
- hasSite: (0, scan_1.siteDir)(root, loaded.repo.collection, loaded.repo.name) !== null,
172
+ hasSite: (0, sitesettings_1.siteSettings)(loaded.repo.dir).enabled && (0, scan_1.siteDir)(root, loaded.repo.collection, loaded.repo.name) !== null,
166
173
  siteUrl: siteOrigin
167
174
  ? `${siteOrigin}/`
168
175
  : `/${encodeURIComponent(loaded.repo.collection)}/${encodeURIComponent(loaded.repo.name)}/site/`,
@@ -171,10 +178,13 @@ async function makeCtx(root, req, loaded, ref, viewer) {
171
178
  openIssues: (0, issues_1.issueCounts)(root, loaded.repo.collection, loaded.repo.name).open,
172
179
  openPulls: (0, pulls_1.pullCounts)(root, loaded.repo.collection, loaded.repo.name).open,
173
180
  forkedFrom: (0, scan_1.forkParent)(loaded.repo.dir),
181
+ upstream: (0, scan_1.upstreamOf)(loaded.repo.dir),
174
182
  viewer,
175
183
  isPrivate: (0, perms_1.repoIsPrivate)(loaded.repo.dir),
176
184
  canPush: (0, perms_1.atLeast)(loaded.role, 'write'),
177
185
  canAdmin: (0, perms_1.atLeast)(loaded.role, 'admin'),
186
+ accountFor: (email) => (vault ? (0, vault_1.accountForEmail)(vault, email) : null),
187
+ hasUser: (name) => (vault ? Object.prototype.hasOwnProperty.call(vault.users, name) : false),
178
188
  };
179
189
  }
180
190
  function baseUrlOf(req) {