testomatio-editor-blocks 0.1.1 → 0.2.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.
Files changed (41) hide show
  1. package/README.md +13 -11
  2. package/package/editor/blocks/markdown.d.ts +5 -0
  3. package/package/editor/blocks/markdown.js +160 -0
  4. package/package/editor/blocks/snippet.d.ts +38 -0
  5. package/package/editor/blocks/snippet.js +65 -0
  6. package/package/editor/blocks/step.d.ts +32 -0
  7. package/package/editor/blocks/step.js +97 -0
  8. package/package/editor/blocks/stepField.d.ts +26 -0
  9. package/package/editor/blocks/stepField.js +316 -0
  10. package/package/editor/customMarkdownConverter.js +111 -80
  11. package/package/editor/customSchema.d.ts +31 -45
  12. package/package/editor/customSchema.js +6 -616
  13. package/package/editor/snippetAutocomplete.d.ts +28 -0
  14. package/package/editor/snippetAutocomplete.js +94 -0
  15. package/package/editor/stepAutocomplete.d.ts +1 -1
  16. package/package/editor/stepAutocomplete.js +15 -2
  17. package/package/editor/stepImageUpload.d.ts +1 -1
  18. package/package/editor/stepImageUpload.js +4 -9
  19. package/package/index.d.ts +2 -2
  20. package/package/index.js +2 -2
  21. package/package/styles.css +57 -0
  22. package/package.json +1 -1
  23. package/src/App.tsx +161 -45
  24. package/src/editor/blocks/blocks.test.ts +22 -0
  25. package/src/editor/blocks/markdown.ts +199 -0
  26. package/src/editor/blocks/snippet.tsx +109 -0
  27. package/src/editor/blocks/step.tsx +175 -0
  28. package/src/editor/blocks/stepField.tsx +487 -0
  29. package/src/editor/customMarkdownConverter.test.ts +121 -36
  30. package/src/editor/customMarkdownConverter.ts +128 -85
  31. package/src/editor/customSchema.tsx +6 -935
  32. package/src/editor/snippetAutocomplete.test.ts +54 -0
  33. package/src/editor/snippetAutocomplete.ts +133 -0
  34. package/src/editor/stepAutocomplete.test.ts +20 -0
  35. package/src/editor/stepAutocomplete.tsx +15 -2
  36. package/src/editor/stepImageUpload.test.ts +25 -0
  37. package/src/editor/stepImageUpload.ts +11 -0
  38. package/src/editor/styles.css +57 -0
  39. package/src/index.ts +2 -2
  40. package/src/editor/customSchema.test.ts +0 -47
  41. package/src/editor/stepImageUpload.tsx +0 -19
@@ -0,0 +1,316 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useStepAutocomplete } from "../stepAutocomplete";
3
+ import { useStepImageUpload } from "../stepImageUpload";
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
+ import { escapeHtml, escapeMarkdownText, htmlToMarkdown, markdownToHtml, normalizePlainText, } from "./markdown";
6
+ export function StepField({ label, value, placeholder, onChange, autoFocus, multiline = false, enableAutocomplete = false, fieldName, suggestionFilter, suggestionsOverride, onSuggestionSelect, readOnly = false, showSuggestionsOnFocus = false, enableImageUpload = false, onImageFile, rightAction, showFormattingButtons = false, showImageButton = false, }) {
7
+ const editorRef = useRef(null);
8
+ const [isFocused, setIsFocused] = useState(false);
9
+ const autoFocusRef = useRef(false);
10
+ const [plainTextValue, setPlainTextValue] = useState("");
11
+ const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(0);
12
+ const [showAllSuggestions, setShowAllSuggestions] = useState(false);
13
+ const stepSuggestions = useStepAutocomplete();
14
+ const suggestions = suggestionsOverride !== null && suggestionsOverride !== void 0 ? suggestionsOverride : stepSuggestions;
15
+ const uploadImage = useStepImageUpload();
16
+ const fileInputRef = useRef(null);
17
+ const [isUploading, setIsUploading] = useState(false);
18
+ const normalizedQuery = normalizePlainText(plainTextValue);
19
+ const suggestionPool = useMemo(() => {
20
+ if (!suggestionFilter) {
21
+ return suggestions;
22
+ }
23
+ const filtered = suggestions.filter(suggestionFilter);
24
+ return filtered.length > 0 ? filtered : suggestions;
25
+ }, [suggestionFilter, suggestions]);
26
+ const filteredSuggestions = useMemo(() => {
27
+ if (!enableAutocomplete) {
28
+ return [];
29
+ }
30
+ const pool = showAllSuggestions || !normalizedQuery
31
+ ? suggestionPool
32
+ : suggestionPool.filter((item) => normalizePlainText(item.title).startsWith(normalizedQuery));
33
+ return pool.slice(0, 8);
34
+ }, [enableAutocomplete, normalizedQuery, showAllSuggestions, suggestionPool]);
35
+ const hasExactMatch = filteredSuggestions.some((item) => normalizePlainText(item.title) === normalizedQuery);
36
+ const shouldShowAutocomplete = enableAutocomplete &&
37
+ isFocused &&
38
+ filteredSuggestions.length > 0 &&
39
+ (!hasExactMatch || showAllSuggestions) &&
40
+ (showAllSuggestions || normalizedQuery.length >= 1);
41
+ useEffect(() => {
42
+ setActiveSuggestionIndex(0);
43
+ }, [normalizedQuery, filteredSuggestions.length, showAllSuggestions]);
44
+ useEffect(() => {
45
+ if (normalizedQuery.length > 0) {
46
+ setShowAllSuggestions(false);
47
+ }
48
+ }, [normalizedQuery]);
49
+ useEffect(() => {
50
+ var _a;
51
+ const element = editorRef.current;
52
+ if (!element || isFocused) {
53
+ return;
54
+ }
55
+ if (value.trim().length === 0) {
56
+ element.innerHTML = "";
57
+ setPlainTextValue("");
58
+ }
59
+ else {
60
+ element.innerHTML = markdownToHtml(value);
61
+ setPlainTextValue((_a = element.textContent) !== null && _a !== void 0 ? _a : "");
62
+ }
63
+ }, [value, isFocused]);
64
+ const syncValue = useCallback(() => {
65
+ var _a;
66
+ const element = editorRef.current;
67
+ if (!element) {
68
+ return;
69
+ }
70
+ const markdown = htmlToMarkdown(element.innerHTML);
71
+ if (markdown !== value) {
72
+ onChange(markdown);
73
+ }
74
+ setPlainTextValue((_a = element.innerText) !== null && _a !== void 0 ? _a : "");
75
+ if (!markdown && element.innerHTML !== "") {
76
+ element.innerHTML = "";
77
+ }
78
+ }, [onChange, value]);
79
+ useEffect(() => {
80
+ if (!autoFocus || autoFocusRef.current || !editorRef.current) {
81
+ return;
82
+ }
83
+ autoFocusRef.current = true;
84
+ const element = editorRef.current;
85
+ const focusElement = () => {
86
+ var _a;
87
+ element.focus();
88
+ setIsFocused(true);
89
+ if (showSuggestionsOnFocus && enableAutocomplete) {
90
+ setShowAllSuggestions(true);
91
+ }
92
+ const selection = typeof window !== "undefined" ? (_a = window.getSelection) === null || _a === void 0 ? void 0 : _a.call(window) : null;
93
+ if (selection) {
94
+ selection.selectAllChildren(element);
95
+ selection.collapseToEnd();
96
+ }
97
+ };
98
+ if (typeof requestAnimationFrame === "function") {
99
+ const frame = requestAnimationFrame(focusElement);
100
+ return () => cancelAnimationFrame(frame);
101
+ }
102
+ const timeout = setTimeout(focusElement, 0);
103
+ return () => clearTimeout(timeout);
104
+ }, [autoFocus, enableAutocomplete, showSuggestionsOnFocus]);
105
+ const ensureCaretInEditor = useCallback(() => {
106
+ var _a;
107
+ const element = editorRef.current;
108
+ if (!element) {
109
+ return false;
110
+ }
111
+ const selection = (_a = window.getSelection) === null || _a === void 0 ? void 0 : _a.call(window);
112
+ if (!selection) {
113
+ return false;
114
+ }
115
+ if (selection.rangeCount === 0 || !element.contains(selection.anchorNode)) {
116
+ const range = document.createRange();
117
+ range.selectNodeContents(element);
118
+ range.collapse(false);
119
+ selection.removeAllRanges();
120
+ selection.addRange(range);
121
+ }
122
+ element.focus();
123
+ return true;
124
+ }, []);
125
+ const handlePaste = useCallback(async (event) => {
126
+ var _a, _b, _c, _d, _e;
127
+ if ((enableImageUpload && uploadImage) || onImageFile) {
128
+ const items = Array.from((_a = event.clipboardData.items) !== null && _a !== void 0 ? _a : []);
129
+ const imageItem = items.find((item) => item.kind === "file" && item.type.startsWith("image/"));
130
+ const file = imageItem === null || imageItem === void 0 ? void 0 : imageItem.getAsFile();
131
+ if (file) {
132
+ event.preventDefault();
133
+ if (onImageFile) {
134
+ await onImageFile(file);
135
+ return;
136
+ }
137
+ if (enableImageUpload && uploadImage) {
138
+ try {
139
+ const result = await uploadImage(file);
140
+ if (result === null || result === void 0 ? void 0 : result.url) {
141
+ ensureCaretInEditor();
142
+ const needsBreak = ((_c = (_b = editorRef.current) === null || _b === void 0 ? void 0 : _b.innerHTML) !== null && _c !== void 0 ? _c : "").trim().length > 0;
143
+ const imgHtml = (needsBreak ? "<br />" : "") +
144
+ `<img src="${escapeHtml(result.url)}" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />`;
145
+ document.execCommand("insertHTML", false, imgHtml);
146
+ syncValue();
147
+ }
148
+ }
149
+ catch (error) {
150
+ console.error("Failed to upload image from paste", error);
151
+ }
152
+ return;
153
+ }
154
+ }
155
+ }
156
+ event.preventDefault();
157
+ const text = (_e = (_d = event.clipboardData) === null || _d === void 0 ? void 0 : _d.getData("text/plain")) !== null && _e !== void 0 ? _e : "";
158
+ const html = markdownToHtml(text);
159
+ ensureCaretInEditor();
160
+ document.execCommand("insertHTML", false, html);
161
+ syncValue();
162
+ }, [enableImageUpload, ensureCaretInEditor, onImageFile, syncValue, uploadImage]);
163
+ const applySuggestion = useCallback((suggestion) => {
164
+ var _a;
165
+ const escaped = escapeMarkdownText(suggestion.title);
166
+ onChange(escaped);
167
+ onSuggestionSelect === null || onSuggestionSelect === void 0 ? void 0 : onSuggestionSelect(suggestion);
168
+ setPlainTextValue(suggestion.title);
169
+ setActiveSuggestionIndex(0);
170
+ setShowAllSuggestions(false);
171
+ if (editorRef.current) {
172
+ editorRef.current.innerHTML = markdownToHtml(escaped);
173
+ editorRef.current.focus();
174
+ const selection = typeof window !== "undefined" ? (_a = window.getSelection) === null || _a === void 0 ? void 0 : _a.call(window) : null;
175
+ if (selection && editorRef.current.firstChild) {
176
+ const range = document.createRange();
177
+ range.selectNodeContents(editorRef.current);
178
+ range.collapse(false);
179
+ selection.removeAllRanges();
180
+ selection.addRange(range);
181
+ }
182
+ }
183
+ }, [onChange, onSuggestionSelect]);
184
+ return (_jsxs("div", { className: "bn-step-field", children: [_jsxs("div", { className: "bn-step-field__top", children: [_jsxs("span", { className: "bn-step-field__label", children: [label, enableAutocomplete && (_jsx("button", { type: "button", className: "bn-step-toolbar__button", onMouseDown: (event) => {
185
+ var _a;
186
+ event.preventDefault();
187
+ setShowAllSuggestions(true);
188
+ (_a = editorRef.current) === null || _a === void 0 ? void 0 : _a.focus();
189
+ }, "aria-label": "Show suggestions", tabIndex: -1, children: "\u2304" }))] }), _jsxs("div", { className: "bn-step-toolbar", "aria-label": `${label} controls`, children: [showFormattingButtons && (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "bn-step-toolbar__button", onMouseDown: (event) => {
190
+ var _a;
191
+ event.preventDefault();
192
+ (_a = editorRef.current) === null || _a === void 0 ? void 0 : _a.focus();
193
+ document.execCommand("bold");
194
+ syncValue();
195
+ }, "aria-label": "Bold", tabIndex: -1, children: "B" }), _jsx("button", { type: "button", className: "bn-step-toolbar__button", onMouseDown: (event) => {
196
+ var _a;
197
+ event.preventDefault();
198
+ (_a = editorRef.current) === null || _a === void 0 ? void 0 : _a.focus();
199
+ document.execCommand("italic");
200
+ syncValue();
201
+ }, "aria-label": "Italic", tabIndex: -1, children: "I" })] })), enableImageUpload && uploadImage && showImageButton && (_jsx("button", { type: "button", className: "bn-step-toolbar__button", onMouseDown: (event) => {
202
+ event.preventDefault();
203
+ const input = fileInputRef.current;
204
+ if (input) {
205
+ input.click();
206
+ }
207
+ }, "aria-label": "Insert image", tabIndex: -1, disabled: isUploading, children: "Img" })), rightAction] })] }), enableImageUpload && (_jsx("input", { ref: fileInputRef, type: "file", accept: "image/*", style: { display: "none" }, onChange: async (event) => {
208
+ var _a;
209
+ const file = (_a = event.target.files) === null || _a === void 0 ? void 0 : _a[0];
210
+ if (!file || !uploadImage) {
211
+ return;
212
+ }
213
+ try {
214
+ setIsUploading(true);
215
+ const response = await uploadImage(file);
216
+ if (response === null || response === void 0 ? void 0 : response.url) {
217
+ const element = editorRef.current;
218
+ if (element) {
219
+ const escapedUrl = escapeHtml(response.url);
220
+ const needsBreak = element.innerHTML.trim().length > 0;
221
+ const imgHtml = (needsBreak ? "<br />" : "") +
222
+ `<img src="${escapedUrl}" alt="" class="bn-inline-image" contenteditable="false" draggable="false" />`;
223
+ element.focus();
224
+ ensureCaretInEditor();
225
+ document.execCommand("insertHTML", false, imgHtml);
226
+ syncValue();
227
+ }
228
+ }
229
+ }
230
+ catch (error) {
231
+ console.error("Failed to upload image", error);
232
+ }
233
+ finally {
234
+ setIsUploading(false);
235
+ event.target.value = "";
236
+ }
237
+ } })), _jsx("div", { ref: editorRef, className: "bn-step-editor", suppressContentEditableWarning: true, "data-placeholder": placeholder, "data-multiline": multiline ? "true" : "false", "data-step-field": fieldName, contentEditable: readOnly ? "false" : "true", onFocus: () => {
238
+ var _a, _b;
239
+ setIsFocused(true);
240
+ if (showSuggestionsOnFocus && enableAutocomplete) {
241
+ setShowAllSuggestions(true);
242
+ }
243
+ setPlainTextValue((_b = (_a = editorRef.current) === null || _a === void 0 ? void 0 : _a.innerText) !== null && _b !== void 0 ? _b : "");
244
+ }, onBlur: () => {
245
+ setIsFocused(false);
246
+ syncValue();
247
+ }, onInput: readOnly ? undefined : syncValue, onPaste: readOnly ? (event) => event.preventDefault() : handlePaste, onKeyDown: (event) => {
248
+ var _a, _b;
249
+ if (readOnly) {
250
+ const allowedKeys = new Set([
251
+ "ArrowDown",
252
+ "ArrowUp",
253
+ "Enter",
254
+ "Tab",
255
+ ]);
256
+ const openKeys = enableAutocomplete && (event.metaKey || event.ctrlKey) && (event.code === "Space" || event.key === "" || event.key === " ");
257
+ if (!allowedKeys.has(event.key) && !openKeys) {
258
+ event.preventDefault();
259
+ return;
260
+ }
261
+ }
262
+ if ((event.key === "a" || event.key === "A") && (event.metaKey || event.ctrlKey)) {
263
+ event.preventDefault();
264
+ const selection = (_a = window.getSelection) === null || _a === void 0 ? void 0 : _a.call(window);
265
+ const node = editorRef.current;
266
+ if (selection && node) {
267
+ const range = document.createRange();
268
+ range.selectNodeContents(node);
269
+ selection.removeAllRanges();
270
+ selection.addRange(range);
271
+ }
272
+ return;
273
+ }
274
+ if (enableAutocomplete && shouldShowAutocomplete) {
275
+ if (event.key === "ArrowDown") {
276
+ event.preventDefault();
277
+ setActiveSuggestionIndex((prev) => prev + 1 >= filteredSuggestions.length ? 0 : prev + 1);
278
+ return;
279
+ }
280
+ if (event.key === "ArrowUp") {
281
+ event.preventDefault();
282
+ setActiveSuggestionIndex((prev) => prev - 1 < 0 ? filteredSuggestions.length - 1 : prev - 1);
283
+ return;
284
+ }
285
+ if (event.key === "Enter" || event.key === "Tab") {
286
+ event.preventDefault();
287
+ const suggestion = (_b = filteredSuggestions[activeSuggestionIndex]) !== null && _b !== void 0 ? _b : filteredSuggestions[0];
288
+ if (suggestion) {
289
+ applySuggestion(suggestion);
290
+ }
291
+ return;
292
+ }
293
+ }
294
+ if (enableAutocomplete && (event.metaKey || event.ctrlKey) && (event.code === "Space" || event.key === "" || event.key === " ")) {
295
+ event.preventDefault();
296
+ setShowAllSuggestions(true);
297
+ return;
298
+ }
299
+ if (event.key === "Enter") {
300
+ event.preventDefault();
301
+ if (multiline && event.shiftKey) {
302
+ document.execCommand("insertLineBreak");
303
+ document.execCommand("insertLineBreak");
304
+ }
305
+ else {
306
+ document.execCommand("insertLineBreak");
307
+ }
308
+ syncValue();
309
+ }
310
+ } }), shouldShowAutocomplete && (_jsx("div", { className: "bn-step-suggestions", role: "listbox", "aria-label": `${label} suggestions`, children: filteredSuggestions.map((suggestion, index) => (_jsxs("button", { type: "button", role: "option", "aria-selected": index === activeSuggestionIndex, className: index === activeSuggestionIndex
311
+ ? "bn-step-suggestion bn-step-suggestion--active"
312
+ : "bn-step-suggestion", onMouseDown: (event) => {
313
+ event.preventDefault();
314
+ applySuggestion(suggestion);
315
+ }, tabIndex: -1, children: [_jsx("span", { className: "bn-step-suggestion__title", children: suggestion.title }), typeof suggestion.usageCount === "number" && suggestion.usageCount > 0 && (_jsxs("span", { className: "bn-step-suggestion__meta", children: [suggestion.usageCount, " uses"] }))] }, suggestion.id))) }))] }));
316
+ }
@@ -20,13 +20,6 @@ const headingPrefixes = {
20
20
  5: "#####",
21
21
  6: "######",
22
22
  };
23
- const STEP_STATUSES = new Set(["draft", "ready", "blocked"]);
24
- function normalizeStatus(value) {
25
- if (value && STEP_STATUSES.has(value)) {
26
- return value;
27
- }
28
- return "draft";
29
- }
30
23
  const SPECIAL_CHAR_REGEX = /([*_`~\[\]()<>\\])/g;
31
24
  const HTML_SPAN_REGEX = /<\/?span[^>]*>/g;
32
25
  const HTML_UNDERLINE_REGEX = /<\/?u>/g;
@@ -163,7 +156,7 @@ function flattenWithBlankLine(lines, appendBlank = false) {
163
156
  return lines;
164
157
  }
165
158
  function serializeBlock(block, ctx, orderedIndex) {
166
- var _a, _b, _c, _d, _e, _f, _g, _h, _j;
159
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
167
160
  const lines = [];
168
161
  const indent = ctx.listDepth > 0 ? " ".repeat(ctx.listDepth) : "";
169
162
  switch (block.type) {
@@ -229,25 +222,34 @@ function serializeBlock(block, ctx, orderedIndex) {
229
222
  lines.push(...serializeChildren(block, ctx));
230
223
  return lines;
231
224
  }
232
- case "testCase": {
233
- const status = (_e = block.props.status) !== null && _e !== void 0 ? _e : "draft";
234
- const reference = block.props.reference;
235
- const attrs = [`status="${status}"`];
236
- if (reference) {
237
- attrs.push(`reference="${escapeMarkdown(reference)}"`);
238
- }
239
- lines.push(`:::test-case ${attrs.join(" ")}`.trimEnd());
240
- const body = inlineToMarkdown(block.content);
241
- if (body.length > 0) {
242
- lines.push(body);
225
+ case "testStep":
226
+ case "snippet": {
227
+ const isSnippet = block.type === "snippet";
228
+ const snippetId = isSnippet ? ((_e = block.props.snippetId) !== null && _e !== void 0 ? _e : "").trim() : "";
229
+ const stepTitle = isSnippet
230
+ ? ((_f = block.props.snippetTitle) !== null && _f !== void 0 ? _f : "").trim()
231
+ : ((_g = block.props.stepTitle) !== null && _g !== void 0 ? _g : "").trim();
232
+ const stepData = isSnippet
233
+ ? ((_h = block.props.snippetData) !== null && _h !== void 0 ? _h : "").trim()
234
+ : ((_j = block.props.stepData) !== null && _j !== void 0 ? _j : "").trim();
235
+ const expectedResult = isSnippet
236
+ ? ((_k = block.props.snippetExpectedResult) !== null && _k !== void 0 ? _k : "").trim()
237
+ : ((_l = block.props.expectedResult) !== null && _l !== void 0 ? _l : "").trim();
238
+ if (isSnippet) {
239
+ if (snippetId) {
240
+ lines.push(`<!-- begin snippet #${snippetId} -->`);
241
+ }
242
+ const dataLines = stepData
243
+ .split(/\r?\n/)
244
+ .filter((line) => !/^<!--\s*(begin|end)\s+snippet/i.test(line.trim()));
245
+ if (dataLines.length > 0) {
246
+ lines.push(...dataLines);
247
+ }
248
+ if (snippetId) {
249
+ lines.push(`<!-- end snippet #${snippetId} -->`);
250
+ }
251
+ return flattenWithBlankLine(lines, true);
243
252
  }
244
- lines.push(":::");
245
- return flattenWithBlankLine(lines, true);
246
- }
247
- case "testStep": {
248
- const stepTitle = ((_f = block.props.stepTitle) !== null && _f !== void 0 ? _f : "").trim();
249
- const stepData = ((_g = block.props.stepData) !== null && _g !== void 0 ? _g : "").trim();
250
- const expectedResult = ((_h = block.props.expectedResult) !== null && _h !== void 0 ? _h : "").trim();
251
253
  if (stepTitle.length > 0) {
252
254
  const normalizedTitle = stepTitle
253
255
  .split(/\r?\n/)
@@ -311,7 +313,7 @@ function serializeBlock(block, ctx, orderedIndex) {
311
313
  return flattenWithBlankLine(lines, true);
312
314
  }
313
315
  const headerRowCount = rows.length
314
- ? Math.min(rows.length, Math.max((_j = tableContent.headerRows) !== null && _j !== void 0 ? _j : 1, 1))
316
+ ? Math.min(rows.length, Math.max((_m = tableContent.headerRows) !== null && _m !== void 0 ? _m : 1, 1))
315
317
  : 0;
316
318
  const columnAlignments = new Array(columnCount).fill("left");
317
319
  const getCellAlignment = (cell) => {
@@ -605,13 +607,19 @@ function parseList(lines, startIndex, listType, indentLevel) {
605
607
  }
606
608
  return { items, nextIndex: index };
607
609
  }
608
- function parseTestStep(lines, index) {
610
+ function parseTestStep(lines, index, snippetId) {
609
611
  const current = lines[index];
610
612
  const trimmed = current.trim();
611
613
  if (!trimmed.startsWith("* ") && !trimmed.startsWith("- ")) {
612
614
  return null;
613
615
  }
614
- const rawTitle = unescapeMarkdown(trimmed.slice(2)).trim();
616
+ let rawTitle = unescapeMarkdown(trimmed.slice(2)).trim();
617
+ let blockType = "testStep";
618
+ const snippetMatch = rawTitle.match(/^snippet\s*[:\-–—]?\s*(.*)$/i);
619
+ if (snippetMatch) {
620
+ blockType = "snippet";
621
+ rawTitle = snippetMatch[1].trim();
622
+ }
615
623
  const titleImages = [];
616
624
  const titleWithPlaceholders = rawTitle
617
625
  .replace(/!\[[^\]]*\]\(([^)]+)\)/g, (match) => {
@@ -620,7 +628,9 @@ function parseTestStep(lines, index) {
620
628
  })
621
629
  .replace(/\s{2,}/g, " ")
622
630
  .trim();
623
- const isLikelyStep = /^step\b/i.test(titleWithPlaceholders) || titleImages.length > 0;
631
+ const isLikelyStep = blockType === "snippet" ||
632
+ /^step\b/i.test(titleWithPlaceholders) ||
633
+ titleImages.length > 0;
624
634
  const stepDataLines = [];
625
635
  let expectedResult = "";
626
636
  let next = index + 1;
@@ -693,50 +703,25 @@ function parseTestStep(lines, index) {
693
703
  const stepDataWithImages = [stepData, titleImages.join("\n")]
694
704
  .filter(Boolean)
695
705
  .join(stepData ? "\n" : "");
696
- return {
697
- block: {
698
- type: "testStep",
699
- props: {
700
- stepTitle: titleWithPlaceholders,
701
- stepData: stepDataWithImages,
702
- expectedResult,
703
- },
704
- children: [],
705
- },
706
- nextIndex: next,
706
+ const blockProps = blockType === "snippet"
707
+ ? {
708
+ snippetId: snippetId !== null && snippetId !== void 0 ? snippetId : "",
709
+ snippetTitle: titleWithPlaceholders,
710
+ snippetData: stepDataWithImages,
711
+ snippetExpectedResult: expectedResult,
712
+ }
713
+ : {
714
+ stepTitle: titleWithPlaceholders,
715
+ stepData: stepDataWithImages,
716
+ expectedResult,
717
+ };
718
+ const parsedBlock = {
719
+ type: blockType,
720
+ props: blockProps,
721
+ children: [],
707
722
  };
708
- }
709
- function parseTestCase(lines, index) {
710
- var _a;
711
- const trimmed = lines[index].trim();
712
- if (!trimmed.startsWith(":::test-case")) {
713
- return null;
714
- }
715
- const statusMatch = trimmed.match(/status="([^"]*)"/);
716
- const referenceMatch = trimmed.match(/reference="([^"]*)"/);
717
- let bodyLines = [];
718
- let next = index + 1;
719
- while (next < lines.length && lines[next].trim() !== ":::") {
720
- bodyLines.push(lines[next]);
721
- next += 1;
722
- }
723
- if (next < lines.length && lines[next].trim() === ":::") {
724
- next += 1;
725
- }
726
- const contentText = bodyLines.join("\n").trim();
727
723
  return {
728
- block: {
729
- type: "testCase",
730
- props: {
731
- ...cloneBaseProps(),
732
- status: normalizeStatus(statusMatch === null || statusMatch === void 0 ? void 0 : statusMatch[1]),
733
- reference: (_a = referenceMatch === null || referenceMatch === void 0 ? void 0 : referenceMatch[1]) !== null && _a !== void 0 ? _a : "",
734
- },
735
- content: contentText
736
- ? [{ type: "text", text: unescapeMarkdown(contentText), styles: {} }]
737
- : undefined,
738
- children: [],
739
- },
724
+ block: parsedBlock,
740
725
  nextIndex: next,
741
726
  };
742
727
  }
@@ -868,6 +853,52 @@ function parseParagraph(lines, index) {
868
853
  nextIndex: next,
869
854
  };
870
855
  }
856
+ function parseSnippetWrapper(lines, index) {
857
+ const trimmed = lines[index].trim();
858
+ const startMatch = trimmed.match(/^<!--\s*begin snippet\s*#?([^\s>]+)\s*-->/i);
859
+ if (!startMatch) {
860
+ return null;
861
+ }
862
+ const snippetId = startMatch[1];
863
+ const innerLines = [];
864
+ let next = index + 1;
865
+ while (next < lines.length) {
866
+ const maybeEnd = lines[next].trim();
867
+ const endMatch = maybeEnd.match(/^<!--\s*end snippet\s*#?([^\s>]+)?\s*-->/i);
868
+ if (endMatch) {
869
+ const endId = endMatch[1];
870
+ if (!endId || endId === snippetId) {
871
+ next += 1;
872
+ break;
873
+ }
874
+ // Ignore unrelated snippet end markers but keep scanning.
875
+ next += 1;
876
+ continue;
877
+ }
878
+ const otherStart = maybeEnd.match(/^<!--\s*begin snippet\s*#?([^\s>]+)\s*-->/i);
879
+ if (otherStart) {
880
+ // Skip nested snippet wrappers from the body entirely.
881
+ next += 1;
882
+ continue;
883
+ }
884
+ innerLines.push(lines[next]);
885
+ next += 1;
886
+ }
887
+ const snippetBlock = {
888
+ type: "snippet",
889
+ props: {
890
+ snippetId,
891
+ snippetTitle: "",
892
+ snippetData: innerLines.join("\n").trim(),
893
+ snippetExpectedResult: "",
894
+ },
895
+ children: [],
896
+ };
897
+ return {
898
+ block: snippetBlock,
899
+ nextIndex: next,
900
+ };
901
+ }
871
902
  export function markdownToBlocks(markdown) {
872
903
  const normalized = markdown.replace(/\r\n/g, "\n");
873
904
  const lines = normalized.split("\n");
@@ -879,16 +910,16 @@ export function markdownToBlocks(markdown) {
879
910
  index += 1;
880
911
  continue;
881
912
  }
882
- const testCase = parseTestCase(lines, index);
883
- if (testCase) {
884
- blocks.push(testCase.block);
885
- index = testCase.nextIndex;
913
+ const snippetWrapper = parseSnippetWrapper(lines, index);
914
+ if (snippetWrapper) {
915
+ blocks.push(snippetWrapper.block);
916
+ index = snippetWrapper.nextIndex;
886
917
  continue;
887
918
  }
888
- const testStep = parseTestStep(lines, index);
889
- if (testStep) {
890
- blocks.push(testStep.block);
891
- index = testStep.nextIndex;
919
+ const stepLikeBlock = parseTestStep(lines, index);
920
+ if (stepLikeBlock) {
921
+ blocks.push(stepLikeBlock.block);
922
+ index = stepLikeBlock.nextIndex;
892
923
  continue;
893
924
  }
894
925
  const table = parseTable(lines, index);