@cueloop/schema 0.1.0-alpha.78 → 0.1.0-alpha.79
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 +1 -1
- package/src/index.ts +1 -0
- package/src/markdown.test.ts +63 -2
- package/src/markdown.ts +188 -90
- package/src/share-links.test.ts +63 -0
- package/src/share-links.ts +29 -0
- package/src/types.ts +27 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cueloop/schema",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.79",
|
|
4
4
|
"description": "The Thread primitive: types, markdown block model, quote-primary anchors, feedback serialization",
|
|
5
5
|
"homepage": "https://github.com/mmurakaru/cueloop#readme",
|
|
6
6
|
"bugs": {
|
package/src/index.ts
CHANGED
package/src/markdown.test.ts
CHANGED
|
@@ -102,13 +102,74 @@ describe("parseBlocks", () => {
|
|
|
102
102
|
|
|
103
103
|
test("unknown constructs degrade to paragraphs, no content lost", () => {
|
|
104
104
|
// Arrange
|
|
105
|
-
const md = "
|
|
105
|
+
const md = "<div>raw html</div>";
|
|
106
106
|
|
|
107
107
|
// Act
|
|
108
108
|
const blocks = parseBlocks(md);
|
|
109
109
|
|
|
110
110
|
// Assert
|
|
111
|
-
expect(blocks.map((block) => block.
|
|
111
|
+
expect(blocks.map((block) => block.kind)).toEqual(["p"]);
|
|
112
|
+
expect(blocks[0]!.text).toBe("<div>raw html</div>");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a GFM table parses to one table block that keeps its source verbatim", () => {
|
|
116
|
+
// Arrange
|
|
117
|
+
const md = "before\n\n| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n\nafter";
|
|
118
|
+
|
|
119
|
+
// Act
|
|
120
|
+
const blocks = parseBlocks(md);
|
|
121
|
+
|
|
122
|
+
// Assert
|
|
123
|
+
expect(blocks.map((block) => block.kind)).toEqual(["p", "table", "p"]);
|
|
124
|
+
const table = blocks.find((block) => block.kind === "table")!;
|
|
125
|
+
|
|
126
|
+
expect(table.text).toBe("| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a single-column table and an aligned delimiter still parse as a table", () => {
|
|
130
|
+
// Assert
|
|
131
|
+
expect(parseBlocks("| only |\n| --- |\n| one |")[0]!.kind).toBe("table");
|
|
132
|
+
expect(parseBlocks("| l | r |\n| :--- | ---: |\n| a | b |")[0]!.kind).toBe("table");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("a plain rule line is an hr, never a table", () => {
|
|
136
|
+
// Assert
|
|
137
|
+
expect(parseBlocks("above\n\n---\n\nbelow").map((block) => block.kind)).toEqual([
|
|
138
|
+
"p",
|
|
139
|
+
"hr",
|
|
140
|
+
"p",
|
|
141
|
+
]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("leading frontmatter parses to a frontmatter block, closed by its fence", () => {
|
|
145
|
+
// Arrange
|
|
146
|
+
const md = "---\ntitle: Plan\nowner: me\n---\n\n# Heading\n";
|
|
147
|
+
|
|
148
|
+
// Act
|
|
149
|
+
const blocks = parseBlocks(md);
|
|
150
|
+
|
|
151
|
+
// Assert
|
|
152
|
+
expect(blocks.map((block) => block.kind)).toEqual(["frontmatter", "h1"]);
|
|
153
|
+
expect(blocks[0]!.text).toBe("title: Plan\nowner: me");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("a leading fence with no closing fence stays an hr, not frontmatter", () => {
|
|
157
|
+
// Assert
|
|
158
|
+
expect(parseBlocks("---\njust a paragraph\n").map((block) => block.kind)).toEqual(["hr", "p"]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("table and frontmatter round-trip through blockToMd", () => {
|
|
162
|
+
// Arrange
|
|
163
|
+
const md = "---\ntitle: Plan\n---\n\n| a | b |\n|---|---|\n| 1 | 2 |";
|
|
164
|
+
const blocks = parseBlocks(md);
|
|
165
|
+
|
|
166
|
+
// Act
|
|
167
|
+
const rebuilt = blocks.map((block) => blockToMd(block)).join("\n\n");
|
|
168
|
+
|
|
169
|
+
// Assert
|
|
170
|
+
expect(parseBlocks(rebuilt).map((block) => [block.kind, block.text])).toEqual(
|
|
171
|
+
blocks.map((block) => [block.kind, block.text]),
|
|
172
|
+
);
|
|
112
173
|
});
|
|
113
174
|
});
|
|
114
175
|
|
package/src/markdown.ts
CHANGED
|
@@ -6,11 +6,22 @@
|
|
|
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;
|
|
@@ -31,110 +42,193 @@ export function stripLeadingBlockMarker(line: string): string {
|
|
|
31
42
|
return line.replace(LEADING_BLOCK_MARKER, "");
|
|
32
43
|
}
|
|
33
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
|
+
|
|
61
|
+
return (
|
|
62
|
+
header !== undefined &&
|
|
63
|
+
header.includes("|") &&
|
|
64
|
+
delimiter !== undefined &&
|
|
65
|
+
isTableDelimiterRow(delimiter)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
34
69
|
function isMarkerLine(line: string): boolean {
|
|
35
70
|
return (
|
|
36
71
|
line.startsWith("```") || LEADING_BLOCK_MARKER.test(line) || /^(---|\*\*\*|___)\s*$/.test(line)
|
|
37
72
|
);
|
|
38
73
|
}
|
|
39
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
|
+
|
|
40
213
|
export function parseBlocks(markdown: string): Block[] {
|
|
41
214
|
const lines = markdown.split("\n");
|
|
42
215
|
const blocks: Block[] = [];
|
|
43
216
|
let lineIndex = 0;
|
|
217
|
+
const frontmatter = frontmatterBlock(lines);
|
|
44
218
|
|
|
219
|
+
if (frontmatter) {
|
|
220
|
+
blocks.push(frontmatter);
|
|
221
|
+
lineIndex = frontmatter.lineEnd + 1;
|
|
222
|
+
}
|
|
45
223
|
while (lineIndex < lines.length) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
if (line.trim() === "") {
|
|
224
|
+
if (lines[lineIndex]!.trim() === "") {
|
|
49
225
|
lineIndex++;
|
|
50
226
|
continue;
|
|
51
227
|
}
|
|
52
|
-
|
|
53
|
-
const start = lineIndex;
|
|
54
|
-
const lang = line.slice(3).trim() || undefined;
|
|
55
|
-
|
|
56
|
-
lineIndex++;
|
|
57
|
-
const body: string[] = [];
|
|
58
|
-
|
|
59
|
-
while (lineIndex < lines.length && !lines[lineIndex]!.startsWith("```")) {
|
|
60
|
-
body.push(lines[lineIndex]!);
|
|
61
|
-
lineIndex++;
|
|
62
|
-
}
|
|
63
|
-
const end = Math.min(lineIndex, lines.length - 1);
|
|
228
|
+
const scan = nextBlock(lines, lineIndex);
|
|
64
229
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
} else if (line.startsWith("### ")) {
|
|
68
|
-
blocks.push({
|
|
69
|
-
kind: "h3",
|
|
70
|
-
text: stripLeadingBlockMarker(line),
|
|
71
|
-
lineStart: lineIndex,
|
|
72
|
-
lineEnd: lineIndex,
|
|
73
|
-
});
|
|
74
|
-
lineIndex++;
|
|
75
|
-
} else if (line.startsWith("## ")) {
|
|
76
|
-
blocks.push({
|
|
77
|
-
kind: "h2",
|
|
78
|
-
text: stripLeadingBlockMarker(line),
|
|
79
|
-
lineStart: lineIndex,
|
|
80
|
-
lineEnd: lineIndex,
|
|
81
|
-
});
|
|
82
|
-
lineIndex++;
|
|
83
|
-
} else if (line.startsWith("# ")) {
|
|
84
|
-
blocks.push({
|
|
85
|
-
kind: "h1",
|
|
86
|
-
text: stripLeadingBlockMarker(line),
|
|
87
|
-
lineStart: lineIndex,
|
|
88
|
-
lineEnd: lineIndex,
|
|
89
|
-
});
|
|
90
|
-
lineIndex++;
|
|
91
|
-
} else if (/^(---|\*\*\*|___)\s*$/.test(line)) {
|
|
92
|
-
blocks.push({ kind: "hr", text: "", lineStart: lineIndex, lineEnd: lineIndex });
|
|
93
|
-
lineIndex++;
|
|
94
|
-
} else if (line.startsWith("> ")) {
|
|
95
|
-
const start = lineIndex;
|
|
96
|
-
const body: string[] = [];
|
|
97
|
-
|
|
98
|
-
while (lineIndex < lines.length && lines[lineIndex]!.startsWith("> ")) {
|
|
99
|
-
body.push(stripLeadingBlockMarker(lines[lineIndex]!));
|
|
100
|
-
lineIndex++;
|
|
101
|
-
}
|
|
102
|
-
blocks.push({
|
|
103
|
-
kind: "quote",
|
|
104
|
-
text: body.join("\n"),
|
|
105
|
-
lineStart: start,
|
|
106
|
-
lineEnd: lineIndex - 1,
|
|
107
|
-
});
|
|
108
|
-
} else if (line.startsWith("- ")) {
|
|
109
|
-
blocks.push({
|
|
110
|
-
kind: "li",
|
|
111
|
-
text: stripLeadingBlockMarker(line),
|
|
112
|
-
lineStart: lineIndex,
|
|
113
|
-
lineEnd: lineIndex,
|
|
114
|
-
});
|
|
115
|
-
lineIndex++;
|
|
116
|
-
} else if (/^\d+\. /.test(line)) {
|
|
117
|
-
blocks.push({
|
|
118
|
-
kind: "oli",
|
|
119
|
-
text: stripLeadingBlockMarker(line),
|
|
120
|
-
lineStart: lineIndex,
|
|
121
|
-
lineEnd: lineIndex,
|
|
122
|
-
});
|
|
123
|
-
lineIndex++;
|
|
124
|
-
} else {
|
|
125
|
-
const start = lineIndex;
|
|
126
|
-
const body: string[] = [];
|
|
127
|
-
|
|
128
|
-
while (
|
|
129
|
-
lineIndex < lines.length &&
|
|
130
|
-
lines[lineIndex]!.trim() !== "" &&
|
|
131
|
-
!isMarkerLine(lines[lineIndex]!)
|
|
132
|
-
) {
|
|
133
|
-
body.push(lines[lineIndex]!);
|
|
134
|
-
lineIndex++;
|
|
135
|
-
}
|
|
136
|
-
blocks.push({ kind: "p", text: body.join("\n"), lineStart: start, lineEnd: lineIndex - 1 });
|
|
137
|
-
}
|
|
230
|
+
blocks.push(scan.block);
|
|
231
|
+
lineIndex = scan.next;
|
|
138
232
|
}
|
|
139
233
|
|
|
140
234
|
return blocks;
|
|
@@ -162,6 +256,10 @@ export function blockToMd(block: Block, ordinal = 1): string {
|
|
|
162
256
|
return "```" + (block.lang ?? "") + "\n" + block.text + "\n```";
|
|
163
257
|
case "hr":
|
|
164
258
|
return "---";
|
|
259
|
+
case "frontmatter":
|
|
260
|
+
return "---\n" + block.text + "\n---";
|
|
261
|
+
case "table":
|
|
262
|
+
return block.text;
|
|
165
263
|
default:
|
|
166
264
|
return block.text;
|
|
167
265
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { SCHEMA_VERSION, type Thread } from "./types";
|
|
3
|
+
import { threadShareLinks, withShareLinks } from "./share-links";
|
|
4
|
+
|
|
5
|
+
function thread(overrides: Partial<Thread> = {}): 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: [{ revision: 1, content: "# Plan\n", submittedAt: "2026-01-01T00:00:00.000Z" }],
|
|
12
|
+
annotations: [],
|
|
13
|
+
verdict: null,
|
|
14
|
+
status: "pending",
|
|
15
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("share-link migration", () => {
|
|
21
|
+
test("a never-shared thread has no links", () => {
|
|
22
|
+
expect(threadShareLinks(thread())).toBeUndefined();
|
|
23
|
+
expect(withShareLinks(thread()).shares).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("a legacy private share migrates into a one-element list", () => {
|
|
27
|
+
const legacy = thread({
|
|
28
|
+
shareId: "p_abc123xy",
|
|
29
|
+
access: { githubLogins: ["octocat"] },
|
|
30
|
+
owner: "SHA256:owner",
|
|
31
|
+
shareBranch: "feature",
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(withShareLinks(legacy).shares).toEqual([
|
|
35
|
+
{
|
|
36
|
+
id: "p_abc123xy",
|
|
37
|
+
requireAuth: true,
|
|
38
|
+
allowlist: ["octocat"],
|
|
39
|
+
owner: "SHA256:owner",
|
|
40
|
+
shareBranch: "feature",
|
|
41
|
+
},
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a legacy public share migrates as requireAuth false with an empty allowlist", () => {
|
|
46
|
+
const legacy = thread({ shareId: "p_public00", owner: "SHA256:owner" });
|
|
47
|
+
const [link] = withShareLinks(legacy).shares!;
|
|
48
|
+
|
|
49
|
+
expect(link).toMatchObject({ id: "p_public00", requireAuth: false, allowlist: [] });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a thread that already has shares is returned untouched", () => {
|
|
53
|
+
const modern = thread({
|
|
54
|
+
shares: [{ id: "p_modern00", name: "team", requireAuth: false, allowlist: [] }],
|
|
55
|
+
// stale legacy fields must not override the explicit shares
|
|
56
|
+
shareId: "p_legacy00",
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(withShareLinks(modern).shares).toEqual([
|
|
60
|
+
{ id: "p_modern00", name: "team", requireAuth: false, allowlist: [] },
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ShareLink, Thread } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A thread's share links, migrating a legacy single share (scalar shareId +
|
|
5
|
+
* access + owner + shareBranch) into a one-element list. Undefined when the
|
|
6
|
+
* thread was never shared. Records already carrying `shares` are returned as-is.
|
|
7
|
+
*/
|
|
8
|
+
export function threadShareLinks(session: Thread): ShareLink[] | undefined {
|
|
9
|
+
if (session.shares !== undefined) return session.shares;
|
|
10
|
+
if (session.shareId === undefined) return undefined;
|
|
11
|
+
|
|
12
|
+
return [
|
|
13
|
+
{
|
|
14
|
+
id: session.shareId,
|
|
15
|
+
requireAuth: session.access !== undefined,
|
|
16
|
+
allowlist: session.access?.githubLogins ?? [],
|
|
17
|
+
owner: session.owner,
|
|
18
|
+
shareBranch: session.shareBranch,
|
|
19
|
+
},
|
|
20
|
+
];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The thread with `shares` populated from the legacy fields when absent; a never-shared thread is unchanged. */
|
|
24
|
+
export function withShareLinks(session: Thread): Thread {
|
|
25
|
+
if (session.shares !== undefined) return session;
|
|
26
|
+
const shares = threadShareLinks(session);
|
|
27
|
+
|
|
28
|
+
return shares ? { ...session, shares } : session;
|
|
29
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -237,6 +237,27 @@ export interface ShareAccess {
|
|
|
237
237
|
githubLogins: string[];
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
/**
|
|
241
|
+
* One published share link for a thread. A thread can have several, each an
|
|
242
|
+
* independent gateway blob keyed by its own `id`. `name` is a local-only label
|
|
243
|
+
* to tell links apart; `requireAuth` gates the link behind the `allowlist` of
|
|
244
|
+
* GitHub logins (empty + requireAuth is a private link with no one added yet).
|
|
245
|
+
*/
|
|
246
|
+
export interface ShareLink {
|
|
247
|
+
/** The share id (`p_…`), the link's address and gateway blob key. */
|
|
248
|
+
id: string;
|
|
249
|
+
/** A local, cosmetic label for the list; never leaves the planner's machine. */
|
|
250
|
+
name?: string;
|
|
251
|
+
/** True = private (only `allowlist` may open it); false = public. */
|
|
252
|
+
requireAuth: boolean;
|
|
253
|
+
/** GitHub logins allowed when `requireAuth`; ignored when public. */
|
|
254
|
+
allowlist: string[];
|
|
255
|
+
/** SSH fingerprint that created the link; the gateway stamps it to gate pulls/pushes/revokes. */
|
|
256
|
+
owner?: string;
|
|
257
|
+
/** The branch this link follows and shows collaborators; `main` when absent. */
|
|
258
|
+
shareBranch?: string;
|
|
259
|
+
}
|
|
260
|
+
|
|
240
261
|
export interface Thread {
|
|
241
262
|
schemaVersion: string;
|
|
242
263
|
id: string;
|
|
@@ -277,13 +298,15 @@ export interface Thread {
|
|
|
277
298
|
shelvedAnnotations?: Annotation[];
|
|
278
299
|
/** The session this one was forked from. */
|
|
279
300
|
parentSessionId?: string;
|
|
280
|
-
/**
|
|
301
|
+
/** Published share links for this thread; each is an independent gateway blob. Migrated from the legacy scalar fields on read. */
|
|
302
|
+
shares?: ShareLink[];
|
|
303
|
+
/** @deprecated Legacy single-share id; migrated into `shares` on read. */
|
|
281
304
|
shareId?: string;
|
|
282
|
-
/**
|
|
305
|
+
/** @deprecated Legacy single-share branch; migrated into `shares` on read. */
|
|
283
306
|
shareBranch?: string;
|
|
284
|
-
/**
|
|
307
|
+
/** @deprecated Legacy single-share owner fingerprint; migrated into `shares` on read. */
|
|
285
308
|
owner?: string;
|
|
286
|
-
/**
|
|
309
|
+
/** @deprecated Legacy single-share allowlist; migrated into `shares` on read. */
|
|
287
310
|
access?: ShareAccess;
|
|
288
311
|
/**
|
|
289
312
|
* Identities that authored annotations here, keyed by id (union-by-id, like
|