@cueloop/schema 0.1.0-alpha.8 → 0.1.0-alpha.81
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 +222 -53
- 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 +419 -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 +232 -83
- package/src/participants.test.ts +105 -0
- package/src/participants.ts +51 -0
- package/src/path-view.test.ts +201 -0
- package/src/path-view.ts +85 -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 +58 -5
- package/src/types.ts +282 -24
- package/src/working-copy.test.ts +70 -23
- package/src/working-copy.ts +24 -5
package/src/markdown.ts
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Markdown block model
|
|
3
|
-
*
|
|
2
|
+
* Markdown block model: parse source into addressable blocks that record
|
|
3
|
+
* their source line ranges. Rendered-to-source mapping is block+line
|
|
4
4
|
* granular; the quote carries sub-block precision. Round-trip safe for the
|
|
5
5
|
* subset cueloop plans use; unknown constructs fall back to paragraph blocks
|
|
6
6
|
* so no content is ever lost.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
export type BlockKind =
|
|
9
|
+
export type BlockKind =
|
|
10
|
+
| "h1"
|
|
11
|
+
| "h2"
|
|
12
|
+
| "h3"
|
|
13
|
+
| "p"
|
|
14
|
+
| "li"
|
|
15
|
+
| "oli"
|
|
16
|
+
| "quote"
|
|
17
|
+
| "code"
|
|
18
|
+
| "hr"
|
|
19
|
+
| "table"
|
|
20
|
+
| "frontmatter";
|
|
10
21
|
|
|
11
22
|
export interface Block {
|
|
12
23
|
kind: BlockKind;
|
|
13
|
-
/** Content with markers stripped; code blocks keep inner lines verbatim. */
|
|
24
|
+
/** Content with markers stripped; code, table, and frontmatter blocks keep inner lines verbatim. */
|
|
14
25
|
text: string;
|
|
15
26
|
/** For code blocks: the fence info string ("ts", "diff", ...). */
|
|
16
27
|
lang?: string;
|
|
@@ -19,113 +30,251 @@ export interface Block {
|
|
|
19
30
|
lineEnd: number;
|
|
20
31
|
}
|
|
21
32
|
|
|
22
|
-
|
|
33
|
+
/**
|
|
34
|
+
* The leading block markers the parser strips from a line: headings (h1-h3),
|
|
35
|
+
* bullet list, ordered list, and blockquote. Single source of truth so the
|
|
36
|
+
* anchor resolver strips exactly the same set and the two never drift.
|
|
37
|
+
*/
|
|
38
|
+
export const LEADING_BLOCK_MARKER = /^(?:#{1,3} |- |\d+\. |> )/;
|
|
39
|
+
|
|
40
|
+
/** Remove one leading markdown block marker, or return the line unchanged. */
|
|
41
|
+
export function stripLeadingBlockMarker(line: string): string {
|
|
42
|
+
return line.replace(LEADING_BLOCK_MARKER, "");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A GFM table delimiter row: one or more dash cells split by pipes, each optionally `:`-aligned. */
|
|
46
|
+
function isTableDelimiterRow(line: string): boolean {
|
|
47
|
+
if (!line.includes("|")) return false;
|
|
48
|
+
const cells = line
|
|
49
|
+
.trim()
|
|
50
|
+
.replace(/^\||\|$/g, "")
|
|
51
|
+
.split("|");
|
|
52
|
+
|
|
53
|
+
return cells.length >= 1 && cells.every((cell) => /^\s*:?-+:?\s*$/.test(cell));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A GFM table opens on a header row of pipe cells whose next line is a delimiter row. */
|
|
57
|
+
function isTableStart(lines: string[], index: number): boolean {
|
|
58
|
+
const header = lines[index];
|
|
59
|
+
const delimiter = lines[index + 1];
|
|
60
|
+
|
|
23
61
|
return (
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
l.startsWith("> ") ||
|
|
29
|
-
/^- /.test(l) ||
|
|
30
|
-
/^\d+\. /.test(l) ||
|
|
31
|
-
/^(---|\*\*\*|___)\s*$/.test(l)
|
|
62
|
+
header !== undefined &&
|
|
63
|
+
header.includes("|") &&
|
|
64
|
+
delimiter !== undefined &&
|
|
65
|
+
isTableDelimiterRow(delimiter)
|
|
32
66
|
);
|
|
33
67
|
}
|
|
34
68
|
|
|
35
|
-
|
|
36
|
-
|
|
69
|
+
function isMarkerLine(line: string): boolean {
|
|
70
|
+
return (
|
|
71
|
+
line.startsWith("```") || LEADING_BLOCK_MARKER.test(line) || /^(---|\*\*\*|___)\s*$/.test(line)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Leading YAML frontmatter: a --- fence on the very first line, closed by a
|
|
77
|
+
* second --- fence. Null when there is no leading fence or no closing fence -
|
|
78
|
+
* a lone leading --- then falls through to an hr.
|
|
79
|
+
*/
|
|
80
|
+
function frontmatterBlock(lines: string[]): Block | null {
|
|
81
|
+
if (lines[0]?.trim() !== "---") return null;
|
|
82
|
+
let close = 1;
|
|
83
|
+
|
|
84
|
+
while (close < lines.length && lines[close]!.trim() !== "---") close++;
|
|
85
|
+
if (close >= lines.length) return null;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
kind: "frontmatter",
|
|
89
|
+
text: lines.slice(1, close).join("\n"),
|
|
90
|
+
lineStart: 0,
|
|
91
|
+
lineEnd: close,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A block consumed from a line span, with the next line to resume parsing at. */
|
|
96
|
+
interface BlockScan {
|
|
97
|
+
block: Block;
|
|
98
|
+
next: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** A one-line block - a heading, a rule, a bullet, or an ordered item - at `lineIndex`, or null. */
|
|
102
|
+
function singleLineBlock(line: string, lineIndex: number): Block | null {
|
|
103
|
+
const marked = (kind: BlockKind): Block => ({
|
|
104
|
+
kind,
|
|
105
|
+
text: stripLeadingBlockMarker(line),
|
|
106
|
+
lineStart: lineIndex,
|
|
107
|
+
lineEnd: lineIndex,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (line.startsWith("### ")) return marked("h3");
|
|
111
|
+
if (line.startsWith("## ")) return marked("h2");
|
|
112
|
+
if (line.startsWith("# ")) return marked("h1");
|
|
113
|
+
if (/^(---|\*\*\*|___)\s*$/.test(line)) {
|
|
114
|
+
return { kind: "hr", text: "", lineStart: lineIndex, lineEnd: lineIndex };
|
|
115
|
+
}
|
|
116
|
+
if (line.startsWith("- ")) return marked("li");
|
|
117
|
+
if (/^\d+\. /.test(line)) return marked("oli");
|
|
118
|
+
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Consume a fenced code block from its opening ``` at `start`. */
|
|
123
|
+
function codeBlock(lines: string[], start: number): BlockScan {
|
|
124
|
+
const lang = lines[start]!.slice(3).trim() || undefined;
|
|
125
|
+
let index = start + 1;
|
|
126
|
+
const body: string[] = [];
|
|
127
|
+
|
|
128
|
+
while (index < lines.length && !lines[index]!.startsWith("```")) {
|
|
129
|
+
body.push(lines[index]!);
|
|
130
|
+
index++;
|
|
131
|
+
}
|
|
132
|
+
const end = Math.min(index, lines.length - 1);
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
block: { kind: "code", text: body.join("\n"), lang, lineStart: start, lineEnd: end },
|
|
136
|
+
next: index + 1,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Consume a blockquote's consecutive `> ` lines from `start`. */
|
|
141
|
+
function quoteBlock(lines: string[], start: number): BlockScan {
|
|
142
|
+
let index = start;
|
|
143
|
+
const body: string[] = [];
|
|
144
|
+
|
|
145
|
+
while (index < lines.length && lines[index]!.startsWith("> ")) {
|
|
146
|
+
body.push(stripLeadingBlockMarker(lines[index]!));
|
|
147
|
+
index++;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
block: { kind: "quote", text: body.join("\n"), lineStart: start, lineEnd: index - 1 },
|
|
152
|
+
next: index,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Consume a GFM table (header, delimiter, then pipe rows) from `start`. */
|
|
157
|
+
function tableBlock(lines: string[], start: number): BlockScan {
|
|
158
|
+
const body: string[] = [lines[start]!, lines[start + 1]!];
|
|
159
|
+
let index = start + 2;
|
|
160
|
+
|
|
161
|
+
// body rows run until a blank line, a fence, or a line without a pipe
|
|
162
|
+
while (
|
|
163
|
+
index < lines.length &&
|
|
164
|
+
lines[index]!.trim() !== "" &&
|
|
165
|
+
lines[index]!.includes("|") &&
|
|
166
|
+
!lines[index]!.startsWith("```")
|
|
167
|
+
) {
|
|
168
|
+
body.push(lines[index]!);
|
|
169
|
+
index++;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
block: { kind: "table", text: body.join("\n"), lineStart: start, lineEnd: index - 1 },
|
|
174
|
+
next: index,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Consume a paragraph: consecutive prose lines until a blank line, a marker, or a table. */
|
|
179
|
+
function paragraphBlock(lines: string[], start: number): BlockScan {
|
|
180
|
+
let index = start;
|
|
181
|
+
const body: string[] = [];
|
|
182
|
+
|
|
183
|
+
while (
|
|
184
|
+
index < lines.length &&
|
|
185
|
+
lines[index]!.trim() !== "" &&
|
|
186
|
+
!isMarkerLine(lines[index]!) &&
|
|
187
|
+
!isTableStart(lines, index)
|
|
188
|
+
) {
|
|
189
|
+
body.push(lines[index]!);
|
|
190
|
+
index++;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
block: { kind: "p", text: body.join("\n"), lineStart: start, lineEnd: index - 1 },
|
|
195
|
+
next: index,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The next block starting at `lineIndex` (never a blank line, never frontmatter). */
|
|
200
|
+
function nextBlock(lines: string[], lineIndex: number): BlockScan {
|
|
201
|
+
const line = lines[lineIndex]!;
|
|
202
|
+
|
|
203
|
+
if (line.startsWith("```")) return codeBlock(lines, lineIndex);
|
|
204
|
+
const single = singleLineBlock(line, lineIndex);
|
|
205
|
+
|
|
206
|
+
if (single) return { block: single, next: lineIndex + 1 };
|
|
207
|
+
if (line.startsWith("> ")) return quoteBlock(lines, lineIndex);
|
|
208
|
+
if (isTableStart(lines, lineIndex)) return tableBlock(lines, lineIndex);
|
|
209
|
+
|
|
210
|
+
return paragraphBlock(lines, lineIndex);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function parseBlocks(markdown: string): Block[] {
|
|
214
|
+
const lines = markdown.split("\n");
|
|
37
215
|
const blocks: Block[] = [];
|
|
38
|
-
let
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
216
|
+
let lineIndex = 0;
|
|
217
|
+
const frontmatter = frontmatterBlock(lines);
|
|
218
|
+
|
|
219
|
+
if (frontmatter) {
|
|
220
|
+
blocks.push(frontmatter);
|
|
221
|
+
lineIndex = frontmatter.lineEnd + 1;
|
|
222
|
+
}
|
|
223
|
+
while (lineIndex < lines.length) {
|
|
224
|
+
if (lines[lineIndex]!.trim() === "") {
|
|
225
|
+
lineIndex++;
|
|
43
226
|
continue;
|
|
44
227
|
}
|
|
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
|
-
}
|
|
228
|
+
const scan = nextBlock(lines, lineIndex);
|
|
229
|
+
|
|
230
|
+
blocks.push(scan.block);
|
|
231
|
+
lineIndex = scan.next;
|
|
92
232
|
}
|
|
233
|
+
|
|
93
234
|
return blocks;
|
|
94
235
|
}
|
|
95
236
|
|
|
96
237
|
/** Serialize one block back to its markdown chunk. */
|
|
97
|
-
export function blockToMd(
|
|
98
|
-
switch (
|
|
238
|
+
export function blockToMd(block: Block, ordinal = 1): string {
|
|
239
|
+
switch (block.kind) {
|
|
99
240
|
case "h1":
|
|
100
|
-
return "# " +
|
|
241
|
+
return "# " + block.text;
|
|
101
242
|
case "h2":
|
|
102
|
-
return "## " +
|
|
243
|
+
return "## " + block.text;
|
|
103
244
|
case "h3":
|
|
104
|
-
return "### " +
|
|
245
|
+
return "### " + block.text;
|
|
105
246
|
case "li":
|
|
106
|
-
return "- " +
|
|
247
|
+
return "- " + block.text;
|
|
107
248
|
case "oli":
|
|
108
|
-
return `${ordinal}. ` +
|
|
249
|
+
return `${ordinal}. ` + block.text;
|
|
109
250
|
case "quote":
|
|
110
|
-
return
|
|
251
|
+
return block.text
|
|
111
252
|
.split("\n")
|
|
112
|
-
.map((
|
|
253
|
+
.map((line) => "> " + line)
|
|
113
254
|
.join("\n");
|
|
114
255
|
case "code":
|
|
115
|
-
return "```" + (
|
|
256
|
+
return "```" + (block.lang ?? "") + "\n" + block.text + "\n```";
|
|
116
257
|
case "hr":
|
|
117
258
|
return "---";
|
|
259
|
+
case "frontmatter":
|
|
260
|
+
return "---\n" + block.text + "\n---";
|
|
261
|
+
case "table":
|
|
262
|
+
return block.text;
|
|
118
263
|
default:
|
|
119
|
-
return
|
|
264
|
+
return block.text;
|
|
120
265
|
}
|
|
121
266
|
}
|
|
122
267
|
|
|
123
268
|
/** The section (nearest preceding heading) a block belongs to. */
|
|
124
269
|
export function sectionOf(blocks: Block[], index: number): string {
|
|
125
|
-
let
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
270
|
+
let sectionTitle = "";
|
|
271
|
+
|
|
272
|
+
for (let blockIndex = 0; blockIndex <= index && blockIndex < blocks.length; blockIndex++) {
|
|
273
|
+
const block = blocks[blockIndex]!;
|
|
274
|
+
|
|
275
|
+
if (block.kind === "h1" || block.kind === "h2" || block.kind === "h3")
|
|
276
|
+
sectionTitle = block.text;
|
|
129
277
|
}
|
|
130
|
-
|
|
278
|
+
|
|
279
|
+
return sectionTitle;
|
|
131
280
|
}
|
|
@@ -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
|
+
}
|