@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.
- package/README.md +7 -6
- package/dist/agefile.js +47 -0
- package/dist/agescript.js +343 -0
- package/dist/api/admin.js +66 -28
- package/dist/api/auth.js +3 -2
- package/dist/api/backup.js +227 -192
- package/dist/api/contents.js +46 -16
- package/dist/api/repos.js +60 -4
- package/dist/api/write.js +138 -8
- package/dist/api.js +34 -1
- package/dist/browse.js +171 -33
- package/dist/build-info.json +2 -2
- package/dist/ci/api.js +138 -4
- package/dist/ci/artifacts.js +82 -2
- package/dist/ci/engine.js +30 -17
- package/dist/ci/expr.js +14 -0
- package/dist/ci/runners.js +60 -1
- package/dist/ci/runs.js +21 -3
- package/dist/ci/trigger.js +6 -4
- package/dist/ci/views.js +24 -3
- package/dist/ci/web.js +48 -0
- package/dist/cli/admin-cmd.js +40 -3
- package/dist/cli/backup-cmd.js +166 -120
- package/dist/cli/gitrun.js +50 -0
- package/dist/cli/pr-cmd.js +155 -0
- package/dist/cli/repo-cmd.js +134 -6
- package/dist/cli/sync-cmd.js +128 -0
- package/dist/cli/target.js +7 -3
- package/dist/config.js +10 -0
- package/dist/credentials.js +9 -8
- package/dist/deploy-cli.js +214 -109
- package/dist/deploy-runner-cli.js +125 -49
- package/dist/domains.js +259 -0
- package/dist/egress.js +23 -6
- package/dist/forms.js +703 -32
- package/dist/git.js +124 -13
- package/dist/githttp.js +80 -23
- package/dist/githubauth.js +185 -0
- package/dist/icons.js +14 -0
- package/dist/import-cli.js +40 -25
- package/dist/index.js +110 -4
- package/dist/issueweb.js +10 -6
- package/dist/lfs.js +9 -2
- package/dist/limit.js +2 -2
- package/dist/logincodes.js +31 -0
- package/dist/maintenance.js +297 -0
- package/dist/markdown.js +193 -3
- package/dist/mdedit.js +29 -0
- package/dist/migrate.js +48 -0
- package/dist/naming.js +25 -0
- package/dist/onetime.js +112 -0
- package/dist/ops.js +293 -16
- package/dist/pagescript.js +355 -24
- package/dist/perms.js +1 -0
- package/dist/profile.js +2 -0
- package/dist/pulls.js +21 -1
- package/dist/pullweb.js +25 -6
- package/dist/releases.js +10 -1
- package/dist/runner/actions.js +12 -1
- package/dist/runner/client.js +40 -2
- package/dist/runner/docker.js +40 -28
- package/dist/runner/download.js +77 -0
- package/dist/runner/externals.js +12 -1
- package/dist/runner/job.js +26 -11
- package/dist/runner/steps.js +42 -7
- package/dist/runner-cli.js +89 -0
- package/dist/scan.js +58 -2
- package/dist/server.js +107 -26
- package/dist/session.js +82 -40
- package/dist/site.js +110 -27
- package/dist/sitepublish.js +177 -0
- package/dist/sitesettings.js +420 -0
- package/dist/siteshost.js +116 -28
- package/dist/source.js +20 -0
- package/dist/style.js +162 -3
- package/dist/topics.js +165 -0
- package/dist/vault.js +399 -6
- package/dist/vendor-age.js +15 -0
- package/dist/views.js +242 -57
- package/dist/web.js +11 -1
- package/dist/webauthn.js +347 -0
- package/dist/webops.js +1263 -33
- package/package.json +4 -4
package/dist/style.js
CHANGED
|
@@ -60,6 +60,10 @@ exports.CSS = `
|
|
|
60
60
|
:root { --touch: 42px; }
|
|
61
61
|
}
|
|
62
62
|
* { box-sizing: border-box; }
|
|
63
|
+
/* The hidden attribute always wins, including over a display this sheet set:
|
|
64
|
+
scripts show and hide parts of a page by toggling it, and a .age-unlock
|
|
65
|
+
form (display: flex) must actually vanish when unlocking hides it. */
|
|
66
|
+
[hidden] { display: none !important; }
|
|
63
67
|
/* The bar at the top stays put, so an anchored line or heading has to be
|
|
64
68
|
pushed clear of it when the page jumps to one. */
|
|
65
69
|
html { scroll-padding-top: 68px; }
|
|
@@ -86,6 +90,7 @@ code, pre, .mono { font-family: var(--font-mono); }
|
|
|
86
90
|
.topbar {
|
|
87
91
|
position: sticky; top: 0; z-index: 30;
|
|
88
92
|
background: var(--surface); border-bottom: 1px solid var(--border);
|
|
93
|
+
margin-bottom: 1em;
|
|
89
94
|
}
|
|
90
95
|
.topbar .container { display: flex; align-items: center; gap: var(--s3); height: 52px; }
|
|
91
96
|
/* The brand is the logotype from logo.ts, which inherits the text colour. Its
|
|
@@ -107,6 +112,13 @@ code, pre, .mono { font-family: var(--font-mono); }
|
|
|
107
112
|
.topbar .crumbs a { color: var(--fg-muted); }
|
|
108
113
|
.topbar .crumbs a:hover { color: var(--accent); }
|
|
109
114
|
.userbox { margin-left: auto; display: flex; align-items: center; gap: 12px; }
|
|
115
|
+
/* A square glyph button in the bar, the size the appearance menu's summary is,
|
|
116
|
+
so the icons beside the account sit on one rhythm. */
|
|
117
|
+
.topbar-icon {
|
|
118
|
+
display: flex; align-items: center; justify-content: center;
|
|
119
|
+
width: var(--touch); height: var(--touch); border-radius: var(--radius); color: var(--fg-muted);
|
|
120
|
+
}
|
|
121
|
+
.topbar-icon:hover { background: var(--surface-hover); color: var(--fg); text-decoration: none; }
|
|
110
122
|
.user-menu > summary { display: flex; align-items: center; gap: 2px; }
|
|
111
123
|
.user-menu .dropdown-menu { width: 220px; }
|
|
112
124
|
.user-menu form { margin: 0; }
|
|
@@ -163,7 +175,12 @@ kbd {
|
|
|
163
175
|
display: flex; align-items: center; gap: var(--s2);
|
|
164
176
|
padding: 7px var(--s4); min-height: var(--touch);
|
|
165
177
|
color: var(--fg); font-size: var(--t-sm);
|
|
178
|
+
min-width: 0;
|
|
166
179
|
}
|
|
180
|
+
/* Both halves of a row stay on their own line, cut with an ellipsis, so a
|
|
181
|
+
long repository name can never widen the panel or wrap its note. */
|
|
182
|
+
.jump-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
183
|
+
.jump-note { flex: none; max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
167
184
|
.jump-item:hover { text-decoration: none; }
|
|
168
185
|
/* The highlight follows the keyboard, and the pointer moves the keyboard, so
|
|
169
186
|
there is only ever one selected row however the reader is driving. */
|
|
@@ -217,7 +234,7 @@ main { padding: var(--s5) var(--s4) var(--s7); }
|
|
|
217
234
|
rather than read: an operator checking which build is live wants it to be
|
|
218
235
|
there, and everybody else wants it out of the way. Ordinary inline flow, so
|
|
219
236
|
a narrow screen wraps the line instead of scrolling it. */
|
|
220
|
-
.pagefoot { border-top: 1px solid var(--border); }
|
|
237
|
+
.pagefoot { border-top: 1px solid var(--border); margin-top: 1em;}
|
|
221
238
|
.pagefoot .container { padding-top: var(--s3); padding-bottom: var(--s5); color: var(--fg-subtle); font-size: var(--t-xs); }
|
|
222
239
|
.pagefoot .mono { font-size: 11px; }
|
|
223
240
|
.foot-sep { color: var(--border); }
|
|
@@ -326,7 +343,14 @@ h3 { font-family: var(--font-head); font-size: var(--t-lg); line-height: 1.35; }
|
|
|
326
343
|
}
|
|
327
344
|
.dd-item:first-child { border-top: none; }
|
|
328
345
|
.dd-item:hover { background: var(--surface); text-decoration: none; }
|
|
329
|
-
button
|
|
346
|
+
/* A row that is a button (a theme, signing out) is drawn as a row that is a
|
|
347
|
+
link: the rule above it, and none of the browser's own button edges. A row
|
|
348
|
+
under a section's heading has the heading's rule already. */
|
|
349
|
+
button.dd-item {
|
|
350
|
+
width: 100%; background: none; font: inherit; font-size: 13px; cursor: pointer;
|
|
351
|
+
border: none; border-top: 1px solid var(--border-soft);
|
|
352
|
+
}
|
|
353
|
+
.dd-section + .dd-item { border-top: none; }
|
|
330
354
|
.dd-item.current { font-weight: 600; }
|
|
331
355
|
.dd-item .dd-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
332
356
|
.dd-check { width: 16px; flex: none; color: var(--accent); }
|
|
@@ -491,6 +515,43 @@ table.listing.tree td.tree-age { width: 1%; white-space: nowrap; }
|
|
|
491
515
|
.blob-binary { padding: 32px; text-align: center; color: var(--fg-muted); }
|
|
492
516
|
.rendered { padding: 24px 12px 28px; background: var(--bg); }
|
|
493
517
|
|
|
518
|
+
/* Age-encrypted files: the unlock card on the blob page and in the editor,
|
|
519
|
+
the passphrase pair the new-file form reveals for a .age name, the slim
|
|
520
|
+
bar that stands over decrypted output, and the show/hide eye every
|
|
521
|
+
passphrase input carries. */
|
|
522
|
+
.age-card { text-align: center; }
|
|
523
|
+
.age-head { display: inline-flex; align-items: center; gap: 8px; color: var(--fg); margin-bottom: 4px; }
|
|
524
|
+
.age-unlock { display: flex; justify-content: center; gap: 8px; margin: 12px 0 4px; flex-wrap: wrap; }
|
|
525
|
+
.age-unlock .age-pass-wrap { width: min(320px, 100%); }
|
|
526
|
+
.age-pass-wrap { position: relative; display: inline-flex; align-items: center; }
|
|
527
|
+
.age-pass-wrap input { width: 100%; padding-right: 36px; }
|
|
528
|
+
.age-eye {
|
|
529
|
+
position: absolute; right: 3px; display: inline-flex; align-items: center; justify-content: center;
|
|
530
|
+
width: 28px; height: 28px; padding: 0; border: 0; border-radius: var(--radius);
|
|
531
|
+
background: none; color: var(--fg-subtle); cursor: pointer;
|
|
532
|
+
}
|
|
533
|
+
.age-eye:hover { color: var(--fg); }
|
|
534
|
+
.age-eye .glyph-eye-off, .age-pass-wrap.showing .glyph-eye { display: none; }
|
|
535
|
+
.age-pass-wrap.showing .glyph-eye-off { display: block; }
|
|
536
|
+
.age-card .form-error { display: inline-block; margin-top: 10px; }
|
|
537
|
+
.age-card .field { max-width: 360px; margin-left: auto; margin-right: auto; text-align: left; }
|
|
538
|
+
.age-card .field .age-pass-wrap { display: flex; }
|
|
539
|
+
.age-bar {
|
|
540
|
+
display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap;
|
|
541
|
+
padding: 8px 12px; margin-bottom: 12px;
|
|
542
|
+
border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface);
|
|
543
|
+
}
|
|
544
|
+
.age-bar-note { display: inline-flex; align-items: baseline; gap: 8px; color: var(--fg-muted); font-size: var(--t-sm); }
|
|
545
|
+
.age-bar-note .glyph, .age-warn .glyph { flex: none; transform: translateY(2px); }
|
|
546
|
+
.age-bar-actions { display: inline-flex; gap: 8px; }
|
|
547
|
+
p.age-bar-note { margin: 0 0 12px; }
|
|
548
|
+
.age-warn { display: flex; align-items: baseline; gap: 6px; color: var(--alert-warning); font-size: var(--t-sm); margin: 4px 0 0; }
|
|
549
|
+
.age-newpass { margin: 12px 0; }
|
|
550
|
+
.age-newpass > summary { cursor: pointer; color: var(--fg-muted); }
|
|
551
|
+
.age-newpass > summary:hover { color: var(--fg); }
|
|
552
|
+
.age-newpass .field { max-width: 360px; margin-top: 10px; }
|
|
553
|
+
.age-output .age-plain { padding: 16px; overflow-x: auto; background: var(--bg); font-size: 13px; line-height: 1.5; }
|
|
554
|
+
|
|
494
555
|
/* Segmented Preview/Code switch on rendered files. */
|
|
495
556
|
.seg { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
|
496
557
|
.seg a { display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; font-size: 13px; line-height: 1.5; color: var(--fg-muted); background: var(--surface); }
|
|
@@ -647,6 +708,11 @@ table.listing:has(+ .empty-state:not([hidden])) { border-top: none; }
|
|
|
647
708
|
.signin .form-box { padding: 16px; }
|
|
648
709
|
.signin .btn { width: 100%; justify-content: center; }
|
|
649
710
|
.signin-note { margin: 16px 0 0; text-align: center; }
|
|
711
|
+
/* The passkey button under the token form: a quiet second door. */
|
|
712
|
+
.signin-alt { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
|
|
713
|
+
.signin-alt .form-error { margin: 10px 0 0; }
|
|
714
|
+
/* A handoff code is read across a room, so it is set like a heading. */
|
|
715
|
+
.handoff-code { font-size: 28px; letter-spacing: 4px; text-align: center; padding: 16px 0; user-select: all; }
|
|
650
716
|
|
|
651
717
|
/* Two fields reading as one address, "collection / name". */
|
|
652
718
|
.name-row { display: flex; align-items: flex-end; gap: 8px; flex-wrap: wrap; }
|
|
@@ -700,6 +766,9 @@ textarea {
|
|
|
700
766
|
/* News, of either kind: the tint, and a rule down the side it is read from. */
|
|
701
767
|
.flash { background: var(--ok-bg); border-left: 3px solid var(--ok-border); padding: 8px 12px; margin-bottom: 16px; max-width: 620px; }
|
|
702
768
|
.form-error { background: var(--err-bg); border-left: 3px solid var(--err-border); padding: 8px 12px; margin-bottom: 16px; max-width: 620px; }
|
|
769
|
+
/* The amber grade of news: a saved setting the running server has not read
|
|
770
|
+
yet, waiting for a restart. Not an error, so it takes the surface tint. */
|
|
771
|
+
.restart-note { background: var(--surface); border-left: 3px solid var(--alert-warning); padding: 8px 12px; margin-bottom: 16px; max-width: 620px; }
|
|
703
772
|
/* A section flagged by the colour of the news it carries, as the flashes and
|
|
704
773
|
the merge box are. Two grades, because they are not the same warning: amber
|
|
705
774
|
for what disrupts other people and can be put back, red for what is gone.
|
|
@@ -1026,6 +1095,43 @@ table.listing.issues td.issue-cell > span { min-width: 0; }
|
|
|
1026
1095
|
.issue-reply textarea { border: none; border-radius: 0; }
|
|
1027
1096
|
.issue-reply .actions { padding: 10px 12px; justify-content: flex-end; border-top: 1px solid var(--border); }
|
|
1028
1097
|
|
|
1098
|
+
/* --- the markdown editor: Write and Preview tabs over the field, and a
|
|
1099
|
+
toolbar that writes markdown into it. The tabs read as file tabs into the
|
|
1100
|
+
writing surface, which is why the current one shares its background and
|
|
1101
|
+
sits on the head's border. --- */
|
|
1102
|
+
.md-editor { border: 1px solid var(--border); border-radius: var(--radius); background: var(--input-bg); }
|
|
1103
|
+
.md-editor:focus-within { outline: 2px solid var(--focus); border-color: var(--accent); }
|
|
1104
|
+
.md-head {
|
|
1105
|
+
display: flex; align-items: flex-end; justify-content: space-between; gap: 8px; flex-wrap: wrap;
|
|
1106
|
+
padding: 6px 8px 0; background: var(--surface); border-bottom: 1px solid var(--border);
|
|
1107
|
+
border-radius: var(--radius) var(--radius) 0 0;
|
|
1108
|
+
}
|
|
1109
|
+
.md-tabs { display: flex; gap: 2px; }
|
|
1110
|
+
.md-tab {
|
|
1111
|
+
border: 1px solid transparent; border-bottom: none; background: none; padding: 6px 12px;
|
|
1112
|
+
font: inherit; font-size: 13px; color: var(--fg-muted); cursor: pointer;
|
|
1113
|
+
border-radius: var(--radius) var(--radius) 0 0; margin-bottom: -1px;
|
|
1114
|
+
}
|
|
1115
|
+
.md-tab:hover { color: var(--fg); }
|
|
1116
|
+
.md-tab.current { background: var(--input-bg); border-color: var(--border); color: var(--fg); font-weight: 600; }
|
|
1117
|
+
.md-toolbar { display: flex; align-items: center; gap: 8px; padding-bottom: 6px; flex-wrap: wrap; }
|
|
1118
|
+
.md-group { display: flex; }
|
|
1119
|
+
.md-group + .md-group { border-left: 1px solid var(--border); padding-left: 8px; }
|
|
1120
|
+
.md-btn {
|
|
1121
|
+
display: inline-flex; align-items: center; justify-content: center; border: none; background: none;
|
|
1122
|
+
padding: 4px 5px; border-radius: var(--radius); color: var(--fg-muted); cursor: pointer;
|
|
1123
|
+
}
|
|
1124
|
+
.md-btn:hover { background: var(--surface-hover); color: var(--fg); }
|
|
1125
|
+
/* Previewing: the buttons would edit a field that is not on screen. */
|
|
1126
|
+
.md-editor.previewing .md-toolbar { visibility: hidden; }
|
|
1127
|
+
.md-editor textarea { border: none; border-radius: 0 0 var(--radius) var(--radius); display: block; }
|
|
1128
|
+
.md-editor textarea[hidden] { display: none; }
|
|
1129
|
+
.md-editor textarea:focus { outline: none; }
|
|
1130
|
+
.md-render { padding: 12px; overflow-x: auto; }
|
|
1131
|
+
/* Inside a reply card the editor sits borderless: the card is the border. */
|
|
1132
|
+
.issue-reply .md-editor, .issue-reply .md-editor:focus-within { border: none; outline: none; border-radius: 0; }
|
|
1133
|
+
.issue-reply .md-head { border-radius: 0; }
|
|
1134
|
+
|
|
1029
1135
|
/* --- releases: notes attached to a tag --- */
|
|
1030
1136
|
.release { display: flex; gap: 24px; align-items: flex-start; padding: 20px 0; border-top: 1px solid var(--border-soft); }
|
|
1031
1137
|
.release:first-of-type { border-top: none; }
|
|
@@ -1151,7 +1257,11 @@ a.chip.label:hover { text-decoration: none; filter: brightness(1.1); }
|
|
|
1151
1257
|
font-weight: 600;
|
|
1152
1258
|
font-size: var(--t-base);
|
|
1153
1259
|
min-width: 0;
|
|
1154
|
-
|
|
1260
|
+
/* One line however long the name is: a name that would wrap is cut with an
|
|
1261
|
+
ellipsis, so no card is ever taller than its neighbours because of it. */
|
|
1262
|
+
white-space: nowrap;
|
|
1263
|
+
overflow: hidden;
|
|
1264
|
+
text-overflow: ellipsis;
|
|
1155
1265
|
}
|
|
1156
1266
|
.rc-name::after { content: ''; position: absolute; inset: 0; }
|
|
1157
1267
|
/* The collection stays whole, so a narrow column breaks the name at the slash
|
|
@@ -1178,6 +1288,38 @@ a.chip.label:hover { text-decoration: none; filter: brightness(1.1); }
|
|
|
1178
1288
|
overflow: hidden;
|
|
1179
1289
|
}
|
|
1180
1290
|
.rc-meta { margin-top: var(--s2); color: var(--fg-subtle); font-size: var(--t-xs); }
|
|
1291
|
+
|
|
1292
|
+
/* --- topics ---
|
|
1293
|
+
|
|
1294
|
+
One look everywhere on purpose: a topic is the same topic on a card, in the
|
|
1295
|
+
About panel, and on its own page, so it wears the accent in a rounded pill
|
|
1296
|
+
rather than a colour of its own the way an issue label does. The tinted
|
|
1297
|
+
background is mixed from the accent so every theme keeps its own hue; the
|
|
1298
|
+
plain chip background underneath is the fallback for a browser without
|
|
1299
|
+
color-mix. */
|
|
1300
|
+
.topic-chips { display: inline-flex; flex-wrap: wrap; gap: 4px; }
|
|
1301
|
+
.chip.topic { background: var(--chip-bg); color: var(--accent); border-radius: 999px; font-weight: 500; }
|
|
1302
|
+
.chip.topic { background: color-mix(in srgb, var(--accent) 11%, transparent); }
|
|
1303
|
+
a.chip.topic:hover { background: color-mix(in srgb, var(--accent) 20%, transparent); text-decoration: none; }
|
|
1304
|
+
.rc-topics { margin-top: var(--s2); }
|
|
1305
|
+
.side-topics { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; margin: 0 0 12px; }
|
|
1306
|
+
/* The editor behind the chips: a summary dressed as one more chip, opening
|
|
1307
|
+
the same popover the other menus use. */
|
|
1308
|
+
.topic-edit { display: inline-block; }
|
|
1309
|
+
.topic-add { display: inline-flex; align-items: center; gap: 4px; border-radius: 999px; padding: 1px 7px; font-size: 12px; background: var(--chip-bg); color: var(--fg-muted); }
|
|
1310
|
+
.topic-add:hover { color: var(--fg); }
|
|
1311
|
+
.topic-add .glyph { margin: 0; }
|
|
1312
|
+
.topic-edit-menu { padding: 10px 12px; width: 300px; }
|
|
1313
|
+
.topic-edit-menu label { display: block; margin-bottom: 4px; }
|
|
1314
|
+
.topic-edit-menu input[type="text"] { width: 100%; }
|
|
1315
|
+
.topic-edit-menu p { margin: 6px 0; }
|
|
1316
|
+
.topic-edit-menu .btn { margin-top: 2px; }
|
|
1317
|
+
/* The vault-wide index: each topic once, with how many repositories carry it. */
|
|
1318
|
+
.topic-index { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--s2); }
|
|
1319
|
+
.topic-index li { display: flex; align-items: center; gap: 10px; }
|
|
1320
|
+
.topic-title { display: flex; align-items: center; gap: 8px; }
|
|
1321
|
+
.topic-title .glyph { color: var(--fg-muted); }
|
|
1322
|
+
|
|
1181
1323
|
.site-link, .ci-mark { display: inline-flex; color: var(--fg-subtle); }
|
|
1182
1324
|
.site-link:hover, .ci-mark:hover { color: var(--accent); }
|
|
1183
1325
|
.ci-success { color: var(--primary); }
|
|
@@ -1215,6 +1357,23 @@ a.chip.label:hover { text-decoration: none; filter: brightness(1.1); }
|
|
|
1215
1357
|
.profile-box { margin-bottom: var(--s6); }
|
|
1216
1358
|
.profile-hint { margin: 0 0 var(--s5); }
|
|
1217
1359
|
|
|
1360
|
+
/* A username linked to its profile page. It keeps the colour and weight of
|
|
1361
|
+
the text around it, so a linked name reads exactly as an unlinked one and
|
|
1362
|
+
only hover says it goes somewhere; the flex is for the identicon some
|
|
1363
|
+
carry. */
|
|
1364
|
+
.user-link { display: inline-flex; align-items: center; gap: 6px; color: inherit; }
|
|
1365
|
+
.user-link:hover { color: var(--accent); text-decoration: underline; }
|
|
1366
|
+
|
|
1367
|
+
/* The user behind a namespace collection, on their page at /<username>: the
|
|
1368
|
+
username beside the chosen name, then the bio and links under the heading,
|
|
1369
|
+
above the profile README and the repositories. */
|
|
1370
|
+
.profile-username { color: var(--fg-muted); font-weight: 400; font-size: var(--t-lg); }
|
|
1371
|
+
.profile-bio { margin: calc(-1 * var(--s3)) 0 var(--s4); max-width: 720px; }
|
|
1372
|
+
.profile-links { display: flex; flex-wrap: wrap; gap: 4px 16px; margin: calc(-1 * var(--s3)) 0 var(--s4); font-size: var(--t-sm); }
|
|
1373
|
+
.profile-links a { display: inline-flex; align-items: center; gap: 4px; color: var(--fg-muted); }
|
|
1374
|
+
.profile-links a:hover { color: var(--accent); }
|
|
1375
|
+
.profile-links .icon { margin-right: 0; }
|
|
1376
|
+
|
|
1218
1377
|
/* --- what a finger has to hit ---
|
|
1219
1378
|
|
|
1220
1379
|
Under a coarse pointer every control grows to --touch, which is the one
|
package/dist/topics.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.TOPICS_FILE = exports.MAX_TOPIC_LENGTH = exports.MAX_TOPICS = void 0;
|
|
37
|
+
exports.isValidTopic = isValidTopic;
|
|
38
|
+
exports.checkTopics = checkTopics;
|
|
39
|
+
exports.parseTopicsInput = parseTopicsInput;
|
|
40
|
+
exports.repoTopics = repoTopics;
|
|
41
|
+
exports.setTopics = setTopics;
|
|
42
|
+
exports.countTopics = countTopics;
|
|
43
|
+
const fs = __importStar(require("fs"));
|
|
44
|
+
const path = __importStar(require("path"));
|
|
45
|
+
const atomic_1 = require("./atomic");
|
|
46
|
+
const filecache_1 = require("./filecache");
|
|
47
|
+
const ops_1 = require("./ops");
|
|
48
|
+
// Topics: free-form tags on a repository, the way GitHub carries them. A topic
|
|
49
|
+
// is organizational and nothing else -- it grants nothing, reserves nothing,
|
|
50
|
+
// and exists only because some repository carries it; the set of topics in a
|
|
51
|
+
// vault is whatever its repositories say, with no registry behind it, exactly
|
|
52
|
+
// as issue labels work.
|
|
53
|
+
//
|
|
54
|
+
// They live in a `topics` file in the bare repository's directory, one per
|
|
55
|
+
// line, beside git's own `description` file: plain enough to read and edit by
|
|
56
|
+
// hand, and it moves with the repository when the repository is renamed. A
|
|
57
|
+
// repository with no such file has no topics, which is what every repository
|
|
58
|
+
// had before this existed.
|
|
59
|
+
/** The most topics a repository may carry, matching GitHub's cap. */
|
|
60
|
+
exports.MAX_TOPICS = 20;
|
|
61
|
+
/** The longest a single topic may be, matching GitHub's cap. */
|
|
62
|
+
exports.MAX_TOPIC_LENGTH = 50;
|
|
63
|
+
/**
|
|
64
|
+
* The GitHub rule, exactly: lowercase letters, digits, and hyphens, starting
|
|
65
|
+
* with a letter or digit. Lowercase-only is what keeps a topic usable in a URL
|
|
66
|
+
* with no escaping, and what makes "WebGPU" and "webgpu" one topic rather
|
|
67
|
+
* than two that each list half the repositories.
|
|
68
|
+
*/
|
|
69
|
+
function isValidTopic(topic) {
|
|
70
|
+
return topic.length <= exports.MAX_TOPIC_LENGTH && /^[a-z0-9][a-z0-9-]*$/.test(topic);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Validate a proposed set of topics: trim, drop empties, dedupe, refuse what
|
|
74
|
+
* the rule refuses. Rejection rather than normalization on purpose -- a
|
|
75
|
+
* caller who typed "WebGPU" is told what to type instead of having their
|
|
76
|
+
* input silently rewritten -- but the refusal names the fix when lowercasing
|
|
77
|
+
* is the fix.
|
|
78
|
+
*/
|
|
79
|
+
function checkTopics(topics) {
|
|
80
|
+
const out = [];
|
|
81
|
+
for (const raw of topics) {
|
|
82
|
+
const topic = raw.trim();
|
|
83
|
+
if (topic === '')
|
|
84
|
+
continue;
|
|
85
|
+
if (!isValidTopic(topic)) {
|
|
86
|
+
const lowered = topic.toLowerCase();
|
|
87
|
+
const hint = topic !== lowered && isValidTopic(lowered) ? ` (try "${lowered}")` : '';
|
|
88
|
+
throw new ops_1.OpError(`"${topic}" is not a usable topic: lowercase letters, digits, and hyphens, starting with a letter or digit, at most ${exports.MAX_TOPIC_LENGTH} characters${hint}`);
|
|
89
|
+
}
|
|
90
|
+
if (!out.includes(topic))
|
|
91
|
+
out.push(topic);
|
|
92
|
+
}
|
|
93
|
+
if (out.length > exports.MAX_TOPICS)
|
|
94
|
+
throw new ops_1.OpError(`A repository may carry at most ${exports.MAX_TOPICS} topics.`);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Topics as a person types them into one input: separated by spaces, commas,
|
|
99
|
+
* or both. The web forms parse with this and then validate with checkTopics,
|
|
100
|
+
* so the two forms and the API refuse the same things.
|
|
101
|
+
*/
|
|
102
|
+
function parseTopicsInput(text) {
|
|
103
|
+
return text.split(/[\s,]+/).filter((t) => t !== '');
|
|
104
|
+
}
|
|
105
|
+
exports.TOPICS_FILE = 'topics';
|
|
106
|
+
const topicsCache = (0, filecache_1.fileCache)({
|
|
107
|
+
read: (file) => {
|
|
108
|
+
let text;
|
|
109
|
+
try {
|
|
110
|
+
text = fs.readFileSync(file, 'utf8');
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
// Reading stays permissive, as everywhere: a hand-edited file's invalid
|
|
116
|
+
// lines are skipped rather than the valid ones going unserved, and a
|
|
117
|
+
// file grown past the cap is capped rather than swelling every listing.
|
|
118
|
+
const out = [];
|
|
119
|
+
for (const line of text.split('\n')) {
|
|
120
|
+
const topic = line.trim();
|
|
121
|
+
if (topic !== '' && isValidTopic(topic) && !out.includes(topic))
|
|
122
|
+
out.push(topic);
|
|
123
|
+
if (out.length === exports.MAX_TOPICS)
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
},
|
|
128
|
+
missing: () => [],
|
|
129
|
+
});
|
|
130
|
+
/** The topics of the repository whose bare directory this is. */
|
|
131
|
+
function repoTopics(repoDir) {
|
|
132
|
+
return topicsCache.get(path.join(repoDir, exports.TOPICS_FILE));
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Replace the repository's topics with these, validated. The whole set at
|
|
136
|
+
* once, as GitHub's API takes them: add-one and remove-one are a caller's
|
|
137
|
+
* read-modify-write, not a protocol.
|
|
138
|
+
*/
|
|
139
|
+
function setTopics(repoDir, topics) {
|
|
140
|
+
const checked = checkTopics(topics);
|
|
141
|
+
const file = path.join(repoDir, exports.TOPICS_FILE);
|
|
142
|
+
if (checked.length === 0) {
|
|
143
|
+
fs.rmSync(file, { force: true });
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
(0, atomic_1.writeFileAtomic)(file, checked.join('\n') + '\n');
|
|
147
|
+
}
|
|
148
|
+
topicsCache.invalidate(file);
|
|
149
|
+
return checked;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The topics a set of repositories carries, with how many carry each, most
|
|
153
|
+
* used first. Both the listing dropdowns and the topics index are built from
|
|
154
|
+
* this, always over repositories the viewer could see anyway.
|
|
155
|
+
*/
|
|
156
|
+
function countTopics(topicLists) {
|
|
157
|
+
const counts = new Map();
|
|
158
|
+
for (const list of topicLists) {
|
|
159
|
+
for (const t of list)
|
|
160
|
+
counts.set(t, (counts.get(t) ?? 0) + 1);
|
|
161
|
+
}
|
|
162
|
+
return [...counts.entries()]
|
|
163
|
+
.map(([topic, count]) => ({ topic, count }))
|
|
164
|
+
.sort((a, b) => b.count - a.count || a.topic.localeCompare(b.topic));
|
|
165
|
+
}
|