testomatio-editor-blocks 0.4.76 → 0.4.78

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.
@@ -0,0 +1,463 @@
1
+ /* eslint-disable react-refresh/only-export-components -- the hook colocates
2
+ with its private popup component; they are one cohesive module. */
3
+ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
4
+ import type { CSSProperties, ReactNode } from "react";
5
+ import { createPortal } from "react-dom";
6
+ import {
7
+ applyMention,
8
+ buildMentionInsertText,
9
+ filterMentionItems,
10
+ getMentionSources,
11
+ normalizeMentionItems,
12
+ parseActiveMention,
13
+ type ActiveMention,
14
+ type MentionItem,
15
+ type MentionSource,
16
+ } from "./mentionAutocomplete";
17
+
18
+ export type UseMentionAutocompleteOptions = {
19
+ /** The textarea the mentions are typed into (e.g. an OverType instance's textarea). */
20
+ textarea: HTMLTextAreaElement | null;
21
+ /** Reads the current full text. Defaults to `textarea.value`. */
22
+ getText?: () => string;
23
+ /**
24
+ * Writes the full text back and positions the caret. Defaults to mutating
25
+ * `textarea.value` directly; pass a custom writer when a wrapper (OverType,
26
+ * a controlled component) owns the value.
27
+ */
28
+ setText?: (text: string, caret: number) => void;
29
+ /** Sources to use. Falls back to the global registry (`setMentionSources`). */
30
+ sources?: MentionSource[];
31
+ /** Master switch; when false the popup never opens. Default true. */
32
+ enabled?: boolean;
33
+ /**
34
+ * When true (default) the hook attaches its own capture-phase keydown listener
35
+ * to the textarea. Set false to drive navigation yourself via the returned
36
+ * `onKeyDown` (useful when another handler must take priority ordering).
37
+ */
38
+ attachKeyDown?: boolean;
39
+ /** Debounce (ms) before an async `search` runs. Default 150. */
40
+ searchDebounceMs?: number;
41
+ };
42
+
43
+ export type UseMentionAutocompleteResult = {
44
+ /** Render this somewhere in the tree; it portals the popup to `document.body`. */
45
+ overlay: ReactNode;
46
+ /**
47
+ * Feed keydown events here when `attachKeyDown` is false. Returns true when the
48
+ * event was consumed (it also calls preventDefault / stopImmediatePropagation).
49
+ */
50
+ onKeyDown: (event: KeyboardEvent) => boolean;
51
+ isOpen: boolean;
52
+ close: () => void;
53
+ };
54
+
55
+ type CaretRect = { left: number; top: number; bottom: number; height: number };
56
+
57
+ const NAV_KEYS = new Set(["ArrowDown", "ArrowUp", "Enter", "Tab", "Escape"]);
58
+
59
+ export function useMentionAutocomplete(
60
+ options: UseMentionAutocompleteOptions,
61
+ ): UseMentionAutocompleteResult {
62
+ const {
63
+ textarea,
64
+ sources: sourcesProp,
65
+ enabled = true,
66
+ attachKeyDown = true,
67
+ searchDebounceMs = 150,
68
+ } = options;
69
+
70
+ const getText = useCallback(
71
+ () => (options.getText ? options.getText() : textarea?.value ?? ""),
72
+ [options, textarea],
73
+ );
74
+
75
+ const setText = useCallback(
76
+ (text: string, caret: number) => {
77
+ if (options.setText) {
78
+ options.setText(text, caret);
79
+ return;
80
+ }
81
+ if (!textarea) return;
82
+ textarea.value = text;
83
+ textarea.setSelectionRange(caret, caret);
84
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
85
+ },
86
+ [options, textarea],
87
+ );
88
+
89
+ const [active, setActive] = useState<ActiveMention | null>(null);
90
+ const [items, setItems] = useState<MentionItem[]>([]);
91
+ const [activeIndex, setActiveIndex] = useState(0);
92
+ const [loading, setLoading] = useState(false);
93
+ const [caretRect, setCaretRect] = useState<CaretRect | null>(null);
94
+
95
+ // Latest committed active mention, for cheap change-detection in refresh().
96
+ const activeRef = useRef<ActiveMention | null>(null);
97
+ // Cache resolved static item lists per source so provider functions run once.
98
+ const staticCacheRef = useRef(new WeakMap<MentionSource, MentionItem[]>());
99
+ // Cache async search results per `${prefix}::${query}`.
100
+ const searchCacheRef = useRef(new Map<string, MentionItem[]>());
101
+
102
+ const sources = sourcesProp ?? getMentionSources();
103
+ const isOpen = enabled && active !== null;
104
+
105
+ const close = useCallback(() => {
106
+ activeRef.current = null;
107
+ setActive(null);
108
+ setItems([]);
109
+ setActiveIndex(0);
110
+ setLoading(false);
111
+ }, []);
112
+
113
+ /** Re-read the caret and (re)detect the active mention, ignoring no-op changes. */
114
+ const refresh = useCallback(() => {
115
+ let next: ActiveMention | null = null;
116
+ if (enabled && textarea && textarea.selectionStart === textarea.selectionEnd) {
117
+ next = parseActiveMention(getText(), textarea.selectionStart ?? 0, sources);
118
+ }
119
+
120
+ const prev = activeRef.current;
121
+ const unchanged =
122
+ (!next && !prev) ||
123
+ (!!next &&
124
+ !!prev &&
125
+ next.source === prev.source &&
126
+ next.token === prev.token &&
127
+ next.start === prev.start &&
128
+ next.end === prev.end);
129
+ if (unchanged) return;
130
+
131
+ activeRef.current = next;
132
+ setActive(next);
133
+ if (next && textarea) {
134
+ setCaretRect(caretCoordinates(textarea, next.start));
135
+ }
136
+ }, [enabled, textarea, getText, sources]);
137
+
138
+ // Track input + caret movement.
139
+ useEffect(() => {
140
+ if (!textarea || !enabled) return;
141
+ const onInput = () => refresh();
142
+ const onSelectionChange = () => {
143
+ if (document.activeElement === textarea) refresh();
144
+ };
145
+ const onBlur = () => close();
146
+ textarea.addEventListener("input", onInput);
147
+ textarea.addEventListener("blur", onBlur);
148
+ document.addEventListener("selectionchange", onSelectionChange);
149
+ return () => {
150
+ textarea.removeEventListener("input", onInput);
151
+ textarea.removeEventListener("blur", onBlur);
152
+ document.removeEventListener("selectionchange", onSelectionChange);
153
+ };
154
+ }, [textarea, enabled, refresh, close]);
155
+
156
+ // Resolve items for the active mention (static filter or async search).
157
+ useEffect(() => {
158
+ if (!active) return;
159
+ const { source, query } = active;
160
+ setActiveIndex(0);
161
+ const limit = source.limit ?? 8;
162
+
163
+ // Async, server-backed source.
164
+ if (typeof source.search === "function") {
165
+ const minChars = source.minChars ?? 0;
166
+ if (query.length < minChars) {
167
+ setItems([]);
168
+ setLoading(false);
169
+ return;
170
+ }
171
+ const key = `${source.prefix}::${query}`;
172
+ const cached = searchCacheRef.current.get(key);
173
+ if (cached) {
174
+ setItems(cached);
175
+ setLoading(false);
176
+ return;
177
+ }
178
+ let cancelled = false;
179
+ setLoading(true);
180
+ const timer = setTimeout(() => {
181
+ Promise.resolve(source.search!(query))
182
+ .then((result) => normalizeMentionItems(result).slice(0, limit))
183
+ .then((resolved) => {
184
+ searchCacheRef.current.set(key, resolved);
185
+ if (!cancelled) {
186
+ setItems(resolved);
187
+ setLoading(false);
188
+ }
189
+ })
190
+ .catch((error) => {
191
+ console.error("Mention search failed", error);
192
+ if (!cancelled) {
193
+ setItems([]);
194
+ setLoading(false);
195
+ }
196
+ });
197
+ }, searchDebounceMs);
198
+ return () => {
199
+ cancelled = true;
200
+ clearTimeout(timer);
201
+ };
202
+ }
203
+
204
+ // Static, in-memory source.
205
+ const resolveStatic = (): Promise<MentionItem[]> => {
206
+ const cached = staticCacheRef.current.get(source);
207
+ if (cached) return Promise.resolve(cached);
208
+ const input = source.items;
209
+ const raw = typeof input === "function" ? input() : input ?? [];
210
+ return Promise.resolve(raw).then((list) => {
211
+ const normalized = normalizeMentionItems(list);
212
+ staticCacheRef.current.set(source, normalized);
213
+ return normalized;
214
+ });
215
+ };
216
+
217
+ let cancelled = false;
218
+ resolveStatic().then((list) => {
219
+ if (!cancelled) setItems(filterMentionItems(list, query, limit));
220
+ });
221
+ return () => {
222
+ cancelled = true;
223
+ };
224
+ }, [active, searchDebounceMs]);
225
+
226
+ const apply = useCallback(
227
+ (item: MentionItem) => {
228
+ if (!active) return;
229
+ const insertText = buildMentionInsertText(active.source, item);
230
+ const result = applyMention(getText(), active, insertText);
231
+ close();
232
+ setText(result.text, result.caret);
233
+ // Return focus to the textarea after the value write settles.
234
+ requestAnimationFrame(() => {
235
+ textarea?.focus();
236
+ textarea?.setSelectionRange(result.caret, result.caret);
237
+ });
238
+ },
239
+ [active, getText, setText, close, textarea],
240
+ );
241
+
242
+ const onKeyDown = useCallback(
243
+ (event: KeyboardEvent): boolean => {
244
+ if (!isOpen || !NAV_KEYS.has(event.key)) return false;
245
+ if (event.key === "Escape") {
246
+ event.preventDefault();
247
+ event.stopImmediatePropagation();
248
+ close();
249
+ return true;
250
+ }
251
+ if (items.length === 0) {
252
+ // Swallow nav keys while open with no results only for Escape (handled).
253
+ // Let other keys through so typing/navigation still works.
254
+ return false;
255
+ }
256
+ if (event.key === "ArrowDown") {
257
+ event.preventDefault();
258
+ event.stopImmediatePropagation();
259
+ setActiveIndex((prev) => (prev + 1 >= items.length ? 0 : prev + 1));
260
+ return true;
261
+ }
262
+ if (event.key === "ArrowUp") {
263
+ event.preventDefault();
264
+ event.stopImmediatePropagation();
265
+ setActiveIndex((prev) => (prev - 1 < 0 ? items.length - 1 : prev - 1));
266
+ return true;
267
+ }
268
+ if (event.key === "Enter" || event.key === "Tab") {
269
+ event.preventDefault();
270
+ event.stopImmediatePropagation();
271
+ apply(items[activeIndex] ?? items[0]);
272
+ return true;
273
+ }
274
+ return false;
275
+ },
276
+ [isOpen, items, activeIndex, apply, close],
277
+ );
278
+
279
+ // Keep a fresh handler for the (optional) self-attached listener.
280
+ const handlerRef = useRef(onKeyDown);
281
+ handlerRef.current = onKeyDown;
282
+
283
+ useEffect(() => {
284
+ if (!textarea || !attachKeyDown || !enabled) return;
285
+ const listener = (event: KeyboardEvent) => {
286
+ handlerRef.current(event);
287
+ };
288
+ const opts: AddEventListenerOptions = { capture: true };
289
+ textarea.addEventListener("keydown", listener, opts);
290
+ return () => textarea.removeEventListener("keydown", listener, opts);
291
+ }, [textarea, attachKeyDown, enabled]);
292
+
293
+ const overlay = isOpen ? (
294
+ <MentionPopup
295
+ active={active}
296
+ items={items}
297
+ activeIndex={activeIndex}
298
+ loading={loading}
299
+ caretRect={caretRect}
300
+ onHover={setActiveIndex}
301
+ onSelect={apply}
302
+ />
303
+ ) : null;
304
+
305
+ return { overlay, onKeyDown, isOpen, close };
306
+ }
307
+
308
+ type MentionPopupProps = {
309
+ active: ActiveMention;
310
+ items: MentionItem[];
311
+ activeIndex: number;
312
+ loading: boolean;
313
+ caretRect: CaretRect | null;
314
+ onHover: (index: number) => void;
315
+ onSelect: (item: MentionItem) => void;
316
+ };
317
+
318
+ function MentionPopup({
319
+ active,
320
+ items,
321
+ activeIndex,
322
+ loading,
323
+ caretRect,
324
+ onHover,
325
+ onSelect,
326
+ }: MentionPopupProps) {
327
+ const listRef = useRef<HTMLDivElement | null>(null);
328
+
329
+ // Keep the highlighted item scrolled into view.
330
+ useLayoutEffect(() => {
331
+ const el = listRef.current?.querySelector<HTMLElement>('[data-active="true"]');
332
+ el?.scrollIntoView({ block: "nearest" });
333
+ }, [activeIndex, items]);
334
+
335
+ if (typeof document === "undefined") return null;
336
+
337
+ const style: CSSProperties = caretRect
338
+ ? { position: "fixed", left: caretRect.left, top: caretRect.bottom + 4 }
339
+ : { position: "fixed", left: 16, top: 16 };
340
+
341
+ const source = active.source;
342
+ const minChars = source.minChars ?? 0;
343
+ const belowMin = typeof source.search === "function" && active.query.length < minChars;
344
+
345
+ return createPortal(
346
+ <div className="bn-mention-popup" role="listbox" style={style} ref={listRef}>
347
+ {source.label && (
348
+ <div className="bn-mention-popup__header">
349
+ <span className="bn-mention-popup__prefix">@{source.prefix}</span>
350
+ <span className="bn-mention-popup__label">{source.label}</span>
351
+ </div>
352
+ )}
353
+ {belowMin ? (
354
+ <div className="bn-mention-popup__status">Keep typing to search…</div>
355
+ ) : loading ? (
356
+ <div className="bn-mention-popup__status">Searching…</div>
357
+ ) : items.length === 0 ? (
358
+ <div className="bn-mention-popup__status">No matches</div>
359
+ ) : (
360
+ items.map((item, index) => (
361
+ <button
362
+ type="button"
363
+ key={item.id}
364
+ role="option"
365
+ data-active={index === activeIndex}
366
+ aria-selected={index === activeIndex}
367
+ className={
368
+ index === activeIndex
369
+ ? "bn-mention-item bn-mention-item--active"
370
+ : "bn-mention-item"
371
+ }
372
+ // onMouseDown (not onClick) + preventDefault keeps the textarea focused.
373
+ onMouseDown={(event) => {
374
+ event.preventDefault();
375
+ onSelect(item);
376
+ }}
377
+ onMouseEnter={() => onHover(index)}
378
+ tabIndex={-1}
379
+ >
380
+ <span className="bn-mention-item__label">{item.label}</span>
381
+ {item.detail && <span className="bn-mention-item__detail">{item.detail}</span>}
382
+ </button>
383
+ ))
384
+ )}
385
+ </div>,
386
+ document.body,
387
+ );
388
+ }
389
+
390
+ /* ------------------------------------------------------------------ *
391
+ * Caret coordinates via the mirror-div technique. Works for any plain
392
+ * <textarea>; returns viewport (fixed) coordinates of the given index.
393
+ * ------------------------------------------------------------------ */
394
+
395
+ const MIRROR_PROPS = [
396
+ "boxSizing",
397
+ "width",
398
+ "borderTopWidth",
399
+ "borderRightWidth",
400
+ "borderBottomWidth",
401
+ "borderLeftWidth",
402
+ "paddingTop",
403
+ "paddingRight",
404
+ "paddingBottom",
405
+ "paddingLeft",
406
+ "fontStyle",
407
+ "fontVariant",
408
+ "fontWeight",
409
+ "fontStretch",
410
+ "fontSize",
411
+ "fontSizeAdjust",
412
+ "lineHeight",
413
+ "fontFamily",
414
+ "textAlign",
415
+ "textTransform",
416
+ "textIndent",
417
+ "textDecoration",
418
+ "letterSpacing",
419
+ "wordSpacing",
420
+ "tabSize",
421
+ "whiteSpace",
422
+ "wordWrap",
423
+ "wordBreak",
424
+ ] as const;
425
+
426
+ function caretCoordinates(textarea: HTMLTextAreaElement, position: number): CaretRect {
427
+ const rect = textarea.getBoundingClientRect();
428
+ const fallback: CaretRect = {
429
+ left: rect.left,
430
+ top: rect.top,
431
+ bottom: rect.bottom,
432
+ height: rect.height,
433
+ };
434
+ if (typeof document === "undefined") return fallback;
435
+
436
+ const style = window.getComputedStyle(textarea);
437
+ const mirror = document.createElement("div");
438
+ const cs = mirror.style;
439
+ cs.position = "absolute";
440
+ cs.visibility = "hidden";
441
+ cs.whiteSpace = "pre-wrap";
442
+ cs.overflow = "hidden";
443
+ const sourceStyle = style as unknown as Record<string, string>;
444
+ const mirrorStyle = cs as unknown as Record<string, string>;
445
+ for (const prop of MIRROR_PROPS) {
446
+ mirrorStyle[prop] = sourceStyle[prop];
447
+ }
448
+ cs.width = `${textarea.clientWidth}px`;
449
+
450
+ const value = textarea.value;
451
+ mirror.textContent = value.slice(0, position);
452
+ const marker = document.createElement("span");
453
+ marker.textContent = value.slice(position) || ".";
454
+ mirror.appendChild(marker);
455
+ document.body.appendChild(mirror);
456
+
457
+ const lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.2;
458
+ const left = rect.left + marker.offsetLeft - textarea.scrollLeft;
459
+ const top = rect.top + marker.offsetTop - textarea.scrollTop;
460
+ document.body.removeChild(mirror);
461
+
462
+ return { left, top, bottom: top + lineHeight, height: lineHeight };
463
+ }
@@ -0,0 +1,46 @@
1
+ import { SuggestionMenuController, useBlockNoteEditor } from "@blocknote/react";
2
+ import type { DefaultReactSuggestionItem } from "@blocknote/react";
3
+ import {
4
+ buildMentionInsertText,
5
+ getMentionSources,
6
+ resolveMentionQuery,
7
+ type MentionSource,
8
+ } from "./mentionAutocomplete";
9
+
10
+ export type MentionMenuProps = {
11
+ /** Sources to use. Falls back to the global registry (`setMentionSources`). */
12
+ sources?: MentionSource[];
13
+ /** Character that opens the menu. Default `@`. */
14
+ triggerCharacter?: string;
15
+ };
16
+
17
+ /**
18
+ * Universal `@`-mention menu for the main BlockNote editor. Render it as a child
19
+ * of `<BlockNoteView>`. On selection it inserts the source's token (e.g.
20
+ * `@T123456`, `@S98765`, `@username`) as inline text at the cursor.
21
+ *
22
+ * <BlockNoteView editor={editor}>
23
+ * <MentionMenu />
24
+ * </BlockNoteView>
25
+ */
26
+ export function MentionMenu({ sources: sourcesProp, triggerCharacter = "@" }: MentionMenuProps) {
27
+ const editor = useBlockNoteEditor();
28
+
29
+ const getItems = async (query: string): Promise<DefaultReactSuggestionItem[]> => {
30
+ const sources = sourcesProp ?? getMentionSources();
31
+ const resolved = await resolveMentionQuery(query, sources);
32
+ if (!resolved) return [];
33
+ const { source, items } = resolved;
34
+ return items.map((item) => ({
35
+ title: item.label,
36
+ subtext: item.detail ?? undefined,
37
+ onItemClick: () => {
38
+ // The controller has already removed the trigger + query text; we insert
39
+ // the token plus a trailing space so the caret is ready for more typing.
40
+ editor.insertInlineContent([buildMentionInsertText(source, item), " "]);
41
+ },
42
+ }));
43
+ };
44
+
45
+ return <SuggestionMenuController triggerCharacter={triggerCharacter} getItems={getItems} />;
46
+ }
@@ -3223,6 +3223,77 @@ describe("test/suite metadata comments", () => {
3223
3223
  expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
3224
3224
  });
3225
3225
 
3226
+ it("parses a YAML-list issues field without splitting URLs on ://", () => {
3227
+ const markdown = [
3228
+ "<!-- test",
3229
+ "id: @T4ffddec3",
3230
+ "type: manual",
3231
+ "issues:",
3232
+ " - https://github.com/testomatio/testomatio/issues/8963",
3233
+ "-->",
3234
+ ].join("\n");
3235
+ const blocks = markdownToBlocks(markdown);
3236
+ expect((blocks[0].props as any).metaFields).toBe(
3237
+ JSON.stringify([
3238
+ { key: "id", value: "@T4ffddec3" },
3239
+ { key: "type", value: "manual" },
3240
+ {
3241
+ key: "issues",
3242
+ value: "https://github.com/testomatio/testomatio/issues/8963",
3243
+ },
3244
+ ]),
3245
+ );
3246
+ });
3247
+
3248
+ it("joins multiple YAML-list issues items with a comma", () => {
3249
+ const markdown = [
3250
+ "<!-- test",
3251
+ "id: @T4ffddec3",
3252
+ "issues:",
3253
+ " - https://github.com/testomatio/testomatio/issues/8963",
3254
+ " - https://github.com/testomatio/testomatio/issues/9001",
3255
+ "-->",
3256
+ ].join("\n");
3257
+ const blocks = markdownToBlocks(markdown);
3258
+ expect((blocks[0].props as any).metaFields).toBe(
3259
+ JSON.stringify([
3260
+ { key: "id", value: "@T4ffddec3" },
3261
+ {
3262
+ key: "issues",
3263
+ value:
3264
+ "https://github.com/testomatio/testomatio/issues/8963, https://github.com/testomatio/testomatio/issues/9001",
3265
+ },
3266
+ ]),
3267
+ );
3268
+ });
3269
+
3270
+ it("round-trips a YAML-list issues field (single item)", () => {
3271
+ const markdown = [
3272
+ "<!-- test",
3273
+ "id: @T4ffddec3",
3274
+ "type: manual",
3275
+ "priority: normal",
3276
+ "issues:",
3277
+ " - https://github.com/testomatio/testomatio/issues/8963",
3278
+ "-->",
3279
+ ].join("\n");
3280
+ const blocks = markdownToBlocks(markdown);
3281
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
3282
+ });
3283
+
3284
+ it("round-trips a YAML-list issues field (multiple items)", () => {
3285
+ const markdown = [
3286
+ "<!-- test",
3287
+ "id: @T4ffddec3",
3288
+ "issues:",
3289
+ " - https://github.com/testomatio/testomatio/issues/8963",
3290
+ " - https://github.com/testomatio/testomatio/issues/9001",
3291
+ "-->",
3292
+ ].join("\n");
3293
+ const blocks = markdownToBlocks(markdown);
3294
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
3295
+ });
3296
+
3226
3297
  it("ignores lines without a colon inside a metadata block", () => {
3227
3298
  const markdown = [
3228
3299
  "<!-- test",
@@ -451,7 +451,20 @@ function serializeBlock(
451
451
  }
452
452
 
453
453
  lines.push(`<!-- ${kind}`);
454
- fields.forEach((field) => lines.push(`${field.key}: ${field.value}`));
454
+ fields.forEach((field) => {
455
+ // List-valued keys (e.g. `issues`) are written back as a YAML list to
456
+ // preserve the backend's format. The single panel field holds the items
457
+ // comma-separated, so split them back out into ` - item` lines.
458
+ if (LIST_META_KEYS.has(field.key.trim().toLowerCase())) {
459
+ const items = field.value.split(",").map((s) => s.trim()).filter(Boolean);
460
+ if (items.length) {
461
+ lines.push(`${field.key}:`);
462
+ items.forEach((item) => lines.push(` - ${item}`));
463
+ return;
464
+ }
465
+ }
466
+ lines.push(`${field.key}: ${field.value}`);
467
+ });
455
468
  lines.push("-->");
456
469
  return lines;
457
470
  }
@@ -1422,20 +1435,41 @@ function parseParagraph(lines: string[], index: number): { block: CustomPartialB
1422
1435
 
1423
1436
  const META_COMMENT_OPEN_REGEX = /^<!--\s*(test|suite)(?=\s|-->|$)/i;
1424
1437
 
1438
+ // Keys whose value is a YAML-style list (`key:` followed by indented `- item`
1439
+ // lines) in the testomat.io comment format — e.g. `issues` holding URLs. These
1440
+ // round-trip back to a list on serialize; everything else stays a flat line.
1441
+ const LIST_META_KEYS = new Set(["issues"]);
1442
+
1425
1443
  function metaFieldsFromBody(bodyLines: string[]): { key: string; value: string }[] {
1426
- const fields: { key: string; value: string }[] = [];
1444
+ const fields: { key: string; value: string; items: string[] }[] = [];
1427
1445
  for (const raw of bodyLines) {
1428
1446
  const line = raw.trim();
1429
1447
  if (!line) continue;
1448
+
1449
+ // YAML list item: belongs to the most recent `key:` field. The whole item
1450
+ // (e.g. `https://github.com/...`) is kept verbatim — because we catch the
1451
+ // list line before the colon check, URLs with `://` are never split.
1452
+ if (line === "-" || line.startsWith("- ")) {
1453
+ const item = line.slice(1).trim();
1454
+ const current = fields[fields.length - 1];
1455
+ if (current && item) current.items.push(item);
1456
+ continue;
1457
+ }
1458
+
1430
1459
  const colon = line.indexOf(":");
1431
1460
  // "Each line is `key: value`; lines without `:` are ignored."
1432
1461
  if (colon === -1) continue;
1433
1462
  const key = line.slice(0, colon).trim();
1434
1463
  const value = line.slice(colon + 1).trim();
1435
1464
  if (!key) continue;
1436
- fields.push({ key, value });
1465
+ fields.push({ key, value, items: [] });
1437
1466
  }
1438
- return fields;
1467
+
1468
+ // A field with collected list items → value is the items joined by ", ".
1469
+ return fields.map(({ key, value, items }) => ({
1470
+ key,
1471
+ value: items.length ? items.join(", ") : value,
1472
+ }));
1439
1473
  }
1440
1474
 
1441
1475
  function parseMetaComment(