@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
@@ -142,19 +142,48 @@ function findKey(e, input) {
142
142
  // and kept for the tab, so typing costs nothing after the first opening.
143
143
  var jumpRepos = null;
144
144
  var jumpCtx = null;
145
+ var jumpCtxRead = false;
145
146
  var jumpSel = 0;
146
- (function () {
147
- var el = document.getElementById('jump-data');
148
- if (el) { try { jumpCtx = JSON.parse(el.textContent); } catch (e) {} }
149
- })();
147
+ // A path on this origin, or null. The context is read out of the page, and
148
+ // a rendered document shares the page; an href that is not a local path is
149
+ // not one the box will send anybody to.
150
+ function localPath(s) {
151
+ return typeof s === 'string' && s.charAt(0) === '/' && s.charAt(1) !== '/' && s.charAt(1) !== '\\\\' ? s : null;
152
+ }
153
+ // The repository the page is about, read when the box first opens rather than
154
+ // when this file loads: the script is in <head>, so at load time the body,
155
+ // and the element, do not exist yet. The selector names the script tag the
156
+ // template emits (src/views.ts); a rendered document cannot contain one.
157
+ function jumpContext() {
158
+ if (jumpCtxRead) return jumpCtx;
159
+ jumpCtxRead = true;
160
+ var el = document.querySelector('script#jump-data[type="application/json"]');
161
+ if (!el) return null;
162
+ var raw;
163
+ try { raw = JSON.parse(el.textContent); } catch (e) { return null; }
164
+ if (!raw || typeof raw.repo !== 'string') return null;
165
+ var sections = [];
166
+ var list = Array.isArray(raw.sections) ? raw.sections : [];
167
+ for (var i = 0; i < list.length; i++) {
168
+ var href = localPath(list[i] && list[i].href);
169
+ if (href && typeof list[i].label === 'string') sections.push({ label: list[i].label, href: href });
170
+ }
171
+ var searchUrl = localPath(raw.searchUrl);
172
+ var findUrl = localPath(raw.findUrl);
173
+ if (!searchUrl || !findUrl) return null;
174
+ jumpCtx = { repo: raw.repo, sections: sections, searchUrl: searchUrl, findUrl: findUrl };
175
+ return jumpCtx;
176
+ }
150
177
  function loadJumpRepos() {
151
178
  if (jumpRepos) return Promise.resolve(jumpRepos);
152
179
  var cached = null;
153
- try { cached = sessionStorage.getItem('mochi.repos'); } catch (e) {}
180
+ // The key carries a version: the entries grew topics, and a tab holding the
181
+ // older list of bare names would otherwise feed it to code expecting objects.
182
+ try { cached = sessionStorage.getItem('mochi.repos.v2'); } catch (e) {}
154
183
  if (cached) { try { jumpRepos = JSON.parse(cached); return Promise.resolve(jumpRepos); } catch (e) {} }
155
184
  return fetch('/assets/repos.json').then(function (r) { return r.json(); }).then(function (list) {
156
185
  jumpRepos = list;
157
- try { sessionStorage.setItem('mochi.repos', JSON.stringify(list)); } catch (e) {}
186
+ try { sessionStorage.setItem('mochi.repos.v2', JSON.stringify(list)); } catch (e) {}
158
187
  return list;
159
188
  }, function () { jumpRepos = []; return jumpRepos; });
160
189
  }
@@ -181,6 +210,7 @@ function jumpScore(hay, q) {
181
210
  function jumpItems() {
182
211
  var q = document.getElementById('jump-q').value.trim().toLowerCase();
183
212
  var out = [];
213
+ var jumpCtx = jumpContext();
184
214
  if (jumpCtx) {
185
215
  for (var s = 0; s < jumpCtx.sections.length; s++) {
186
216
  var sec = jumpCtx.sections[s];
@@ -190,17 +220,27 @@ function jumpItems() {
190
220
  }
191
221
  var repos = jumpRepos || [];
192
222
  for (var i = 0; i < repos.length; i++) {
193
- var name = repos[i];
223
+ var entry = repos[i];
224
+ var name = typeof entry === 'string' ? entry : entry.name;
225
+ var topics = (entry && entry.topics) || [];
194
226
  var slash = name.indexOf('/');
195
227
  var short = name.slice(slash + 1);
228
+ var note = name.slice(0, slash);
196
229
  var sc = jumpScore(short, q);
197
230
  // Falling back to the whole path lets "concept/bench" find a repository,
198
231
  // but only as a literal substring: a collection name spread across it a
199
232
  // letter at a time would match nearly everything.
200
233
  if (sc < 0 && q !== '' && name.toLowerCase().indexOf(q) !== -1) sc = 950;
234
+ // Last, the topics: typing one finds what carries it, below any match on
235
+ // a name, and the note says which topic answered.
236
+ if (sc < 0 && q !== '') {
237
+ for (var t = 0; t < topics.length; t++) {
238
+ if (topics[t].indexOf(q) !== -1) { sc = 960; note = note + ' · ' + topics[t]; break; }
239
+ }
240
+ }
201
241
  // A repository is what the box is mostly for, so its matches sort above a
202
242
  // section of equal quality rather than below.
203
- if (sc >= 0) out.push({ score: sc - 1, group: 'Repositories', label: short, note: name.slice(0, slash), href: '/' + name.split('/').map(encodeURIComponent).join('/') });
243
+ if (sc >= 0) out.push({ score: sc - 1, group: 'Repositories', label: short, note: note, href: '/' + name.split('/').map(encodeURIComponent).join('/') });
204
244
  }
205
245
  out.sort(function (a, b) { return a.score - b.score || a.label.localeCompare(b.label); });
206
246
  out = out.slice(0, 15);
@@ -222,7 +262,7 @@ function renderJump() {
222
262
  var it = items[i];
223
263
  if (it.group !== group) { group = it.group; html += '<li class="jump-group" role="presentation">' + escapeHtml(group) + '</li>'; }
224
264
  html += '<li role="option" aria-selected="' + (i === jumpSel) + '"><a class="jump-item' + (i === jumpSel ? ' on' : '') +
225
- '" href="' + escapeHtml(it.href) + '" data-jump-index="' + i + '">' + escapeHtml(it.label) +
265
+ '" href="' + escapeHtml(it.href) + '" data-jump-index="' + i + '"><span class="jump-name">' + escapeHtml(it.label) + '</span>' +
226
266
  (it.note ? '<span class="jump-note">' + escapeHtml(it.note) + '</span>' : '') + '</a></li>';
227
267
  }
228
268
  list.innerHTML = html || '<li class="jump-empty" role="presentation">' +
@@ -267,6 +307,20 @@ document.addEventListener('click', function (e) {
267
307
  var dlg = document.getElementById('jump');
268
308
  if (dlg && dlg.open && e.target === dlg) dlg.close();
269
309
  });
310
+ // Ids inside a rendered document carry a user-content- prefix (see
311
+ // src/markdown.ts), so a link from outside it to /repo#install names an
312
+ // element that is not there under that name. When the fragment finds nothing,
313
+ // the prefixed element is scrolled to instead, on load and on every change.
314
+ function followHash() {
315
+ var h = location.hash ? String(location.hash).slice(1) : '';
316
+ if (!h || document.getElementById(h)) return;
317
+ var name;
318
+ try { name = decodeURIComponent(h); } catch (e) { name = h; }
319
+ var el = document.getElementById('user-content-' + name);
320
+ if (el && el.scrollIntoView) el.scrollIntoView();
321
+ }
322
+ document.addEventListener('DOMContentLoaded', followHash);
323
+ window.addEventListener('hashchange', followHash);
270
324
  // The filter box above a listing: hide the rows that do not match, and say so
271
325
  // when none do.
272
326
  function filterRows(input) {
@@ -314,6 +368,129 @@ function filterMenu(input) {
314
368
  }
315
369
  }
316
370
 
371
+ // ---- passkeys ----
372
+ //
373
+ // The WebAuthn ceremonies, client half. The server speaks JSON on
374
+ // /login/passkey* and /account/passkeys*, with every binary field base64url,
375
+ // which is also the encoding the WebAuthn API's own ids use. Buttons carry
376
+ // data attributes and start hidden; support is detected here, so a browser
377
+ // without WebAuthn (or a page served over plain http, where the API does not
378
+ // exist) simply never shows them.
379
+
380
+ function pkDecode(s) {
381
+ s = String(s).replace(/-/g, '+').replace(/_/g, '/');
382
+ while (s.length % 4) s += '=';
383
+ var bin = atob(s);
384
+ var out = new Uint8Array(bin.length);
385
+ for (var i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
386
+ return out.buffer;
387
+ }
388
+ function pkEncode(buf) {
389
+ var b = new Uint8Array(buf);
390
+ var bin = '';
391
+ for (var i = 0; i < b.length; i++) bin += String.fromCharCode(b[i]);
392
+ return btoa(bin).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
393
+ }
394
+ function pkSay(message) {
395
+ var el = document.querySelector('[data-passkey-error]');
396
+ if (!el) return;
397
+ el.textContent = message || '';
398
+ el.hidden = !message;
399
+ }
400
+ function pkFetch(url, body) {
401
+ return fetch(url, {
402
+ method: 'POST',
403
+ headers: { 'content-type': 'application/json' },
404
+ body: JSON.stringify(body),
405
+ }).then(function (r) {
406
+ return r.json().catch(function () { return {}; }).then(function (d) {
407
+ if (!r.ok) throw new Error(d.error || ('the server answered ' + r.status));
408
+ return d;
409
+ });
410
+ });
411
+ }
412
+ // A cancelled or timed-out prompt is the user's decision, not a fault to
413
+ // paint red; anything else is said as the server or browser said it.
414
+ function pkProblem(e) {
415
+ if (e && e.name === 'NotAllowedError') pkSay('Cancelled, or the prompt timed out. Try again when ready.');
416
+ else pkSay((e && e.message) || 'Something went wrong.');
417
+ }
418
+ function pkRegister(btn) {
419
+ var form = closestOf(btn, '[data-passkey-form]');
420
+ var csrf = form.querySelector('input[name=csrf]').value;
421
+ var label = (form.querySelector('input[name=name]') || { value: '' }).value;
422
+ pkSay('');
423
+ btn.disabled = true;
424
+ pkFetch('/account/passkeys/challenge', { csrf: csrf })
425
+ .then(function (o) {
426
+ var publicKey = {
427
+ challenge: pkDecode(o.challenge),
428
+ rp: o.rp,
429
+ user: { id: pkDecode(o.user.id), name: o.user.name, displayName: o.user.displayName },
430
+ pubKeyCredParams: o.pubKeyCredParams,
431
+ authenticatorSelection: o.authenticatorSelection,
432
+ timeout: o.timeout,
433
+ attestation: o.attestation,
434
+ excludeCredentials: (o.excludeCredentials || []).map(function (c) {
435
+ return { type: 'public-key', id: pkDecode(c.id) };
436
+ }),
437
+ };
438
+ return navigator.credentials.create({ publicKey: publicKey });
439
+ })
440
+ .then(function (cred) {
441
+ return pkFetch('/account/passkeys', {
442
+ csrf: csrf,
443
+ name: label,
444
+ id: cred.id,
445
+ clientDataJSON: pkEncode(cred.response.clientDataJSON),
446
+ attestationObject: pkEncode(cred.response.attestationObject),
447
+ });
448
+ })
449
+ .then(function (d) { location.href = d.next; })
450
+ .catch(pkProblem)
451
+ .then(function () { btn.disabled = false; });
452
+ }
453
+ function pkLogin(btn) {
454
+ pkSay('');
455
+ btn.disabled = true;
456
+ pkFetch('/login/passkey/challenge', {})
457
+ .then(function (o) {
458
+ return navigator.credentials.get({
459
+ publicKey: {
460
+ challenge: pkDecode(o.challenge),
461
+ rpId: o.rpId,
462
+ timeout: o.timeout,
463
+ userVerification: o.userVerification,
464
+ allowCredentials: [],
465
+ },
466
+ });
467
+ })
468
+ .then(function (cred) {
469
+ return pkFetch('/login/passkey', {
470
+ next: btn.getAttribute('data-next') || '/',
471
+ id: cred.id,
472
+ clientDataJSON: pkEncode(cred.response.clientDataJSON),
473
+ authenticatorData: pkEncode(cred.response.authenticatorData),
474
+ signature: pkEncode(cred.response.signature),
475
+ userHandle: cred.response.userHandle ? pkEncode(cred.response.userHandle) : null,
476
+ });
477
+ })
478
+ .then(function (d) { location.href = d.next; })
479
+ .catch(pkProblem)
480
+ .then(function () { btn.disabled = false; });
481
+ }
482
+ document.addEventListener('DOMContentLoaded', function () {
483
+ var supported = Boolean(window.PublicKeyCredential);
484
+ var login = document.querySelector('[data-passkey-login]');
485
+ if (login && supported) login.hidden = false;
486
+ var add = document.querySelector('[data-passkey-add]');
487
+ var unsupported = document.querySelector('[data-passkey-unsupported]');
488
+ if (add && !supported) {
489
+ add.disabled = true;
490
+ if (unsupported) unsupported.hidden = false;
491
+ }
492
+ });
493
+
317
494
  // ---- wiring ----
318
495
  //
319
496
  // The markup carries data attributes rather than inline handlers, and the
@@ -346,6 +523,14 @@ document.addEventListener('click', function (e) {
346
523
  if (lines) { copyLines(lines); return; }
347
524
  var select = closestOf(t, '[data-select-all]');
348
525
  if (select) { select.select(); return; }
526
+ var pkAdd = closestOf(t, '[data-passkey-add]');
527
+ if (pkAdd) { pkRegister(pkAdd); return; }
528
+ var pkGo = closestOf(t, '[data-passkey-login-btn]');
529
+ if (pkGo) { pkLogin(pkGo); return; }
530
+ var mdTab = closestOf(t, '[data-md-pane]');
531
+ if (mdTab) { mdPane(mdEditorOf(mdTab), mdTab.getAttribute('data-md-pane')); return; }
532
+ var mdBtn = closestOf(t, '[data-md-act]');
533
+ if (mdBtn) { mdAct(mdEditorOf(mdBtn), mdBtn.getAttribute('data-md-act')); return; }
349
534
  });
350
535
 
351
536
  document.addEventListener('input', function (e) {
@@ -368,6 +553,14 @@ document.addEventListener('keydown', function (e) {
368
553
  if (!el || !el.matches) return;
369
554
  if (el.id === 'jump-q') { jumpKey(e); return; }
370
555
  if (el.matches('.find-input')) { findKey(e, el); return; }
556
+ // The formatting shortcuts, only inside a markdown editor's field: the same
557
+ // four GitHub answers to, so hands need not relearn anything.
558
+ if (el.tagName === 'TEXTAREA' && (e.metaKey || e.ctrlKey) && !e.altKey && !e.shiftKey) {
559
+ var ed = mdEditorOf(el);
560
+ if (!ed) return;
561
+ var act = { b: 'bold', i: 'italic', k: 'link', e: 'code' }[String(e.key).toLowerCase()];
562
+ if (act) { e.preventDefault(); mdAct(ed, act); }
563
+ }
371
564
  });
372
565
 
373
566
  // mouseenter does not bubble, so the jump list is followed with mouseover,
@@ -399,27 +592,165 @@ function pickWorkflow(sel) {
399
592
  }
400
593
  }
401
594
 
402
- // The editor: Tab indents rather than leaving the field, since code is what is
403
- // being typed (Shift-Tab still moves focus out, so the keyboard is not
404
- // trapped), and navigating away from an edited buffer asks first. Committing
405
- // does not ask; submitting is the point.
595
+ // ---- the markdown editor ----
596
+ //
597
+ // The toolbar writes markdown into the textarea rather than styling anything:
598
+ // what is stored is what was typed. Every button is a toggle where a toggle
599
+ // makes sense (bold twice is plain again), and the Preview tab shows the draft
600
+ // through the same renderer the saved page will use, fetched from the
601
+ // repository's own preview endpoint so relative links, #12, and commit ids
602
+ // resolve as they will when saved.
603
+
604
+ function mdEditorOf(el) { return closestOf(el, '[data-md-editor]'); }
605
+
606
+ // Replace [start, end) with text, leave [selStart, selEnd) selected, and keep
607
+ // the field focused so the next keystroke lands where the edit did.
608
+ function mdSet(input, start, end, text, selStart, selEnd) {
609
+ input.focus();
610
+ input.setRangeText(text, start, end, 'preserve');
611
+ input.setSelectionRange(selStart, selEnd);
612
+ }
613
+
614
+ // Wrap the selection in the marks, or take them off again when they are
615
+ // already there, inside the selection or just around it.
616
+ function mdSurround(input, before, after, placeholder) {
617
+ var s = input.selectionStart, e = input.selectionEnd, v = input.value;
618
+ var sel = v.slice(s, e);
619
+ if (sel.length >= before.length + after.length &&
620
+ sel.slice(0, before.length) === before && sel.slice(sel.length - after.length) === after) {
621
+ var inner = sel.slice(before.length, sel.length - after.length);
622
+ mdSet(input, s, e, inner, s, s + inner.length);
623
+ return;
624
+ }
625
+ if (s >= before.length && v.slice(s - before.length, s) === before && v.slice(e, e + after.length) === after) {
626
+ mdSet(input, s - before.length, e + after.length, sel, s - before.length, s - before.length + sel.length);
627
+ return;
628
+ }
629
+ var body = sel || placeholder || '';
630
+ mdSet(input, s, e, before + body + after, s + before.length, s + before.length + body.length);
631
+ }
632
+
633
+ // The whole lines the selection touches, and where they sit in the value.
634
+ function mdLines(input) {
635
+ var v = input.value;
636
+ var s = v.lastIndexOf('\\n', input.selectionStart - 1) + 1;
637
+ var e = v.indexOf('\\n', input.selectionEnd);
638
+ if (e === -1) e = v.length;
639
+ return { start: s, end: e, lines: v.slice(s, e).split('\\n') };
640
+ }
641
+
642
+ // Prefix each selected line, or strip the prefix when every line has one
643
+ // already; blank lines between prefixed ones are passed over.
644
+ function mdPrefix(input, make, strip) {
645
+ var r = mdLines(input);
646
+ var on = r.lines.every(function (l) { return l === '' || strip.test(l); });
647
+ var out = [];
648
+ for (var i = 0; i < r.lines.length; i++) {
649
+ var l = r.lines[i];
650
+ out.push(on ? l.replace(strip, '') : (l === '' && r.lines.length > 1 ? l : make(i) + l));
651
+ }
652
+ var text = out.join('\\n');
653
+ mdSet(input, r.start, r.end, text, r.start, r.start + text.length);
654
+ }
655
+
656
+ // A link: a selected URL becomes the target with the cursor in the text, and
657
+ // anything else becomes the text with the url placeholder selected.
658
+ function mdLink(input) {
659
+ var s = input.selectionStart, e = input.selectionEnd;
660
+ var sel = input.value.slice(s, e);
661
+ if (/^https?:\\/\\/\\S+$/.test(sel)) {
662
+ mdSet(input, s, e, '[](' + sel + ')', s + 1, s + 1);
663
+ } else {
664
+ var body = sel || 'link text';
665
+ mdSet(input, s, e, '[' + body + '](url)', s + body.length + 3, s + body.length + 6);
666
+ }
667
+ }
668
+
669
+ function mdAct(ed, act) {
670
+ var input = ed && ed.querySelector('textarea');
671
+ if (!input || input.hidden) return;
672
+ if (act === 'bold') return mdSurround(input, '**', '**', 'bold text');
673
+ if (act === 'italic') return mdSurround(input, '_', '_', 'italic text');
674
+ if (act === 'strike') return mdSurround(input, '~~', '~~', 'struck text');
675
+ if (act === 'link') return mdLink(input);
676
+ if (act === 'code') {
677
+ var sel = input.value.slice(input.selectionStart, input.selectionEnd);
678
+ if (sel.indexOf('\\n') !== -1) return mdSurround(input, '\`\`\`\\n', '\\n\`\`\`', '');
679
+ return mdSurround(input, '\`', '\`', 'code');
680
+ }
681
+ if (act === 'heading') return mdPrefix(input, function () { return '### '; }, /^#{1,6} /);
682
+ if (act === 'quote') return mdPrefix(input, function () { return '> '; }, /^> /);
683
+ if (act === 'ul') return mdPrefix(input, function () { return '- '; }, /^[-*] (?!\\[[ xX]\\] )/);
684
+ if (act === 'ol') return mdPrefix(input, function (i) { return (i + 1) + '. '; }, /^\\d+\\. /);
685
+ if (act === 'task') return mdPrefix(input, function () { return '- [ ] '; }, /^[-*] \\[[ xX]\\] /);
686
+ }
687
+
688
+ // The tabs. Write is the field back; Preview posts the draft, with the CSRF
689
+ // value of the form it sits in, and shows what came back. The height of the
690
+ // field is kept so the page does not jump, and an unchanged draft is not
691
+ // posted again.
692
+ function mdPane(ed, pane) {
693
+ var input = ed.querySelector('textarea');
694
+ var render = ed.querySelector('.md-render');
695
+ var tabs = ed.querySelectorAll('[data-md-pane]');
696
+ for (var i = 0; i < tabs.length; i++) {
697
+ var on = tabs[i].getAttribute('data-md-pane') === pane;
698
+ tabs[i].classList[on ? 'add' : 'remove']('current');
699
+ tabs[i].setAttribute('aria-selected', String(on));
700
+ }
701
+ var previewing = pane === 'preview';
702
+ ed.classList[previewing ? 'add' : 'remove']('previewing');
703
+ input.hidden = previewing;
704
+ render.hidden = !previewing;
705
+ if (!previewing) { input.focus(); return; }
706
+ render.style.minHeight = input.offsetHeight + 'px';
707
+ if (ed.mdShown === input.value) return;
708
+ render.innerHTML = '<p class="muted">Rendering\\u2026</p>';
709
+ var form = closestOf(ed, 'form');
710
+ var csrf = form && form.querySelector('input[name=csrf]');
711
+ var params = new URLSearchParams();
712
+ params.set('csrf', csrf ? csrf.value : '');
713
+ params.set('text', input.value);
714
+ params.set('ref', ed.getAttribute('data-md-ref') || '');
715
+ params.set('dir', ed.getAttribute('data-md-dir') || '');
716
+ fetch(ed.getAttribute('data-md-preview'), { method: 'POST', body: params })
717
+ .then(function (r) { if (!r.ok) throw new Error('preview ' + r.status); return r.text(); })
718
+ .then(function (h) { ed.mdShown = input.value; render.innerHTML = h; })
719
+ .catch(function () { render.innerHTML = '<p class="muted">The preview could not be rendered. The draft is untouched.</p>'; });
720
+ }
721
+
722
+ // The editors: in one of the code kind, Tab indents rather than leaving the
723
+ // field, since code is what is being typed (Shift-Tab still moves focus out,
724
+ // so the keyboard is not trapped). Navigating away from any edited buffer asks
725
+ // first; committing does not ask, since submitting is the point.
406
726
  document.addEventListener('DOMContentLoaded', function () {
407
- var ta = document.querySelector('textarea.code-editor');
408
- if (!ta) return;
409
- var form = ta.closest('form');
410
- var initial = ta.value;
727
+ var tas = document.querySelectorAll('textarea.code-editor, [data-md-editor] textarea');
728
+ if (!tas.length) return;
729
+ var initial = [];
411
730
  var submitted = false;
412
- if (form) form.addEventListener('submit', function () { submitted = true; });
731
+ function watch(ta) {
732
+ initial.push(ta.value);
733
+ var form = ta.closest('form');
734
+ if (form && !form.mdWatched) {
735
+ form.mdWatched = true;
736
+ form.addEventListener('submit', function () { submitted = true; });
737
+ }
738
+ if (!ta.classList.contains('code-editor')) return;
739
+ ta.addEventListener('keydown', function (e) {
740
+ if (e.key !== 'Tab' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
741
+ e.preventDefault();
742
+ ta.setRangeText(' ', ta.selectionStart, ta.selectionEnd, 'end');
743
+ });
744
+ }
745
+ for (var i = 0; i < tas.length; i++) watch(tas[i]);
413
746
  window.addEventListener('beforeunload', function (e) {
414
- if (submitted || ta.value === initial) return;
747
+ if (submitted) return;
748
+ var dirty = false;
749
+ for (var i = 0; i < tas.length; i++) if (tas[i].value !== initial[i]) dirty = true;
750
+ if (!dirty) return;
415
751
  e.preventDefault();
416
752
  e.returnValue = '';
417
753
  });
418
- ta.addEventListener('keydown', function (e) {
419
- if (e.key !== 'Tab' || e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) return;
420
- e.preventDefault();
421
- ta.setRangeText(' ', ta.selectionStart, ta.selectionEnd, 'end');
422
- });
423
754
  });
424
755
 
425
756
  // A live job's log: the endpoint and the offset to resume from are on the
package/dist/perms.js CHANGED
@@ -44,6 +44,7 @@ exports.collectionOwners = collectionOwners;
44
44
  exports.isCollectionOwner = isCollectionOwner;
45
45
  exports.addCollectionOwner = addCollectionOwner;
46
46
  exports.removeCollectionOwner = removeCollectionOwner;
47
+ exports.tokenIsScoped = tokenIsScoped;
47
48
  exports.isSiteAdmin = isSiteAdmin;
48
49
  exports.repoRole = repoRole;
49
50
  exports.canReadRepo = canReadRepo;
package/dist/profile.js CHANGED
@@ -5,6 +5,7 @@ exports.collectionProfile = collectionProfile;
5
5
  const markdown_1 = require("./markdown");
6
6
  const render_1 = require("./render");
7
7
  const scan_1 = require("./scan");
8
+ const vault_1 = require("./vault");
8
9
  const views_1 = require("./views");
9
10
  /**
10
11
  * A collection's profile README: the page a collection shows above its
@@ -63,6 +64,7 @@ async function collectionProfile(root, collection) {
63
64
  blobBase: `${base}/blob/${(0, views_1.encPath)(branch)}/${exports.PROFILE_DIR}`,
64
65
  issueBase: `${base}/issues`,
65
66
  commitBase: `${base}/commit`,
67
+ mentions: (name) => (0, vault_1.userExists)(root, name),
66
68
  })
67
69
  : `<pre>${(0, render_1.esc)(readme.text)}</pre>`;
68
70
  return {
package/dist/pulls.js CHANGED
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.MergeConflict = exports.MAX_TITLE = exports.MAX_BODY = void 0;
36
+ exports.MergeConflict = exports.DuplicatePullError = exports.MAX_TITLE = exports.MAX_BODY = void 0;
37
37
  exports.pullsDir = pullsDir;
38
38
  exports.listPulls = listPulls;
39
39
  exports.pullCounts = pullCounts;
@@ -144,6 +144,20 @@ function readPull(root, collection, repo, n) {
144
144
  return null;
145
145
  return { ...summaryFrom(n, dir, doc), body: doc.body, commentList: (0, discussion_1.readComments)(dir) };
146
146
  }
147
+ /**
148
+ * A pull request refused because one is already open for the same base and
149
+ * head. Its own class because the number is what a caller wants: the web
150
+ * handler sends the reader to the existing pull request rather than
151
+ * describing it.
152
+ */
153
+ class DuplicatePullError extends ops_1.OpError {
154
+ number;
155
+ constructor(number, head, base) {
156
+ super(`Pull request #${number} for ${head} into ${base} is already open.`, 'exists');
157
+ this.number = number;
158
+ }
159
+ }
160
+ exports.DuplicatePullError = DuplicatePullError;
147
161
  function createPull(root, collection, repo, input) {
148
162
  const dir = pullsDir(root, collection, repo);
149
163
  if (!dir)
@@ -152,6 +166,12 @@ function createPull(root, collection, repo, input) {
152
166
  const body = (0, discussion_1.checkBody)(PULL, input.body);
153
167
  if (input.base === input.head)
154
168
  throw new ops_1.OpError('A branch cannot be merged into itself.');
169
+ // One open pull request per (base, head): a second would show the same
170
+ // commits and go stale the moment the first merges. The existing one is
171
+ // where new discussion belongs, so the refusal carries its number.
172
+ const open = listPulls(root, collection, repo).find((p) => p.state === 'open' && p.base === input.base && p.head === input.head);
173
+ if (open)
174
+ throw new DuplicatePullError(open.number, input.head, input.base);
155
175
  const now = new Date().toISOString();
156
176
  const n = (0, discussion_1.allocate)(PULL, dir, (sub) => (0, discussion_1.writeDoc)(path.join(sub, 'pull.md'), {
157
177
  title,
package/dist/pullweb.js CHANGED
@@ -41,6 +41,7 @@ const git_1 = require("./git");
41
41
  const html_1 = require("./html");
42
42
  const icons_1 = require("./icons");
43
43
  const markdown_1 = require("./markdown");
44
+ const mdedit_1 = require("./mdedit");
44
45
  const ops_1 = require("./ops");
45
46
  const pulls = __importStar(require("./pulls"));
46
47
  const render_1 = require("./render");
@@ -74,6 +75,7 @@ function body(ctx, text) {
74
75
  blobBase: `${base}/blob/${ref}`,
75
76
  issueBase: `${base}/issues`,
76
77
  commitBase: `${base}/commit`,
78
+ mentions: ctx.hasUser,
77
79
  }));
78
80
  }
79
81
  function stateBadge(state) {
@@ -101,7 +103,7 @@ function listPage(ctx, list, state, counts) {
101
103
  const rows = list.map((p) => (0, html_1.html) `<tr>
102
104
  <td class="issue-cell">${stateIcon(p.state)}<span>
103
105
  <a class="issue-link" href="${base}/${p.number}">${p.title}</a>
104
- <div class="muted small">#${p.number} opened ${(0, render_1.timeTag)(p.created)} by ${p.author} &middot; ${branchPair(ctx, p)}</div></span></td>
106
+ <div class="muted small">#${p.number} opened ${(0, render_1.timeTag)(p.created)} by ${(0, views_1.userLink)(p.author)} &middot; ${branchPair(ctx, p)}</div></span></td>
105
107
  <td class="right muted small">${p.comments > 0
106
108
  ? (0, html_1.html) `<a class="issue-comments" href="${base}/${p.number}" title="${p.comments} comment${p.comments === 1 ? '' : 's'}">${(0, icons_1.icon)('comment')}<span>${p.comments}</span></a>`
107
109
  : ''}</td>
@@ -126,7 +128,7 @@ ${rows.length
126
128
  }
127
129
  function commentCard(author, when, rendered, note = '') {
128
130
  return (0, html_1.html) `<article class="issue-comment">
129
- <div class="issue-comment-head">${(0, avatar_1.avatar)(author, 24)}<b>${author}</b><span class="muted small">${note || 'commented'} ${(0, render_1.timeTag)(when)}</span></div>
131
+ <div class="issue-comment-head">${(0, views_1.userLink)(author, { face: 24, bold: true })}<span class="muted small">${note || 'commented'} ${(0, render_1.timeTag)(when)}</span></div>
130
132
  <div class="issue-comment-body markdown-body">${rendered}</div>
131
133
  </article>`;
132
134
  }
@@ -185,21 +187,23 @@ function pullPage(ctx, pull, view) {
185
187
  : '';
186
188
  replyBox = (0, html_1.html) `<form class="issue-reply" method="post" action="${base}/${pull.number}/comment">${(0, views_1.csrfField)(viewer)}
187
189
  <div class="issue-comment-head">${(0, avatar_1.avatar)(viewer.auth.username, 24)}<b>${viewer.auth.username}</b></div>
188
- <textarea class="code-editor" name="body" rows="6" placeholder="Leave a comment"></textarea>
190
+ ${(0, mdedit_1.markdownEditor)({ name: 'body', rows: 6, placeholder: 'Leave a comment', preview: (0, mdedit_1.previewUrl)(ctx) })}
189
191
  <div class="actions">${toggle}<button class="btn btn-primary" type="submit">Comment</button></div>
190
192
  </form>`;
191
193
  }
192
194
  else {
193
195
  replyBox = (0, html_1.html) `<p class="muted"><a href="/login?next=${encodeURIComponent(`${base}/${pull.number}`)}">Sign in</a> to comment.</p>`;
194
196
  }
195
- const commitRows = view.commits.map((c) => (0, html_1.html) `<div class="commit-row"><span class="commit-main"><a class="title" href="${repo}/commit/${c.sha}">${c.subject}</a><div class="muted small">${c.author} committed ${(0, render_1.timeTag)(c.date)}</div></span><a class="sha" href="${repo}/commit/${c.sha}">${c.sha.slice(0, 7)}</a></div>`);
197
+ const commitRows = view.commits.map((c) => (0, html_1.html) `<div class="commit-row"><span class="commit-main"><a class="title" href="${repo}/commit/${c.sha}">${c.subject}</a><div class="muted small">${(0, views_1.gitAuthor)(ctx, c.author, c.email)} committed ${(0, render_1.timeTag)(c.date)}</div></span><a class="sha" href="${repo}/commit/${c.sha}">${c.sha.slice(0, 7)}</a></div>`);
196
198
  const description = body(ctx, pull.body);
197
199
  const content = (0, html_1.html) `${(0, views_1.repoHeader)(ctx, 'pulls')}
198
200
  <div class="issue-head">
199
201
  <h1 class="issue-title">${pull.title} <span class="muted">#${pull.number}</span></h1>
200
202
  <span class="right-group"><a class="btn" href="${base}">Back to pull requests</a></span>
201
203
  </div>
202
- <div class="issue-sub">${stateBadge(pull.state)}<span class="muted"><b>${pull.author}</b> wants to merge ${view.ahead} commit${view.ahead === 1 ? '' : 's'} into </span>${branchPair(ctx, pull)}</div>
204
+ <div class="issue-sub">${stateBadge(pull.state)}<span class="muted">${(0, views_1.userLink)(pull.author, {
205
+ bold: true,
206
+ })} wants to merge ${view.ahead} commit${view.ahead === 1 ? '' : 's'} into </span>${branchPair(ctx, pull)}</div>
203
207
  ${mergeBox(ctx, pull, view.preview, ctx.canPush)}
204
208
  <div class="issue-thread">
205
209
  ${commentCard(pull.author, pull.created, description.text ? description : (0, html_1.raw)('<p class="muted">No description.</p>'), 'opened this')}
@@ -233,7 +237,15 @@ ${info
233
237
  <div class="field"><label for="title">Title</label>
234
238
  <input type="text" id="title" name="title" value="${values.title}" maxlength="${pulls.MAX_TITLE}" required autofocus></div>
235
239
  <div class="field"><label for="body">Description</label>
236
- <textarea class="code-editor" id="body" name="body" rows="10" placeholder="What this changes, and why. Markdown is welcome.">${values.body}</textarea></div>
240
+ ${(0, mdedit_1.markdownEditor)({
241
+ name: 'body',
242
+ id: 'body',
243
+ rows: 10,
244
+ placeholder: 'What this changes, and why. Markdown is welcome.',
245
+ value: values.body,
246
+ preview: (0, mdedit_1.previewUrl)(ctx),
247
+ ref: values.head,
248
+ })}</div>
237
249
  <div class="actions"><button class="btn btn-primary" type="submit">${(0, icons_1.icon)('git-pull-request')}<span>Create pull request</span></button><a class="btn" href="${base}">Cancel</a></div>
238
250
  </form>
239
251
  </div>`;
@@ -319,6 +331,13 @@ function registerPulls(app, root, engine) {
319
331
  res.redirect(`${pullsUrl(ctx)}/${created.number}`);
320
332
  }
321
333
  catch (e) {
334
+ // A pull request already open for this base and head is where this one
335
+ // was headed anyway, so the reader is sent there instead of shown a form
336
+ // error describing it.
337
+ if (e instanceof pulls.DuplicatePullError) {
338
+ res.redirect(`${pullsUrl(ctx)}/${e.number}`);
339
+ return;
340
+ }
322
341
  const message = e instanceof ops_1.OpError ? e.message : 'Could not open the pull request.';
323
342
  res
324
343
  .status(400)
package/dist/releases.js CHANGED
@@ -50,6 +50,7 @@ const git_1 = require("./git");
50
50
  const html_1 = require("./html");
51
51
  const icons_1 = require("./icons");
52
52
  const markdown_1 = require("./markdown");
53
+ const mdedit_1 = require("./mdedit");
53
54
  const render_1 = require("./render");
54
55
  const session_1 = require("./session");
55
56
  const layout_1 = require("./layout");
@@ -257,7 +258,14 @@ ${editing
257
258
  <div class="field"><label for="name">Title</label>
258
259
  <input type="text" id="name" name="name" maxlength="${MAX_NAME}" value="${editing?.name ?? ''}" placeholder="Leave empty to use the tag name"></div>
259
260
  <div class="field"><label for="body">Notes</label>
260
- <textarea class="code-editor" id="body" name="body" rows="16" placeholder="What changed, and what it means for anyone upgrading. Markdown.">${editing?.body ?? ''}</textarea></div>
261
+ ${(0, mdedit_1.markdownEditor)({
262
+ name: 'body',
263
+ id: 'body',
264
+ rows: 16,
265
+ placeholder: 'What changed, and what it means for anyone upgrading. Markdown.',
266
+ value: editing?.body ?? '',
267
+ preview: (0, mdedit_1.previewUrl)(ctx),
268
+ })}</div>
261
269
  <div class="field"><label class="checkbox"><input type="checkbox" name="prerelease" value="1"${editing?.prerelease ? (0, html_1.raw)(' checked') : ''}> This is a pre-release</label></div>
262
270
  <div class="actions"><button type="submit" class="btn btn-primary">${editing ? 'Save release' : 'Publish release'}</button><a class="btn" href="${base}/releases">Cancel</a>${del}</div>
263
271
  </form>
@@ -430,6 +438,7 @@ ${releaseCard(ctx, release, ref, latest, notesHtml(ctx, release))}`;
430
438
  blobBase: `${base}/blob/${(0, views_1.encPath)(release.tag)}`,
431
439
  issueBase: `${base}/issues`,
432
440
  commitBase: `${base}/commit`,
441
+ mentions: ctx.hasUser,
433
442
  });
434
443
  }
435
444
  }