@bevel-software/platform-shared 0.25.2 → 0.27.0

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 (38) hide show
  1. package/dist/git/pr.types.d.ts +101 -0
  2. package/dist/git/pr.types.d.ts.map +1 -1
  3. package/dist/git/types.d.ts +76 -2
  4. package/dist/git/types.d.ts.map +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/workflow/events.d.ts +24 -1
  10. package/dist/workflow/events.d.ts.map +1 -1
  11. package/dist/workflow/events.js +1 -0
  12. package/dist/workflow/events.js.map +1 -1
  13. package/dist/workflow/interface.d.ts +42 -1
  14. package/dist/workflow/interface.d.ts.map +1 -1
  15. package/dist/workflow/types.d.ts +57 -3
  16. package/dist/workflow/types.d.ts.map +1 -1
  17. package/dist/workspace/kb-layout.d.ts +40 -101
  18. package/dist/workspace/kb-layout.d.ts.map +1 -1
  19. package/dist/workspace/kb-layout.js +40 -159
  20. package/dist/workspace/kb-layout.js.map +1 -1
  21. package/dist/workspace/md-links.d.ts +138 -0
  22. package/dist/workspace/md-links.d.ts.map +1 -0
  23. package/dist/workspace/md-links.js +703 -0
  24. package/dist/workspace/md-links.js.map +1 -0
  25. package/dist/workspace/platform-files.d.ts +25 -24
  26. package/dist/workspace/platform-files.d.ts.map +1 -1
  27. package/dist/workspace/platform-files.js +41 -25
  28. package/dist/workspace/platform-files.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/git/pr.types.ts +99 -0
  31. package/src/git/types.ts +80 -2
  32. package/src/index.ts +1 -0
  33. package/src/workflow/events.ts +26 -0
  34. package/src/workflow/interface.ts +42 -0
  35. package/src/workflow/types.ts +48 -4
  36. package/src/workspace/kb-layout.ts +39 -172
  37. package/src/workspace/md-links.ts +757 -0
  38. package/src/workspace/platform-files.ts +44 -26
@@ -0,0 +1,703 @@
1
+ /**
2
+ * The markdown link grammar a move rewrites with: where the links in a page
3
+ * are, what each one points at, and how to point it somewhere else without
4
+ * changing anything else about it.
5
+ *
6
+ * Pure and dependency-free, so the backend's move and the web app can share
7
+ * it. The resolver half is the web app's (`resolveKbHref` in the frontend's
8
+ * `routing/kb-routes.ts`) restated here; a parity test there holds the two to
9
+ * one answer on a shared fixture set.
10
+ *
11
+ * What it deliberately is NOT: a markdown parser. It finds link
12
+ * DESTINATIONS — inline links, images, reference definitions, and the inline
13
+ * links in a node's frontmatter (the `nodeType` link) — as character spans, so
14
+ * a rewrite splices the new destination into the old one's place and every
15
+ * other byte of the file stays as it was. Code — fenced blocks, indented
16
+ * blocks and inline code — is skipped: a path written there is an example,
17
+ * not a link.
18
+ */
19
+ import { extractFrontmatter } from './frontmatter.js';
20
+ /**
21
+ * An id-link destination: a node's frontmatter id (`project-hexis`), optionally
22
+ * with a heading anchor. The web app's `NODE_ID_LINK_RE`, restated: an id-link
23
+ * points at a node wherever it lives, so a move never changes one.
24
+ */
25
+ export const MD_ID_LINK_RE = /^[a-z0-9][a-z0-9_-]*(#[^/]+)?$/;
26
+ /** The app route a copied link names a file by: `/workspace/<branch>/<path>`. */
27
+ const WORKSPACE_ROUTE_PREFIX = '/workspace/';
28
+ /**
29
+ * Every link destination in a markdown page, in document order. Code is
30
+ * skipped: fenced blocks (``` and ~~~, in lists and quotes too) and inline code
31
+ * spans. A footnote (`[^1]: …`) is not a reference definition.
32
+ */
33
+ export function scanMarkdownLinks(text) {
34
+ const out = [];
35
+ let bodyStart = 0;
36
+ const fm = extractFrontmatter(text);
37
+ if (fm) {
38
+ bodyStart = text.length - fm.body.length;
39
+ const fmStart = text.indexOf('\n') + 1;
40
+ const fmEnd = fmStart + fm.frontmatter.length;
41
+ // Line by line: a frontmatter value is one line, and a link never spans
42
+ // two. Only a value that IS one link counts — what the web app's
43
+ // frontmatter panel renders as a link (`nodeType` the usual one); a link
44
+ // written inside a prose value shows as text there, so it is left alone.
45
+ let lineStart = fmStart;
46
+ while (lineStart < fmEnd) {
47
+ const nl = text.indexOf('\n', lineStart);
48
+ const lineEnd = nl === -1 || nl > fmEnd ? fmEnd : nl;
49
+ const value = FRONTMATTER_LINK_VALUE_RE.exec(text.slice(lineStart, lineEnd).replace(/\r$/, ''));
50
+ // Only the link itself: a trailing YAML comment is not part of the value.
51
+ if (value) {
52
+ const linkStart = lineStart + value[1].length;
53
+ scanInline(text, linkStart, linkStart + value[3].length, true, out);
54
+ }
55
+ lineStart = lineEnd + 1;
56
+ }
57
+ }
58
+ scanBody(text, bodyStart, {
59
+ prose: (from, to) => scanInline(text, from, to, false, out),
60
+ definition: (span) => out.push(span),
61
+ });
62
+ return out;
63
+ }
64
+ /**
65
+ * `text` with everything that cannot start a live HTML tag blanked to spaces
66
+ * — fenced and indented code blocks, inline code spans, the frontmatter, and
67
+ * an escaped `\<`, which is a literal `<` — every kept character at its
68
+ * offset. It locates tag spans and nothing else: inside a raw tag a backslash
69
+ * is not an escape, so every other escape is kept as the two characters it
70
+ * is, and the attribute values are read from the original text.
71
+ */
72
+ function maskMarkdownCode(text) {
73
+ const chars = Array.from({ length: text.length }, (_, i) => (text[i] === '\n' ? '\n' : ' '));
74
+ const keep = (from, to) => {
75
+ let i = from;
76
+ while (i < to) {
77
+ const c = text[i];
78
+ if (c === '\\') {
79
+ if (i + 1 < to && text[i + 1] === '<') {
80
+ i += 2;
81
+ continue;
82
+ }
83
+ // The escaped character is literal text: it can neither open a
84
+ // backtick run nor start a tag, so it is carried along unprocessed,
85
+ // as `scanInline` steps over every escape.
86
+ chars[i] = c;
87
+ if (i + 1 < to)
88
+ chars[i + 1] = text[i + 1];
89
+ i += 2;
90
+ continue;
91
+ }
92
+ if (c === '`') {
93
+ let n = 1;
94
+ while (i + n < to && text[i + n] === '`')
95
+ n += 1;
96
+ const close = findBacktickRun(text, i + n, to, n);
97
+ if (close < 0) {
98
+ for (let k = i; k < i + n; k += 1)
99
+ chars[k] = text[k];
100
+ i += n;
101
+ }
102
+ else {
103
+ i = close + n;
104
+ }
105
+ continue;
106
+ }
107
+ chars[i] = c;
108
+ i += 1;
109
+ }
110
+ };
111
+ const fm = extractFrontmatter(text);
112
+ scanBody(text, fm ? text.length - fm.body.length : 0, {
113
+ prose: keep,
114
+ definition: (span) => keep(span.start, span.end),
115
+ });
116
+ // An HTML comment is not rendered: a tag inside one is blanked with it.
117
+ return chars.join('').replace(/<!--[\s\S]*?-->/g, (c) => c.replace(/[^\n]/g, ' '));
118
+ }
119
+ /**
120
+ * The `href`/`src` values of the raw HTML tags a markdown page carries —
121
+ * what the app renders as a link or a picture that the markdown grammar does
122
+ * not rewrite. Only an unescaped `<tag …>` outside code counts: a tag in a
123
+ * fence, a code span or behind a `\<` is an example, and a bare `href=` in
124
+ * prose is prose.
125
+ */
126
+ export function scanMarkdownHtmlLinks(text) {
127
+ const out = [];
128
+ const masked = maskMarkdownCode(text);
129
+ const tag = new RegExp(HTML_TAG_RE.source, 'g');
130
+ for (let m = tag.exec(masked); m !== null; m = tag.exec(masked)) {
131
+ // The span located on the mask, the attributes read from the page itself.
132
+ out.push(...attributeLinks(text.slice(m.index, m.index + m[0].length)));
133
+ }
134
+ return out;
135
+ }
136
+ /**
137
+ * A frontmatter line whose whole value is one markdown link, quoted or not
138
+ * (the panel's `FRONTMATTER_LINK_RE`, on the value YAML hands it — which is
139
+ * why a trailing ` # comment`, stripped by YAML, may follow). An unquoted
140
+ * one is matched too: YAML reads it as a flow sequence, so the panel shows no
141
+ * link, but the graph tooling reads `nodeType` lines with a line regex, not
142
+ * YAML, and still follows it. Group 1 is everything before the link; group 3
143
+ * is the link.
144
+ */
145
+ const FRONTMATTER_LINK_VALUE_RE = /^([ \t]*[^\s:#][^:]*:[ \t]+(["']?))(\[[^\]]+\]\(<?[^)>]+>?\))\2(?:[ \t]+#.*)?[ \t]*$/;
146
+ /**
147
+ * An opening code fence: its character and length. Group 1 is what precedes
148
+ * the fence on the line — quote and list markers and the fence's own indent —
149
+ * which may be at most three columns past its container.
150
+ */
151
+ const FENCE_OPEN_RE = /^((?:[ \t]*>[ \t]?)*[ \t]*(?:(?:[-*+]|\d{1,9}[.)])[ \t]+)?)(`{3,}|~{3,})(.*)$/;
152
+ /** The fence's own indent: the columns after the last quote or list marker. */
153
+ function fenceIndent(prefix, listIndent) {
154
+ const quoted = prefix.lastIndexOf('>');
155
+ if (quoted >= 0)
156
+ return indentOf(prefix.slice(quoted + 1).replace(/^[ \t]/, ''));
157
+ const item = LIST_ITEM_RE.exec(prefix);
158
+ if (item)
159
+ return 0;
160
+ return indentOf(prefix) - (listIndent ?? 0);
161
+ }
162
+ /** A list item's marker, with the spaces after it. */
163
+ const LIST_ITEM_RE = /^( *)([-*+]|\d{1,9}[.)])( +|$)/;
164
+ /** The column a line's text starts at, tabs stopping every 4 columns. */
165
+ function indentOf(line) {
166
+ let col = 0;
167
+ for (const c of line) {
168
+ if (c === ' ')
169
+ col += 1;
170
+ else if (c === '\t')
171
+ col += 4 - (col % 4);
172
+ else
173
+ break;
174
+ }
175
+ return col;
176
+ }
177
+ function scanBody(text, from, sink) {
178
+ let fence = null;
179
+ // An indented code block: four columns past where the enclosing list item's
180
+ // text starts (column 0 outside a list), opened where a paragraph cannot be
181
+ // continued — after a blank line, a heading, a fence, or at the top.
182
+ let indentedCode = false;
183
+ /** The column the current list item's text starts at, or null outside a list. */
184
+ let listIndent = null;
185
+ let mayOpenCode = true;
186
+ let prevBlank = true;
187
+ // The run of prose lines since the last blank line or fence: inline
188
+ // constructs (a link split over two lines) live within one such run.
189
+ let chunkStart = -1;
190
+ let chunkEnd = -1;
191
+ const flush = () => {
192
+ if (chunkStart >= 0)
193
+ sink.prose(chunkStart, chunkEnd);
194
+ chunkStart = -1;
195
+ };
196
+ let lineStart = from;
197
+ while (lineStart <= text.length) {
198
+ const nl = text.indexOf('\n', lineStart);
199
+ const lineEnd = nl === -1 ? text.length : nl;
200
+ const line = text.slice(lineStart, lineEnd).replace(/\r$/, '');
201
+ // Indentation is counted past the blockquote markers, so `> code`
202
+ // is an indented block inside the quote, as CommonMark reads it — and a
203
+ // bare `>` is the quote's blank line.
204
+ const quoted = /^(?:[ \t]*>[ \t]?)+/.exec(line)?.[0] ?? '';
205
+ const inner = line.slice(quoted.length);
206
+ const blank = inner.trim() === '';
207
+ const indent = indentOf(inner);
208
+ if (fence) {
209
+ const prefix = /^(?:[ \t]*>[ \t]?)*[ \t]*/.exec(line)[0];
210
+ const run = line.slice(prefix.length).match(/^(`+|~+)[ \t]*$/);
211
+ // A closing fence, like an opening one, sits at most three columns in.
212
+ if (run && run[1][0] === fence.char && run[1].length >= fence.len && fenceIndent(prefix, listIndent) <= 3) {
213
+ fence = null;
214
+ mayOpenCode = true;
215
+ }
216
+ }
217
+ else if (blank) {
218
+ flush();
219
+ mayOpenCode = true;
220
+ }
221
+ else if (indentedCode && indent >= (listIndent ?? 0) + 4) {
222
+ // Still inside the indented code block.
223
+ }
224
+ else {
225
+ indentedCode = false;
226
+ // A line back at the margin after a blank line has left the list.
227
+ if (listIndent !== null && prevBlank && indent < listIndent)
228
+ listIndent = null;
229
+ const item = indent < (listIndent ?? 0) + 4 ? LIST_ITEM_RE.exec(inner) : null;
230
+ const fenceOpen = line.match(FENCE_OPEN_RE);
231
+ // Four columns in, a fence line is code or a paragraph's continuation.
232
+ const open = fenceOpen && fenceIndent(fenceOpen[1], listIndent) <= 3 ? fenceOpen : null;
233
+ if (!item && mayOpenCode && indent >= (listIndent ?? 0) + 4) {
234
+ flush();
235
+ indentedCode = true;
236
+ }
237
+ else if (open && !(open[2][0] === '`' && open[3].includes('`'))) {
238
+ // A backtick fence's info string may hold no backtick (that is inline code).
239
+ flush();
240
+ fence = { char: open[2][0], len: open[2].length };
241
+ }
242
+ else {
243
+ if (item) {
244
+ const gap = item[3].length;
245
+ listIndent = item[1].length + item[2].length + (gap === 0 || gap > 4 ? 1 : gap);
246
+ }
247
+ const def = matchDefinition(line, lineStart);
248
+ if (def) {
249
+ flush();
250
+ sink.definition(def);
251
+ mayOpenCode = false;
252
+ }
253
+ else {
254
+ if (chunkStart < 0)
255
+ chunkStart = lineStart;
256
+ chunkEnd = lineEnd;
257
+ // A heading ends its block; a paragraph line can be continued.
258
+ mayOpenCode = /^ {0,3}#{1,6}(?:[ \t]|$)/.test(line);
259
+ if (mayOpenCode)
260
+ flush();
261
+ }
262
+ }
263
+ }
264
+ prevBlank = blank;
265
+ if (nl === -1)
266
+ break;
267
+ lineStart = nl + 1;
268
+ }
269
+ flush();
270
+ }
271
+ /**
272
+ * `[label]: destination "title"` on one line, in a blockquote too; never a
273
+ * footnote (`[^1]: …`).
274
+ */
275
+ const DEFINITION_RE = /^((?:[ \t]{0,3}>[ \t]?)* {0,3}\[(?!\^)(?:[^\]\\]|\\.)+\]:[ \t]*)(<[^<>\n]*>|[^\s<][^\s]*)(?=[ \t]|$)/;
276
+ function matchDefinition(line, lineStart) {
277
+ const m = DEFINITION_RE.exec(line);
278
+ if (!m)
279
+ return null;
280
+ const angle = m[2].startsWith('<');
281
+ const start = lineStart + m[1].length + (angle ? 1 : 0);
282
+ const destination = angle ? m[2].slice(1, -1) : m[2];
283
+ if (destination === '')
284
+ return null;
285
+ return { kind: 'definition', start, end: start + destination.length, destination, angle, inFrontmatter: false };
286
+ }
287
+ /**
288
+ * The inline links and images in `text[from, to)`. Brackets are matched as a
289
+ * stack, so a link wrapping an image (`[![a](x.png)](y.md)`) yields both;
290
+ * backslash escapes and code spans are stepped over.
291
+ */
292
+ function scanInline(text, from, to, inFrontmatter, out) {
293
+ const openers = [];
294
+ let i = from;
295
+ while (i < to) {
296
+ const c = text[i];
297
+ if (c === '\\') {
298
+ i += 2;
299
+ continue;
300
+ }
301
+ if (c === '`') {
302
+ let n = 1;
303
+ while (i + n < to && text[i + n] === '`')
304
+ n += 1;
305
+ const close = findBacktickRun(text, i + n, to, n);
306
+ i = close < 0 ? i + n : close + n;
307
+ continue;
308
+ }
309
+ if (c === '!' && text[i + 1] === '[') {
310
+ openers.push({ image: true });
311
+ i += 2;
312
+ continue;
313
+ }
314
+ if (c === '[') {
315
+ openers.push({ image: false });
316
+ i += 1;
317
+ continue;
318
+ }
319
+ if (c === ']') {
320
+ const opener = openers.pop();
321
+ if (opener && text[i + 1] === '(') {
322
+ const dest = parseInlineDestination(text, i + 2, to);
323
+ if (dest) {
324
+ if (dest.end > dest.start) {
325
+ out.push({
326
+ kind: opener.image ? 'image' : 'link',
327
+ start: dest.start,
328
+ end: dest.end,
329
+ destination: text.slice(dest.start, dest.end),
330
+ angle: dest.angle,
331
+ inFrontmatter,
332
+ });
333
+ }
334
+ i = dest.close + 1;
335
+ continue;
336
+ }
337
+ }
338
+ }
339
+ i += 1;
340
+ }
341
+ }
342
+ /** Where a run of exactly `n` backticks starts in `text[from, to)`, or -1. */
343
+ function findBacktickRun(text, from, to, n) {
344
+ let i = from;
345
+ while (i < to) {
346
+ if (text[i] !== '`') {
347
+ i += 1;
348
+ continue;
349
+ }
350
+ let m = 1;
351
+ while (i + m < to && text[i + m] === '`')
352
+ m += 1;
353
+ if (m === n)
354
+ return i;
355
+ i += m;
356
+ }
357
+ return -1;
358
+ }
359
+ /** Skip spaces and tabs, and at most one line break. */
360
+ function skipSpace(text, p, to) {
361
+ let newline = false;
362
+ while (p < to) {
363
+ const c = text[p];
364
+ if (c === ' ' || c === '\t' || c === '\r')
365
+ p += 1;
366
+ else if (c === '\n' && !newline) {
367
+ newline = true;
368
+ p += 1;
369
+ }
370
+ else
371
+ break;
372
+ }
373
+ return p;
374
+ }
375
+ /**
376
+ * The destination of an inline link whose `(` ends just before `p`, with the
377
+ * offset of its closing `)`; null when what follows is not a link destination
378
+ * (then the brackets were just text).
379
+ */
380
+ function parseInlineDestination(text, p, to) {
381
+ p = skipSpace(text, p, to);
382
+ let start;
383
+ let end;
384
+ let angle = false;
385
+ if (text[p] === '<') {
386
+ angle = true;
387
+ start = p + 1;
388
+ let q = start;
389
+ while (q < to && text[q] !== '>') {
390
+ if (text[q] === '\n' || text[q] === '<')
391
+ return null;
392
+ q += text[q] === '\\' ? 2 : 1;
393
+ }
394
+ if (q >= to)
395
+ return null;
396
+ end = q;
397
+ p = q + 1;
398
+ }
399
+ else {
400
+ start = p;
401
+ let depth = 0;
402
+ while (p < to) {
403
+ const c = text[p];
404
+ if (c === '\\' && p + 1 < to) {
405
+ p += 2;
406
+ continue;
407
+ }
408
+ if (c.charCodeAt(0) <= 0x20)
409
+ break;
410
+ if (c === '(')
411
+ depth += 1;
412
+ else if (c === ')') {
413
+ if (depth === 0)
414
+ break;
415
+ depth -= 1;
416
+ }
417
+ p += 1;
418
+ }
419
+ if (depth !== 0)
420
+ return null;
421
+ end = p;
422
+ }
423
+ const afterDest = p;
424
+ p = skipSpace(text, p, to);
425
+ if (text[p] === ')')
426
+ return { start, end, angle, close: p };
427
+ // A title must be separated from the destination by whitespace.
428
+ if (p === afterDest)
429
+ return null;
430
+ const opener = text[p];
431
+ const closer = opener === '"' ? '"' : opener === "'" ? "'" : opener === '(' ? ')' : null;
432
+ if (closer === null)
433
+ return null;
434
+ p += 1;
435
+ while (p < to && text[p] !== closer)
436
+ p += text[p] === '\\' ? 2 : 1;
437
+ if (p >= to)
438
+ return null;
439
+ p = skipSpace(text, p + 1, to);
440
+ return text[p] === ')' ? { start, end, angle, close: p } : null;
441
+ }
442
+ /**
443
+ * The `href`/`src` values in an HTML page. A move never rewrites an HTML page
444
+ * (most of its links are built in scripts nobody can see), so these are only
445
+ * read, to report the page.
446
+ */
447
+ export function scanHtmlLinks(text) {
448
+ return scanHtmlLinkTargets(text).map((t) => t.destination);
449
+ }
450
+ /** {@link scanHtmlLinks}, with each value's attribute kept: `src` is an image, `href` a link. */
451
+ export function scanHtmlLinkTargets(text) {
452
+ const out = [];
453
+ const tag = new RegExp(HTML_TAG_RE.source, 'g');
454
+ for (let m = tag.exec(text); m !== null; m = tag.exec(text))
455
+ out.push(...attributeLinks(m[0]));
456
+ return out;
457
+ }
458
+ /** An opening tag: its end is the first `>` outside a quoted attribute value. */
459
+ const HTML_TAG_RE = /<[a-zA-Z][a-zA-Z0-9-]*(?:\s+(?:"[^"]*"|'[^']*'|[^<>"'])*)?>/g;
460
+ /**
461
+ * The `href` and `src` values of ONE tag, read attribute by attribute: the
462
+ * name has to be exactly that (a `data-href` is not a link), and a value that
463
+ * happens to contain `href=` is a value, not an attribute.
464
+ */
465
+ function attributeLinks(tag) {
466
+ const out = [];
467
+ const nameEnd = tag.search(/[\s/>]/);
468
+ const attrs = nameEnd < 0 ? '' : tag.slice(nameEnd, -1);
469
+ const re = /([^\s"'=<>/]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
470
+ for (let m = re.exec(attrs); m !== null; m = re.exec(attrs)) {
471
+ const name = m[1].toLowerCase();
472
+ if (name !== 'href' && name !== 'src')
473
+ continue;
474
+ const destination = m[2] ?? m[3] ?? m[4];
475
+ if (destination !== undefined)
476
+ out.push({ destination, image: name === 'src' });
477
+ }
478
+ return out;
479
+ }
480
+ /** An href as a browser reads it: outer controls and spaces off, inner tabs and line breaks out. */
481
+ export function normalizeMdHref(href) {
482
+ const inner = href.replace(/[\t\n\r]/g, '');
483
+ let start = 0;
484
+ let end = inner.length;
485
+ while (start < end && inner.charCodeAt(start) <= 0x20)
486
+ start += 1;
487
+ while (end > start && inner.charCodeAt(end - 1) <= 0x20)
488
+ end -= 1;
489
+ return inner.slice(start, end);
490
+ }
491
+ /** Leaves the workspace: a scheme, or protocol-relative. */
492
+ function isExternal(href) {
493
+ return /^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//');
494
+ }
495
+ function safeDecode(s) {
496
+ try {
497
+ return decodeURIComponent(s);
498
+ }
499
+ catch {
500
+ return s;
501
+ }
502
+ }
503
+ /** The markdown backslash escapes of ASCII punctuation, undone — what a renderer hands the resolver. */
504
+ function unescapeMarkdown(s) {
505
+ return s.replace(/\\([!-/:-@[-`{-~])/g, '$1');
506
+ }
507
+ /** A path whose later segment is the clone folder, cut back to start there. */
508
+ function stripJunkBeforeKbDir(path, kbDirName) {
509
+ if (!kbDirName)
510
+ return path;
511
+ const segs = path.split('/');
512
+ const idx = segs.indexOf(kbDirName);
513
+ return idx > 0 ? segs.slice(idx).join('/') : path;
514
+ }
515
+ /** `relative` against the file `basePath`, as the web app resolves it. */
516
+ export function resolveMdRelativePath(basePath, relative) {
517
+ const baseDir = relative.startsWith('/')
518
+ ? ''
519
+ : basePath.includes('/')
520
+ ? basePath.slice(0, basePath.lastIndexOf('/'))
521
+ : '';
522
+ const parts = baseDir ? baseDir.split('/') : [];
523
+ for (const segment of relative.split('/')) {
524
+ if (segment === '..')
525
+ parts.pop();
526
+ else if (segment !== '.' && segment !== '')
527
+ parts.push(segment);
528
+ }
529
+ return parts.join('/');
530
+ }
531
+ /**
532
+ * What a destination written in `basePath` points at, or null when it names
533
+ * no workspace path a move could affect: an external URL, an id-link, a
534
+ * same-page `#anchor`, or a bare `/workspace/<branch>`.
535
+ */
536
+ export function resolveMdLink(destination, opts) {
537
+ const href = normalizeMdHref(unescapeMarkdown(destination));
538
+ if (!href || isExternal(href) || MD_ID_LINK_RE.test(href))
539
+ return null;
540
+ const hashIdx = href.indexOf('#');
541
+ const hash = hashIdx >= 0 ? href.slice(hashIdx) : '';
542
+ const location = hashIdx >= 0 ? href.slice(0, hashIdx) : href;
543
+ if (!location)
544
+ return null;
545
+ const repair = (p) => (opts.image ? p : stripJunkBeforeKbDir(p, opts.kbDirName));
546
+ if (location.startsWith(WORKSPACE_ROUTE_PREFIX)) {
547
+ const rest = location.slice(WORKSPACE_ROUTE_PREFIX.length);
548
+ const slash = rest.indexOf('/');
549
+ if (slash < 0)
550
+ return null;
551
+ return {
552
+ form: 'workspace',
553
+ branch: safeDecode(rest.slice(0, slash)),
554
+ path: repair(safeDecode(rest.slice(slash + 1))),
555
+ hash,
556
+ };
557
+ }
558
+ return {
559
+ form: location.startsWith('/') ? 'root' : 'relative',
560
+ branch: null,
561
+ path: repair(resolveMdRelativePath(opts.basePath, safeDecode(location))),
562
+ hash,
563
+ };
564
+ }
565
+ // ── Rewriting ───────────────────────────────────────────────────────────────
566
+ /** `target` relative to the folder `dir` (both workspace-relative). */
567
+ function relativeFrom(dir, target) {
568
+ const from = dir ? dir.split('/') : [];
569
+ const to = target.split('/');
570
+ let i = 0;
571
+ while (i < from.length && i < to.length && from[i] === to[i])
572
+ i += 1;
573
+ const rel = [...Array(from.length - i).fill('..'), ...to.slice(i)].join('/');
574
+ return rel === '' ? '.' : rel;
575
+ }
576
+ /** Characters that would end or break a destination written without angle brackets. */
577
+ function escapeBare(segment) {
578
+ return segment.replace(/[\s<>()\\]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0')}`);
579
+ }
580
+ function encodePath(path, how) {
581
+ let out;
582
+ if (how.encoded) {
583
+ out = path
584
+ .split('/')
585
+ .map((s) => encodeURIComponent(s).replace(/[()]/g, (c) => (c === '(' ? '%28' : '%29')))
586
+ .join('/');
587
+ }
588
+ else if (how.angle) {
589
+ out = path.replace(/[<>\n]/g, (c) => encodeURIComponent(c));
590
+ }
591
+ else {
592
+ // Only what would break the link: a balanced bare path stays as it is.
593
+ out = /[\s<>\\]/.test(path) || !balanced(path) ? escapeBare(path) : path;
594
+ }
595
+ // A frontmatter link sits inside a YAML quoted string, double or single:
596
+ // either quote in the path would end the string early.
597
+ return how.inFrontmatter ? out.replace(/["']/g, (c) => (c === '"' ? '%22' : '%27')) : out;
598
+ }
599
+ function balanced(path) {
600
+ let depth = 0;
601
+ for (const c of path) {
602
+ if (c === '(')
603
+ depth += 1;
604
+ else if (c === ')' && --depth < 0)
605
+ return false;
606
+ }
607
+ return depth === 0;
608
+ }
609
+ /** The parent folder of a workspace-relative file path. */
610
+ function dirOf(path) {
611
+ return path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '';
612
+ }
613
+ /**
614
+ * The destination `span` must carry once its file sits at `newBase` and its
615
+ * target at `newTarget`, in the form it was written in: relative stays
616
+ * relative (`./` and a trailing `/` kept), root-anchored stays root-anchored,
617
+ * an app URL keeps its branch segment; encoding, anchor — and, because only
618
+ * the destination is spliced, the title — stay as they were.
619
+ */
620
+ export function retargetMdDestination(span, resolved, newBase, newTarget) {
621
+ const href = normalizeMdHref(unescapeMarkdown(span.destination));
622
+ const hashIdx = href.indexOf('#');
623
+ const location = hashIdx >= 0 ? href.slice(0, hashIdx) : href;
624
+ const how = { encoded: /%[0-9a-f]{2}/i.test(location), angle: span.angle, inFrontmatter: span.inFrontmatter };
625
+ const trailingSlash = location.length > 1 && location.endsWith('/') ? '/' : '';
626
+ let next;
627
+ if (resolved.form === 'workspace') {
628
+ const rest = location.slice(WORKSPACE_ROUTE_PREFIX.length);
629
+ const branchSegment = rest.slice(0, rest.indexOf('/'));
630
+ next = `${WORKSPACE_ROUTE_PREFIX}${branchSegment}/${encodePath(newTarget, how)}`;
631
+ }
632
+ else if (resolved.form === 'root') {
633
+ next = `/${encodePath(newTarget, how)}`;
634
+ }
635
+ else {
636
+ let rel = relativeFrom(dirOf(newBase), newTarget);
637
+ if (location.startsWith('./') && !rel.startsWith('../') && rel !== '.')
638
+ rel = `./${rel}`;
639
+ next = encodePath(rel, how);
640
+ }
641
+ return `${next}${trailingSlash && !next.endsWith('/') ? '/' : ''}${resolved.hash}`;
642
+ }
643
+ /**
644
+ * Rewrite the links in one markdown page so that, once it sits at `newPath`
645
+ * and every moved path is at its new place, each points at the same target it
646
+ * did before. A link that already does is left alone, as is every byte that is
647
+ * not a changed destination.
648
+ */
649
+ export function rewriteMdLinks(text, opts) {
650
+ const edits = [];
651
+ for (const span of scanMarkdownLinks(text)) {
652
+ const image = span.kind === 'image';
653
+ const before = resolveMdLink(span.destination, { basePath: opts.oldPath, kbDirName: opts.kbDirName, image });
654
+ // A link naming another branch opens that branch, which this move does
655
+ // not touch. An image does not: the web app serves every image from the
656
+ // branch the page is read on, whatever branch its URL names.
657
+ if (!before || (!image && before.branch !== null && before.branch !== opts.branch))
658
+ continue;
659
+ const target = opts.mapPath(before.path) ?? before.path;
660
+ const after = resolveMdLink(span.destination, { basePath: opts.newPath, kbDirName: opts.kbDirName, image });
661
+ if (after && after.path === target)
662
+ continue;
663
+ const to = retargetMdDestination(span, before, opts.newPath, target);
664
+ if (to === span.destination)
665
+ continue;
666
+ edits.push({ start: span.start, end: span.end, from: span.destination, to });
667
+ }
668
+ if (edits.length === 0)
669
+ return { text, edits: [] };
670
+ let out = '';
671
+ let at = 0;
672
+ for (const e of edits) {
673
+ out += text.slice(at, e.start) + e.to;
674
+ at = e.end;
675
+ }
676
+ out += text.slice(at);
677
+ return { text: out, edits: edits.map(({ from, to }) => ({ from, to })) };
678
+ }
679
+ /**
680
+ * The destinations in an HTML page that point at a moved path, or (for a page
681
+ * that moves itself) whose relative target the move would break. Reported,
682
+ * never rewritten.
683
+ */
684
+ export function htmlLinksAffectedByMove(text, opts,
685
+ /** The targets to judge: an HTML page's by default, a markdown page's from {@link scanMarkdownHtmlLinks}. */
686
+ targets = scanHtmlLinkTargets(text)) {
687
+ const out = [];
688
+ for (const { destination, image } of targets) {
689
+ // A picture resolves as the markdown image rule resolves one: no
690
+ // mangled-path repair, and served from the branch the page is read on
691
+ // whatever branch its address names — so another branch's app URL on an
692
+ // image is a reference to THIS branch and is judged, where a link's is not.
693
+ const before = resolveMdLink(destination, { basePath: opts.oldPath, kbDirName: opts.kbDirName, image });
694
+ if (!before || (!image && before.branch !== null && before.branch !== opts.branch))
695
+ continue;
696
+ const target = opts.mapPath(before.path) ?? before.path;
697
+ const after = resolveMdLink(destination, { basePath: opts.newPath, kbDirName: opts.kbDirName, image });
698
+ if (!after || after.path !== target)
699
+ out.push(destination);
700
+ }
701
+ return out;
702
+ }
703
+ //# sourceMappingURL=md-links.js.map