@cueloop/schema 0.1.0-alpha.9 → 0.1.0-alpha.91
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/package.json +17 -17
- package/src/anchor.test.ts +340 -40
- package/src/anchor.ts +378 -43
- package/src/diff.test.ts +42 -25
- package/src/diff.ts +112 -77
- package/src/feedback.test.ts +318 -41
- package/src/feedback.ts +224 -54
- package/src/fuzzy.test.ts +110 -0
- package/src/fuzzy.ts +132 -0
- package/src/herdr.test.ts +50 -0
- package/src/herdr.ts +54 -0
- package/src/history.test.ts +401 -0
- package/src/history.ts +425 -0
- package/src/index.ts +9 -0
- package/src/inline.test.ts +195 -0
- package/src/inline.ts +240 -0
- package/src/markdown.test.ts +138 -22
- package/src/markdown.ts +333 -84
- package/src/participants.test.ts +105 -0
- package/src/participants.ts +51 -0
- package/src/path-view.test.ts +206 -0
- package/src/path-view.ts +90 -0
- package/src/share-access.test.ts +20 -0
- package/src/share-access.ts +13 -0
- package/src/share-links.test.ts +63 -0
- package/src/share-links.ts +29 -0
- package/src/thread-surface.test.ts +6 -0
- package/src/thread-surface.ts +6 -0
- package/src/types.test.ts +66 -5
- package/src/types.ts +346 -24
- package/src/working-copy.test.ts +209 -24
- package/src/working-copy.ts +148 -12
package/src/markdown.ts
CHANGED
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Markdown block model
|
|
3
|
-
*
|
|
4
|
-
* granular; the quote carries sub-block precision. Round-trip safe for the
|
|
2
|
+
* Markdown block model: parse source into addressable blocks with source line
|
|
3
|
+
* ranges and project their text offsets back to source on demand. Round-trip safe for the
|
|
5
4
|
* subset cueloop plans use; unknown constructs fall back to paragraph blocks
|
|
6
5
|
* so no content is ever lost.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
|
-
export type BlockKind =
|
|
8
|
+
export type BlockKind =
|
|
9
|
+
| "h1"
|
|
10
|
+
| "h2"
|
|
11
|
+
| "h3"
|
|
12
|
+
| "p"
|
|
13
|
+
| "li"
|
|
14
|
+
| "oli"
|
|
15
|
+
| "quote"
|
|
16
|
+
| "code"
|
|
17
|
+
| "hr"
|
|
18
|
+
| "table"
|
|
19
|
+
| "frontmatter";
|
|
10
20
|
|
|
11
21
|
export interface Block {
|
|
12
22
|
kind: BlockKind;
|
|
13
|
-
/** Content with markers stripped; code blocks keep inner lines verbatim. */
|
|
23
|
+
/** Content with markers stripped; code, table, and frontmatter blocks keep inner lines verbatim. */
|
|
14
24
|
text: string;
|
|
15
25
|
/** For code blocks: the fence info string ("ts", "diff", ...). */
|
|
16
26
|
lang?: string;
|
|
@@ -19,113 +29,352 @@ export interface Block {
|
|
|
19
29
|
lineEnd: number;
|
|
20
30
|
}
|
|
21
31
|
|
|
22
|
-
|
|
32
|
+
export interface BlockTextSourceSegment {
|
|
33
|
+
textStart: number;
|
|
34
|
+
textEnd: number;
|
|
35
|
+
sourceStart: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The leading block markers the parser strips from a line: headings (h1-h3),
|
|
40
|
+
* bullet list, ordered list, and blockquote. Single source of truth so the
|
|
41
|
+
* anchor resolver strips exactly the same set and the two never drift.
|
|
42
|
+
*/
|
|
43
|
+
export const LEADING_BLOCK_MARKER = /^(?:#{1,3} |- |\d+\. |> )/;
|
|
44
|
+
|
|
45
|
+
/** Remove one leading markdown block marker, or return the line unchanged. */
|
|
46
|
+
export function stripLeadingBlockMarker(line: string): string {
|
|
47
|
+
return line.replace(LEADING_BLOCK_MARKER, "");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** A GFM table delimiter row: one or more dash cells split by pipes, each optionally `:`-aligned. */
|
|
51
|
+
function isTableDelimiterRow(line: string): boolean {
|
|
52
|
+
if (!line.includes("|")) return false;
|
|
53
|
+
const cells = line
|
|
54
|
+
.trim()
|
|
55
|
+
.replace(/^\||\|$/g, "")
|
|
56
|
+
.split("|");
|
|
57
|
+
|
|
58
|
+
return cells.length >= 1 && cells.every((cell) => /^\s*:?-+:?\s*$/.test(cell));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A GFM table opens on a header row of pipe cells whose next line is a delimiter row. */
|
|
62
|
+
function isTableStart(lines: string[], index: number): boolean {
|
|
63
|
+
const header = lines[index];
|
|
64
|
+
const delimiter = lines[index + 1];
|
|
65
|
+
|
|
23
66
|
return (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
l.startsWith("> ") ||
|
|
29
|
-
/^- /.test(l) ||
|
|
30
|
-
/^\d+\. /.test(l) ||
|
|
31
|
-
/^(---|\*\*\*|___)\s*$/.test(l)
|
|
67
|
+
header !== undefined &&
|
|
68
|
+
header.includes("|") &&
|
|
69
|
+
delimiter !== undefined &&
|
|
70
|
+
isTableDelimiterRow(delimiter)
|
|
32
71
|
);
|
|
33
72
|
}
|
|
34
73
|
|
|
35
|
-
|
|
36
|
-
|
|
74
|
+
function isMarkerLine(line: string): boolean {
|
|
75
|
+
return (
|
|
76
|
+
line.startsWith("```") || LEADING_BLOCK_MARKER.test(line) || /^(---|\*\*\*|___)\s*$/.test(line)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Leading YAML frontmatter: a --- fence on the very first line, closed by a
|
|
82
|
+
* second --- fence. Null when there is no leading fence or no closing fence -
|
|
83
|
+
* a lone leading --- then falls through to an hr.
|
|
84
|
+
*/
|
|
85
|
+
function frontmatterBlock(lines: string[]): Block | null {
|
|
86
|
+
if (lines[0]?.trim() !== "---") return null;
|
|
87
|
+
let close = 1;
|
|
88
|
+
|
|
89
|
+
while (close < lines.length && lines[close]!.trim() !== "---") close++;
|
|
90
|
+
if (close >= lines.length) return null;
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
kind: "frontmatter",
|
|
94
|
+
text: lines.slice(1, close).join("\n"),
|
|
95
|
+
lineStart: 0,
|
|
96
|
+
lineEnd: close,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A block consumed from a line span, with the next line to resume parsing at. */
|
|
101
|
+
interface BlockScan {
|
|
102
|
+
block: Block;
|
|
103
|
+
next: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** A one-line block - a heading, a rule, a bullet, or an ordered item - at `lineIndex`, or null. */
|
|
107
|
+
function singleLineBlock(line: string, lineIndex: number): Block | null {
|
|
108
|
+
const marked = (kind: BlockKind): Block => ({
|
|
109
|
+
kind,
|
|
110
|
+
text: stripLeadingBlockMarker(line),
|
|
111
|
+
lineStart: lineIndex,
|
|
112
|
+
lineEnd: lineIndex,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
if (line.startsWith("### ")) return marked("h3");
|
|
116
|
+
if (line.startsWith("## ")) return marked("h2");
|
|
117
|
+
if (line.startsWith("# ")) return marked("h1");
|
|
118
|
+
if (/^(---|\*\*\*|___)\s*$/.test(line)) {
|
|
119
|
+
return { kind: "hr", text: "", lineStart: lineIndex, lineEnd: lineIndex };
|
|
120
|
+
}
|
|
121
|
+
if (line.startsWith("- ")) return marked("li");
|
|
122
|
+
if (/^\d+\. /.test(line)) return marked("oli");
|
|
123
|
+
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Consume a fenced code block from its opening ``` at `start`. */
|
|
128
|
+
function codeBlock(lines: string[], start: number): BlockScan {
|
|
129
|
+
const lang = lines[start]!.slice(3).trim() || undefined;
|
|
130
|
+
let index = start + 1;
|
|
131
|
+
const body: string[] = [];
|
|
132
|
+
|
|
133
|
+
while (index < lines.length && !lines[index]!.startsWith("```")) {
|
|
134
|
+
body.push(lines[index]!);
|
|
135
|
+
index++;
|
|
136
|
+
}
|
|
137
|
+
const end = Math.min(index, lines.length - 1);
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
block: { kind: "code", text: body.join("\n"), lang, lineStart: start, lineEnd: end },
|
|
141
|
+
next: index + 1,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Consume a blockquote's consecutive `> ` lines from `start`. */
|
|
146
|
+
function quoteBlock(lines: string[], start: number): BlockScan {
|
|
147
|
+
let index = start;
|
|
148
|
+
const body: string[] = [];
|
|
149
|
+
|
|
150
|
+
while (index < lines.length && lines[index]!.startsWith("> ")) {
|
|
151
|
+
body.push(stripLeadingBlockMarker(lines[index]!));
|
|
152
|
+
index++;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
block: { kind: "quote", text: body.join("\n"), lineStart: start, lineEnd: index - 1 },
|
|
157
|
+
next: index,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Consume a GFM table (header, delimiter, then pipe rows) from `start`. */
|
|
162
|
+
function tableBlock(lines: string[], start: number): BlockScan {
|
|
163
|
+
const body: string[] = [lines[start]!, lines[start + 1]!];
|
|
164
|
+
let index = start + 2;
|
|
165
|
+
|
|
166
|
+
// body rows run until a blank line, a fence, or a line without a pipe
|
|
167
|
+
while (
|
|
168
|
+
index < lines.length &&
|
|
169
|
+
lines[index]!.trim() !== "" &&
|
|
170
|
+
lines[index]!.includes("|") &&
|
|
171
|
+
!lines[index]!.startsWith("```")
|
|
172
|
+
) {
|
|
173
|
+
body.push(lines[index]!);
|
|
174
|
+
index++;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
block: { kind: "table", text: body.join("\n"), lineStart: start, lineEnd: index - 1 },
|
|
179
|
+
next: index,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Consume a paragraph: consecutive prose lines until a blank line, a marker, or a table. */
|
|
184
|
+
function paragraphBlock(lines: string[], start: number): BlockScan {
|
|
185
|
+
let index = start;
|
|
186
|
+
const body: string[] = [];
|
|
187
|
+
|
|
188
|
+
while (
|
|
189
|
+
index < lines.length &&
|
|
190
|
+
lines[index]!.trim() !== "" &&
|
|
191
|
+
!isMarkerLine(lines[index]!) &&
|
|
192
|
+
!isTableStart(lines, index)
|
|
193
|
+
) {
|
|
194
|
+
body.push(lines[index]!);
|
|
195
|
+
index++;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
block: { kind: "p", text: body.join("\n"), lineStart: start, lineEnd: index - 1 },
|
|
200
|
+
next: index,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The next block starting at `lineIndex` (never a blank line, never frontmatter). */
|
|
205
|
+
function nextBlock(lines: string[], lineIndex: number): BlockScan {
|
|
206
|
+
const line = lines[lineIndex]!;
|
|
207
|
+
|
|
208
|
+
if (line.startsWith("```")) return codeBlock(lines, lineIndex);
|
|
209
|
+
const single = singleLineBlock(line, lineIndex);
|
|
210
|
+
|
|
211
|
+
if (single) return { block: single, next: lineIndex + 1 };
|
|
212
|
+
if (line.startsWith("> ")) return quoteBlock(lines, lineIndex);
|
|
213
|
+
if (isTableStart(lines, lineIndex)) return tableBlock(lines, lineIndex);
|
|
214
|
+
|
|
215
|
+
return paragraphBlock(lines, lineIndex);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Project marker-free block text boundaries onto the source consumed by the parser. */
|
|
219
|
+
function blockSourceSegments(
|
|
220
|
+
sourceLines: string[],
|
|
221
|
+
block: Block,
|
|
222
|
+
sourceStart = sourceLines
|
|
223
|
+
.slice(0, block.lineStart)
|
|
224
|
+
.reduce((offset, line) => offset + line.length + 1, 0),
|
|
225
|
+
): BlockTextSourceSegment[] | undefined {
|
|
226
|
+
const rawLines = sourceLines.slice(block.lineStart, block.lineEnd + 1);
|
|
227
|
+
let contentLines: Array<{ rawLine: number; prefix: number }>;
|
|
228
|
+
|
|
229
|
+
if (block.kind === "code") {
|
|
230
|
+
const hasClosingFence = rawLines.at(-1)?.startsWith("```") ?? false;
|
|
231
|
+
const end = hasClosingFence ? rawLines.length - 1 : rawLines.length;
|
|
232
|
+
|
|
233
|
+
contentLines = rawLines.slice(1, end).map((_, index) => ({ rawLine: index + 1, prefix: 0 }));
|
|
234
|
+
} else if (block.kind === "frontmatter") {
|
|
235
|
+
contentLines = rawLines.slice(1, -1).map((_, index) => ({ rawLine: index + 1, prefix: 0 }));
|
|
236
|
+
} else if (block.kind === "quote") {
|
|
237
|
+
contentLines = rawLines.map((_, rawLine) => ({ rawLine, prefix: 2 }));
|
|
238
|
+
} else if (
|
|
239
|
+
block.kind === "h1" ||
|
|
240
|
+
block.kind === "h2" ||
|
|
241
|
+
block.kind === "h3" ||
|
|
242
|
+
block.kind === "li" ||
|
|
243
|
+
block.kind === "oli"
|
|
244
|
+
) {
|
|
245
|
+
contentLines = [{ rawLine: 0, prefix: rawLines[0]!.length - block.text.length }];
|
|
246
|
+
} else {
|
|
247
|
+
contentLines = rawLines.map((_, rawLine) => ({ rawLine, prefix: 0 }));
|
|
248
|
+
}
|
|
249
|
+
const content = contentLines
|
|
250
|
+
.map(({ rawLine, prefix }) => rawLines[rawLine]!.slice(prefix))
|
|
251
|
+
.join("\n");
|
|
252
|
+
|
|
253
|
+
if (content !== block.text) return undefined;
|
|
254
|
+
const rawLineStarts: number[] = [];
|
|
255
|
+
let rawOffset = 0;
|
|
256
|
+
|
|
257
|
+
for (const line of rawLines) {
|
|
258
|
+
rawLineStarts.push(rawOffset);
|
|
259
|
+
rawOffset += line.length + 1;
|
|
260
|
+
}
|
|
261
|
+
let textOffset = 0;
|
|
262
|
+
const segments = contentLines.map(({ rawLine, prefix }) => {
|
|
263
|
+
const line = rawLines[rawLine]!.slice(prefix);
|
|
264
|
+
const lineStart = sourceStart + rawLineStarts[rawLine]! + prefix;
|
|
265
|
+
const segment = {
|
|
266
|
+
textStart: textOffset,
|
|
267
|
+
textEnd: textOffset + line.length,
|
|
268
|
+
sourceStart: lineStart,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
textOffset += line.length + 1;
|
|
272
|
+
|
|
273
|
+
return segment;
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
return segments;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Project several parsed blocks onto source offsets without splitting the Markdown per block. */
|
|
280
|
+
export function blockTextSourceSegments(
|
|
281
|
+
markdown: string,
|
|
282
|
+
blocks: readonly Block[],
|
|
283
|
+
): Array<readonly BlockTextSourceSegment[] | undefined> {
|
|
284
|
+
const sourceLines = markdown.split("\n");
|
|
285
|
+
const lineStarts: number[] = [];
|
|
286
|
+
let sourceOffset = 0;
|
|
287
|
+
|
|
288
|
+
for (const line of sourceLines) {
|
|
289
|
+
lineStarts.push(sourceOffset);
|
|
290
|
+
sourceOffset += line.length + 1;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return blocks.map((block) =>
|
|
294
|
+
blockSourceSegments(sourceLines, block, lineStarts[block.lineStart] ?? sourceOffset),
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Resolve a marker-free block-text boundary through the parser's source projection. */
|
|
299
|
+
export function sourceOffsetAt(
|
|
300
|
+
markdown: string,
|
|
301
|
+
block: Block,
|
|
302
|
+
textOffset: number,
|
|
303
|
+
): number | undefined {
|
|
304
|
+
for (const segment of blockSourceSegments(markdown.split("\n"), block) ?? []) {
|
|
305
|
+
if (textOffset < segment.textStart || textOffset > segment.textEnd) continue;
|
|
306
|
+
|
|
307
|
+
return segment.sourceStart + textOffset - segment.textStart;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function parseBlocks(markdown: string): Block[] {
|
|
314
|
+
const lines = markdown.split("\n");
|
|
37
315
|
const blocks: Block[] = [];
|
|
38
|
-
let
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
316
|
+
let lineIndex = 0;
|
|
317
|
+
const frontmatter = frontmatterBlock(lines);
|
|
318
|
+
|
|
319
|
+
if (frontmatter) {
|
|
320
|
+
blocks.push(frontmatter);
|
|
321
|
+
lineIndex = frontmatter.lineEnd + 1;
|
|
322
|
+
}
|
|
323
|
+
while (lineIndex < lines.length) {
|
|
324
|
+
if (lines[lineIndex]!.trim() === "") {
|
|
325
|
+
lineIndex++;
|
|
43
326
|
continue;
|
|
44
327
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const body: string[] = [];
|
|
50
|
-
while (i < lines.length && !lines[i]!.startsWith("```")) {
|
|
51
|
-
body.push(lines[i]!);
|
|
52
|
-
i++;
|
|
53
|
-
}
|
|
54
|
-
const end = Math.min(i, lines.length - 1);
|
|
55
|
-
i++;
|
|
56
|
-
blocks.push({ kind: "code", text: body.join("\n"), lang, lineStart: start, lineEnd: end });
|
|
57
|
-
} else if (l.startsWith("### ")) {
|
|
58
|
-
blocks.push({ kind: "h3", text: l.slice(4), lineStart: i, lineEnd: i });
|
|
59
|
-
i++;
|
|
60
|
-
} else if (l.startsWith("## ")) {
|
|
61
|
-
blocks.push({ kind: "h2", text: l.slice(3), lineStart: i, lineEnd: i });
|
|
62
|
-
i++;
|
|
63
|
-
} else if (l.startsWith("# ")) {
|
|
64
|
-
blocks.push({ kind: "h1", text: l.slice(2), lineStart: i, lineEnd: i });
|
|
65
|
-
i++;
|
|
66
|
-
} else if (/^(---|\*\*\*|___)\s*$/.test(l)) {
|
|
67
|
-
blocks.push({ kind: "hr", text: "", lineStart: i, lineEnd: i });
|
|
68
|
-
i++;
|
|
69
|
-
} else if (l.startsWith("> ")) {
|
|
70
|
-
const start = i;
|
|
71
|
-
const body: string[] = [];
|
|
72
|
-
while (i < lines.length && lines[i]!.startsWith("> ")) {
|
|
73
|
-
body.push(lines[i]!.slice(2));
|
|
74
|
-
i++;
|
|
75
|
-
}
|
|
76
|
-
blocks.push({ kind: "quote", text: body.join("\n"), lineStart: start, lineEnd: i - 1 });
|
|
77
|
-
} else if (/^- /.test(l)) {
|
|
78
|
-
blocks.push({ kind: "li", text: l.slice(2), lineStart: i, lineEnd: i });
|
|
79
|
-
i++;
|
|
80
|
-
} else if (/^\d+\. /.test(l)) {
|
|
81
|
-
blocks.push({ kind: "oli", text: l.replace(/^\d+\. /, ""), lineStart: i, lineEnd: i });
|
|
82
|
-
i++;
|
|
83
|
-
} else {
|
|
84
|
-
const start = i;
|
|
85
|
-
const body: string[] = [];
|
|
86
|
-
while (i < lines.length && lines[i]!.trim() !== "" && !isMarkerLine(lines[i]!)) {
|
|
87
|
-
body.push(lines[i]!);
|
|
88
|
-
i++;
|
|
89
|
-
}
|
|
90
|
-
blocks.push({ kind: "p", text: body.join("\n"), lineStart: start, lineEnd: i - 1 });
|
|
91
|
-
}
|
|
328
|
+
const scan = nextBlock(lines, lineIndex);
|
|
329
|
+
|
|
330
|
+
blocks.push(scan.block);
|
|
331
|
+
lineIndex = scan.next;
|
|
92
332
|
}
|
|
333
|
+
|
|
93
334
|
return blocks;
|
|
94
335
|
}
|
|
95
336
|
|
|
96
337
|
/** Serialize one block back to its markdown chunk. */
|
|
97
|
-
export function blockToMd(
|
|
98
|
-
switch (
|
|
338
|
+
export function blockToMd(block: Block, ordinal = 1): string {
|
|
339
|
+
switch (block.kind) {
|
|
99
340
|
case "h1":
|
|
100
|
-
return "# " +
|
|
341
|
+
return "# " + block.text;
|
|
101
342
|
case "h2":
|
|
102
|
-
return "## " +
|
|
343
|
+
return "## " + block.text;
|
|
103
344
|
case "h3":
|
|
104
|
-
return "### " +
|
|
345
|
+
return "### " + block.text;
|
|
105
346
|
case "li":
|
|
106
|
-
return "- " +
|
|
347
|
+
return "- " + block.text;
|
|
107
348
|
case "oli":
|
|
108
|
-
return `${ordinal}. ` +
|
|
349
|
+
return `${ordinal}. ` + block.text;
|
|
109
350
|
case "quote":
|
|
110
|
-
return
|
|
351
|
+
return block.text
|
|
111
352
|
.split("\n")
|
|
112
|
-
.map((
|
|
353
|
+
.map((line) => "> " + line)
|
|
113
354
|
.join("\n");
|
|
114
355
|
case "code":
|
|
115
|
-
return "```" + (
|
|
356
|
+
return "```" + (block.lang ?? "") + "\n" + block.text + "\n```";
|
|
116
357
|
case "hr":
|
|
117
358
|
return "---";
|
|
359
|
+
case "frontmatter":
|
|
360
|
+
return "---\n" + block.text + "\n---";
|
|
361
|
+
case "table":
|
|
362
|
+
return block.text;
|
|
118
363
|
default:
|
|
119
|
-
return
|
|
364
|
+
return block.text;
|
|
120
365
|
}
|
|
121
366
|
}
|
|
122
367
|
|
|
123
368
|
/** The section (nearest preceding heading) a block belongs to. */
|
|
124
369
|
export function sectionOf(blocks: Block[], index: number): string {
|
|
125
|
-
let
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
370
|
+
let sectionTitle = "";
|
|
371
|
+
|
|
372
|
+
for (let blockIndex = 0; blockIndex <= index && blockIndex < blocks.length; blockIndex++) {
|
|
373
|
+
const block = blocks[blockIndex]!;
|
|
374
|
+
|
|
375
|
+
if (block.kind === "h1" || block.kind === "h2" || block.kind === "h3")
|
|
376
|
+
sectionTitle = block.text;
|
|
129
377
|
}
|
|
130
|
-
|
|
378
|
+
|
|
379
|
+
return sectionTitle;
|
|
131
380
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { registerParticipant, registeredGithubLogin } from "./participants";
|
|
3
|
+
import { SCHEMA_VERSION, type Thread } from "./types";
|
|
4
|
+
|
|
5
|
+
function emptySession(): Thread {
|
|
6
|
+
return {
|
|
7
|
+
schemaVersion: SCHEMA_VERSION,
|
|
8
|
+
id: "ses_1",
|
|
9
|
+
workspace: { repoRoot: "/repo", branch: "main" },
|
|
10
|
+
artifact: { type: "plan", content: "# Plan\n", meta: {} },
|
|
11
|
+
revisions: [],
|
|
12
|
+
annotations: [],
|
|
13
|
+
message: null,
|
|
14
|
+
status: "pending",
|
|
15
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("registerParticipant", () => {
|
|
20
|
+
test("adds a new author with a display name", () => {
|
|
21
|
+
// Act
|
|
22
|
+
const next = registerParticipant(emptySession(), "SHA256:ana", "Ana");
|
|
23
|
+
|
|
24
|
+
// Assert
|
|
25
|
+
expect(next.participants).toEqual([{ id: "SHA256:ana", provider: "ssh", name: "Ana" }]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("records presence without a name (renders anonymous)", () => {
|
|
29
|
+
// Act
|
|
30
|
+
const next = registerParticipant(emptySession(), "SHA256:ana");
|
|
31
|
+
|
|
32
|
+
// Assert
|
|
33
|
+
expect(next.participants).toEqual([{ id: "SHA256:ana", provider: "ssh" }]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a nameless revisit never erases a name a past visit set", () => {
|
|
37
|
+
// Arrange
|
|
38
|
+
const named = registerParticipant(emptySession(), "SHA256:ana", "Ana");
|
|
39
|
+
|
|
40
|
+
// Act
|
|
41
|
+
const next = registerParticipant(named, "SHA256:ana");
|
|
42
|
+
|
|
43
|
+
// Assert
|
|
44
|
+
expect(next).toBe(named);
|
|
45
|
+
expect(next.participants).toEqual([{ id: "SHA256:ana", provider: "ssh", name: "Ana" }]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("a later name updates the existing identity in place", () => {
|
|
49
|
+
// Arrange
|
|
50
|
+
const present = registerParticipant(emptySession(), "SHA256:ana");
|
|
51
|
+
|
|
52
|
+
// Act
|
|
53
|
+
const next = registerParticipant(present, "SHA256:ana", "Ana");
|
|
54
|
+
|
|
55
|
+
// Assert
|
|
56
|
+
expect(next.participants).toEqual([{ id: "SHA256:ana", provider: "ssh", name: "Ana" }]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("records a verified github source with its login handle", () => {
|
|
60
|
+
// Act
|
|
61
|
+
const next = registerParticipant(emptySession(), "SHA256:ana", "Ana", {
|
|
62
|
+
provider: "github",
|
|
63
|
+
handle: "ana",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Assert
|
|
67
|
+
expect(next.participants).toEqual([
|
|
68
|
+
{ id: "SHA256:ana", provider: "github", name: "Ana", handle: "ana" },
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("a later nameless write preserves the github provider and handle", () => {
|
|
73
|
+
// Arrange
|
|
74
|
+
const verified = registerParticipant(emptySession(), "SHA256:ana", "Ana", {
|
|
75
|
+
provider: "github",
|
|
76
|
+
handle: "ana",
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Act
|
|
80
|
+
const next = registerParticipant(verified, "SHA256:ana");
|
|
81
|
+
|
|
82
|
+
// Assert
|
|
83
|
+
expect(next.participants).toEqual([
|
|
84
|
+
{ id: "SHA256:ana", provider: "github", name: "Ana", handle: "ana" },
|
|
85
|
+
]);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("registeredGithubLogin", () => {
|
|
90
|
+
test("returns the handle a fingerprint verified on a past visit", () => {
|
|
91
|
+
const session = registerParticipant(emptySession(), "SHA256:ana", "Ana", {
|
|
92
|
+
provider: "github",
|
|
93
|
+
handle: "ana-gh",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(registeredGithubLogin(session, "SHA256:ana")).toBe("ana-gh");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("returns undefined for an ssh-only or unknown participant", () => {
|
|
100
|
+
const session = registerParticipant(emptySession(), "SHA256:ana", "Ana");
|
|
101
|
+
|
|
102
|
+
expect(registeredGithubLogin(session, "SHA256:ana")).toBeUndefined();
|
|
103
|
+
expect(registeredGithubLogin(session, "SHA256:unknown")).toBeUndefined();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The participant registry: every annotation carries an `author` id, and the
|
|
3
|
+
* rail resolves that id to a display name through `session.participants`. This
|
|
4
|
+
* registers an author in that registry so a name search on "participant" or
|
|
5
|
+
* "author name" lands here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Identity, Thread } from "./types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Return the session with `author` present in the participant registry, setting
|
|
12
|
+
* the display name when one is given. A nameless call only records presence (the
|
|
13
|
+
* rail renders anonymous) and never erases a name a past visit set. Immutable:
|
|
14
|
+
* the input session is not mutated.
|
|
15
|
+
*/
|
|
16
|
+
export interface ParticipantSource {
|
|
17
|
+
provider: "ssh" | "github";
|
|
18
|
+
handle?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function registeredGithubLogin(session: Thread, author: string): string | undefined {
|
|
22
|
+
const existing = (session.participants ?? []).find((participant) => participant.id === author);
|
|
23
|
+
|
|
24
|
+
return existing?.provider === "github" ? existing.handle : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function registerParticipant(
|
|
28
|
+
session: Thread,
|
|
29
|
+
author: string,
|
|
30
|
+
name?: string,
|
|
31
|
+
source?: ParticipantSource,
|
|
32
|
+
): Thread {
|
|
33
|
+
const participants = session.participants ?? [];
|
|
34
|
+
const existing = participants.find((participant) => participant.id === author);
|
|
35
|
+
const trimmed = name?.trim();
|
|
36
|
+
|
|
37
|
+
if (existing && !trimmed && !source) return session;
|
|
38
|
+
const resolvedName = trimmed ?? existing?.name;
|
|
39
|
+
const resolvedHandle = source?.handle ?? existing?.handle;
|
|
40
|
+
const next: Identity = { id: author, provider: source?.provider ?? existing?.provider ?? "ssh" };
|
|
41
|
+
|
|
42
|
+
if (resolvedName) next.name = resolvedName;
|
|
43
|
+
if (resolvedHandle) next.handle = resolvedHandle;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
...session,
|
|
47
|
+
participants: existing
|
|
48
|
+
? participants.map((participant) => (participant.id === author ? next : participant))
|
|
49
|
+
: [...participants, next],
|
|
50
|
+
};
|
|
51
|
+
}
|