@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
@@ -0,0 +1,297 @@
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.repoNeedsGc = repoNeedsGc;
37
+ exports.gcSweep = gcSweep;
38
+ exports.runsSweep = runsSweep;
39
+ exports.firstSweepDelay = firstSweepDelay;
40
+ exports.startMaintenance = startMaintenance;
41
+ const fs = __importStar(require("fs"));
42
+ const path = __importStar(require("path"));
43
+ const artifacts_1 = require("./ci/artifacts");
44
+ const runs_1 = require("./ci/runs");
45
+ const config_1 = require("./config");
46
+ const git_1 = require("./git");
47
+ const layout_1 = require("./layout");
48
+ const scan_1 = require("./scan");
49
+ // Periodic upkeep: `git gc` over the repositories in the vault, and CI
50
+ // retention over their run history (runsSweep, below).
51
+ //
52
+ // Git adds objects and never removes them. A force push, a branch deleted, a
53
+ // history rewritten: each one leaves the commits, trees, and blobs it abandoned
54
+ // in the object store, unreachable from any ref, still readable by anyone who
55
+ // knows the hash, and still counted against the disk. Only a garbage collection
56
+ // walks out from the refs and drops what it could not reach.
57
+ //
58
+ // Nothing else here runs one. Git's own automatic pass is tuned for a working
59
+ // copy accumulating loose objects, and a bare repository on a forge receives
60
+ // packs instead, so in practice it never fires. Without this sweep a vault
61
+ // keeps every mistake anyone has ever pushed to it.
62
+ //
63
+ // What makes a sweep safe to run beside live traffic is the grace period. A
64
+ // push uploads its objects before it moves the branch that will make them
65
+ // reachable, so for a moment those objects look exactly like abandoned ones; a
66
+ // collection that spared nothing could delete a push in flight. PRUNE_GRACE
67
+ // keeps this one away from anything recent, which is also why it is not the
68
+ // answer to "I pushed a secret, get it out now": that wants --prune=now, and
69
+ // wants to know that nobody is mid-push.
70
+ /** How often to sweep, measured from the end of the last sweep. */
71
+ const SWEEP_MS = 6 * 60 * 60 * 1000;
72
+ /**
73
+ * The soonest a sweep runs after the server starts, when one is due. Long
74
+ * enough that the request which woke a sleeping machine is answered before the
75
+ * sweep competes with it for the disk, and well inside the few idle minutes
76
+ * after which a scale-to-zero host stops the machine again.
77
+ */
78
+ const MIN_SWEEP_DELAY_MS = 60 * 1000;
79
+ /**
80
+ * Written at the vault root when a sweep finishes. Its mtime is what the next
81
+ * sweep is due from, so the schedule survives a restart: a vault on a host
82
+ * that stops it after a few idle minutes (Fly's autostop, say) never stays up
83
+ * for a timer counted from process start, and would otherwise never sweep at
84
+ * all. It is not vault state and backups leave it out (see ROOT_FILES in
85
+ * src/api/backup.ts); losing it costs one sweep sooner than needed.
86
+ */
87
+ const SWEEP_STAMP = 'mochi-last-sweep';
88
+ /**
89
+ * Objects unreachable but newer than this are kept. Two days is far longer than
90
+ * any push or clone this server will serve, and it is the whole safety
91
+ * argument for running gc unattended, so it is deliberately generous.
92
+ */
93
+ const PRUNE_GRACE = '2.days.ago';
94
+ /** A repository still collecting after this long is left to the next sweep. */
95
+ const GC_TIMEOUT_MS = 30 * 60 * 1000;
96
+ /**
97
+ * Written in the repository directory after a successful collection. Its mtime
98
+ * is the only state the sweep keeps, and losing it costs one extra gc.
99
+ */
100
+ const STAMP = 'mochi-last-gc';
101
+ // Queue depth, interval, and grace are constants rather than settings, as the
102
+ // gate queues in src/limit.ts are: nobody tunes them without reading this
103
+ // comment, and they can be promoted to config.json if anyone asks.
104
+ function mtimeOf(file) {
105
+ try {
106
+ return fs.statSync(file).mtimeMs;
107
+ }
108
+ catch {
109
+ return 0;
110
+ }
111
+ }
112
+ /** The newest mtime anywhere under dir, which is only walked for `refs`. */
113
+ function newestUnder(dir) {
114
+ let newest = 0;
115
+ const visit = (p) => {
116
+ let stat;
117
+ try {
118
+ stat = fs.statSync(p);
119
+ }
120
+ catch {
121
+ return;
122
+ }
123
+ if (stat.mtimeMs > newest)
124
+ newest = stat.mtimeMs;
125
+ if (!stat.isDirectory())
126
+ return;
127
+ let entries;
128
+ try {
129
+ entries = fs.readdirSync(p, { withFileTypes: true });
130
+ }
131
+ catch {
132
+ return;
133
+ }
134
+ for (const entry of entries)
135
+ visit(path.join(p, entry.name));
136
+ };
137
+ visit(dir);
138
+ return newest;
139
+ }
140
+ /**
141
+ * Whether anything has happened to this repository since it was last collected.
142
+ *
143
+ * Loose refs are walked, since `refs` holds one small file per branch and tag.
144
+ * The object store is not: `objects` and `objects/pack` change whenever a push
145
+ * lands, which is all this needs to know, and walking a real repository's
146
+ * fanout directories on every sweep would cost more than the gc it is deciding
147
+ * whether to run.
148
+ *
149
+ * Exported for the tests, which build a repository shape without a git binary.
150
+ */
151
+ function repoNeedsGc(repoDir) {
152
+ const last = mtimeOf(path.join(repoDir, STAMP));
153
+ // Never collected: collect it, whatever its mtimes say.
154
+ if (last === 0)
155
+ return true;
156
+ const changed = Math.max(newestUnder(path.join(repoDir, 'refs')), mtimeOf(path.join(repoDir, 'packed-refs')), mtimeOf(path.join(repoDir, 'objects')), mtimeOf(path.join(repoDir, 'objects', 'pack')));
157
+ return changed > last;
158
+ }
159
+ /**
160
+ * Collect every repository that has changed since its last collection, one at a
161
+ * time, and return the names of the ones collected.
162
+ *
163
+ * Serial on purpose: a gc is CPU and disk bound, and a vault whose repositories
164
+ * all changed should spend one core on catching up rather than every core.
165
+ */
166
+ async function gcSweep(root) {
167
+ const collected = [];
168
+ for (const { name: collection } of (0, scan_1.listCollections)(root)) {
169
+ for (const dirName of (0, scan_1.listRepoDirs)(root, collection)) {
170
+ const dir = (0, layout_1.repoPath)(root, collection, dirName);
171
+ const full = `${collection}/${(0, scan_1.displayName)(dirName)}`;
172
+ if (!repoNeedsGc(dir))
173
+ continue;
174
+ try {
175
+ await (0, git_1.execGit)(dir, ['gc', '--quiet', `--prune=${PRUNE_GRACE}`], { timeoutMs: GC_TIMEOUT_MS });
176
+ }
177
+ catch (e) {
178
+ // A gc that fails is not an outage, and the commonest reason is that
179
+ // another one holds the repository's gc.pid lock. No stamp is written,
180
+ // so the next sweep tries again.
181
+ console.error(`gc ${full}: ${e instanceof Error ? e.message : String(e)}`);
182
+ continue;
183
+ }
184
+ // After the gc, so that the work the gc itself did to `objects` and
185
+ // `packed-refs` does not read as a reason to collect again.
186
+ try {
187
+ fs.writeFileSync(path.join(dir, STAMP), '');
188
+ }
189
+ catch {
190
+ // Without a stamp the repository is collected again next sweep, which
191
+ // is wasteful and harmless.
192
+ }
193
+ collected.push(full);
194
+ }
195
+ }
196
+ return collected;
197
+ }
198
+ /**
199
+ * Apply CI retention to every repository, and delete the site artifacts that
200
+ * completed runs have already deployed. Returns the bytes of deployed
201
+ * artifacts removed.
202
+ *
203
+ * Retention is otherwise applied only when a repository starts a run, so a
204
+ * repository that has gone quiet keeps whatever it had, and lowering the run
205
+ * count in config.json would reach it never. This is also what clears the
206
+ * site archives kept by runs from before deploys deleted their own.
207
+ */
208
+ function runsSweep(root) {
209
+ const retain = (0, config_1.loadConfig)(root).ci;
210
+ let freed = 0;
211
+ for (const { name: collection } of (0, scan_1.listCollections)(root)) {
212
+ for (const dirName of (0, scan_1.listRepoDirs)(root, collection)) {
213
+ const repo = (0, scan_1.displayName)(dirName);
214
+ try {
215
+ freed += (0, artifacts_1.pruneDeployedArtifacts)(root, collection, repo);
216
+ (0, runs_1.pruneRuns)(root, collection, repo, retain);
217
+ }
218
+ catch (e) {
219
+ console.error(`runs ${collection}/${repo}: ${e instanceof Error ? e.message : String(e)}`);
220
+ }
221
+ }
222
+ }
223
+ return freed;
224
+ }
225
+ /**
226
+ * How long after starting to run the first sweep, given when the last one
227
+ * finished (0 for never). A sweep that is due runs MIN_SWEEP_DELAY_MS after
228
+ * start; one that is not waits out the rest of SWEEP_MS, so restarting a
229
+ * vault neither costs a sweep nor postpones one. A stamp from the future (a
230
+ * clock that moved) waits at most one full interval.
231
+ */
232
+ function firstSweepDelay(lastSweepMs, now) {
233
+ const due = lastSweepMs + SWEEP_MS - now;
234
+ return Math.min(SWEEP_MS, Math.max(MIN_SWEEP_DELAY_MS, due));
235
+ }
236
+ /**
237
+ * Start the sweep. Returns a function that stops it, which the tests use;
238
+ * the server itself runs it for as long as it runs.
239
+ *
240
+ * The timer is unref'd, so a sweep pending is never the reason a process stays
241
+ * alive, and a sweep already running is never joined by a second one. A sweep
242
+ * cut short by the machine stopping writes no stamp, so it is due again on the
243
+ * next start; the per-repository gc stamps mean it resumes rather than repeats.
244
+ */
245
+ function startMaintenance(root) {
246
+ let running = false;
247
+ let timer = null;
248
+ let stopped = false;
249
+ const stampFile = path.join(root, SWEEP_STAMP);
250
+ const schedule = (delay) => {
251
+ if (stopped)
252
+ return;
253
+ timer = setTimeout(() => void tick(), delay);
254
+ timer.unref();
255
+ };
256
+ const tick = async () => {
257
+ if (running)
258
+ return;
259
+ running = true;
260
+ try {
261
+ const freed = runsSweep(root);
262
+ if (freed > 0)
263
+ console.log(`runs: removed ${Math.round(freed / 1048576)} MB of deployed site artifacts`);
264
+ }
265
+ catch (e) {
266
+ console.error(`runs sweep: ${e instanceof Error ? e.message : String(e)}`);
267
+ }
268
+ try {
269
+ const collected = await gcSweep(root);
270
+ if (collected.length > 0) {
271
+ console.log(`gc: collected ${collected.length} repositor${collected.length === 1 ? 'y' : 'ies'}`);
272
+ }
273
+ }
274
+ catch (e) {
275
+ console.error(`gc sweep: ${e instanceof Error ? e.message : String(e)}`);
276
+ }
277
+ finally {
278
+ running = false;
279
+ }
280
+ // Written even when a part of the sweep failed: those failures are logged,
281
+ // and retrying them on every start of a machine that wakes many times a
282
+ // day would turn one bad repository into constant work.
283
+ try {
284
+ fs.writeFileSync(stampFile, '');
285
+ }
286
+ catch (e) {
287
+ console.error(`sweep stamp: ${e instanceof Error ? e.message : String(e)}`);
288
+ }
289
+ schedule(SWEEP_MS);
290
+ };
291
+ schedule(firstSweepDelay(mtimeOf(stampFile), Date.now()));
292
+ return () => {
293
+ stopped = true;
294
+ if (timer)
295
+ clearTimeout(timer);
296
+ };
297
+ }
package/dist/markdown.js CHANGED
@@ -54,6 +54,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
54
54
  return (mod && mod.__esModule) ? mod : { "default": mod };
55
55
  };
56
56
  Object.defineProperty(exports, "__esModule", { value: true });
57
+ exports.USER_CONTENT_PREFIX = void 0;
57
58
  exports.isMarkdownFile = isMarkdownFile;
58
59
  exports.renderMarkdown = renderMarkdown;
59
60
  const crypto = __importStar(require("crypto"));
@@ -71,6 +72,17 @@ const views_1 = require("./views");
71
72
  // of a longer word, and a commit id must contain at least one letter, so that
72
73
  // a plain number written in passing is not mistaken for one.
73
74
  const CROSS_REF = /(?<![\w#/-])(?:#(\d{1,9})|([0-9a-f]{7,40}))(?![\w-])/g;
75
+ // GitHub's @mentions: an @ followed by something shaped like a username (see
76
+ // isValidUserName in src/scan.ts). The pattern refuses an @ that ends a word,
77
+ // so an email address written in passing is not half-linked. The match is
78
+ // greedy over the username characters; trailing punctuation that keeps the
79
+ // name from resolving is peeled off afterwards, so "@bob." mentions bob.
80
+ const MENTION = /(?<![\w@.-])@([A-Za-z0-9][A-Za-z0-9._-]*)/g;
81
+ // A channel named in passing: # and a channel's name, lowercase letters,
82
+ // digits, and hyphens. As with a mention, a # inside a word (a URL's
83
+ // fragment, an HTML entity) is refused, and a trailing hyphen that keeps the
84
+ // name from resolving is peeled off.
85
+ const CHANNEL_REF = /(?<![\w#&/-])#([a-z0-9][a-z0-9-]*)/g;
74
86
  function isMarkdownFile(filename) {
75
87
  const base = filename.split('/').pop() ?? filename;
76
88
  return /\.(md|markdown)$/i.test(base);
@@ -124,9 +136,15 @@ const ALLOWED_TAGS = [
124
136
  'abbr', 'cite', 'q', 'input',
125
137
  ];
126
138
  // No `style` anywhere: an inline style on document content could cover the
127
- // rest of the page, which is a phishing surface even without scripting.
139
+ // rest of the page, which is a phishing surface even without scripting. No
140
+ // `class` either, for the same reason by another route: the stylesheet is
141
+ // the interface's own, so `<div class="topbar">` in a README would stick to
142
+ // the top of the page over the real one, with a Sign in button of its own.
143
+ // The classes the renderer itself emits are allowed by name below, and only
144
+ // on the elements it emits them on; a class an author wrote is dropped, as
145
+ // GitHub drops it. `id` stays, prefixed (see userContentId).
128
146
  const ALLOWED_ATTRS = {
129
- '*': ['id', 'class', 'title', 'align', 'dir', 'lang', 'role', 'aria-label', 'aria-hidden'],
147
+ '*': ['id', 'title', 'align', 'dir', 'lang', 'role', 'aria-label', 'aria-hidden'],
130
148
  a: ['href', 'name', 'target', 'rel'],
131
149
  img: ['src', 'alt', 'width', 'height', 'loading'],
132
150
  th: ['colspan', 'rowspan', 'scope', 'valign'],
@@ -138,19 +156,56 @@ const ALLOWED_ATTRS = {
138
156
  details: ['open'],
139
157
  input: ['type', 'checked', 'disabled'],
140
158
  };
159
+ // What the renderer puts a class on, and which. Anything else is an author's
160
+ // and is dropped; see ALLOWED_ATTRS. The footnote entries are
161
+ // markdown-it-footnote's own markup.
162
+ const ALLOWED_CLASSES = {
163
+ code: [/^language-[A-Za-z0-9_+#.-]+$/, 'math-error'],
164
+ div: ['code-block', 'math-block'],
165
+ blockquote: ['alert', /^alert-(note|tip|important|warning|caution)$/],
166
+ p: ['alert-title'],
167
+ li: ['task-item', 'footnote-item'],
168
+ a: ['heading-anchor', 'footnote-backref'],
169
+ sup: ['footnote-ref'],
170
+ hr: ['footnotes-sep'],
171
+ section: ['footnotes'],
172
+ ol: ['footnotes-list'],
173
+ };
174
+ /**
175
+ * The prefix every id in a rendered document carries. The document and the
176
+ * page around it share one id space, and the page's script finds its own
177
+ * elements by id: a README with `<div id="jump">` would otherwise be what
178
+ * openJump() found and tried to show as a dialog. GitHub prefixes with the
179
+ * same word. Links within the document (`#install`, a footnote's `#fn1`, a
180
+ * heading's own anchor) are rewritten to match, so a document's table of
181
+ * contents keeps working; a link from outside the document to `/repo#install`
182
+ * finds nothing by that name, and the page script takes it to the prefixed
183
+ * element instead (followHash in src/pagescript.ts).
184
+ */
185
+ exports.USER_CONTENT_PREFIX = 'user-content-';
186
+ function prefixId(attribs) {
187
+ if (attribs.id !== undefined && attribs.id !== '')
188
+ attribs.id = exports.USER_CONTENT_PREFIX + attribs.id;
189
+ }
141
190
  function sanitizeOptions(opts) {
142
191
  return {
143
192
  allowedTags: ALLOWED_TAGS,
144
193
  allowedAttributes: ALLOWED_ATTRS,
194
+ allowedClasses: ALLOWED_CLASSES,
145
195
  allowedSchemes: ['http', 'https', 'mailto', 'ftp'],
146
196
  allowedSchemesAppliedToAttributes: ['href', 'src', 'cite'],
147
197
  allowProtocolRelative: false,
198
+ ...(opts.showRefusedHtml ? { disallowedTagsMode: 'escape', nonTextTags: [] } : {}),
148
199
  transformTags: {
149
200
  // Rewriting here rather than in a renderer rule covers links written as
150
201
  // markdown and links written as HTML with one implementation.
151
202
  a: (tagName, attribs) => {
152
203
  const href = attribs.href;
153
- if (href && isRelative(href)) {
204
+ if (href && href.startsWith('#')) {
205
+ if (href.length > 1)
206
+ attribs.href = `#${exports.USER_CONTENT_PREFIX}${href.slice(1)}`;
207
+ }
208
+ else if (href && isRelative(href)) {
154
209
  attribs.href = resolveRef(href, opts.blobBase);
155
210
  }
156
211
  else if (href && /^https?:/i.test(href)) {
@@ -165,6 +220,12 @@ function sanitizeOptions(opts) {
165
220
  attribs.loading = 'lazy';
166
221
  return { tagName, attribs };
167
222
  },
223
+ // Every element, the two above included: sanitize-html applies this
224
+ // entry and then the element's own, so the id prefix lives here alone.
225
+ '*': (tagName, attribs) => {
226
+ prefixId(attribs);
227
+ return { tagName, attribs };
228
+ },
168
229
  },
169
230
  };
170
231
  }
@@ -276,6 +337,135 @@ function buildMarkdownIt() {
276
337
  block.children = out;
277
338
  }
278
339
  });
340
+ // Mentions ride the same shape as mochi_refs above: a core rule over the
341
+ // inline token stream, leaving text inside links alone. It runs after the
342
+ // refs rule, so a mention never lands inside a link that rule just made.
343
+ md.core.ruler.push('mochi_mentions', (state) => {
344
+ const mentions = state.env.opts.mentions;
345
+ if (!mentions)
346
+ return;
347
+ for (const block of state.tokens) {
348
+ if (block.type !== 'inline' || !block.children)
349
+ continue;
350
+ const out = [];
351
+ let inLink = 0;
352
+ let changed = false;
353
+ for (const token of block.children) {
354
+ if (token.type === 'link_open')
355
+ inLink++;
356
+ else if (token.type === 'link_close')
357
+ inLink--;
358
+ if (token.type !== 'text' || inLink > 0) {
359
+ out.push(token);
360
+ continue;
361
+ }
362
+ let last = 0;
363
+ MENTION.lastIndex = 0;
364
+ for (let m = MENTION.exec(token.content); m; m = MENTION.exec(token.content)) {
365
+ // Dots, dashes, and underscores are legal in a username and common
366
+ // as the punctuation right after one: "@bob." at the end of a
367
+ // sentence means bob. Trailing punctuation is peeled only while the
368
+ // name does not resolve, so a username that really ends that way
369
+ // still wins.
370
+ let name = m[1];
371
+ while (name !== '' && !mentions(name)) {
372
+ const cut = name.search(/[._-]+$/);
373
+ if (cut === -1) {
374
+ name = '';
375
+ break;
376
+ }
377
+ name = name.slice(0, cut);
378
+ }
379
+ if (name === '')
380
+ continue;
381
+ if (m.index > last) {
382
+ const before = new state.Token('text', '', 0);
383
+ before.content = token.content.slice(last, m.index);
384
+ out.push(before);
385
+ }
386
+ const open = new state.Token('link_open', 'a', 1);
387
+ open.attrSet('href', `/${encodeURIComponent(name)}`);
388
+ const label = new state.Token('text', '', 0);
389
+ label.content = `@${name}`;
390
+ const close = new state.Token('link_close', 'a', -1);
391
+ out.push(open, label, close);
392
+ last = m.index + 1 + name.length;
393
+ MENTION.lastIndex = last;
394
+ changed = true;
395
+ }
396
+ if (last === 0) {
397
+ out.push(token);
398
+ }
399
+ else if (last < token.content.length) {
400
+ const rest = new state.Token('text', '', 0);
401
+ rest.content = token.content.slice(last);
402
+ out.push(rest);
403
+ }
404
+ }
405
+ if (changed)
406
+ block.children = out;
407
+ }
408
+ });
409
+ // Channel names, by the mentions rule's shape and after it.
410
+ md.core.ruler.push('mochi_channels', (state) => {
411
+ const channels = state.env.opts.channels;
412
+ if (!channels)
413
+ return;
414
+ for (const block of state.tokens) {
415
+ if (block.type !== 'inline' || !block.children)
416
+ continue;
417
+ const out = [];
418
+ let inLink = 0;
419
+ let changed = false;
420
+ for (const token of block.children) {
421
+ if (token.type === 'link_open')
422
+ inLink++;
423
+ else if (token.type === 'link_close')
424
+ inLink--;
425
+ if (token.type !== 'text' || inLink > 0) {
426
+ out.push(token);
427
+ continue;
428
+ }
429
+ let last = 0;
430
+ CHANNEL_REF.lastIndex = 0;
431
+ for (let m = CHANNEL_REF.exec(token.content); m; m = CHANNEL_REF.exec(token.content)) {
432
+ let name = m[1];
433
+ let href = channels(name);
434
+ while (href === null && /-$/.test(name)) {
435
+ name = name.replace(/-+$/, '');
436
+ href = name === '' ? null : channels(name);
437
+ }
438
+ if (href === null)
439
+ continue;
440
+ if (m.index > last) {
441
+ const before = new state.Token('text', '', 0);
442
+ before.content = token.content.slice(last, m.index);
443
+ out.push(before);
444
+ }
445
+ const open = new state.Token('link_open', 'a', 1);
446
+ open.attrSet('href', href);
447
+ const label = new state.Token('text', '', 0);
448
+ label.content = `#${name}`;
449
+ const close = new state.Token('link_close', 'a', -1);
450
+ out.push(open, label, close);
451
+ last = m.index + 1 + name.length;
452
+ CHANNEL_REF.lastIndex = last;
453
+ changed = true;
454
+ }
455
+ if (last === 0) {
456
+ out.push(token);
457
+ }
458
+ else if (last < token.content.length) {
459
+ const rest = new state.Token('text', '', 0);
460
+ rest.content = token.content.slice(last);
461
+ out.push(rest);
462
+ }
463
+ }
464
+ if (changed)
465
+ block.children = out;
466
+ }
467
+ });
468
+ md.renderer.rules.softbreak = (_tokens, _idx, options, env) => options.breaks || env.opts.breaks ? '<br>\n' : '\n';
279
469
  md.renderer.rules.fence = (tokens, idx, _options, env) => {
280
470
  const token = tokens[idx];
281
471
  const info = token.info.trim().split(/\s+/)[0].toLowerCase();
package/dist/mdedit.js ADDED
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.previewUrl = previewUrl;
4
+ exports.markdownEditor = markdownEditor;
5
+ const html_1 = require("./html");
6
+ const icons_1 = require("./icons");
7
+ const views_1 = require("./views");
8
+ function previewUrl(ctx) {
9
+ return `${(0, views_1.repoUrl)(ctx)}/preview`;
10
+ }
11
+ function markdownEditor(o) {
12
+ const btn = (act, glyph, title) => (0, html_1.html) `<button type="button" class="md-btn" data-md-act="${act}" title="${title}" aria-label="${title}" tabindex="-1">${(0, icons_1.icon)(glyph)}</button>`;
13
+ const groups = [
14
+ (0, html_1.html) `<span class="md-group">${btn('heading', 'heading', 'Heading')}${btn('bold', 'bold', 'Bold (Ctrl+B)')}${btn('italic', 'italic', 'Italic (Ctrl+I)')}${btn('strike', 'strikethrough', 'Strikethrough')}</span>`,
15
+ (0, html_1.html) `<span class="md-group">${btn('quote', 'quote', 'Quote')}${btn('code', 'code', 'Code (Ctrl+E)')}${btn('link', 'link', 'Link (Ctrl+K)')}</span>`,
16
+ (0, html_1.html) `<span class="md-group">${btn('ul', 'list-unordered', 'Bulleted list')}${btn('ol', 'list-ordered', 'Numbered list')}${btn('task', 'tasklist', 'Task list')}</span>`,
17
+ ];
18
+ return (0, html_1.html) `<div class="md-editor" data-md-editor data-md-preview="${o.preview}"${o.ref ? (0, html_1.html) ` data-md-ref="${o.ref}"` : ''}${o.dir ? (0, html_1.html) ` data-md-dir="${o.dir}"` : ''}>
19
+ <div class="md-head">
20
+ <div class="md-tabs" role="tablist">
21
+ <button type="button" class="md-tab current" data-md-pane="write" role="tab" aria-selected="true">Write</button>
22
+ <button type="button" class="md-tab" data-md-pane="preview" role="tab" aria-selected="false">Preview</button>
23
+ </div>
24
+ <div class="md-toolbar" role="toolbar" aria-label="Formatting">${(0, html_1.joinHtml)(groups)}</div>
25
+ </div>
26
+ <textarea class="${o.codeEditor ? 'code-editor ' : ''}md-input" name="${o.name}"${o.id ? (0, html_1.html) ` id="${o.id}"` : ''} rows="${String(o.rows)}"${o.placeholder ? (0, html_1.html) ` placeholder="${o.placeholder}"` : ''}${o.codeEditor ? (0, html_1.html) ` spellcheck="false"` : ''}>${o.value ?? ''}</textarea>
27
+ <div class="md-render markdown-body" hidden></div>
28
+ </div>`;
29
+ }
package/dist/migrate.js CHANGED
@@ -35,6 +35,8 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.migrateLayout = migrateLayout;
37
37
  exports.migratePermissions = migratePermissions;
38
+ exports.migratePushPolicy = migratePushPolicy;
39
+ const child_process_1 = require("child_process");
38
40
  const fs = __importStar(require("fs"));
39
41
  const path = __importStar(require("path"));
40
42
  const layout_1 = require("./layout");
@@ -332,3 +334,49 @@ function migratePermissions(root) {
332
334
  (0, vault_1.finishPermsMigration)(root, siteAdmins);
333
335
  return notes;
334
336
  }
337
+ /**
338
+ * Unset `receive.denyNonFastForwards` on every repository that still carries
339
+ * it.
340
+ *
341
+ * Repositories created before force pushes were allowed were configured to
342
+ * refuse any push that moved a branch to a commit that was not a descendant of
343
+ * where it already was. Nothing overrides that per push, so a vault upgraded
344
+ * from that era would keep refusing force pushes on its existing repositories
345
+ * while allowing them on every new one, which is the sort of split nobody could
346
+ * diagnose from the outside.
347
+ *
348
+ * The setting is read from the repository's own config file before git is run,
349
+ * so a vault whose repositories are all current pays a small read each and
350
+ * spawns nothing. Deletes are deliberately left refused: see createRepo.
351
+ */
352
+ function migratePushPolicy(root) {
353
+ const changed = [];
354
+ for (const { name: collection } of (0, scan_1.listCollections)(root)) {
355
+ for (const dirName of (0, scan_1.listRepoDirs)(root, collection)) {
356
+ const dir = path.join((0, layout_1.collectionsDir)(root), collection, layout_1.REPOS_DIR, dirName);
357
+ const configFile = path.join(dir, 'config');
358
+ let text;
359
+ try {
360
+ text = fs.readFileSync(configFile, 'utf8');
361
+ }
362
+ catch {
363
+ continue;
364
+ }
365
+ if (!/denyNonFastForwards/i.test(text))
366
+ continue;
367
+ try {
368
+ (0, child_process_1.execFileSync)('git', ['-C', dir, 'config', '--unset-all', 'receive.denyNonFastForwards'], {
369
+ stdio: 'ignore',
370
+ });
371
+ changed.push(`${collection}/${(0, scan_1.displayName)(dirName)}`);
372
+ }
373
+ catch {
374
+ // A repository whose config could not be changed keeps refusing force
375
+ // pushes, which is the behaviour it has had all along. It is named so
376
+ // that the operator can unset it by hand.
377
+ changed.push(`${collection}/${(0, scan_1.displayName)(dirName)} (failed)`);
378
+ }
379
+ }
380
+ }
381
+ return changed;
382
+ }
package/dist/naming.js ADDED
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ // The spellings that name the product rather than describe the mechanism.
3
+ //
4
+ // Everything here defaults to the mochi spellings, so mochiforge itself is
5
+ // unchanged by this file existing. A sibling application built on these
6
+ // modules (dango is the first) calls setNaming() once, before anything else
7
+ // runs, and the token prefix, session cookie, state file, environment
8
+ // variables, and CLI wording all take its name instead. The values are read
9
+ // at call time, never captured at module load, which is what makes the one
10
+ // early setNaming() call sufficient.
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.naming = void 0;
13
+ exports.setNaming = setNaming;
14
+ exports.naming = {
15
+ product: 'mochi',
16
+ tokenPrefix: 'mochi_',
17
+ cookieName: 'mochi_session',
18
+ stateFile: 'vault.json',
19
+ rootNoun: 'vault',
20
+ envPrefix: 'MOCHI',
21
+ configDirName: 'mochi',
22
+ };
23
+ function setNaming(overrides) {
24
+ Object.assign(exports.naming, overrides);
25
+ }