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.
- package/package/editor/blocks/snippet.js +32 -138
- package/package/editor/blocks/step.d.ts +6 -0
- package/package/editor/blocks/step.js +119 -52
- package/package/editor/blocks/stepField.d.ts +4 -1
- package/package/editor/blocks/stepField.js +651 -39
- package/package/editor/blocks/stepHorizontalView.d.ts +14 -0
- package/package/editor/blocks/stepHorizontalView.js +7 -0
- package/package/editor/customMarkdownConverter.d.ts +1 -0
- package/package/editor/customMarkdownConverter.js +153 -41
- package/package/editor/customSchema.d.ts +6 -0
- package/package/styles.css +569 -122
- package/package.json +5 -1
- package/src/editor/blocks/snippet.tsx +92 -212
- package/src/editor/blocks/step.tsx +268 -123
- package/src/editor/blocks/stepField.tsx +907 -95
- package/src/editor/blocks/stepHorizontalView.tsx +90 -0
- package/src/editor/customMarkdownConverter.test.ts +594 -29
- package/src/editor/customMarkdownConverter.ts +183 -42
- package/src/editor/markdownToBlocks.test.ts +2 -0
- package/src/editor/styles.css +565 -133
|
@@ -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*)
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
if (isStyledTextInlineContent(item)) {
|
|
191
|
-
return applyTextStyles(escapeMarkdown(item.text), item.styles);
|
|
192
|
-
}
|
|
188
|
+
const result: string[] = [];
|
|
189
|
+
let i = 0;
|
|
193
190
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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(``);
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
745
|
-
//
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|