opencode-snippets 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,74 +1,84 @@
1
- import { expandHashtags } from "../src/expander.js";
2
- import type { SnippetRegistry } from "../src/types.js";
1
+ import { assembleMessage, expandHashtags, parseSnippetBlocks } from "../src/expander.js";
2
+ import type { SnippetInfo, SnippetRegistry } from "../src/types.js";
3
+
4
+ /** Helper to create a SnippetInfo from just content */
5
+ function snippet(content: string, name = "test"): SnippetInfo {
6
+ return { name, content, aliases: [], filePath: "", source: "global" };
7
+ }
8
+
9
+ /** Helper to create a registry from [key, content] pairs */
10
+ function createRegistry(entries: [string, string][]): SnippetRegistry {
11
+ return new Map(entries.map(([key, content]) => [key, snippet(content, key)]));
12
+ }
3
13
 
4
14
  describe("expandHashtags - Recursive Includes and Loop Detection", () => {
5
15
  describe("Basic expansion", () => {
6
16
  it("should expand a single hashtag", () => {
7
- const registry: SnippetRegistry = new Map([["greeting", "Hello, World!"]]);
17
+ const registry = createRegistry([["greeting", "Hello, World!"]]);
8
18
 
9
19
  const result = expandHashtags("Say #greeting", registry);
10
20
 
11
- expect(result).toBe("Say Hello, World!");
21
+ expect(result.text).toBe("Say Hello, World!");
12
22
  });
13
23
 
14
24
  it("should expand multiple hashtags in one text", () => {
15
- const registry: SnippetRegistry = new Map([
25
+ const registry = createRegistry([
16
26
  ["greeting", "Hello"],
17
27
  ["name", "Alice"],
18
28
  ]);
19
29
 
20
30
  const result = expandHashtags("#greeting, #name!", registry);
21
31
 
22
- expect(result).toBe("Hello, Alice!");
32
+ expect(result.text).toBe("Hello, Alice!");
23
33
  });
24
34
 
25
35
  it("should leave unknown hashtags unchanged", () => {
26
- const registry: SnippetRegistry = new Map([["known", "content"]]);
36
+ const registry = createRegistry([["known", "content"]]);
27
37
 
28
38
  const result = expandHashtags("This is #known and #unknown", registry);
29
39
 
30
- expect(result).toBe("This is content and #unknown");
40
+ expect(result.text).toBe("This is content and #unknown");
31
41
  });
32
42
 
33
43
  it("should handle empty text", () => {
34
- const registry: SnippetRegistry = new Map([["test", "content"]]);
44
+ const registry = createRegistry([["test", "content"]]);
35
45
 
36
46
  const result = expandHashtags("", registry);
37
47
 
38
- expect(result).toBe("");
48
+ expect(result.text).toBe("");
39
49
  });
40
50
 
41
51
  it("should handle text with no hashtags", () => {
42
- const registry: SnippetRegistry = new Map([["test", "content"]]);
52
+ const registry = createRegistry([["test", "content"]]);
43
53
 
44
54
  const result = expandHashtags("No hashtags here", registry);
45
55
 
46
- expect(result).toBe("No hashtags here");
56
+ expect(result.text).toBe("No hashtags here");
47
57
  });
48
58
 
49
59
  it("should handle case-insensitive hashtags", () => {
50
- const registry: SnippetRegistry = new Map([["greeting", "Hello"]]);
60
+ const registry = createRegistry([["greeting", "Hello"]]);
51
61
 
52
62
  const result = expandHashtags("#Greeting #GREETING #greeting", registry);
53
63
 
54
- expect(result).toBe("Hello Hello Hello");
64
+ expect(result.text).toBe("Hello Hello Hello");
55
65
  });
56
66
  });
57
67
 
58
68
  describe("Recursive expansion", () => {
59
69
  it("should expand nested hashtags one level deep", () => {
60
- const registry: SnippetRegistry = new Map([
70
+ const registry = createRegistry([
61
71
  ["outer", "Start #inner End"],
62
72
  ["inner", "Middle"],
63
73
  ]);
64
74
 
65
75
  const result = expandHashtags("#outer", registry);
66
76
 
67
- expect(result).toBe("Start Middle End");
77
+ expect(result.text).toBe("Start Middle End");
68
78
  });
69
79
 
70
80
  it("should expand nested hashtags multiple levels deep", () => {
71
- const registry: SnippetRegistry = new Map([
81
+ const registry = createRegistry([
72
82
  ["level1", "L1 #level2"],
73
83
  ["level2", "L2 #level3"],
74
84
  ["level3", "L3 #level4"],
@@ -77,11 +87,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
77
87
 
78
88
  const result = expandHashtags("#level1", registry);
79
89
 
80
- expect(result).toBe("L1 L2 L3 L4");
90
+ expect(result.text).toBe("L1 L2 L3 L4");
81
91
  });
82
92
 
83
93
  it("should expand multiple nested hashtags in one snippet", () => {
84
- const registry: SnippetRegistry = new Map([
94
+ const registry = createRegistry([
85
95
  ["main", "Start #a and #b End"],
86
96
  ["a", "Content A"],
87
97
  ["b", "Content B"],
@@ -89,11 +99,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
89
99
 
90
100
  const result = expandHashtags("#main", registry);
91
101
 
92
- expect(result).toBe("Start Content A and Content B End");
102
+ expect(result.text).toBe("Start Content A and Content B End");
93
103
  });
94
104
 
95
105
  it("should expand complex nested structure", () => {
96
- const registry: SnippetRegistry = new Map([
106
+ const registry = createRegistry([
97
107
  ["greeting", "#hello #name"],
98
108
  ["hello", "Hello"],
99
109
  ["name", "#firstname #lastname"],
@@ -103,23 +113,23 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
103
113
 
104
114
  const result = expandHashtags("#greeting", registry);
105
115
 
106
- expect(result).toBe("Hello John Doe");
116
+ expect(result.text).toBe("Hello John Doe");
107
117
  });
108
118
  });
109
119
 
110
120
  describe("Loop detection - Direct cycles", () => {
111
121
  it("should detect and prevent simple self-reference", { timeout: 100 }, () => {
112
- const registry: SnippetRegistry = new Map([["self", "I reference #self"]]);
122
+ const registry = createRegistry([["self", "I reference #self"]]);
113
123
 
114
124
  const result = expandHashtags("#self", registry);
115
125
 
116
126
  // Loop detected after 15 expansions, #self left as-is
117
127
  const expected = `${"I reference ".repeat(15)}#self`;
118
- expect(result).toBe(expected);
128
+ expect(result.text).toBe(expected);
119
129
  });
120
130
 
121
131
  it("should detect and prevent two-way circular reference", () => {
122
- const registry: SnippetRegistry = new Map([
132
+ const registry = createRegistry([
123
133
  ["a", "A references #b"],
124
134
  ["b", "B references #a"],
125
135
  ]);
@@ -128,11 +138,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
128
138
 
129
139
  // Should expand alternating A and B 15 times then stop
130
140
  const expected = `${"A references B references ".repeat(15)}#a`;
131
- expect(result).toBe(expected);
141
+ expect(result.text).toBe(expected);
132
142
  });
133
143
 
134
144
  it("should detect and prevent three-way circular reference", () => {
135
- const registry: SnippetRegistry = new Map([
145
+ const registry = createRegistry([
136
146
  ["a", "A -> #b"],
137
147
  ["b", "B -> #c"],
138
148
  ["c", "C -> #a"],
@@ -142,11 +152,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
142
152
 
143
153
  // Should expand cycling through A, B, C 15 times then stop
144
154
  const expected = `${"A -> B -> C -> ".repeat(15)}#a`;
145
- expect(result).toBe(expected);
155
+ expect(result.text).toBe(expected);
146
156
  });
147
157
 
148
158
  it("should detect loops in longer chains", () => {
149
- const registry: SnippetRegistry = new Map([
159
+ const registry = createRegistry([
150
160
  ["a", "#b"],
151
161
  ["b", "#c"],
152
162
  ["c", "#d"],
@@ -157,13 +167,13 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
157
167
  const result = expandHashtags("#a", registry);
158
168
 
159
169
  // Should expand until loop detected
160
- expect(result).toBe("#b");
170
+ expect(result.text).toBe("#b");
161
171
  });
162
172
  });
163
173
 
164
174
  describe("Loop detection - Complex scenarios", () => {
165
175
  it("should allow same snippet in different branches", () => {
166
- const registry: SnippetRegistry = new Map([
176
+ const registry = createRegistry([
167
177
  ["main", "#branch1 and #branch2"],
168
178
  ["branch1", "B1 uses #shared"],
169
179
  ["branch2", "B2 uses #shared"],
@@ -173,11 +183,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
173
183
  const result = expandHashtags("#main", registry);
174
184
 
175
185
  // #shared should be expanded in both branches
176
- expect(result).toBe("B1 uses Shared content and B2 uses Shared content");
186
+ expect(result.text).toBe("B1 uses Shared content and B2 uses Shared content");
177
187
  });
178
188
 
179
189
  it("should handle partial loops with valid branches", () => {
180
- const registry: SnippetRegistry = new Map([
190
+ const registry = createRegistry([
181
191
  ["main", "#valid and #loop"],
182
192
  ["valid", "Valid content"],
183
193
  ["loop", "Loop #loop"],
@@ -187,11 +197,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
187
197
 
188
198
  // Valid expands once, loop expands 15 times
189
199
  const expected = `Valid content and ${"Loop ".repeat(15)}#loop`;
190
- expect(result).toBe(expected);
200
+ expect(result.text).toBe(expected);
191
201
  });
192
202
 
193
203
  it("should handle multiple independent loops", () => {
194
- const registry: SnippetRegistry = new Map([
204
+ const registry = createRegistry([
195
205
  ["main", "#loop1 and #loop2"],
196
206
  ["loop1", "L1 #loop1"],
197
207
  ["loop2", "L2 #loop2"],
@@ -201,11 +211,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
201
211
 
202
212
  // Each loop expands 15 times independently
203
213
  const expected = `${"L1 ".repeat(15)}#loop1 and ${"L2 ".repeat(15)}#loop2`;
204
- expect(result).toBe(expected);
214
+ expect(result.text).toBe(expected);
205
215
  });
206
216
 
207
217
  it("should handle nested loops", () => {
208
- const registry: SnippetRegistry = new Map([
218
+ const registry = createRegistry([
209
219
  ["outer", "Outer #inner"],
210
220
  ["inner", "Inner #outer and #self"],
211
221
  ["self", "Self #self"],
@@ -215,14 +225,14 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
215
225
 
216
226
  // Complex nested loop - outer/inner cycle 15 times, plus self cycles
217
227
  // This is complex expansion behavior, just verify it doesn't hang
218
- expect(result).toContain("Outer");
219
- expect(result).toContain("Inner");
220
- expect(result).toContain("#outer");
221
- expect(result).toContain("#self");
228
+ expect(result.text).toContain("Outer");
229
+ expect(result.text).toContain("Inner");
230
+ expect(result.text).toContain("#outer");
231
+ expect(result.text).toContain("#self");
222
232
  });
223
233
 
224
234
  it("should handle diamond pattern (same snippet reached via multiple paths)", () => {
225
- const registry: SnippetRegistry = new Map([
235
+ const registry = createRegistry([
226
236
  ["top", "#left #right"],
227
237
  ["left", "Left #bottom"],
228
238
  ["right", "Right #bottom"],
@@ -232,11 +242,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
232
242
  const result = expandHashtags("#top", registry);
233
243
 
234
244
  // Diamond: top -> left -> bottom, top -> right -> bottom
235
- expect(result).toBe("Left Bottom Right Bottom");
245
+ expect(result.text).toBe("Left Bottom Right Bottom");
236
246
  });
237
247
 
238
248
  it("should handle loop after valid expansion", () => {
239
- const registry: SnippetRegistry = new Map([
249
+ const registry = createRegistry([
240
250
  ["a", "#b #c"],
241
251
  ["b", "Valid B"],
242
252
  ["c", "#d"],
@@ -245,7 +255,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
245
255
 
246
256
  const result = expandHashtags("#a", registry);
247
257
 
248
- expect(result).toBe("Valid B #c");
258
+ expect(result.text).toBe("Valid B #c");
249
259
  });
250
260
  });
251
261
 
@@ -255,19 +265,19 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
255
265
 
256
266
  const result = expandHashtags("#anything", registry);
257
267
 
258
- expect(result).toBe("#anything");
268
+ expect(result.text).toBe("#anything");
259
269
  });
260
270
 
261
271
  it("should handle snippet with empty content", () => {
262
- const registry: SnippetRegistry = new Map([["empty", ""]]);
272
+ const registry = createRegistry([["empty", ""]]);
263
273
 
264
274
  const result = expandHashtags("Before #empty After", registry);
265
275
 
266
- expect(result).toBe("Before After");
276
+ expect(result.text).toBe("Before After");
267
277
  });
268
278
 
269
279
  it("should handle snippet containing only hashtags", () => {
270
- const registry: SnippetRegistry = new Map([
280
+ const registry = createRegistry([
271
281
  ["only-refs", "#a #b"],
272
282
  ["a", "A"],
273
283
  ["b", "B"],
@@ -275,11 +285,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
275
285
 
276
286
  const result = expandHashtags("#only-refs", registry);
277
287
 
278
- expect(result).toBe("A B");
288
+ expect(result.text).toBe("A B");
279
289
  });
280
290
 
281
291
  it("should handle hashtags at start, middle, and end", () => {
282
- const registry: SnippetRegistry = new Map([
292
+ const registry = createRegistry([
283
293
  ["start", "Start"],
284
294
  ["middle", "Middle"],
285
295
  ["end", "End"],
@@ -287,11 +297,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
287
297
 
288
298
  const result = expandHashtags("#start text #middle text #end", registry);
289
299
 
290
- expect(result).toBe("Start text Middle text End");
300
+ expect(result.text).toBe("Start text Middle text End");
291
301
  });
292
302
 
293
303
  it("should handle consecutive hashtags", () => {
294
- const registry: SnippetRegistry = new Map([
304
+ const registry = createRegistry([
295
305
  ["a", "A"],
296
306
  ["b", "B"],
297
307
  ["c", "C"],
@@ -299,11 +309,11 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
299
309
 
300
310
  const result = expandHashtags("#a#b#c", registry);
301
311
 
302
- expect(result).toBe("ABC");
312
+ expect(result.text).toBe("ABC");
303
313
  });
304
314
 
305
315
  it("should handle hashtags with hyphens and underscores", () => {
306
- const registry: SnippetRegistry = new Map([
316
+ const registry = createRegistry([
307
317
  ["my-snippet", "Hyphenated"],
308
318
  ["my_snippet", "Underscored"],
309
319
  ["my-complex_name", "Mixed"],
@@ -311,53 +321,53 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
311
321
 
312
322
  const result = expandHashtags("#my-snippet #my_snippet #my-complex_name", registry);
313
323
 
314
- expect(result).toBe("Hyphenated Underscored Mixed");
324
+ expect(result.text).toBe("Hyphenated Underscored Mixed");
315
325
  });
316
326
 
317
327
  it("should handle hashtags with numbers", () => {
318
- const registry: SnippetRegistry = new Map([
328
+ const registry = createRegistry([
319
329
  ["test123", "Test with numbers"],
320
330
  ["123test", "Numbers first"],
321
331
  ]);
322
332
 
323
333
  const result = expandHashtags("#test123 #123test", registry);
324
334
 
325
- expect(result).toBe("Test with numbers Numbers first");
335
+ expect(result.text).toBe("Test with numbers Numbers first");
326
336
  });
327
337
 
328
338
  it("should not expand hashtags in URLs", () => {
329
- const registry: SnippetRegistry = new Map([["issue", "ISSUE"]]);
339
+ const registry = createRegistry([["issue", "ISSUE"]]);
330
340
 
331
341
  // Note: The current implementation WILL expand #issue in URLs
332
342
  // This test documents current behavior
333
343
  const result = expandHashtags("See https://github.com/user/repo/issues/#issue", registry);
334
344
 
335
- expect(result).toBe("See https://github.com/user/repo/issues/ISSUE");
345
+ expect(result.text).toBe("See https://github.com/user/repo/issues/ISSUE");
336
346
  });
337
347
 
338
348
  it("should handle multiline content", () => {
339
- const registry: SnippetRegistry = new Map([["multiline", "Line 1\nLine 2\nLine 3"]]);
349
+ const registry = createRegistry([["multiline", "Line 1\nLine 2\nLine 3"]]);
340
350
 
341
351
  const result = expandHashtags("Start\n#multiline\nEnd", registry);
342
352
 
343
- expect(result).toBe("Start\nLine 1\nLine 2\nLine 3\nEnd");
353
+ expect(result.text).toBe("Start\nLine 1\nLine 2\nLine 3\nEnd");
344
354
  });
345
355
 
346
356
  it("should handle nested multiline content", () => {
347
- const registry: SnippetRegistry = new Map([
357
+ const registry = createRegistry([
348
358
  ["outer", "Outer start\n#inner\nOuter end"],
349
359
  ["inner", "Inner line 1\nInner line 2"],
350
360
  ]);
351
361
 
352
362
  const result = expandHashtags("#outer", registry);
353
363
 
354
- expect(result).toBe("Outer start\nInner line 1\nInner line 2\nOuter end");
364
+ expect(result.text).toBe("Outer start\nInner line 1\nInner line 2\nOuter end");
355
365
  });
356
366
  });
357
367
 
358
368
  describe("Real-world scenarios", () => {
359
369
  it("should expand code review template with nested snippets", () => {
360
- const registry: SnippetRegistry = new Map([
370
+ const registry = createRegistry([
361
371
  ["review", "Code Review Checklist:\n#security\n#performance\n#tests"],
362
372
  ["security", "- Check for SQL injection\n- Validate input"],
363
373
  ["performance", "- Check for N+1 queries\n- Review algorithm complexity"],
@@ -366,14 +376,14 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
366
376
 
367
377
  const result = expandHashtags("#review", registry);
368
378
 
369
- expect(result).toContain("Code Review Checklist:");
370
- expect(result).toContain("Check for SQL injection");
371
- expect(result).toContain("Check for N+1 queries");
372
- expect(result).toContain("Unit tests present");
379
+ expect(result.text).toContain("Code Review Checklist:");
380
+ expect(result.text).toContain("Check for SQL injection");
381
+ expect(result.text).toContain("Check for N+1 queries");
382
+ expect(result.text).toContain("Unit tests present");
373
383
  });
374
384
 
375
385
  it("should expand documentation template with shared components", () => {
376
- const registry: SnippetRegistry = new Map([
386
+ const registry = createRegistry([
377
387
  ["doc", "# Documentation\n#header\n#body\n#footer"],
378
388
  ["header", "Author: #author\nDate: 2024-01-01"],
379
389
  ["author", "John Doe"],
@@ -384,12 +394,12 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
384
394
  const result = expandHashtags("#doc", registry);
385
395
 
386
396
  // #author should be expanded in both header and footer
387
- expect(result).toContain("Author: John Doe");
388
- expect(result).toContain("Contact: John Doe");
397
+ expect(result.text).toContain("Author: John Doe");
398
+ expect(result.text).toContain("Contact: John Doe");
389
399
  });
390
400
 
391
401
  it("should handle instruction composition", () => {
392
- const registry: SnippetRegistry = new Map([
402
+ const registry = createRegistry([
393
403
  ["careful", "Think step by step. #verify"],
394
404
  ["verify", "Double-check your work."],
395
405
  ["complete", "Be thorough. #careful"],
@@ -397,7 +407,9 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
397
407
 
398
408
  const result = expandHashtags("Instructions: #complete", registry);
399
409
 
400
- expect(result).toBe("Instructions: Be thorough. Think step by step. Double-check your work.");
410
+ expect(result.text).toBe(
411
+ "Instructions: Be thorough. Think step by step. Double-check your work.",
412
+ );
401
413
  });
402
414
  });
403
415
 
@@ -408,15 +420,15 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
408
420
 
409
421
  // Create a chain: level0 -> level1 -> level2 -> ... -> level49 -> "End"
410
422
  for (let i = 0; i < depth - 1; i++) {
411
- registry.set(`level${i}`, `L${i} #level${i + 1}`);
423
+ registry.set(`level${i}`, snippet(`L${i} #level${i + 1}`, `level${i}`));
412
424
  }
413
- registry.set(`level${depth - 1}`, "End");
425
+ registry.set(`level${depth - 1}`, snippet("End", `level${depth - 1}`));
414
426
 
415
427
  const result = expandHashtags("#level0", registry);
416
428
 
417
- expect(result).toContain("L0");
418
- expect(result).toContain("End");
419
- expect(result.split(" ").length).toBe(depth);
429
+ expect(result.text).toContain("L0");
430
+ expect(result.text).toContain("End");
431
+ expect(result.text.split(" ").length).toBe(depth);
420
432
  });
421
433
 
422
434
  it("should handle many snippets in one text", () => {
@@ -424,15 +436,15 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
424
436
  const count = 100;
425
437
 
426
438
  for (let i = 0; i < count; i++) {
427
- registry.set(`snippet${i}`, `Content${i}`);
439
+ registry.set(`snippet${i}`, snippet(`Content${i}`, `snippet${i}`));
428
440
  }
429
441
 
430
442
  const hashtags = Array.from({ length: count }, (_, i) => `#snippet${i}`).join(" ");
431
443
  const result = expandHashtags(hashtags, registry);
432
444
 
433
- expect(result.split(" ").length).toBe(count);
434
- expect(result).toContain("Content0");
435
- expect(result).toContain(`Content${count - 1}`);
445
+ expect(result.text.split(" ").length).toBe(count);
446
+ expect(result.text).toContain("Content0");
447
+ expect(result.text).toContain(`Content${count - 1}`);
436
448
  });
437
449
 
438
450
  it("should handle wide branching (many children)", () => {
@@ -440,17 +452,387 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => {
440
452
  const branches = 20;
441
453
 
442
454
  const children = Array.from({ length: branches }, (_, i) => `#child${i}`).join(" ");
443
- registry.set("parent", children);
455
+ registry.set("parent", snippet(children, "parent"));
444
456
 
445
457
  for (let i = 0; i < branches; i++) {
446
- registry.set(`child${i}`, `Child${i}`);
458
+ registry.set(`child${i}`, snippet(`Child${i}`, `child${i}`));
447
459
  }
448
460
 
449
461
  const result = expandHashtags("#parent", registry);
450
462
 
451
463
  for (let i = 0; i < branches; i++) {
452
- expect(result).toContain(`Child${i}`);
464
+ expect(result.text).toContain(`Child${i}`);
453
465
  }
454
466
  });
455
467
  });
456
468
  });
469
+
470
+ describe("parseSnippetBlocks", () => {
471
+ describe("Basic parsing", () => {
472
+ it("should return full content as inline when no blocks present", () => {
473
+ const result = parseSnippetBlocks("Just some content");
474
+
475
+ expect(result).toEqual({
476
+ inline: "Just some content",
477
+ prepend: [],
478
+ append: [],
479
+ });
480
+ });
481
+
482
+ it("should extract append block and inline content", () => {
483
+ const result = parseSnippetBlocks("Inline text\n<append>\nAppend content\n</append>");
484
+
485
+ expect(result).toEqual({
486
+ inline: "Inline text",
487
+ prepend: [],
488
+ append: ["Append content"],
489
+ });
490
+ });
491
+
492
+ it("should extract prepend block and inline content", () => {
493
+ const result = parseSnippetBlocks("<prepend>\nPrepend content\n</prepend>\nInline text");
494
+
495
+ expect(result).toEqual({
496
+ inline: "Inline text",
497
+ prepend: ["Prepend content"],
498
+ append: [],
499
+ });
500
+ });
501
+
502
+ it("should extract both prepend and append blocks", () => {
503
+ const content = `<prepend>
504
+ Before content
505
+ </prepend>
506
+ Inline text
507
+ <append>
508
+ After content
509
+ </append>`;
510
+
511
+ const result = parseSnippetBlocks(content);
512
+
513
+ expect(result).toEqual({
514
+ inline: "Inline text",
515
+ prepend: ["Before content"],
516
+ append: ["After content"],
517
+ });
518
+ });
519
+
520
+ it("should handle multiple blocks of the same type", () => {
521
+ const content = `<append>
522
+ First append
523
+ </append>
524
+ Inline
525
+ <append>
526
+ Second append
527
+ </append>`;
528
+
529
+ const result = parseSnippetBlocks(content);
530
+
531
+ expect(result).toEqual({
532
+ inline: "Inline",
533
+ prepend: [],
534
+ append: ["First append", "Second append"],
535
+ });
536
+ });
537
+ });
538
+
539
+ describe("Edge cases", () => {
540
+ it("should handle empty inline (only blocks)", () => {
541
+ const content = `<append>
542
+ Only append content
543
+ </append>`;
544
+
545
+ const result = parseSnippetBlocks(content);
546
+
547
+ expect(result).toEqual({
548
+ inline: "",
549
+ prepend: [],
550
+ append: ["Only append content"],
551
+ });
552
+ });
553
+
554
+ it("should handle unclosed tag leniently (rest is block content)", () => {
555
+ const content = "Inline\n<append>\nUnclosed append content";
556
+
557
+ const result = parseSnippetBlocks(content);
558
+
559
+ expect(result).toEqual({
560
+ inline: "Inline",
561
+ prepend: [],
562
+ append: ["Unclosed append content"],
563
+ });
564
+ });
565
+
566
+ it("should return null for nested tags", () => {
567
+ const content = "<append>\n<prepend>\nnested\n</prepend>\n</append>";
568
+
569
+ const result = parseSnippetBlocks(content);
570
+
571
+ expect(result).toBeNull();
572
+ });
573
+
574
+ it("should trim content inside blocks", () => {
575
+ const content = "<append>\n \n Content with whitespace \n \n</append>";
576
+
577
+ const result = parseSnippetBlocks(content);
578
+
579
+ expect(result?.append[0]).toBe("Content with whitespace");
580
+ });
581
+
582
+ it("should trim inline content", () => {
583
+ const content = " \n Inline with whitespace \n ";
584
+
585
+ const result = parseSnippetBlocks(content);
586
+
587
+ expect(result?.inline).toBe("Inline with whitespace");
588
+ });
589
+
590
+ it("should be case-insensitive for tags", () => {
591
+ const content = "<APPEND>\nContent\n</APPEND>";
592
+
593
+ const result = parseSnippetBlocks(content);
594
+
595
+ expect(result).toEqual({
596
+ inline: "",
597
+ prepend: [],
598
+ append: ["Content"],
599
+ });
600
+ });
601
+
602
+ it("should handle empty blocks", () => {
603
+ const content = "Inline<append></append>";
604
+
605
+ const result = parseSnippetBlocks(content);
606
+
607
+ expect(result).toEqual({
608
+ inline: "Inline",
609
+ prepend: [],
610
+ append: [],
611
+ });
612
+ });
613
+ });
614
+
615
+ describe("Real-world content", () => {
616
+ it("should parse Jira MCP example", () => {
617
+ const content = `Jira MCP server
618
+ <append>
619
+ ## Jira MCP Usage
620
+
621
+ Use these custom field mappings when creating issues:
622
+ - customfield_16570 => Acceptance Criteria
623
+ - customfield_11401 => Team
624
+ </append>`;
625
+
626
+ const result = parseSnippetBlocks(content);
627
+
628
+ expect(result?.inline).toBe("Jira MCP server");
629
+ expect(result?.append).toHaveLength(1);
630
+ expect(result?.append[0]).toContain("Jira MCP Usage");
631
+ expect(result?.append[0]).toContain("customfield_16570");
632
+ });
633
+ });
634
+ });
635
+
636
+ describe("assembleMessage", () => {
637
+ it("should assemble text only", () => {
638
+ const result = assembleMessage({
639
+ text: "Main content",
640
+ prepend: [],
641
+ append: [],
642
+ });
643
+
644
+ expect(result).toBe("Main content");
645
+ });
646
+
647
+ it("should assemble with append blocks", () => {
648
+ const result = assembleMessage({
649
+ text: "Main content",
650
+ prepend: [],
651
+ append: ["Appended section"],
652
+ });
653
+
654
+ expect(result).toBe("Main content\n\nAppended section");
655
+ });
656
+
657
+ it("should assemble with prepend blocks", () => {
658
+ const result = assembleMessage({
659
+ text: "Main content",
660
+ prepend: ["Prepended section"],
661
+ append: [],
662
+ });
663
+
664
+ expect(result).toBe("Prepended section\n\nMain content");
665
+ });
666
+
667
+ it("should assemble with both prepend and append", () => {
668
+ const result = assembleMessage({
669
+ text: "Main content",
670
+ prepend: ["Before"],
671
+ append: ["After"],
672
+ });
673
+
674
+ expect(result).toBe("Before\n\nMain content\n\nAfter");
675
+ });
676
+
677
+ it("should join multiple prepend blocks", () => {
678
+ const result = assembleMessage({
679
+ text: "Main",
680
+ prepend: ["First", "Second"],
681
+ append: [],
682
+ });
683
+
684
+ expect(result).toBe("First\n\nSecond\n\nMain");
685
+ });
686
+
687
+ it("should join multiple append blocks", () => {
688
+ const result = assembleMessage({
689
+ text: "Main",
690
+ prepend: [],
691
+ append: ["First", "Second"],
692
+ });
693
+
694
+ expect(result).toBe("Main\n\nFirst\n\nSecond");
695
+ });
696
+
697
+ it("should handle empty text with blocks", () => {
698
+ const result = assembleMessage({
699
+ text: "",
700
+ prepend: ["Before"],
701
+ append: ["After"],
702
+ });
703
+
704
+ expect(result).toBe("Before\n\nAfter");
705
+ });
706
+
707
+ it("should handle whitespace-only text with blocks", () => {
708
+ const result = assembleMessage({
709
+ text: " ",
710
+ prepend: ["Before"],
711
+ append: ["After"],
712
+ });
713
+
714
+ expect(result).toBe("Before\n\nAfter");
715
+ });
716
+ });
717
+
718
+ describe("Prepend/Append integration with expandHashtags", () => {
719
+ it("should collect append blocks during expansion", () => {
720
+ const registry = createRegistry([
721
+ ["jira", "Jira MCP server\n<append>\nJira reference docs\n</append>"],
722
+ ]);
723
+
724
+ const result = expandHashtags("Create a ticket in #jira", registry);
725
+
726
+ expect(result.text).toBe("Create a ticket in Jira MCP server");
727
+ expect(result.append).toEqual(["Jira reference docs"]);
728
+ });
729
+
730
+ it("should collect prepend blocks during expansion", () => {
731
+ const registry = createRegistry([
732
+ ["context", "<prepend>\nImportant context\n</prepend>\nUse the context"],
733
+ ]);
734
+
735
+ const result = expandHashtags("#context please", registry);
736
+
737
+ expect(result.text).toBe("Use the context please");
738
+ expect(result.prepend).toEqual(["Important context"]);
739
+ });
740
+
741
+ it("should collect blocks from nested snippets", () => {
742
+ const registry = createRegistry([
743
+ ["outer", "Outer #inner text"],
744
+ ["inner", "Inner\n<append>\nInner's append\n</append>"],
745
+ ]);
746
+
747
+ const result = expandHashtags("#outer", registry);
748
+
749
+ expect(result.text).toBe("Outer Inner text");
750
+ expect(result.append).toEqual(["Inner's append"]);
751
+ });
752
+
753
+ it("should collect blocks from multiple snippets", () => {
754
+ const registry = createRegistry([
755
+ ["a", "A text\n<append>\nA's append\n</append>"],
756
+ ["b", "B text\n<append>\nB's append\n</append>"],
757
+ ]);
758
+
759
+ const result = expandHashtags("#a and #b", registry);
760
+
761
+ expect(result.text).toBe("A text and B text");
762
+ expect(result.append).toEqual(["A's append", "B's append"]);
763
+ });
764
+
765
+ it("should handle empty inline with only blocks", () => {
766
+ const registry = createRegistry([["ref", "<append>\nReference material\n</append>"]]);
767
+
768
+ const result = expandHashtags("Use #ref here", registry);
769
+
770
+ expect(result.text).toBe("Use here");
771
+ expect(result.append).toEqual(["Reference material"]);
772
+ });
773
+
774
+ it("should assemble full message correctly", () => {
775
+ const registry = createRegistry([
776
+ ["jira", "Jira MCP server\n<append>\n## Jira Usage\n- Field mappings here\n</append>"],
777
+ ]);
778
+
779
+ const result = expandHashtags("Create a bug ticket in #jira about the memory leak", registry);
780
+ const assembled = assembleMessage(result);
781
+
782
+ expect(assembled).toBe(
783
+ "Create a bug ticket in Jira MCP server about the memory leak\n\n## Jira Usage\n- Field mappings here",
784
+ );
785
+ });
786
+
787
+ it("should collect multiple append blocks from single snippet", () => {
788
+ const registry = createRegistry([
789
+ ["multi", "Inline\n<append>\nFirst append\n</append>\n<append>\nSecond append\n</append>"],
790
+ ]);
791
+
792
+ const result = expandHashtags("#multi", registry);
793
+
794
+ expect(result.text).toBe("Inline");
795
+ expect(result.append).toEqual(["First append", "Second append"]);
796
+ });
797
+
798
+ it("should collect multiple prepend blocks from single snippet", () => {
799
+ const registry = createRegistry([
800
+ [
801
+ "multi",
802
+ "<prepend>\nFirst prepend\n</prepend>\n<prepend>\nSecond prepend\n</prepend>\nInline",
803
+ ],
804
+ ]);
805
+
806
+ const result = expandHashtags("#multi", registry);
807
+
808
+ expect(result.text).toBe("Inline");
809
+ expect(result.prepend).toEqual(["First prepend", "Second prepend"]);
810
+ });
811
+
812
+ it("should assemble multiple prepends and appends in correct order", () => {
813
+ const registry = createRegistry([
814
+ ["a", "<prepend>\nA prepend\n</prepend>\nA inline\n<append>\nA append\n</append>"],
815
+ ["b", "<prepend>\nB prepend\n</prepend>\nB inline\n<append>\nB append\n</append>"],
816
+ ]);
817
+
818
+ const result = expandHashtags("#a then #b", registry);
819
+ const assembled = assembleMessage(result);
820
+
821
+ // Prepends first (in order), then inline, then appends (in order)
822
+ expect(assembled).toBe(
823
+ "A prepend\n\nB prepend\n\nA inline then B inline\n\nA append\n\nB append",
824
+ );
825
+ });
826
+
827
+ it("should handle mix of snippets with and without blocks", () => {
828
+ const registry = createRegistry([
829
+ ["plain", "Plain content"],
830
+ ["withblocks", "Block inline\n<append>\nBlock append\n</append>"],
831
+ ]);
832
+
833
+ const result = expandHashtags("#plain and #withblocks", registry);
834
+ const assembled = assembleMessage(result);
835
+
836
+ expect(assembled).toBe("Plain content and Block inline\n\nBlock append");
837
+ });
838
+ });