@stevecastaneda/paseo-progress 1.11.0 → 1.12.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/OVERVIEW.md +2 -2
- package/README.md +14 -3
- package/client/attachments.tsx +1 -1
- package/client/blocks.tsx +41 -0
- package/client/deliverables.tsx +1 -1
- package/client/dialog-stack.tsx +2 -2
- package/client/history.tsx +1 -1
- package/client/launcher.tsx +1 -1
- package/client/panel.tsx +1 -1
- package/client/pill-popover.tsx +1 -1
- package/client/pills.tsx +4 -13
- package/client/preview.tsx +54 -19
- package/client/questions.tsx +1 -1
- package/client/section-title.tsx +1 -1
- package/client/settings.tsx +4 -4
- package/client/show-more.tsx +1 -1
- package/client/spinner.tsx +1 -1
- package/client/tabs.tsx +1 -1
- package/client/ticket-dialog.tsx +1 -1
- package/client/whats-new.tsx +3 -3
- package/client/write-up.tsx +25 -44
- package/index.client.tsx +1 -2
- package/package.json +3 -3
- package/paseo-plugin.json +1 -1
- package/server/open.ts +30 -24
- package/server/whats-new.ts +1 -1
- package/shared/attachments.ts +19 -4
- package/shared/{write-up.ts → blocks.ts} +1 -21
- package/shared/markdown.ts +483 -0
- package/shared/rpc.ts +4 -2
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
// A Markdown file has its relative links and images pointed at its own folder,
|
|
2
|
+
// because Paseo's Markdown resolves them from the workspace and plugins can't
|
|
3
|
+
// tell it otherwise.
|
|
4
|
+
|
|
5
|
+
const LINE_SEARCH_BYTES = 8 * 1024;
|
|
6
|
+
|
|
7
|
+
export function previewText(bytes: Uint8Array, truncated: boolean): string {
|
|
8
|
+
return new TextDecoder().decode(truncated ? cleanCut(bytes) : bytes);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function cleanCut(bytes: Uint8Array): Uint8Array {
|
|
12
|
+
let end = wholeCharacters(bytes);
|
|
13
|
+
const newline = end ? bytes.lastIndexOf(0x0a, end - 1) : -1;
|
|
14
|
+
if (newline >= 0 && end - (newline + 1) <= LINE_SEARCH_BYTES) end = newline + 1;
|
|
15
|
+
return bytes.subarray(0, end);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function wholeCharacters(bytes: Uint8Array): number {
|
|
19
|
+
const end = bytes.length;
|
|
20
|
+
for (let back = 1; back <= Math.min(4, end); back++) {
|
|
21
|
+
const byte = bytes[end - back];
|
|
22
|
+
if (isContinuationByte(byte)) continue;
|
|
23
|
+
return sequenceLength(byte) > back ? end - back : end;
|
|
24
|
+
}
|
|
25
|
+
return end;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isContinuationByte(byte: number): boolean {
|
|
29
|
+
return (byte & 0xc0) === 0x80;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sequenceLength(leadByte: number): number {
|
|
33
|
+
return leadByte >= 0xf0 ? 4 : leadByte >= 0xe0 ? 3 : leadByte >= 0xc0 ? 2 : 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// `path` is the daemon's node:path, passed in because Paseo refuses Node modules
|
|
37
|
+
// in shared code.
|
|
38
|
+
export interface FileLocation {
|
|
39
|
+
root: string;
|
|
40
|
+
folder: string;
|
|
41
|
+
path: { resolve(...paths: string[]): string; relative(from: string, to: string): string; isAbsolute(path: string): boolean; sep: string };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function prepareMarkdown(bytes: Uint8Array, truncated: boolean, where: FileLocation): string {
|
|
45
|
+
const text = previewText(bytes, truncated).replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
|
|
46
|
+
return rewriteTargets(fenceFrontMatter(text), where);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Paseo's Markdown would draw front matter as a rule and a heading.
|
|
50
|
+
function fenceFrontMatter(text: string): string {
|
|
51
|
+
const close = /^(?:---|\.\.\.)[ \t]*$/m;
|
|
52
|
+
const open = /^---[ \t]*\n/.exec(text);
|
|
53
|
+
if (!open) return text;
|
|
54
|
+
const end = close.exec(text.slice(open[0].length));
|
|
55
|
+
if (!end) return text;
|
|
56
|
+
const body = text.slice(open[0].length, open[0].length + end.index);
|
|
57
|
+
const fence = "`".repeat(Math.max(3, ...Array.from(body.matchAll(/^[ \t]*(`+)/gm), (match) => match[1].length + 1)));
|
|
58
|
+
return `${fence}yaml\n${body}${fence}${text.slice(open[0].length + end.index + end[0].length)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type LineKind = "blank" | "text" | "fence-open" | "fence-body" | "fence-close" | "indented-code" | "comment";
|
|
62
|
+
|
|
63
|
+
interface Line {
|
|
64
|
+
start: number;
|
|
65
|
+
end: number;
|
|
66
|
+
kind: LineKind;
|
|
67
|
+
topLevelItem: boolean;
|
|
68
|
+
definition: boolean;
|
|
69
|
+
headingLevel: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type Fence = { run: string; quotes: number; indent: number };
|
|
73
|
+
|
|
74
|
+
const QUOTES = /^(?:[ ]{0,3}>[ ]?)*/;
|
|
75
|
+
const FENCE = /^[ \t]*(`{3,}(?=[^`]*$)|~{3,})/;
|
|
76
|
+
const ITEM = /^[ \t]*(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)/;
|
|
77
|
+
const DEFINITION = /^ {0,3}\[(?!\^)(?:[^\\\]]|\\.)+\]:/;
|
|
78
|
+
|
|
79
|
+
function scanLines(source: string): Line[] {
|
|
80
|
+
const lines: Line[] = [];
|
|
81
|
+
let fence: Fence | null = null;
|
|
82
|
+
let comment = false;
|
|
83
|
+
let list = false;
|
|
84
|
+
let previous: Line | undefined;
|
|
85
|
+
for (let start = 0; start <= source.length;) {
|
|
86
|
+
const newline = source.indexOf("\n", start);
|
|
87
|
+
const end = newline < 0 ? source.length : newline;
|
|
88
|
+
const raw = source.slice(start, end);
|
|
89
|
+
const quotePrefix = QUOTES.exec(raw)![0];
|
|
90
|
+
const quotes = quotePrefix.split(">").length - 1;
|
|
91
|
+
const rest = raw.slice(quotePrefix.length);
|
|
92
|
+
const blank = !rest.trim();
|
|
93
|
+
const indent = indentOf(rest);
|
|
94
|
+
const line: Line = { start, end, kind: "text", topLevelItem: false, definition: false, headingLevel: 0 };
|
|
95
|
+
if (fence && (quotes < fence.quotes || (!blank && indent < fence.indent))) fence = null;
|
|
96
|
+
if (fence) {
|
|
97
|
+
const close = FENCE.exec(rest);
|
|
98
|
+
const closes = close !== null && rest.trim() === close[1] && close[1][0] === fence.run[0] && close[1].length >= fence.run.length;
|
|
99
|
+
line.kind = closes ? "fence-close" : "fence-body";
|
|
100
|
+
if (closes) fence = null;
|
|
101
|
+
} else if (comment) {
|
|
102
|
+
line.kind = "comment";
|
|
103
|
+
comment = !raw.includes("-->") || codeAndCommentMask(raw.slice(raw.lastIndexOf("-->") + 3)).comment;
|
|
104
|
+
} else if (blank) {
|
|
105
|
+
line.kind = "blank";
|
|
106
|
+
} else {
|
|
107
|
+
const item = ITEM.test(rest);
|
|
108
|
+
const heading = /^ {0,3}(#{1,6})(?:[ \t]|$)/.exec(rest)?.[1].length ?? 0;
|
|
109
|
+
if (list && indent === 0 && !item && (previous?.kind === "blank" || heading)) list = false;
|
|
110
|
+
const paragraphAbove = previous?.kind === "text" && !previous.headingLevel && !previous.definition;
|
|
111
|
+
if (indent >= 4 && !list && !paragraphAbove) line.kind = "indented-code";
|
|
112
|
+
else if (FENCE.test(rest) && (indent < 4 || list)) {
|
|
113
|
+
line.kind = "fence-open";
|
|
114
|
+
fence = { run: FENCE.exec(rest)![1], quotes, indent: list ? indent : 0 };
|
|
115
|
+
} else {
|
|
116
|
+
if (item) list = true;
|
|
117
|
+
line.topLevelItem = item && quotes === 0 && indent < 2;
|
|
118
|
+
line.headingLevel = heading;
|
|
119
|
+
line.definition = DEFINITION.test(rest) && !paragraphAbove;
|
|
120
|
+
comment = codeAndCommentMask(raw).comment;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
lines.push(line);
|
|
124
|
+
previous = line;
|
|
125
|
+
start = end + 1;
|
|
126
|
+
}
|
|
127
|
+
return lines;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function indentOf(text: string): number {
|
|
131
|
+
let columns = 0;
|
|
132
|
+
for (const character of text) {
|
|
133
|
+
if (character === " ") columns++;
|
|
134
|
+
else if (character === "\t") columns += 4 - (columns % 4);
|
|
135
|
+
else break;
|
|
136
|
+
}
|
|
137
|
+
return columns;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function codeAndCommentMask(text: string): { mask: Uint8Array; comment: boolean } {
|
|
141
|
+
const mask = new Uint8Array(text.length);
|
|
142
|
+
for (let index = 0; index < text.length;) {
|
|
143
|
+
if (text[index] === "\\") {
|
|
144
|
+
index += 2;
|
|
145
|
+
} else if (text[index] === "`") {
|
|
146
|
+
const run = runLength(text, index);
|
|
147
|
+
let close = text.indexOf("`", index + run);
|
|
148
|
+
while (close >= 0 && runLength(text, close) !== run) close = text.indexOf("`", close + runLength(text, close));
|
|
149
|
+
if (close < 0) {
|
|
150
|
+
index += run;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
mask.fill(1, index, close + run);
|
|
154
|
+
index = close + run;
|
|
155
|
+
} else if (text.startsWith("<!--", index)) {
|
|
156
|
+
const close = text.indexOf("-->", index + 4);
|
|
157
|
+
if (close < 0) {
|
|
158
|
+
mask.fill(1, index);
|
|
159
|
+
return { mask, comment: true };
|
|
160
|
+
}
|
|
161
|
+
mask.fill(1, index, close + 3);
|
|
162
|
+
index = close + 3;
|
|
163
|
+
} else {
|
|
164
|
+
index++;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { mask, comment: false };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function runLength(text: string, index: number): number {
|
|
171
|
+
let end = index;
|
|
172
|
+
while (text[end] === "`") end++;
|
|
173
|
+
return end - index;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
type Edit = { start: number; end: number; text: string };
|
|
177
|
+
|
|
178
|
+
// HTML <img src> and <a href> count too: Paseo turns them into Markdown first.
|
|
179
|
+
function rewriteTargets(source: string, where: FileLocation): string {
|
|
180
|
+
const lines = scanLines(source);
|
|
181
|
+
const edits: Edit[] = [];
|
|
182
|
+
for (let first = 0; first < lines.length; first++) {
|
|
183
|
+
if (lines[first].kind !== "text") continue;
|
|
184
|
+
let last = first;
|
|
185
|
+
while (last + 1 < lines.length && lines[last + 1].kind === "text") last++;
|
|
186
|
+
const offset = lines[first].start;
|
|
187
|
+
const text = source.slice(offset, lines[last].end);
|
|
188
|
+
const { mask } = codeAndCommentMask(text);
|
|
189
|
+
const add = (start: number, end: number, target: string, image: boolean) => {
|
|
190
|
+
const replacement = localTarget(target, where, image);
|
|
191
|
+
if (replacement !== null) edits.push({ start: offset + start, end: offset + end, text: replacement });
|
|
192
|
+
};
|
|
193
|
+
inlineTargets(text, mask, add);
|
|
194
|
+
for (let index = first; index <= last; index++) {
|
|
195
|
+
if (!lines[index].definition) continue;
|
|
196
|
+
const lineText = source.slice(lines[index].start, lines[index].end);
|
|
197
|
+
const target = definitionTarget(lineText);
|
|
198
|
+
if (target && !mask[lines[index].start - offset + target.start]) add(lines[index].start - offset + target.start, lines[index].start - offset + target.end, target.target, false);
|
|
199
|
+
}
|
|
200
|
+
for (const tag of text.matchAll(/<(img|a)\b[^>]*>/gi)) {
|
|
201
|
+
if (mask[tag.index]) continue;
|
|
202
|
+
const name = tag[1].toLowerCase() === "img" ? "src" : "href";
|
|
203
|
+
const attribute = new RegExp(`(?<=\\s)${name}[ \\t\\n]*=[ \\t\\n]*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>\`]+))`, "i").exec(tag[0]);
|
|
204
|
+
if (!attribute) continue;
|
|
205
|
+
const value = attribute[1] ?? attribute[2] ?? attribute[3];
|
|
206
|
+
const start = tag.index + attribute.index + attribute[0].length - value.length - (attribute[3] === undefined ? 1 : 0);
|
|
207
|
+
add(start, start + value.length, value, name === "src");
|
|
208
|
+
}
|
|
209
|
+
first = last;
|
|
210
|
+
}
|
|
211
|
+
let result = "";
|
|
212
|
+
let last = 0;
|
|
213
|
+
for (const edit of edits.sort((a, b) => a.start - b.start)) {
|
|
214
|
+
if (edit.start < last) continue;
|
|
215
|
+
result += source.slice(last, edit.start) + edit.text;
|
|
216
|
+
last = edit.end;
|
|
217
|
+
}
|
|
218
|
+
return result + source.slice(last);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function inlineTargets(text: string, mask: Uint8Array, add: (start: number, end: number, target: string, image: boolean) => void) {
|
|
222
|
+
for (let close = text.indexOf("]("); close >= 0; close = text.indexOf("](", close + 1)) {
|
|
223
|
+
if (mask[close] || escaped(text, close)) continue;
|
|
224
|
+
const open = linkOpen(text, mask, close);
|
|
225
|
+
if (open < 0) continue;
|
|
226
|
+
const destination = inlineDestination(text, close + 2);
|
|
227
|
+
if (destination) add(destination.start, destination.end, destination.target, open > 0 && text[open - 1] === "!" && !escaped(text, open - 1));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function linkOpen(text: string, mask: Uint8Array, close: number): number {
|
|
232
|
+
let depth = 0;
|
|
233
|
+
for (let index = close - 1; index >= 0; index--) {
|
|
234
|
+
if (mask[index] || escaped(text, index)) continue;
|
|
235
|
+
if (text[index] === "]") depth++;
|
|
236
|
+
else if (text[index] === "[" && depth-- === 0) return index;
|
|
237
|
+
}
|
|
238
|
+
return -1;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function escaped(text: string, index: number): boolean {
|
|
242
|
+
let slashes = 0;
|
|
243
|
+
while (index - slashes - 1 >= 0 && text[index - slashes - 1] === "\\") slashes++;
|
|
244
|
+
return slashes % 2 === 1;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function inlineDestination(text: string, from: number): { start: number; end: number; target: string } | null {
|
|
248
|
+
let index = skipSpace(text, from);
|
|
249
|
+
const start = index;
|
|
250
|
+
if (text[index] === "<") {
|
|
251
|
+
const close = angleEnd(text, index);
|
|
252
|
+
if (close < 0) return null;
|
|
253
|
+
index = close + 1;
|
|
254
|
+
} else {
|
|
255
|
+
let depth = 0;
|
|
256
|
+
for (; index < text.length; index++) {
|
|
257
|
+
const character = text[index];
|
|
258
|
+
if (character === "\\" && isPunctuation(text[index + 1])) index++;
|
|
259
|
+
else if (character === "(") depth++;
|
|
260
|
+
else if (character === ")" && depth-- === 0) break;
|
|
261
|
+
else if (/[\s\x00-\x1f]/.test(character)) break;
|
|
262
|
+
}
|
|
263
|
+
if (depth > 0) return null;
|
|
264
|
+
}
|
|
265
|
+
const end = index;
|
|
266
|
+
index = skipSpace(text, index);
|
|
267
|
+
if (index > end && /["'(]/.test(text[index] ?? "")) {
|
|
268
|
+
index = titleEnd(text, index);
|
|
269
|
+
if (index < 0) return null;
|
|
270
|
+
index = skipSpace(text, index);
|
|
271
|
+
}
|
|
272
|
+
if (text[index] !== ")") return null;
|
|
273
|
+
return { start, end, target: unescape(text[start] === "<" ? text.slice(start + 1, end - 1) : text.slice(start, end)) };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function definitionTarget(line: string): { start: number; end: number; target: string } | null {
|
|
277
|
+
const label = DEFINITION.exec(line.slice(QUOTES.exec(line)![0].length));
|
|
278
|
+
if (!label) return null;
|
|
279
|
+
const start = skipSpace(line, QUOTES.exec(line)![0].length + label[0].length);
|
|
280
|
+
let end = start;
|
|
281
|
+
if (line[start] === "<") end = angleEnd(line, start) + 1;
|
|
282
|
+
else while (end < line.length && !/\s/.test(line[end])) end++;
|
|
283
|
+
if (end <= start) return null;
|
|
284
|
+
const after = line.slice(end);
|
|
285
|
+
if (after.trim() && !/^\s+("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\((?:[^)\\]|\\.)*\))\s*$/.test(after)) return null;
|
|
286
|
+
return { start, end, target: unescape(line[start] === "<" ? line.slice(start + 1, end - 1) : line.slice(start, end)) };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function angleEnd(text: string, open: number): number {
|
|
290
|
+
for (let index = open + 1; index < text.length; index++) {
|
|
291
|
+
if (text[index] === "\\") index++;
|
|
292
|
+
else if (text[index] === ">") return index;
|
|
293
|
+
else if (text[index] === "<" || text[index] === "\n") return -1;
|
|
294
|
+
}
|
|
295
|
+
return -1;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function titleEnd(text: string, open: number): number {
|
|
299
|
+
const close = text[open] === "(" ? ")" : text[open];
|
|
300
|
+
for (let index = open + 1; index < text.length; index++) {
|
|
301
|
+
if (text[index] === "\\") index++;
|
|
302
|
+
else if (text[index] === close) return index + 1;
|
|
303
|
+
}
|
|
304
|
+
return -1;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function skipSpace(text: string, index: number): number {
|
|
308
|
+
let newlines = 0;
|
|
309
|
+
while (index < text.length && /[ \t\n]/.test(text[index]) && (text[index] !== "\n" || newlines++ === 0)) index++;
|
|
310
|
+
return index;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function isPunctuation(character: string | undefined): boolean {
|
|
314
|
+
return character !== undefined && /[!-/:-@[-`{-~]/.test(character);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function unescape(text: string): string {
|
|
318
|
+
return text.replace(/\\([!-/:-@[-`{-~])/g, "$1");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// A link keeps its fragment (`#L12` jumps to a line); an image doesn't need one.
|
|
322
|
+
// A query never reaches a local file.
|
|
323
|
+
function localTarget(written: string, where: FileLocation, image: boolean): string | null {
|
|
324
|
+
if (/^(?:[a-z][a-z0-9+.-]*:|[#/\\~])/i.test(written)) return null;
|
|
325
|
+
const split = written.search(/[?#]/);
|
|
326
|
+
const path = split < 0 ? written : written.slice(0, split);
|
|
327
|
+
if (!path) return null;
|
|
328
|
+
const hash = written.indexOf("#");
|
|
329
|
+
const fragment = image || hash < 0 ? "" : written.slice(hash + 1);
|
|
330
|
+
const { resolve, relative, isAbsolute, sep } = where.path;
|
|
331
|
+
const decoded = decode(path);
|
|
332
|
+
const absolute = resolve(where.folder, decoded);
|
|
333
|
+
const inside = relative(where.root, absolute);
|
|
334
|
+
if (inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) return null;
|
|
335
|
+
const folder = decoded.endsWith("/") || decoded.endsWith(sep);
|
|
336
|
+
const slashed = absolute.split(sep).join("/") + (folder && !absolute.endsWith(sep) ? "/" : "");
|
|
337
|
+
// Paseo reads `//server/share` as a web address, so a file on a network
|
|
338
|
+
// share keeps its relative targets.
|
|
339
|
+
if (slashed.startsWith("//")) return null;
|
|
340
|
+
return encodeTarget(slashed) + (fragment ? `#${encodeTarget(decode(fragment))}` : "");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function decode(text: string): string {
|
|
344
|
+
try {
|
|
345
|
+
return decodeURIComponent(text);
|
|
346
|
+
} catch {
|
|
347
|
+
return text;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// A Windows drive's colon stays, so Paseo still sees `C:/`.
|
|
352
|
+
function encodeTarget(path: string): string {
|
|
353
|
+
const drive = /^[A-Za-z]:\//.exec(path)?.[0] ?? "";
|
|
354
|
+
let encoded = drive;
|
|
355
|
+
for (const character of path.slice(drive.length)) {
|
|
356
|
+
if (/[A-Za-z0-9\-._~!$*+,;@/]/.test(character)) encoded += character;
|
|
357
|
+
else {
|
|
358
|
+
const escape = encodeURIComponent(character);
|
|
359
|
+
encoded += escape === character ? `%${character.charCodeAt(0).toString(16).toUpperCase()}` : escape;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return encoded;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export function flattenHeadings(markdown: string, level: number): string {
|
|
366
|
+
let result = "";
|
|
367
|
+
let last = 0;
|
|
368
|
+
for (const line of scanLines(markdown)) {
|
|
369
|
+
if (!line.headingLevel) continue;
|
|
370
|
+
const marks = /^((?:[ ]{0,3}>[ ]?)*[ ]{0,3})#+/.exec(markdown.slice(line.start, line.end))!;
|
|
371
|
+
result += markdown.slice(last, line.start + marks[1].length) + "#".repeat(level);
|
|
372
|
+
last = line.start + marks[0].length;
|
|
373
|
+
}
|
|
374
|
+
return result + markdown.slice(last);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function dropTitle(markdown: string): string {
|
|
378
|
+
const lines = scanLines(markdown);
|
|
379
|
+
let index = lines.findIndex((line) => line.kind !== "blank");
|
|
380
|
+
if (isFrontMatter(markdown, lines[index])) {
|
|
381
|
+
while (index < lines.length && lines[index].kind !== "fence-close") index++;
|
|
382
|
+
while (++index < lines.length && lines[index].kind === "blank");
|
|
383
|
+
}
|
|
384
|
+
const title = lines[index];
|
|
385
|
+
if (!title || title.kind !== "text") return markdown;
|
|
386
|
+
const underline = lines[index + 1]?.kind === "text" && /^ {0,3}=+[ \t]*$/.test(markdown.slice(lines[index + 1].start, lines[index + 1].end));
|
|
387
|
+
if (title.headingLevel !== 1 && (title.headingLevel || title.topLevelItem || !underline)) return markdown;
|
|
388
|
+
let next = index + (underline && title.headingLevel !== 1 ? 2 : 1);
|
|
389
|
+
while (lines[next]?.kind === "blank") next++;
|
|
390
|
+
return markdown.slice(0, title.start) + (next < lines.length ? markdown.slice(lines[next].start) : "");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function isFrontMatter(markdown: string, line: Line | undefined): boolean {
|
|
394
|
+
return line?.kind === "fence-open" && /^```+yaml\s*$/.test(markdown.slice(line.start, line.end));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
type Unit = { kind: "paragraph" | "item" | "fence" | "code"; start: number; end: number; visible: number; heading: boolean; lines: Line[] };
|
|
398
|
+
|
|
399
|
+
export function markdownExcerpt(markdown: string, maxChars: number): { text: string; more: boolean } {
|
|
400
|
+
const body = dropTitle(markdown);
|
|
401
|
+
const { units, definitions } = blockUnits(body);
|
|
402
|
+
if (!units.length) return { text: "", more: false };
|
|
403
|
+
for (const each of units) each.visible = visibleLength(body, each, maxChars);
|
|
404
|
+
let taken = 0;
|
|
405
|
+
for (let total = 0; taken < units.length && (taken === 0 || total + units[taken].visible <= maxChars); taken++) total += units[taken].visible;
|
|
406
|
+
if (taken > 1 && units[taken - 1].heading) taken--;
|
|
407
|
+
const first = units[0];
|
|
408
|
+
const { end, closing } = taken === 1 && first.visible > 2 * maxChars ? cutFirstBlock(body, first, maxChars) : { end: units[taken - 1].end, closing: "" };
|
|
409
|
+
const references = definitions.filter((line) => line.start < first.start || line.start >= end).map((line) => body.slice(line.start, line.end));
|
|
410
|
+
const text = body.slice(first.start, end) + closing + (references.length ? `\n\n${references.join("\n")}` : "");
|
|
411
|
+
return { text, more: taken < units.length || end < units[taken - 1].end };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function blockUnits(body: string): { units: Unit[]; definitions: Line[] } {
|
|
415
|
+
const units: Unit[] = [];
|
|
416
|
+
const definitions: Line[] = [];
|
|
417
|
+
let unit: Unit | null = null;
|
|
418
|
+
let afterBlank = false;
|
|
419
|
+
const close = () => {
|
|
420
|
+
if (unit) units.push(unit);
|
|
421
|
+
unit = null;
|
|
422
|
+
};
|
|
423
|
+
for (const line of scanLines(body)) {
|
|
424
|
+
const text = body.slice(line.start, line.end);
|
|
425
|
+
const current = unit as Unit | null;
|
|
426
|
+
const inFence = line.kind === "fence-body" || line.kind === "fence-close";
|
|
427
|
+
const continues = current !== null && (
|
|
428
|
+
(current.kind === "fence" && inFence)
|
|
429
|
+
|| (current.kind === "code" && line.kind === "indented-code")
|
|
430
|
+
|| (current.kind === "item" && !line.topLevelItem && line.kind !== "blank" && (inFence || indentOf(text) >= 2 || (!afterBlank && line.kind === "text" && !line.headingLevel)))
|
|
431
|
+
|| (current.kind === "paragraph" && line.kind === "text" && !line.headingLevel && !line.topLevelItem && !line.definition));
|
|
432
|
+
if (continues) {
|
|
433
|
+
current.end = line.end;
|
|
434
|
+
current.lines.push(line);
|
|
435
|
+
if (current.kind === "fence" && line.kind === "fence-close") close();
|
|
436
|
+
} else if (line.definition) {
|
|
437
|
+
definitions.push(line);
|
|
438
|
+
} else if (line.kind === "blank") {
|
|
439
|
+
if (current?.kind === "paragraph" || current?.kind === "fence") close();
|
|
440
|
+
} else if (line.kind !== "comment") {
|
|
441
|
+
close();
|
|
442
|
+
const kind = line.kind === "fence-open" || inFence ? "fence" : line.kind === "indented-code" ? "code" : line.topLevelItem ? "item" : "paragraph";
|
|
443
|
+
unit = { kind, start: line.start, end: line.end, visible: 0, heading: line.headingLevel > 0, lines: [line] };
|
|
444
|
+
if (line.headingLevel) close();
|
|
445
|
+
}
|
|
446
|
+
afterBlank = line.kind === "blank";
|
|
447
|
+
}
|
|
448
|
+
close();
|
|
449
|
+
if (units.length && isFrontMatter(body, units[0].lines[0])) units.shift();
|
|
450
|
+
return { units, definitions };
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function cutFirstBlock(body: string, first: Unit, maxChars: number): { end: number; closing: string } {
|
|
454
|
+
const counted = first.kind === "fence" ? first.lines.filter((line) => line.kind === "fence-body") : first.lines;
|
|
455
|
+
let kept = 0;
|
|
456
|
+
for (let visible = 0; kept < counted.length; kept++) {
|
|
457
|
+
visible += counted[kept].end - counted[kept].start;
|
|
458
|
+
if (kept && visible > maxChars) break;
|
|
459
|
+
}
|
|
460
|
+
if (kept === counted.length) return { end: first.end, closing: "" };
|
|
461
|
+
const fence = first.kind === "fence" ? /^([ \t>]*)(`{3,}|~{3,})/.exec(body.slice(first.start, first.lines[0].end)) : null;
|
|
462
|
+
return { end: counted[kept - 1].end, closing: fence ? `\n${fence[1]}${fence[2]}` : "" };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function visibleLength(body: string, unit: Unit, maxChars: number): number {
|
|
466
|
+
const text = body.slice(unit.start, unit.end);
|
|
467
|
+
if (unit.kind === "fence" || unit.kind === "code") return (unit.kind === "fence" ? unit.lines.slice(1, -1) : unit.lines).reduce((sum, line) => sum + line.end - line.start, 0);
|
|
468
|
+
if (/!\[[^\]]*\]\(|<img\b/i.test(text)) return maxChars;
|
|
469
|
+
return text
|
|
470
|
+
.replace(/<!--[\s\S]*?-->|<[^>]+>/g, "")
|
|
471
|
+
.replace(/\]\([^)]*\)/g, "]")
|
|
472
|
+
.replace(/^[ \t>]*(?:(?:[-*+]|\d+[.)])[ \t]+(?:\[[ xX]\][ \t]+)?|#{1,6}[ \t]+)?/gm, "")
|
|
473
|
+
.replace(/[*_`~[\]|\\]/g, "").length;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function textExcerpt(text: string, maxChars: number): { text: string; more: boolean } {
|
|
477
|
+
const whole = text.trimEnd();
|
|
478
|
+
if (whole.length <= maxChars) return { text: whole, more: false };
|
|
479
|
+
const line = whole.lastIndexOf("\n", maxChars);
|
|
480
|
+
if (line > 0) return { text: whole.slice(0, line).trimEnd(), more: true };
|
|
481
|
+
const space = whole.lastIndexOf(" ", maxChars);
|
|
482
|
+
return { text: `${whole.slice(0, space > 0 ? space : maxChars)}…`, more: true };
|
|
483
|
+
}
|
package/shared/rpc.ts
CHANGED
|
@@ -142,8 +142,9 @@ export const canOpenOnHost = defineRpc({
|
|
|
142
142
|
output: z.object({ canOpen: z.boolean() }),
|
|
143
143
|
});
|
|
144
144
|
|
|
145
|
-
// An image, text or HTML file's contents, for the preview dialog. A
|
|
146
|
-
// back as its type and size only; its bytes follow in pieces
|
|
145
|
+
// An image, text, Markdown or HTML file's contents, for the preview dialog. A
|
|
146
|
+
// video comes back as its type and size only; its bytes follow in pieces
|
|
147
|
+
// (readDeliverablePiece).
|
|
147
148
|
export const previewDeliverable = defineRpc({
|
|
148
149
|
name: "progress.deliverable.preview",
|
|
149
150
|
input: z.object({
|
|
@@ -156,6 +157,7 @@ export const previewDeliverable = defineRpc({
|
|
|
156
157
|
z.object({ kind: z.literal("image"), dataUri: z.string(), bytes: z.number() }),
|
|
157
158
|
z.object({ kind: z.literal("video"), mimeType: z.string(), bytes: z.number() }),
|
|
158
159
|
z.object({ kind: z.literal("text"), text: z.string(), bytes: z.number(), truncated: z.boolean() }),
|
|
160
|
+
z.object({ kind: z.literal("markdown"), markdown: z.string(), bytes: z.number(), truncated: z.boolean() }),
|
|
159
161
|
z.object({ kind: z.literal("html"), html: z.string(), bytes: z.number() }),
|
|
160
162
|
z.object({ kind: z.literal("folder"), entries: z.array(z.object({ name: z.string(), folder: z.boolean() })), truncated: z.boolean() }),
|
|
161
163
|
]),
|