testomatio-editor-blocks 0.1.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.
@@ -0,0 +1,1237 @@
1
+ import {
2
+ isLinkInlineContent,
3
+ isStyledTextInlineContent,
4
+ } from "@blocknote/core";
5
+ import type {
6
+ Block,
7
+ InlineContent,
8
+ PartialBlock,
9
+ Styles,
10
+ } from "@blocknote/core";
11
+ import type { customSchema } from "./customSchema";
12
+
13
+ // Types derived from the custom schema so the converter stays type-safe when the schema evolves.
14
+ type Schema = typeof customSchema;
15
+ export type CustomEditorBlock = Block<
16
+ Schema["blockSchema"],
17
+ Schema["inlineContentSchema"],
18
+ Schema["styleSchema"]
19
+ >;
20
+ export type CustomPartialBlock = PartialBlock<
21
+ Schema["blockSchema"],
22
+ Schema["inlineContentSchema"],
23
+ Schema["styleSchema"]
24
+ >;
25
+ type EditorInline = InlineContent<
26
+ Schema["inlineContentSchema"],
27
+ Schema["styleSchema"]
28
+ >;
29
+ type EditorStyles = Styles<Schema["styleSchema"]>;
30
+
31
+ const BASE_BLOCK_PROPS = {
32
+ textAlignment: "left" as const,
33
+ textColor: "default" as const,
34
+ backgroundColor: "default" as const,
35
+ };
36
+
37
+ const BASE_CELL_PROPS = {
38
+ backgroundColor: "default" as const,
39
+ textColor: "default" as const,
40
+ textAlignment: "left" as const,
41
+ };
42
+
43
+ const TABLE_BLOCK_PROPS = {
44
+ textColor: "default" as const,
45
+ };
46
+
47
+ type MarkdownContext = {
48
+ listDepth: number;
49
+ insideQuote: boolean;
50
+ };
51
+
52
+ const headingPrefixes: Record<number, string> = {
53
+ 1: "#",
54
+ 2: "##",
55
+ 3: "###",
56
+ 4: "####",
57
+ 5: "#####",
58
+ 6: "######",
59
+ };
60
+
61
+ const STEP_STATUSES = new Set(["draft", "ready", "blocked"] as const);
62
+ type StepStatus = "draft" | "ready" | "blocked";
63
+
64
+ function normalizeStatus(value: string | undefined): StepStatus {
65
+ if (value && STEP_STATUSES.has(value as StepStatus)) {
66
+ return value as StepStatus;
67
+ }
68
+ return "draft";
69
+ }
70
+
71
+ const SPECIAL_CHAR_REGEX = /([*_`~\[\]()<>\\])/g;
72
+ const HTML_SPAN_REGEX = /<\/?span[^>]*>/g;
73
+ const HTML_UNDERLINE_REGEX = /<\/?u>/g;
74
+ const EXPECTED_LABEL_REGEX = /^(?:[*_`]*\s*)?(expected(?:\s+result)?)\s*(?:[*_`]*\s*)?(?:\s*[:\-–—]?\s*)/i;
75
+ // Matches any non-empty line that falls between the step title and the expected result line.
76
+ const STEP_DATA_LINE_REGEX =
77
+ /^(?!\s*(?:[*_`]*\s*)?(?:expected(?:\s+result)?)\b).+/i;
78
+ const NUMBERED_STEP_REGEX = /^\d+[.)]\s+/;
79
+
80
+ function escapeMarkdown(text: string): string {
81
+ return text.replace(SPECIAL_CHAR_REGEX, "\\$1");
82
+ }
83
+
84
+ function stripHtmlWrappers(text: string): string {
85
+ return text
86
+ .replace(HTML_SPAN_REGEX, "")
87
+ .replace(HTML_UNDERLINE_REGEX, "");
88
+ }
89
+
90
+ function stripExpectedPrefix(text: string): string {
91
+ const match = text.match(EXPECTED_LABEL_REGEX);
92
+ if (!match) {
93
+ return text;
94
+ }
95
+ const label = match[0];
96
+ let remainder = text.slice(label.length).trimStart();
97
+
98
+ const cleanupLeading = (value: string) => {
99
+ let result = value.trimStart();
100
+ result = result.replace(/^\\+(?=[*_`~:[\]])/, "");
101
+ result = result.replace(/^(?:[*_`~]+)(?=\s|$)/, "");
102
+ return result.trimStart();
103
+ };
104
+
105
+ remainder = cleanupLeading(remainder);
106
+ remainder = stripLeadingFormatting(remainder);
107
+ return remainder.trimStart();
108
+ }
109
+
110
+ function stripLeadingFormatting(text: string): string {
111
+ let result = text.trimStart();
112
+ let changed = true;
113
+ while (changed) {
114
+ changed = false;
115
+
116
+ // Remove escaped markers like \* or \_ at the start
117
+ if (/^\\+[*_`~]/.test(result)) {
118
+ result = result.replace(/^\\+/, "");
119
+ changed = true;
120
+ result = result.trimStart();
121
+ continue;
122
+ }
123
+
124
+ // Remove leading sequences of markdown emphasis markers
125
+ const leadingMarkers = result.match(/^([*_`~]{1,3})(\s+|$)/);
126
+ if (leadingMarkers) {
127
+ result = result.slice(leadingMarkers[1].length).trimStart();
128
+ changed = true;
129
+ continue;
130
+ }
131
+ }
132
+ return result;
133
+ }
134
+
135
+ function unescapeMarkdown(text: string): string {
136
+ return stripHtmlWrappers(text).replace(/\\([*_`~\[\]()<>\\])/g, "$1");
137
+ }
138
+
139
+ function applyTextStyles(text: string, styles: EditorStyles | undefined): string {
140
+ if (!styles) {
141
+ return text;
142
+ }
143
+
144
+ const hasCode = styles.code === true;
145
+ let result = text;
146
+
147
+ if (hasCode) {
148
+ result = "`" + result.replace(/`/g, "\\`") + "`";
149
+ // Code style supersedes other styles in Markdown.
150
+ return result;
151
+ }
152
+
153
+ const wrappers: Array<{ prefix: string; suffix?: string }> = [];
154
+
155
+ if (styles.bold) {
156
+ wrappers.push({ prefix: "**", suffix: "**" });
157
+ }
158
+
159
+ if (styles.italic) {
160
+ wrappers.push({ prefix: "*", suffix: "*" });
161
+ }
162
+
163
+ if (styles.strike) {
164
+ wrappers.push({ prefix: "~~", suffix: "~~" });
165
+ }
166
+
167
+ if (styles.underline) {
168
+ wrappers.push({ prefix: "<u>", suffix: "</u>" });
169
+ }
170
+
171
+ if (styles.textColor && styles.textColor !== "default") {
172
+ wrappers.push({
173
+ prefix: `<span style="color: ${styles.textColor}">`,
174
+ suffix: "</span>",
175
+ });
176
+ }
177
+
178
+ if (styles.backgroundColor && styles.backgroundColor !== "default") {
179
+ wrappers.push({
180
+ prefix: `<span style="background-color: ${styles.backgroundColor}">`,
181
+ suffix: "</span>",
182
+ });
183
+ }
184
+
185
+ for (const wrapper of wrappers) {
186
+ const suffix = wrapper.suffix ?? wrapper.prefix;
187
+ result = `${wrapper.prefix}${result}${suffix}`;
188
+ }
189
+
190
+ return result;
191
+ }
192
+
193
+ function inlineToMarkdown(content: CustomEditorBlock["content"]): string {
194
+ if (!content || !Array.isArray(content)) {
195
+ return "";
196
+ }
197
+
198
+ return (content as EditorInline[])
199
+ .map((item) => {
200
+ if (isStyledTextInlineContent(item)) {
201
+ return applyTextStyles(escapeMarkdown(item.text), item.styles);
202
+ }
203
+
204
+ if (isLinkInlineContent(item)) {
205
+ const inner = inlineToMarkdown(item.content);
206
+ const safeHref = escapeMarkdown(item.href);
207
+ return `[${inner}](${safeHref})`;
208
+ }
209
+
210
+ if (Array.isArray((item as any).content)) {
211
+ return inlineToMarkdown((item as any).content);
212
+ }
213
+
214
+ return "";
215
+ })
216
+ .join("");
217
+ }
218
+
219
+ function serializeChildren(block: CustomEditorBlock, ctx: MarkdownContext): string[] {
220
+ if (!block.children?.length) {
221
+ return [];
222
+ }
223
+
224
+ const childCtx = { ...ctx, listDepth: ctx.listDepth + 1 };
225
+ return serializeBlocks(block.children, childCtx);
226
+ }
227
+
228
+ function flattenWithBlankLine(lines: string[], appendBlank = false): string[] {
229
+ if (appendBlank && (lines.length === 0 || lines.at(-1) !== "")) {
230
+ return [...lines, ""];
231
+ }
232
+ return lines;
233
+ }
234
+
235
+ function serializeBlock(
236
+ block: CustomEditorBlock,
237
+ ctx: MarkdownContext,
238
+ orderedIndex?: number,
239
+ ): string[] {
240
+ const lines: string[] = [];
241
+ const indent = ctx.listDepth > 0 ? " ".repeat(ctx.listDepth) : "";
242
+
243
+ switch (block.type) {
244
+ case "paragraph": {
245
+ const text = inlineToMarkdown(block.content);
246
+ if (text.length > 0) {
247
+ lines.push(ctx.insideQuote ? `> ${text}` : text);
248
+ }
249
+ return flattenWithBlankLine(lines, !ctx.insideQuote);
250
+ }
251
+ case "heading": {
252
+ const level = (block.props as any).level ?? 1;
253
+ const prefix = headingPrefixes[level] ?? headingPrefixes[3];
254
+ const text = inlineToMarkdown(block.content);
255
+ lines.push(`${prefix} ${text}`.trimEnd());
256
+ return flattenWithBlankLine(lines, true);
257
+ }
258
+ case "quote": {
259
+ const quoteContent = serializeBlocks(block.children ?? [], {
260
+ ...ctx,
261
+ listDepth: ctx.listDepth,
262
+ insideQuote: true,
263
+ });
264
+ if (block.content?.length) {
265
+ const quoteText = inlineToMarkdown(block.content)
266
+ .split(/\n/)
267
+ .map((fragment) => `> ${fragment}`);
268
+ lines.push(...quoteText);
269
+ }
270
+ lines.push(...quoteContent.map((line) => (line ? `> ${line}` : ">")));
271
+ return flattenWithBlankLine(lines, true);
272
+ }
273
+ case "codeBlock": {
274
+ const language = (block.props as any).language || "";
275
+ const fence = "```" + language;
276
+ const body = inlineToMarkdown(block.content);
277
+ lines.push(fence);
278
+ if (body.length > 0) {
279
+ lines.push(body);
280
+ }
281
+ lines.push("```");
282
+ return flattenWithBlankLine(lines, true);
283
+ }
284
+ case "bulletListItem": {
285
+ const text = inlineToMarkdown(block.content);
286
+ lines.push(`${indent}- ${text}`.trimEnd());
287
+ lines.push(...serializeChildren(block, ctx));
288
+ return lines;
289
+ }
290
+ case "numberedListItem": {
291
+ const number = orderedIndex ??
292
+ (typeof (block.props as any).start === "number"
293
+ ? (block.props as any).start
294
+ : 1);
295
+ const text = inlineToMarkdown(block.content);
296
+ lines.push(`${indent}${number}. ${text}`.trimEnd());
297
+ lines.push(...serializeChildren(block, ctx));
298
+ return lines;
299
+ }
300
+ case "checkListItem": {
301
+ const checked = (block.props as any).checked ? "x" : " ";
302
+ const text = inlineToMarkdown(block.content);
303
+ lines.push(`${indent}- [${checked}] ${text}`.trimEnd());
304
+ lines.push(...serializeChildren(block, ctx));
305
+ return lines;
306
+ }
307
+ case "testCase": {
308
+ const status = (block.props as any).status ?? "draft";
309
+ const reference = (block.props as any).reference;
310
+ const attrs = [`status="${status}"`];
311
+ if (reference) {
312
+ attrs.push(`reference="${escapeMarkdown(reference)}"`);
313
+ }
314
+ lines.push(`:::test-case ${attrs.join(" ")}`.trimEnd());
315
+ const body = inlineToMarkdown(block.content);
316
+ if (body.length > 0) {
317
+ lines.push(body);
318
+ }
319
+ lines.push(":::");
320
+ return flattenWithBlankLine(lines, true);
321
+ }
322
+ case "testStep": {
323
+ const stepTitle = ((block.props as any).stepTitle ?? "").trim();
324
+ const stepData = ((block.props as any).stepData ?? "").trim();
325
+ const expectedResult = ((block.props as any).expectedResult ?? "").trim();
326
+
327
+ if (stepTitle.length > 0) {
328
+ const normalizedTitle = stepTitle
329
+ .split(/\r?\n/)
330
+ .map((segment: string) => segment.trim())
331
+ .filter((segment: string) => segment.length > 0)
332
+ .join(" ");
333
+
334
+ if (normalizedTitle.length > 0) {
335
+ lines.push(`* ${normalizedTitle}`);
336
+ }
337
+ }
338
+
339
+ if (stepData.length > 0) {
340
+ const dataLines = stepData.split(/\r?\n/);
341
+ dataLines.forEach((dataLine: string) => {
342
+ const trimmedLine = dataLine.trim();
343
+ if (trimmedLine.length > 0) {
344
+ lines.push(` ${trimmedLine}`);
345
+ } else {
346
+ lines.push(" ");
347
+ }
348
+ });
349
+ }
350
+
351
+ const normalizedExpected = stripExpectedPrefix(expectedResult).trim();
352
+ if (normalizedExpected.length > 0) {
353
+ const expectedLines = normalizedExpected.split(/\r?\n/);
354
+ const label = "*Expected Result*";
355
+ expectedLines.forEach((expectedLine: string, index: number) => {
356
+ const trimmedLine = expectedLine.trim();
357
+ if (trimmedLine.length === 0) {
358
+ return;
359
+ }
360
+
361
+ if (index === 0) {
362
+ lines.push(` ${label}: ${trimmedLine}`);
363
+ } else {
364
+ lines.push(` ${trimmedLine}`);
365
+ }
366
+ });
367
+ }
368
+
369
+ if (lines.length === 0) {
370
+ return lines;
371
+ }
372
+
373
+ return flattenWithBlankLine(lines, false);
374
+ }
375
+ case "table": {
376
+ const tableContent = block.content as any;
377
+ if (!tableContent || tableContent.type !== "tableContent") {
378
+ return flattenWithBlankLine(lines, true);
379
+ }
380
+
381
+ const rows: any[] = Array.isArray(tableContent.rows)
382
+ ? tableContent.rows
383
+ : [];
384
+
385
+ if (rows.length === 0) {
386
+ return flattenWithBlankLine(lines, true);
387
+ }
388
+
389
+ const columnCount = rows.reduce((max, row) => {
390
+ const length = Array.isArray(row.cells) ? row.cells.length : 0;
391
+ return Math.max(max, length);
392
+ }, 0);
393
+
394
+ if (columnCount === 0) {
395
+ return flattenWithBlankLine(lines, true);
396
+ }
397
+
398
+ const headerRowCount = rows.length
399
+ ? Math.min(rows.length, Math.max(tableContent.headerRows ?? 1, 1))
400
+ : 0;
401
+
402
+ const columnAlignments: Array<"left" | "center" | "right" | "justify"> =
403
+ new Array(columnCount).fill("left");
404
+
405
+ const getCellAlignment = (
406
+ cell: any,
407
+ ): "left" | "center" | "right" | "justify" => {
408
+ if (cell && typeof cell === "object" && cell.props?.textAlignment) {
409
+ return cell.props.textAlignment;
410
+ }
411
+ return "left";
412
+ };
413
+
414
+ const getCellText = (cell: any): string => {
415
+ if (Array.isArray(cell)) {
416
+ return inlineToMarkdown(cell as any);
417
+ }
418
+ if (cell && typeof cell === "object" && Array.isArray(cell.content)) {
419
+ return inlineToMarkdown(cell.content as any);
420
+ }
421
+ return "";
422
+ };
423
+
424
+ rows.forEach((row: any) => {
425
+ if (!Array.isArray(row.cells)) {
426
+ return;
427
+ }
428
+ row.cells.forEach((cell: any, index: number) => {
429
+ const alignment = getCellAlignment(cell);
430
+ if (alignment !== "left") {
431
+ columnAlignments[index] = alignment;
432
+ }
433
+ });
434
+ });
435
+
436
+ const normalizeRow = (row: any): string[] => {
437
+ const cells = Array.isArray(row.cells) ? row.cells : [];
438
+ const cellTexts = cells.map(getCellText);
439
+ while (cellTexts.length < columnCount) {
440
+ cellTexts.push("");
441
+ }
442
+ return cellTexts;
443
+ };
444
+
445
+ const formattedRows = rows.map(normalizeRow);
446
+ const formatCell = (value: string) => (value.length ? value : " ");
447
+ const toAlignmentToken = (alignment: string) => {
448
+ switch (alignment) {
449
+ case "center":
450
+ return ":---:";
451
+ case "right":
452
+ return "---:";
453
+ case "justify":
454
+ return ":---:";
455
+ default:
456
+ return "---";
457
+ }
458
+ };
459
+
460
+ const headerRow = formattedRows[0];
461
+ lines.push(
462
+ `| ${headerRow.map((cell) => formatCell(cell)).join(" | ")} |`,
463
+ );
464
+ lines.push(
465
+ `| ${columnAlignments
466
+ .map((alignment) => toAlignmentToken(alignment))
467
+ .join(" | ")} |`,
468
+ );
469
+
470
+ const bodyStartIndex = headerRowCount > 0 ? headerRowCount : 1;
471
+ formattedRows.slice(bodyStartIndex).forEach((row) => {
472
+ lines.push(`| ${row.map((cell) => formatCell(cell)).join(" | ")} |`);
473
+ });
474
+
475
+ return flattenWithBlankLine(lines, true);
476
+ }
477
+ }
478
+
479
+ const fallbackBlock = block as unknown as CustomEditorBlock;
480
+ if (fallbackBlock.content) {
481
+ const text = inlineToMarkdown(fallbackBlock.content);
482
+ if (text.length > 0) {
483
+ lines.push(text);
484
+ }
485
+ }
486
+ lines.push(...serializeChildren(fallbackBlock, ctx));
487
+ return flattenWithBlankLine(lines, false);
488
+ }
489
+
490
+ function serializeBlocks(blocks: CustomEditorBlock[], ctx: MarkdownContext): string[] {
491
+ const lines: string[] = [];
492
+ let orderedIndex: number | null = null;
493
+
494
+ for (const block of blocks) {
495
+ if (block.type === "numberedListItem") {
496
+ if (typeof (block.props as any).start === "number") {
497
+ orderedIndex = (block.props as any).start as number;
498
+ } else if (orderedIndex === null) {
499
+ orderedIndex = 1;
500
+ }
501
+
502
+ lines.push(...serializeBlock(block, ctx, orderedIndex));
503
+ orderedIndex += 1;
504
+ continue;
505
+ }
506
+
507
+ orderedIndex = null;
508
+ lines.push(...serializeBlock(block, ctx));
509
+ }
510
+
511
+ return lines;
512
+ }
513
+
514
+ export function blocksToMarkdown(blocks: CustomEditorBlock[]): string {
515
+ const lines = serializeBlocks(blocks, { listDepth: 0, insideQuote: false });
516
+ const cleaned = lines
517
+ // Collapse more than two blank lines into just two for readability.
518
+ .join("\n")
519
+ .replace(/\n{3,}/g, "\n\n")
520
+ .trimEnd();
521
+
522
+ return cleaned;
523
+ }
524
+
525
+ function parseInlineMarkdown(text: string): EditorInline[] {
526
+ const cleaned = stripHtmlWrappers(text);
527
+ const result: EditorInline[] = [];
528
+ let buffer = "";
529
+
530
+ const pushPlain = () => {
531
+ if (buffer.length === 0) {
532
+ return;
533
+ }
534
+ result.push({ type: "text", text: unescapeMarkdown(buffer), styles: {} });
535
+ buffer = "";
536
+ };
537
+
538
+ let i = 0;
539
+ while (i < cleaned.length) {
540
+ if (cleaned.startsWith("**", i)) {
541
+ const end = cleaned.indexOf("**", i + 2);
542
+ if (end !== -1) {
543
+ pushPlain();
544
+ const inner = cleaned.slice(i + 2, end);
545
+ result.push({
546
+ type: "text",
547
+ text: unescapeMarkdown(inner),
548
+ styles: { bold: true },
549
+ });
550
+ i = end + 2;
551
+ continue;
552
+ }
553
+ }
554
+
555
+ if (cleaned.startsWith("~~", i)) {
556
+ const end = cleaned.indexOf("~~", i + 2);
557
+ if (end !== -1) {
558
+ pushPlain();
559
+ const inner = cleaned.slice(i + 2, end);
560
+ result.push({
561
+ type: "text",
562
+ text: unescapeMarkdown(inner),
563
+ styles: { strike: true },
564
+ });
565
+ i = end + 2;
566
+ continue;
567
+ }
568
+ }
569
+
570
+ if (cleaned.startsWith("`", i)) {
571
+ const end = cleaned.indexOf("`", i + 1);
572
+ if (end !== -1) {
573
+ pushPlain();
574
+ const inner = cleaned.slice(i + 1, end);
575
+ result.push({
576
+ type: "text",
577
+ text: unescapeMarkdown(inner),
578
+ styles: { code: true },
579
+ });
580
+ i = end + 1;
581
+ continue;
582
+ }
583
+ }
584
+
585
+ if (cleaned[i] === "[") {
586
+ const endLabel = cleaned.indexOf("]", i + 1);
587
+ const startLink = cleaned.indexOf("(", endLabel + 1);
588
+ const endLink = cleaned.indexOf(")", startLink + 1);
589
+ if (endLabel !== -1 && startLink === endLabel + 1 && endLink !== -1) {
590
+ pushPlain();
591
+ const label = cleaned.slice(i + 1, endLabel);
592
+ const href = cleaned.slice(startLink + 1, endLink);
593
+ result.push({
594
+ type: "link",
595
+ href: unescapeMarkdown(href),
596
+ content: parseInlineMarkdown(label),
597
+ } as any);
598
+ i = endLink + 1;
599
+ continue;
600
+ }
601
+ }
602
+
603
+ if (cleaned.startsWith("*", i)) {
604
+ const end = cleaned.indexOf("*", i + 1);
605
+ if (end !== -1) {
606
+ pushPlain();
607
+ const inner = cleaned.slice(i + 1, end);
608
+ result.push({
609
+ type: "text",
610
+ text: unescapeMarkdown(inner),
611
+ styles: { italic: true },
612
+ });
613
+ i = end + 1;
614
+ continue;
615
+ }
616
+ }
617
+
618
+ buffer += cleaned[i];
619
+ i += 1;
620
+ }
621
+
622
+ pushPlain();
623
+ return result;
624
+ }
625
+
626
+ function createTextContent(text: string) {
627
+ const inline = parseInlineMarkdown(text.trim());
628
+ return inline.length === 0 ? undefined : inline;
629
+ }
630
+
631
+ function cloneBaseProps() {
632
+ return { ...BASE_BLOCK_PROPS };
633
+ }
634
+
635
+ function cloneCellProps() {
636
+ return { ...BASE_CELL_PROPS };
637
+ }
638
+
639
+ function detectListType(trimmed: string): "bullet" | "numbered" | "check" | null {
640
+ if (/^[-*+]\s+\[[xX\s]\]\s+/.test(trimmed)) {
641
+ return "check";
642
+ }
643
+ if (/^\d+[.)]\s+/.test(trimmed)) {
644
+ return "numbered";
645
+ }
646
+ if (/^[-*+]\s+/.test(trimmed)) {
647
+ return "bullet";
648
+ }
649
+ return null;
650
+ }
651
+
652
+ function countIndent(line: string): number {
653
+ const match = line.match(/^ */);
654
+ return match ? match[0].length : 0;
655
+ }
656
+
657
+ type ListParseResult = {
658
+ items: CustomPartialBlock[];
659
+ nextIndex: number;
660
+ };
661
+
662
+ function parseList(
663
+ lines: string[],
664
+ startIndex: number,
665
+ listType: "bullet" | "numbered" | "check",
666
+ indentLevel: number,
667
+ ): ListParseResult {
668
+ const items: CustomPartialBlock[] = [];
669
+ let index = startIndex;
670
+
671
+ while (index < lines.length) {
672
+ const rawLine = lines[index];
673
+ const trimmed = rawLine.trim();
674
+
675
+ if (!trimmed) {
676
+ index += 1;
677
+ continue;
678
+ }
679
+
680
+ let indent = countIndent(rawLine);
681
+ const baseIndent = indentLevel * 2;
682
+ if (indent > baseIndent && indent <= baseIndent + 1) {
683
+ indent = baseIndent;
684
+ }
685
+ if (indent < indentLevel * 2) {
686
+ break;
687
+ }
688
+
689
+ if (indent > indentLevel * 2) {
690
+ const lastItem = items.at(-1);
691
+ if (!lastItem) {
692
+ break;
693
+ }
694
+ const nestedType = detectListType(trimmed);
695
+ if (!nestedType) {
696
+ break;
697
+ }
698
+ const nested = parseList(lines, index, nestedType, indentLevel + 1);
699
+ lastItem.children = [...(lastItem.children ?? []), ...nested.items];
700
+ index = nested.nextIndex;
701
+ continue;
702
+ }
703
+
704
+ const detectedType = detectListType(trimmed);
705
+ if (detectedType !== listType) {
706
+ break;
707
+ }
708
+
709
+ if (listType === "check") {
710
+ const checkMatch = trimmed.match(/^[-*+]\s+\[([xX\s])\]\s+(.*)$/);
711
+ const checked = (checkMatch?.[1] ?? "").toLowerCase() === "x";
712
+ const text = checkMatch?.[2] ?? trimmed.slice(6);
713
+ items.push({
714
+ type: "checkListItem",
715
+ props: { ...cloneBaseProps(), checked },
716
+ content: createTextContent(unescapeMarkdown(text)),
717
+ children: [],
718
+ });
719
+ } else if (listType === "numbered") {
720
+ const match = trimmed.match(/^(\d+)[.)]\s+(.*)$/);
721
+ const start = match ? Number(match[1]) : 1;
722
+ const text = match ? match[2] : trimmed;
723
+ items.push({
724
+ type: "numberedListItem",
725
+ props: { ...cloneBaseProps(), start },
726
+ content: createTextContent(unescapeMarkdown(text)),
727
+ children: [],
728
+ });
729
+ } else {
730
+ const bulletMatch = trimmed.match(/^[-*+]\s+(.*)$/);
731
+ const text = bulletMatch?.[1] ?? trimmed.slice(2);
732
+ items.push({
733
+ type: "bulletListItem",
734
+ props: cloneBaseProps(),
735
+ content: createTextContent(unescapeMarkdown(text)),
736
+ children: [],
737
+ });
738
+ }
739
+
740
+ index += 1;
741
+ }
742
+
743
+ return { items, nextIndex: index };
744
+ }
745
+
746
+ function parseTestStep(lines: string[], index: number): { block: CustomPartialBlock; nextIndex: number } | null {
747
+ const current = lines[index];
748
+ const trimmed = current.trim();
749
+ if (!trimmed.startsWith("* ") && !trimmed.startsWith("- ")) {
750
+ return null;
751
+ }
752
+
753
+ const stepTitle = unescapeMarkdown(trimmed.slice(2)).trim();
754
+ const stepDataLines: string[] = [];
755
+ let expectedResult = "";
756
+ let next = index + 1;
757
+ let inExpectedResult = false;
758
+
759
+ while (next < lines.length) {
760
+ const line = lines[next];
761
+ const hasIndent = /^\s{2,}/.test(line);
762
+ const rawTrimmed = line.trim();
763
+
764
+ if (!rawTrimmed) {
765
+ if (stepDataLines.length > 0) {
766
+ stepDataLines.push("");
767
+ }
768
+ next += 1;
769
+ continue;
770
+ }
771
+ const isNumberedStep = NUMBERED_STEP_REGEX.test(rawTrimmed);
772
+ const isNewStep =
773
+ (!hasIndent && (rawTrimmed.startsWith("* ") || rawTrimmed.startsWith("- "))) ||
774
+ (!hasIndent && isNumberedStep);
775
+
776
+ if (isNewStep) {
777
+ break;
778
+ }
779
+
780
+ if (
781
+ rawTrimmed.startsWith("#") ||
782
+ rawTrimmed.startsWith(":::") ||
783
+ rawTrimmed.startsWith(">") ||
784
+ rawTrimmed.startsWith("|")
785
+ ) {
786
+ break;
787
+ }
788
+
789
+ if (rawTrimmed.match(EXPECTED_LABEL_REGEX)) {
790
+ inExpectedResult = true;
791
+ const withoutLabel = stripExpectedPrefix(rawTrimmed);
792
+ expectedResult = unescapeMarkdown(withoutLabel);
793
+ next += 1;
794
+ continue;
795
+ }
796
+
797
+ if (rawTrimmed.startsWith("```")) {
798
+ stepDataLines.push(unescapeMarkdown(rawTrimmed));
799
+ next += 1;
800
+ while (next < lines.length) {
801
+ const fenceLine = lines[next];
802
+ const fenceTrimmed = fenceLine.trim();
803
+ stepDataLines.push(unescapeMarkdown(fenceTrimmed));
804
+ next += 1;
805
+ if (fenceTrimmed.startsWith("```")) {
806
+ break;
807
+ }
808
+ }
809
+ continue;
810
+ }
811
+
812
+ if (inExpectedResult) {
813
+ const withoutLabel = stripExpectedPrefix(rawTrimmed);
814
+ expectedResult += "\n" + unescapeMarkdown(withoutLabel);
815
+ next += 1;
816
+ continue;
817
+ }
818
+
819
+ if (STEP_DATA_LINE_REGEX.test(rawTrimmed)) {
820
+ const content = unescapeMarkdown(rawTrimmed);
821
+ stepDataLines.push(content);
822
+ next += 1;
823
+ continue;
824
+ }
825
+
826
+ break;
827
+ }
828
+
829
+ const stepData = stepDataLines
830
+ .map((line) => line.trimEnd())
831
+ .join("\n")
832
+ .trim();
833
+
834
+ // Only parse as test step if there's expected result or data content
835
+ if (expectedResult || stepData) {
836
+ return {
837
+ block: {
838
+ type: "testStep",
839
+ props: {
840
+ stepTitle,
841
+ stepData,
842
+ expectedResult,
843
+ },
844
+ children: [],
845
+ },
846
+ nextIndex: next,
847
+ };
848
+ }
849
+
850
+ return null;
851
+ }
852
+
853
+ function parseTestCase(lines: string[], index: number): { block: CustomPartialBlock; nextIndex: number } | null {
854
+ const trimmed = lines[index].trim();
855
+ if (!trimmed.startsWith(":::test-case")) {
856
+ return null;
857
+ }
858
+
859
+ const statusMatch = trimmed.match(/status="([^"]*)"/);
860
+ const referenceMatch = trimmed.match(/reference="([^"]*)"/);
861
+
862
+ let bodyLines: string[] = [];
863
+ let next = index + 1;
864
+ while (next < lines.length && lines[next].trim() !== ":::") {
865
+ bodyLines.push(lines[next]);
866
+ next += 1;
867
+ }
868
+
869
+ if (next < lines.length && lines[next].trim() === ":::") {
870
+ next += 1;
871
+ }
872
+
873
+ const contentText = bodyLines.join("\n").trim();
874
+
875
+ return {
876
+ block: {
877
+ type: "testCase",
878
+ props: {
879
+ ...cloneBaseProps(),
880
+ status: normalizeStatus(statusMatch?.[1]),
881
+ reference: referenceMatch?.[1] ?? "",
882
+ },
883
+ content: contentText
884
+ ? [{ type: "text", text: unescapeMarkdown(contentText), styles: {} }]
885
+ : undefined,
886
+ children: [],
887
+ },
888
+ nextIndex: next,
889
+ };
890
+ }
891
+
892
+ function parseHeading(lines: string[], index: number): { block: CustomPartialBlock; nextIndex: number } | null {
893
+ const trimmed = lines[index].trim();
894
+ if (!trimmed.startsWith("#")) {
895
+ return null;
896
+ }
897
+
898
+ const match = trimmed.match(/^(#+)\s+(.*)$/);
899
+ if (!match) {
900
+ return null;
901
+ }
902
+
903
+ const rawLevel = Math.min(match[1].length, 3);
904
+ const level = (rawLevel === 1 || rawLevel === 2 ? rawLevel : 3) as 1 | 2 | 3;
905
+ const text = match[2];
906
+
907
+ return {
908
+ block: {
909
+ type: "heading",
910
+ props: { ...cloneBaseProps(), level },
911
+ content: createTextContent(unescapeMarkdown(text)),
912
+ children: [],
913
+ },
914
+ nextIndex: index + 1,
915
+ };
916
+ }
917
+
918
+ function parseCodeBlock(lines: string[], index: number): { block: CustomPartialBlock; nextIndex: number } | null {
919
+ const trimmed = lines[index].trim();
920
+ if (!trimmed.startsWith("```") ) {
921
+ return null;
922
+ }
923
+
924
+ const language = trimmed.slice(3).trim();
925
+ const body: string[] = [];
926
+ let next = index + 1;
927
+ while (next < lines.length && !lines[next].startsWith("```") ) {
928
+ body.push(lines[next]);
929
+ next += 1;
930
+ }
931
+
932
+ if (next < lines.length && lines[next].startsWith("```")) {
933
+ next += 1;
934
+ }
935
+
936
+ return {
937
+ block: {
938
+ type: "codeBlock",
939
+ props: { language },
940
+ content: body.length
941
+ ? [{ type: "text", text: body.join("\n"), styles: {} }]
942
+ : undefined,
943
+ children: [],
944
+ },
945
+ nextIndex: next,
946
+ };
947
+ }
948
+
949
+ function parseQuote(lines: string[], index: number): { block: CustomPartialBlock; nextIndex: number } | null {
950
+ if (!lines[index].trim().startsWith(">")) {
951
+ return null;
952
+ }
953
+
954
+ const collected: string[] = [];
955
+ let next = index;
956
+ while (next < lines.length) {
957
+ const trimmed = lines[next].trim();
958
+ if (!trimmed.startsWith(">")) {
959
+ break;
960
+ }
961
+ collected.push(trimmed.replace(/^>\s?/, ""));
962
+ next += 1;
963
+ }
964
+
965
+ return {
966
+ block: {
967
+ type: "quote",
968
+ props: cloneBaseProps(),
969
+ content: createTextContent(unescapeMarkdown(collected.join("\n"))),
970
+ children: [],
971
+ },
972
+ nextIndex: next,
973
+ };
974
+ }
975
+
976
+ function parseParagraph(lines: string[], index: number): { block: CustomPartialBlock; nextIndex: number } {
977
+ const buffer: string[] = [];
978
+ let next = index;
979
+
980
+ const isTermination = (line: string) => {
981
+ const trimmed = line.trim();
982
+ if (!trimmed) {
983
+ return true;
984
+ }
985
+ if (trimmed.startsWith(":::test-case")) {
986
+ return true;
987
+ }
988
+ if (trimmed.startsWith("* ")) {
989
+ return true;
990
+ }
991
+ if (trimmed.startsWith("#")) {
992
+ return true;
993
+ }
994
+ if (trimmed.startsWith(">")) {
995
+ return true;
996
+ }
997
+ if (trimmed.startsWith("```") ) {
998
+ return true;
999
+ }
1000
+ if (detectListType(trimmed)) {
1001
+ return true;
1002
+ }
1003
+ return false;
1004
+ };
1005
+
1006
+ while (next < lines.length) {
1007
+ const line = lines[next];
1008
+ if (
1009
+ isTableRowLine(line) &&
1010
+ next + 1 < lines.length &&
1011
+ isSeparatorRow(lines[next + 1])
1012
+ ) {
1013
+ break;
1014
+ }
1015
+ if (isTermination(line) && buffer.length > 0) {
1016
+ break;
1017
+ }
1018
+ if (!line.trim()) {
1019
+ next += 1;
1020
+ break;
1021
+ }
1022
+ buffer.push(line.trim());
1023
+ next += 1;
1024
+ }
1025
+
1026
+ return {
1027
+ block: {
1028
+ type: "paragraph",
1029
+ props: cloneBaseProps(),
1030
+ content: createTextContent(unescapeMarkdown(buffer.join(" "))),
1031
+ children: [],
1032
+ },
1033
+ nextIndex: next,
1034
+ };
1035
+ }
1036
+
1037
+ export function markdownToBlocks(markdown: string): CustomPartialBlock[] {
1038
+ const normalized = markdown.replace(/\r\n/g, "\n");
1039
+ const lines = normalized.split("\n");
1040
+ const blocks: CustomPartialBlock[] = [];
1041
+ let index = 0;
1042
+
1043
+ while (index < lines.length) {
1044
+ const line = lines[index];
1045
+ if (!line.trim()) {
1046
+ index += 1;
1047
+ continue;
1048
+ }
1049
+
1050
+ const testCase = parseTestCase(lines, index);
1051
+ if (testCase) {
1052
+ blocks.push(testCase.block);
1053
+ index = testCase.nextIndex;
1054
+ continue;
1055
+ }
1056
+
1057
+ const testStep = parseTestStep(lines, index);
1058
+ if (testStep) {
1059
+ blocks.push(testStep.block);
1060
+ index = testStep.nextIndex;
1061
+ continue;
1062
+ }
1063
+
1064
+ const table = parseTable(lines, index);
1065
+ if (table) {
1066
+ blocks.push(table.block);
1067
+ index = table.nextIndex;
1068
+ continue;
1069
+ }
1070
+
1071
+ const heading = parseHeading(lines, index);
1072
+ if (heading) {
1073
+ blocks.push(heading.block);
1074
+ index = heading.nextIndex;
1075
+ continue;
1076
+ }
1077
+
1078
+ const code = parseCodeBlock(lines, index);
1079
+ if (code) {
1080
+ blocks.push(code.block);
1081
+ index = code.nextIndex;
1082
+ continue;
1083
+ }
1084
+
1085
+ const quote = parseQuote(lines, index);
1086
+ if (quote) {
1087
+ blocks.push(quote.block);
1088
+ index = quote.nextIndex;
1089
+ continue;
1090
+ }
1091
+
1092
+ const listType = detectListType(line.trim());
1093
+ if (listType) {
1094
+ const { items, nextIndex } = parseList(lines, index, listType, 0);
1095
+ blocks.push(...items);
1096
+ index = nextIndex;
1097
+ continue;
1098
+ }
1099
+
1100
+ const paragraph = parseParagraph(lines, index);
1101
+ blocks.push(paragraph.block);
1102
+ index = paragraph.nextIndex;
1103
+ }
1104
+
1105
+ return blocks;
1106
+ }
1107
+
1108
+ function splitTableRow(line: string): string[] {
1109
+ let value = line.trim();
1110
+ if (value.startsWith("|")) {
1111
+ value = value.slice(1);
1112
+ }
1113
+ if (value.endsWith("|")) {
1114
+ value = value.slice(0, -1);
1115
+ }
1116
+
1117
+ const cells: string[] = [];
1118
+ let current = "";
1119
+ let i = 0;
1120
+ while (i < value.length) {
1121
+ const char = value[i];
1122
+ if (char === "\\" && i + 1 < value.length) {
1123
+ current += value[i + 1];
1124
+ i += 2;
1125
+ continue;
1126
+ }
1127
+ if (char === "|") {
1128
+ cells.push(current.trim());
1129
+ current = "";
1130
+ i += 1;
1131
+ continue;
1132
+ }
1133
+ current += char;
1134
+ i += 1;
1135
+ }
1136
+ cells.push(current.trim());
1137
+ return cells;
1138
+ }
1139
+
1140
+ function isTableRowLine(line: string): boolean {
1141
+ const trimmed = line.trim();
1142
+ if (!trimmed.startsWith("|")) {
1143
+ return false;
1144
+ }
1145
+ const cells = splitTableRow(trimmed);
1146
+ return cells.length >= 2;
1147
+ }
1148
+
1149
+ function isSeparatorRow(line: string): boolean {
1150
+ const trimmed = line.trim();
1151
+ if (!trimmed.startsWith("|")) {
1152
+ return false;
1153
+ }
1154
+ const cells = splitTableRow(trimmed);
1155
+ if (!cells.length) {
1156
+ return false;
1157
+ }
1158
+ return cells.every((cell) => /^:?[-]{3,}:?$/.test(cell.replace(/\s+/g, "")));
1159
+ }
1160
+
1161
+ function alignmentFromToken(token: string): "left" | "center" | "right" | "justify" {
1162
+ const trimmed = token.trim();
1163
+ if (trimmed.startsWith(":") && trimmed.endsWith(":")) {
1164
+ return "center";
1165
+ }
1166
+ if (trimmed.endsWith(":")) {
1167
+ return "right";
1168
+ }
1169
+ if (trimmed.startsWith(":")) {
1170
+ return "left";
1171
+ }
1172
+ return "left";
1173
+ }
1174
+
1175
+ function parseTable(lines: string[], index: number): { block: CustomPartialBlock; nextIndex: number } | null {
1176
+ if (!isTableRowLine(lines[index])) {
1177
+ return null;
1178
+ }
1179
+
1180
+ if (index + 1 >= lines.length || !isSeparatorRow(lines[index + 1])) {
1181
+ return null;
1182
+ }
1183
+
1184
+ const headerCells = splitTableRow(lines[index]);
1185
+ const alignmentCells = splitTableRow(lines[index + 1]);
1186
+
1187
+ const columnCount = Math.max(headerCells.length, alignmentCells.length);
1188
+ const columnAlignments: Array<"left" | "center" | "right" | "justify"> =
1189
+ new Array(columnCount).fill("left");
1190
+ alignmentCells.forEach((token, idx) => {
1191
+ columnAlignments[idx] = alignmentFromToken(token);
1192
+ });
1193
+
1194
+ const allRows: string[][] = [headerCells];
1195
+ let next = index + 2;
1196
+ while (next < lines.length && isTableRowLine(lines[next])) {
1197
+ allRows.push(splitTableRow(lines[next]));
1198
+ next += 1;
1199
+ }
1200
+
1201
+ const normalizedRows = allRows.map((row) => {
1202
+ const cells = [...row];
1203
+ while (cells.length < columnCount) {
1204
+ cells.push("");
1205
+ }
1206
+ return cells;
1207
+ });
1208
+
1209
+ const tableRows = normalizedRows.map((row) => {
1210
+ const cells = row.map((cellText, columnIndex) => ({
1211
+ type: "tableCell" as const,
1212
+ props: {
1213
+ ...cloneCellProps(),
1214
+ textAlignment: columnAlignments[columnIndex] ?? "left",
1215
+ },
1216
+ content: createTextContent(cellText) ?? [],
1217
+ }));
1218
+ return { cells };
1219
+ }) as Array<{ cells: any[] }>;
1220
+
1221
+ const tableBlock: CustomPartialBlock = {
1222
+ type: "table",
1223
+ props: { ...TABLE_BLOCK_PROPS },
1224
+ content: {
1225
+ type: "tableContent",
1226
+ columnWidths: new Array(columnCount).fill(undefined),
1227
+ headerRows: 1,
1228
+ rows: tableRows,
1229
+ },
1230
+ children: [],
1231
+ };
1232
+
1233
+ return {
1234
+ block: tableBlock,
1235
+ nextIndex: next,
1236
+ };
1237
+ }