testomatio-editor-blocks 0.4.0 → 0.4.6

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.
@@ -61,7 +61,7 @@ const headingPrefixes: Record<number, string> = {
61
61
  const SPECIAL_CHAR_REGEX = /([*_`~\[\]()<>\\])/g;
62
62
  const HTML_SPAN_REGEX = /<\/?span[^>]*>/g;
63
63
  const HTML_UNDERLINE_REGEX = /<\/?u>/g;
64
- const EXPECTED_LABEL_REGEX = /^(?:[*_`]*\s*)?(expected(?:\s+result)?)\s*(?:[*_`]*\s*)?(?:\s*[:\-–—]?\s*)/i;
64
+ const EXPECTED_LABEL_REGEX = /^(?:[*_`]*\s*)?(expected(?:\s+result)?)\s*(?:[*_`]*\s*)?\s*[:\-–—]\s*/i;
65
65
  // Matches any non-empty line that falls between the step title and the expected result line.
66
66
  const STEP_DATA_LINE_REGEX =
67
67
  /^(?!\s*(?:[*_`]*\s*)?(?:expected(?:\s+result)?)\b).+/i;
@@ -185,25 +185,45 @@ function inlineToMarkdown(content: CustomEditorBlock["content"]): string {
185
185
  return "";
186
186
  }
187
187
 
188
- return (content as EditorInline[])
189
- .map((item) => {
190
- if (isStyledTextInlineContent(item)) {
191
- return applyTextStyles(escapeMarkdown(item.text), item.styles);
192
- }
188
+ const result: string[] = [];
189
+ let i = 0;
193
190
 
194
- if (isLinkInlineContent(item)) {
195
- const inner = inlineToMarkdown(item.content);
196
- const safeHref = escapeMarkdown(item.href);
197
- return `[${inner}](${safeHref})`;
191
+ while (i < content.length) {
192
+ const item = content[i];
193
+
194
+ if (isStyledTextInlineContent(item)) {
195
+ // Check if this is a "!" followed by a link (image syntax)
196
+ if (item.text === "!" && i + 1 < content.length && isLinkInlineContent(content[i + 1])) {
197
+ const link = content[i + 1] as any;
198
+ const inner = inlineToMarkdown(link.content);
199
+ const safeHref = escapeMarkdown(link.href);
200
+ result.push(`![${inner}](${safeHref})`);
201
+ i += 2;
202
+ continue;
198
203
  }
204
+ result.push(applyTextStyles(escapeMarkdown(item.text), item.styles));
205
+ i += 1;
206
+ continue;
207
+ }
199
208
 
200
- if (Array.isArray((item as any).content)) {
201
- return inlineToMarkdown((item as any).content);
202
- }
209
+ if (isLinkInlineContent(item)) {
210
+ const inner = inlineToMarkdown(item.content);
211
+ const safeHref = escapeMarkdown(item.href);
212
+ result.push(`[${inner}](${safeHref})`);
213
+ i += 1;
214
+ continue;
215
+ }
203
216
 
204
- return "";
205
- })
206
- .join("");
217
+ if (Array.isArray((item as any).content)) {
218
+ result.push(inlineToMarkdown((item as any).content));
219
+ i += 1;
220
+ continue;
221
+ }
222
+
223
+ i += 1;
224
+ }
225
+
226
+ return result.join("");
207
227
  }
208
228
 
209
229
  function inlineContentToPlainText(content: CustomEditorBlock["content"]): string {
@@ -246,6 +266,7 @@ function serializeBlock(
246
266
  block: CustomEditorBlock,
247
267
  ctx: MarkdownContext,
248
268
  orderedIndex?: number,
269
+ stepIndex?: number,
249
270
  ): string[] {
250
271
  const lines: string[] = [];
251
272
  const indent = ctx.listDepth > 0 ? " ".repeat(ctx.listDepth) : "";
@@ -355,7 +376,9 @@ function serializeBlock(
355
376
  .join(" ");
356
377
 
357
378
  if (normalizedTitle.length > 0) {
358
- lines.push(`* ${normalizedTitle}`);
379
+ const listStyle = (block.props as any).listStyle ?? "bullet";
380
+ const prefix = listStyle === "ordered" ? `${(stepIndex ?? 0) + 1}.` : "*";
381
+ lines.push(`${prefix} ${normalizedTitle}`);
359
382
  }
360
383
  }
361
384
 
@@ -513,6 +536,7 @@ function serializeBlock(
513
536
  function serializeBlocks(blocks: CustomEditorBlock[], ctx: MarkdownContext): string[] {
514
537
  const lines: string[] = [];
515
538
  let orderedIndex: number | null = null;
539
+ let stepIndex = 0;
516
540
 
517
541
  for (const block of blocks) {
518
542
  if (block.type === "numberedListItem") {
@@ -527,6 +551,14 @@ function serializeBlocks(blocks: CustomEditorBlock[], ctx: MarkdownContext): str
527
551
  continue;
528
552
  }
529
553
 
554
+ if (block.type === "testStep") {
555
+ lines.push(...serializeBlock(block, ctx, undefined, stepIndex));
556
+ stepIndex += 1;
557
+ orderedIndex = null;
558
+ continue;
559
+ }
560
+
561
+ stepIndex = 0;
530
562
  orderedIndex = null;
531
563
  lines.push(...serializeBlock(block, ctx));
532
564
  }
@@ -613,10 +645,13 @@ function parseInlineMarkdown(text: string): EditorInline[] {
613
645
  pushPlain();
614
646
  const label = cleaned.slice(i + 1, endLabel);
615
647
  const href = cleaned.slice(startLink + 1, endLink);
648
+ const parsedLabel = parseInlineMarkdown(label);
649
+ // Ensure link content is never undefined - if empty, add empty text
650
+ const linkContent = parsedLabel.length > 0 ? parsedLabel : [{ type: "text", text: "", styles: {} }];
616
651
  result.push({
617
652
  type: "link",
618
653
  href: unescapeMarkdown(href),
619
- content: parseInlineMarkdown(label),
654
+ content: linkContent,
620
655
  } as any);
621
656
  i = endLink + 1;
622
657
  continue;
@@ -741,14 +776,24 @@ function parseList(
741
776
  break;
742
777
  }
743
778
 
744
- // Only try to parse as testStep for top-level bullet items (indentLevel === 0)
745
- // Nested bullets within numbered lists should remain as regular bulletListItem
746
- if (listType === "bullet" && indentLevel === 0) {
747
- const nextStep = parseTestStep(lines, index, allowEmptySteps);
748
- if (nextStep) {
749
- items.push(nextStep.block);
750
- index = nextStep.nextIndex;
751
- continue;
779
+ // Only try to parse as testStep for top-level items (indentLevel === 0)
780
+ // when we're under a Steps heading AND the list type is bullet
781
+ // Numbered lists under Steps heading are only parsed as test steps if they look like test steps
782
+ if (indentLevel === 0 && (allowEmptySteps || listType === "bullet")) {
783
+ // For bullet lists, always try to parse as test steps
784
+ // For numbered lists, only try if they have step-like characteristics
785
+ const looksLikeTestStep = listType === "bullet" ||
786
+ (listType === "numbered" && (
787
+ isLikelyStep(lines, index)
788
+ ));
789
+
790
+ if (looksLikeTestStep) {
791
+ const nextStep = parseTestStep(lines, index, allowEmptySteps);
792
+ if (nextStep) {
793
+ items.push(nextStep.block);
794
+ index = nextStep.nextIndex;
795
+ continue;
796
+ }
752
797
  }
753
798
  }
754
799
 
@@ -789,6 +834,26 @@ function parseList(
789
834
  return { items, nextIndex: index };
790
835
  }
791
836
 
837
+ function isLikelyStep(lines: string[], index: number): boolean {
838
+ // Look ahead to see if there's indented content or expected result
839
+ if (index + 1 >= lines.length) return false;
840
+
841
+ const nextLine = lines[index + 1];
842
+ const hasIndent = /^\s{2,}/.test(nextLine);
843
+
844
+ // Check if the next line contains expected result markers
845
+ const nextTrimmed = nextLine.trim();
846
+ const hasExpectedResult = EXPECTED_LABEL_REGEX.test(nextTrimmed);
847
+
848
+ // Only consider it a test step if:
849
+ // 1. It has an expected result, OR
850
+ // 2. The next line is indented but doesn't start with a numbered or bullet list
851
+ if (hasExpectedResult) return true;
852
+ if (hasIndent && !/^\d+[.)]/.test(nextTrimmed) && !/^[-*+]/.test(nextTrimmed)) return true;
853
+
854
+ return false;
855
+ }
856
+
792
857
  function parseTestStep(
793
858
  lines: string[],
794
859
  index: number,
@@ -797,11 +862,28 @@ function parseTestStep(
797
862
  ): { block: CustomPartialBlock; nextIndex: number } | null {
798
863
  const current = lines[index];
799
864
  const trimmed = current.trim();
800
- if (!trimmed.startsWith("* ") && !trimmed.startsWith("- ")) {
865
+ const isBullet = trimmed.startsWith("* ") || trimmed.startsWith("- ");
866
+ const isNumbered = /^\d+[.)]\s+/.test(trimmed);
867
+
868
+ if (!isBullet && !isNumbered) {
801
869
  return null;
802
870
  }
803
871
 
804
- let rawTitle = unescapeMarkdown(trimmed.slice(2)).trim();
872
+ // For numbered lists, only parse as test steps if called from parseList with allowEmpty=true
873
+ // The first call to parseTestStep from markdownToBlocks uses allowEmpty=stepsHeadingLevel !== null
874
+ // which should be false unless we're under a Steps heading
875
+ if (isNumbered && !allowEmpty) {
876
+ return null;
877
+ }
878
+
879
+ let rawTitle: string;
880
+ if (isBullet) {
881
+ rawTitle = unescapeMarkdown(trimmed.slice(2)).trim();
882
+ } else {
883
+ // For numbered lists, remove the number and delimiter
884
+ rawTitle = unescapeMarkdown(trimmed.replace(/^\d+[.)]\s+/, "")).trim();
885
+ }
886
+
805
887
  let blockType: "testStep" | "snippet" = "testStep";
806
888
  const snippetMatch = rawTitle.match(/^snippet\s*[:\-–—]?\s*(.*)$/i);
807
889
  if (snippetMatch) {
@@ -825,7 +907,6 @@ function parseTestStep(
825
907
  let expectedResult = "";
826
908
  let next = index + 1;
827
909
  let inExpectedResult = false;
828
- let foundFirstExpected = false;
829
910
 
830
911
  while (next < lines.length) {
831
912
  const line = lines[next];
@@ -867,7 +948,6 @@ function parseTestStep(
867
948
  rawTrimmed.match(/^\*expected\*:\s*(.*)$/i);
868
949
 
869
950
  if (expectedMatch || expectedStarMatch) {
870
- foundFirstExpected = true;
871
951
  inExpectedResult = true;
872
952
  const label = expectedMatch ? expectedMatch[0] : (expectedStarMatch ? expectedStarMatch[0] : '');
873
953
  let content = rawTrimmed.slice(label.length).trim();
@@ -887,7 +967,6 @@ function parseTestStep(
887
967
 
888
968
  // Check for lines that start with * and contain Expected (but don't match the above patterns)
889
969
  if (rawTrimmed.match(/^\*[^*]*expected/i)) {
890
- foundFirstExpected = true;
891
970
  inExpectedResult = true;
892
971
  // Remove the leading * and trim
893
972
  let content = rawTrimmed.slice(1).trim();
@@ -940,15 +1019,6 @@ function parseTestStep(
940
1019
  } else {
941
1020
  expectedResult = expectedContent;
942
1021
  }
943
- } else if (foundFirstExpected && rawTrimmed.startsWith("*") && !rawTrimmed.startsWith("* ")) {
944
- // Non-indented lines starting with single * (not list item) are likely more expected results
945
- // Remove the leading * and treat the rest as content
946
- const expectedContent = unescapeMarkdown(rawTrimmed.slice(1).trim());
947
- if (expectedResult.length > 0) {
948
- expectedResult += "\n" + expectedContent;
949
- } else {
950
- expectedResult = expectedContent;
951
- }
952
1022
  }
953
1023
  next += 1;
954
1024
  continue;
@@ -978,6 +1048,7 @@ function parseTestStep(
978
1048
  .trim();
979
1049
 
980
1050
  if (
1051
+ !isBullet &&
981
1052
  !isLikelyStep &&
982
1053
  !expectedResult &&
983
1054
  stepDataLines.length === 0 &&
@@ -1002,6 +1073,7 @@ function parseTestStep(
1002
1073
  stepTitle: titleWithPlaceholders,
1003
1074
  stepData: stepDataWithImages,
1004
1075
  expectedResult,
1076
+ listStyle: isNumbered ? "ordered" : "bullet",
1005
1077
  };
1006
1078
 
1007
1079
  const parsedBlock: CustomPartialBlock = {
@@ -1215,6 +1287,75 @@ function parseSnippetWrapper(
1215
1287
  };
1216
1288
  }
1217
1289
 
1290
+ // Post-process blocks to fix malformed image blocks
1291
+ export function fixMalformedImageBlocks(blocks: CustomPartialBlock[]): CustomPartialBlock[] {
1292
+ const result: CustomPartialBlock[] = [];
1293
+ let i = 0;
1294
+
1295
+ while (i < blocks.length) {
1296
+ const current = blocks[i];
1297
+ const next = blocks[i + 1];
1298
+
1299
+ // Skip empty paragraphs
1300
+ if (
1301
+ current.type === "paragraph" &&
1302
+ (!current.content || !Array.isArray(current.content) || current.content.length === 0)
1303
+ ) {
1304
+ i += 1;
1305
+ continue;
1306
+ }
1307
+
1308
+ // Check if current is a paragraph with just "!" - this is definitely a malformed image
1309
+ if (
1310
+ current.type === "paragraph" &&
1311
+ current.content &&
1312
+ Array.isArray(current.content) &&
1313
+ current.content.length === 1 &&
1314
+ (current.content[0] as any)?.type === "text" &&
1315
+ (current.content[0] as any)?.text === "!"
1316
+ ) {
1317
+ // This is a malformed image block, skip it entirely
1318
+ // The full image was likely parsed as ![](...) but got corrupted
1319
+ i += 1;
1320
+ continue;
1321
+ }
1322
+
1323
+ // Check if current is a paragraph with just "!" and next is an empty paragraph
1324
+ if (
1325
+ current.type === "paragraph" &&
1326
+ next?.type === "paragraph" &&
1327
+ current.content &&
1328
+ Array.isArray(current.content) &&
1329
+ current.content.length === 1 &&
1330
+ (current.content[0] as any)?.type === "text" &&
1331
+ (current.content[0] as any)?.text === "!" &&
1332
+ (!next.content || !Array.isArray(next.content) || next.content.length === 0)
1333
+ ) {
1334
+ // This looks like a malformed image, skip both blocks
1335
+ i += 2;
1336
+ continue;
1337
+ }
1338
+
1339
+ // Check if current has "!" but no link
1340
+ if (
1341
+ current.type === "paragraph" &&
1342
+ current.content &&
1343
+ Array.isArray(current.content) &&
1344
+ current.content.some((item: any) => item.type === "text" && item.text === "!") &&
1345
+ !current.content.some((item: any) => item.type === "link")
1346
+ ) {
1347
+ // Skip malformed image block
1348
+ i += 1;
1349
+ continue;
1350
+ }
1351
+
1352
+ result.push(current);
1353
+ i += 1;
1354
+ }
1355
+
1356
+ return result;
1357
+ }
1358
+
1218
1359
  export function markdownToBlocks(markdown: string): CustomPartialBlock[] {
1219
1360
  const normalized = markdown.replace(/\r\n/g, "\n");
1220
1361
  const lines = normalized.split("\n");
@@ -1257,7 +1398,7 @@ export function markdownToBlocks(markdown: string): CustomPartialBlock[] {
1257
1398
  const headingText = inlineContentToPlainText(headingBlock.content as any);
1258
1399
  const normalizedHeading = headingText.trim().toLowerCase();
1259
1400
 
1260
- if (normalizedHeading === "steps") {
1401
+ if (normalizedHeading.replace(/[:\-–—]$/, "") === "steps") {
1261
1402
  stepsHeadingLevel = headingLevel;
1262
1403
  } else if (
1263
1404
  stepsHeadingLevel !== null &&
@@ -1305,7 +1446,7 @@ export function markdownToBlocks(markdown: string): CustomPartialBlock[] {
1305
1446
  index = paragraph.nextIndex;
1306
1447
  }
1307
1448
 
1308
- return blocks;
1449
+ return fixMalformedImageBlocks(blocks);
1309
1450
  }
1310
1451
 
1311
1452
  function splitTableRow(line: string): string[] {
@@ -21,6 +21,7 @@ describe("markdownToBlocks", () => {
21
21
  stepTitle: "Open the Login page.",
22
22
  stepData: "",
23
23
  expectedResult: "The Login page loads successfully.",
24
+ listStyle: "bullet",
24
25
  },
25
26
  children: [],
26
27
  },
@@ -99,6 +100,7 @@ describe("markdownToBlocks", () => {
99
100
  stepTitle: "Step 1: Send a chat message to the user.",
100
101
  stepData: "",
101
102
  expectedResult: "The user receives a real-time notification for the chat message.",
103
+ listStyle: "bullet",
102
104
  },
103
105
  children: [],
104
106
  });