testomatio-editor-blocks 0.4.51 → 0.4.52

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.
@@ -400,7 +400,7 @@ export const stepBlock = createReactBlockSpec({
400
400
  writeExpectedCollapsedPreference(true);
401
401
  editor.updateBlock(block.id, { props: { expectedResult: "" } });
402
402
  }, [editor, block.id]);
403
- const viewToggleButton = (_jsx("button", { type: "button", className: `bn-teststep__view-toggle${!effectiveVertical ? " bn-teststep__view-toggle--horizontal" : ""}${forceVertical ? " bn-teststep__view-toggle--disabled" : ""}`, "data-tooltip": forceVertical ? "Not enough space for horizontal view" : "Switch step view", "aria-label": forceVertical ? "Not enough space for horizontal view" : "Switch step view", onClick: forceVertical ? undefined : handleToggleView, "aria-disabled": forceVertical, children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: [_jsx("mask", { id: "mask-toggle", style: { maskType: "alpha" }, maskUnits: "userSpaceOnUse", x: "0", y: "0", width: "16", height: "16", children: _jsx("rect", { width: "16", height: "16", fill: "#D9D9D9" }) }), _jsx("g", { mask: "url(#mask-toggle)", children: _jsx("path", { d: "M12.6667 2C13.0333 2 13.3472 2.13056 13.6083 2.39167C13.8694 2.65278 14 2.96667 14 3.33333L14 12.6667C14 13.0333 13.8694 13.3472 13.6083 13.6083C13.3472 13.8694 13.0333 14 12.6667 14L10 14C9.63333 14 9.31944 13.8694 9.05833 13.6083C8.79722 13.3472 8.66667 13.0333 8.66667 12.6667L8.66667 3.33333C8.66667 2.96667 8.79722 2.65278 9.05833 2.39167C9.31945 2.13055 9.63333 2 10 2L12.6667 2ZM6 2C6.36667 2 6.68056 2.13055 6.94167 2.39167C7.20278 2.65278 7.33333 2.96667 7.33333 3.33333L7.33333 12.6667C7.33333 13.0333 7.20278 13.3472 6.94167 13.6083C6.68055 13.8694 6.36667 14 6 14L3.33333 14C2.96667 14 2.65278 13.8694 2.39167 13.6083C2.13056 13.3472 2 13.0333 2 12.6667L2 3.33333C2 2.96667 2.13056 2.65278 2.39167 2.39167C2.65278 2.13055 2.96667 2 3.33333 2L6 2ZM3.33333 12.6667L6 12.6667L6 3.33333L3.33333 3.33333L3.33333 12.6667Z", fill: "currentColor" }) })] }) }));
403
+ const viewToggleButton = (_jsx("button", { type: "button", className: `bn-teststep__view-toggle${!effectiveVertical ? " bn-teststep__view-toggle--horizontal" : ""}${forceVertical ? " bn-teststep__view-toggle--disabled" : ""}`, "data-tooltip": forceVertical ? "Not enough space for horizontal view" : "Switch step view", "aria-label": forceVertical ? "Not enough space for horizontal view" : "Switch step view", onClick: forceVertical ? undefined : handleToggleView, "aria-disabled": forceVertical, tabIndex: -1, children: _jsxs("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: [_jsx("mask", { id: "mask-toggle", style: { maskType: "alpha" }, maskUnits: "userSpaceOnUse", x: "0", y: "0", width: "16", height: "16", children: _jsx("rect", { width: "16", height: "16", fill: "#D9D9D9" }) }), _jsx("g", { mask: "url(#mask-toggle)", children: _jsx("path", { d: "M12.6667 2C13.0333 2 13.3472 2.13056 13.6083 2.39167C13.8694 2.65278 14 2.96667 14 3.33333L14 12.6667C14 13.0333 13.8694 13.3472 13.6083 13.6083C13.3472 13.8694 13.0333 14 12.6667 14L10 14C9.63333 14 9.31944 13.8694 9.05833 13.6083C8.79722 13.3472 8.66667 13.0333 8.66667 12.6667L8.66667 3.33333C8.66667 2.96667 8.79722 2.65278 9.05833 2.39167C9.31945 2.13055 9.63333 2 10 2L12.6667 2ZM6 2C6.36667 2 6.68056 2.13055 6.94167 2.39167C7.20278 2.65278 7.33333 2.96667 7.33333 3.33333L7.33333 12.6667C7.33333 13.0333 7.20278 13.3472 6.94167 13.6083C6.68055 13.8694 6.36667 14 6 14L3.33333 14C2.96667 14 2.65278 13.8694 2.39167 13.6083C2.13056 13.3472 2 13.0333 2 12.6667L2 3.33333C2 2.96667 2.13056 2.65278 2.39167 2.39167C2.65278 2.13055 2.96667 2 3.33333 2L6 2ZM3.33333 12.6667L6 12.6667L6 3.33333L3.33333 3.33333L3.33333 12.6667Z", fill: "currentColor" }) })] }) }));
404
404
  if (!effectiveVertical) {
405
405
  return (_jsx(StepHorizontalView, { ref: containerRef, blockId: block.id, stepNumber: stepNumber, stepValue: combinedStepValue, expectedResult: expectedResult, onStepChange: handleCombinedStepChange, onExpectedChange: handleExpectedChange, onInsertNextStep: handleInsertNextStep, onFieldFocus: handleFieldFocus, viewToggle: viewToggleButton }));
406
406
  }
@@ -6,8 +6,7 @@ export type CustomPartialBlock = PartialBlock<Schema["blockSchema"], Schema["inl
6
6
  export declare function blocksToMarkdown(blocks: CustomEditorBlock[]): string;
7
7
  export declare function fixMalformedImageBlocks(blocks: CustomPartialBlock[]): CustomPartialBlock[];
8
8
  export interface MarkdownToBlocksOptions {
9
- /** When true, every blank line produces an empty paragraph block. */
10
9
  preserveBlankLines?: boolean;
11
10
  }
12
- export declare function markdownToBlocks(markdown: string, options?: MarkdownToBlocksOptions): CustomPartialBlock[];
11
+ export declare function markdownToBlocks(markdown: string, _options?: MarkdownToBlocksOptions): CustomPartialBlock[];
13
12
  export {};
@@ -23,6 +23,7 @@ const headingPrefixes = {
23
23
  6: "######",
24
24
  };
25
25
  const SPECIAL_CHAR_REGEX = /([*_`~\[\]()<\\])/g;
26
+ const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
26
27
  const HTML_SPAN_REGEX = /<\/?span[^>]*>/g;
27
28
  const HTML_UNDERLINE_REGEX = /<\/?u>/g;
28
29
  const EXPECTED_LABEL_REGEX = /^(?:[*_`]*\s*)?(expected(?:\s+result)?)\s*(?:[*_`]*\s*)?\s*[:\-–—]\s*/i;
@@ -30,7 +31,17 @@ const EXPECTED_LABEL_REGEX = /^(?:[*_`]*\s*)?(expected(?:\s+result)?)\s*(?:[*_`]
30
31
  const STEP_DATA_LINE_REGEX = /^(?!\s*(?:[*_`]*\s*)?(?:expected(?:\s+result)?)\b).+/i;
31
32
  const NUMBERED_STEP_REGEX = /^\d+[.)]\s+/;
32
33
  function escapeMarkdown(text) {
33
- return text.replace(SPECIAL_CHAR_REGEX, "\\$1");
34
+ let result = "";
35
+ let lastIndex = 0;
36
+ HTML_COMMENT_REGEX.lastIndex = 0;
37
+ let match;
38
+ while ((match = HTML_COMMENT_REGEX.exec(text)) !== null) {
39
+ result += text.slice(lastIndex, match.index).replace(SPECIAL_CHAR_REGEX, "\\$1");
40
+ result += match[0];
41
+ lastIndex = match.index + match[0].length;
42
+ }
43
+ result += text.slice(lastIndex).replace(SPECIAL_CHAR_REGEX, "\\$1");
44
+ return result;
34
45
  }
35
46
  function stripHtmlWrappers(text) {
36
47
  return text
@@ -206,12 +217,6 @@ function serializeChildren(block, ctx) {
206
217
  const childCtx = { ...ctx, listDepth: ctx.listDepth + 1 };
207
218
  return serializeBlocks(block.children, childCtx);
208
219
  }
209
- function flattenWithBlankLine(lines, appendBlank = false) {
210
- if (appendBlank && (lines.length === 0 || lines.at(-1) !== "")) {
211
- return [...lines, ""];
212
- }
213
- return lines;
214
- }
215
220
  function serializeBlock(block, ctx, orderedIndex, stepIndex) {
216
221
  var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
217
222
  const lines = [];
@@ -219,17 +224,21 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
219
224
  switch (block.type) {
220
225
  case "paragraph": {
221
226
  const text = inlineToMarkdown(block.content);
222
- if (text.length > 0) {
223
- lines.push(ctx.insideQuote ? `> ${text}` : text);
227
+ if (text.length === 0) {
228
+ // Empty paragraph = one blank line in the output. Under the 1:1
229
+ // block model, this is the only mechanism that produces blank lines
230
+ // between top-level blocks.
231
+ return [""];
224
232
  }
225
- return flattenWithBlankLine(lines, !ctx.insideQuote);
233
+ lines.push(ctx.insideQuote ? `> ${text}` : text);
234
+ return lines;
226
235
  }
227
236
  case "heading": {
228
237
  const level = (_a = block.props.level) !== null && _a !== void 0 ? _a : 1;
229
238
  const prefix = (_b = headingPrefixes[level]) !== null && _b !== void 0 ? _b : headingPrefixes[3];
230
239
  const text = inlineToMarkdown(block.content);
231
240
  lines.push(`${prefix} ${text}`.trimEnd());
232
- return flattenWithBlankLine(lines, true);
241
+ return lines;
233
242
  }
234
243
  case "quote": {
235
244
  const quoteContent = serializeBlocks((_c = block.children) !== null && _c !== void 0 ? _c : [], {
@@ -244,7 +253,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
244
253
  lines.push(...quoteText);
245
254
  }
246
255
  lines.push(...quoteContent.map((line) => (line ? `> ${line}` : ">")));
247
- return flattenWithBlankLine(lines, true);
256
+ return lines;
248
257
  }
249
258
  case "codeBlock": {
250
259
  const language = block.props.language || "";
@@ -255,7 +264,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
255
264
  lines.push(body);
256
265
  }
257
266
  lines.push("```");
258
- return flattenWithBlankLine(lines, true);
267
+ return lines;
259
268
  }
260
269
  case "bulletListItem": {
261
270
  const text = inlineToMarkdown(block.content);
@@ -287,7 +296,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
287
296
  const size = width ? ` =${width}x*` : "";
288
297
  lines.push(`![${caption}](${url}${size})`);
289
298
  }
290
- return flattenWithBlankLine(lines, true);
299
+ return lines;
291
300
  }
292
301
  case "file":
293
302
  case "video":
@@ -299,7 +308,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
299
308
  const displayUrl = caption || resolveFileDisplayUrl(url);
300
309
  lines.push(`[![${name}](${displayUrl})](${url})`);
301
310
  }
302
- return flattenWithBlankLine(lines, true);
311
+ return lines;
303
312
  }
304
313
  case "testStep":
305
314
  case "snippet": {
@@ -327,7 +336,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
327
336
  if (snippetId) {
328
337
  lines.push(`<!-- end snippet #${snippetId} -->`);
329
338
  }
330
- return flattenWithBlankLine(lines, true);
339
+ return lines;
331
340
  }
332
341
  const normalizedTitle = stepTitle
333
342
  .split(/\r?\n/)
@@ -370,28 +379,25 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
370
379
  }
371
380
  });
372
381
  }
373
- if (lines.length === 0) {
374
- return lines;
375
- }
376
- return flattenWithBlankLine(lines, false);
382
+ return lines;
377
383
  }
378
384
  case "table": {
379
385
  const tableContent = block.content;
380
386
  if (!tableContent || tableContent.type !== "tableContent") {
381
- return flattenWithBlankLine(lines, true);
387
+ return lines;
382
388
  }
383
389
  const rows = Array.isArray(tableContent.rows)
384
390
  ? tableContent.rows
385
391
  : [];
386
392
  if (rows.length === 0) {
387
- return flattenWithBlankLine(lines, true);
393
+ return lines;
388
394
  }
389
395
  const columnCount = rows.reduce((max, row) => {
390
396
  const length = Array.isArray(row.cells) ? row.cells.length : 0;
391
397
  return Math.max(max, length);
392
398
  }, 0);
393
399
  if (columnCount === 0) {
394
- return flattenWithBlankLine(lines, true);
400
+ return lines;
395
401
  }
396
402
  const headerRowCount = rows.length
397
403
  ? Math.min(rows.length, Math.max((_o = tableContent.headerRows) !== null && _o !== void 0 ? _o : 1, 1))
@@ -468,7 +474,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
468
474
  formattedRows.slice(bodyStartIndex).forEach((row) => {
469
475
  lines.push(`| ${row.map((cell) => formatCell(cell)).join(" | ")} |`);
470
476
  });
471
- return flattenWithBlankLine(lines, true);
477
+ return lines;
472
478
  }
473
479
  }
474
480
  const fallbackBlock = block;
@@ -479,7 +485,7 @@ function serializeBlock(block, ctx, orderedIndex, stepIndex) {
479
485
  }
480
486
  }
481
487
  lines.push(...serializeChildren(fallbackBlock, ctx));
482
- return flattenWithBlankLine(lines, false);
488
+ return lines;
483
489
  }
484
490
  function serializeBlocks(blocks, ctx) {
485
491
  const lines = [];
@@ -1172,8 +1178,8 @@ export function fixMalformedImageBlocks(blocks) {
1172
1178
  }
1173
1179
  return result;
1174
1180
  }
1175
- export function markdownToBlocks(markdown, options) {
1176
- var _a, _b, _c;
1181
+ export function markdownToBlocks(markdown, _options) {
1182
+ var _a, _b;
1177
1183
  const normalized = markdown.replace(/\r\n/g, "\n");
1178
1184
  const lines = normalized.split("\n");
1179
1185
  const blocks = [];
@@ -1182,22 +1188,12 @@ export function markdownToBlocks(markdown, options) {
1182
1188
  while (index < lines.length) {
1183
1189
  const line = lines[index];
1184
1190
  if (!line.trim()) {
1185
- if (options === null || options === void 0 ? void 0 : options.preserveBlankLines) {
1191
+ // Drop blank lines until we've emitted at least one block, so leading
1192
+ // blanks don't produce a ghost empty paragraph at the top of the doc.
1193
+ if (blocks.length > 0) {
1186
1194
  blocks.push({ type: "paragraph", content: [], children: [] });
1187
- index += 1;
1188
- continue;
1189
1195
  }
1190
1196
  index += 1;
1191
- // Count consecutive blank lines
1192
- let blankCount = 1;
1193
- while (index < lines.length && !lines[index].trim()) {
1194
- blankCount++;
1195
- index++;
1196
- }
1197
- // Create empty paragraph for each extra blank line beyond the first
1198
- for (let i = 1; i < blankCount; i++) {
1199
- blocks.push({ type: "paragraph", content: [], children: [] });
1200
- }
1201
1197
  continue;
1202
1198
  }
1203
1199
  const snippetWrapper = stepsHeadingLevel !== null
@@ -1293,15 +1289,15 @@ export function markdownToBlocks(markdown, options) {
1293
1289
  blocks.push(paragraph.block);
1294
1290
  index = paragraph.nextIndex;
1295
1291
  }
1296
- // Insert empty paragraphs between consecutive headings so users can type between them
1297
- const result = [];
1298
- for (let i = 0; i < blocks.length; i++) {
1299
- result.push(blocks[i]);
1300
- if (blocks[i].type === "heading" && ((_c = blocks[i + 1]) === null || _c === void 0 ? void 0 : _c.type) === "heading") {
1301
- result.push({ type: "paragraph", content: [], children: [] });
1302
- }
1292
+ // Drop trailing empty paragraphs so a trailing blank line in the source
1293
+ // doesn't leave a ghost empty block at the end of the document.
1294
+ while (blocks.length > 0 &&
1295
+ blocks[blocks.length - 1].type === "paragraph" &&
1296
+ (!blocks[blocks.length - 1].content ||
1297
+ blocks[blocks.length - 1].content.length === 0)) {
1298
+ blocks.pop();
1303
1299
  }
1304
- return fixMalformedImageBlocks(result);
1300
+ return fixMalformedImageBlocks(blocks);
1305
1301
  }
1306
1302
  function splitTableRow(line) {
1307
1303
  let value = line.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testomatio-editor-blocks",
3
- "version": "0.4.51",
3
+ "version": "0.4.52",
4
4
  "description": "Custom BlockNote schema, markdown conversion helpers, and UI for Testomatio-style test cases and steps.",
5
5
  "type": "module",
6
6
  "main": "./package/index.js",
package/src/App.tsx CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  filterSuggestionItems,
12
12
  insertOrUpdateBlock,
13
13
  } from "@blocknote/core";
14
+ import { autoPlacement, offset, shift, size } from "@floating-ui/react";
14
15
  import {
15
16
  blocksToMarkdown,
16
17
  markdownToBlocks,
@@ -354,7 +355,28 @@ function CustomSlashMenu() {
354
355
  return filterSuggestionItems(items, query);
355
356
  };
356
357
 
357
- return <SuggestionMenuController triggerCharacter="/" getItems={getItems} />;
358
+ return (
359
+ <SuggestionMenuController
360
+ triggerCharacter="/"
361
+ getItems={getItems}
362
+ floatingOptions={{
363
+ middleware: [
364
+ offset(10),
365
+ autoPlacement({
366
+ allowedPlacements: ["bottom-start", "top-start"],
367
+ }),
368
+ shift(),
369
+ size({
370
+ apply({ availableHeight, elements }) {
371
+ Object.assign(elements.floating.style, {
372
+ maxHeight: `${Math.max(availableHeight - 10, 0)}px`,
373
+ });
374
+ },
375
+ }),
376
+ ],
377
+ }}
378
+ />
379
+ );
358
380
  }
359
381
 
360
382
  function App() {
@@ -463,6 +463,7 @@ export const stepBlock = createReactBlockSpec(
463
463
  aria-label={forceVertical ? "Not enough space for horizontal view" : "Switch step view"}
464
464
  onClick={forceVertical ? undefined : handleToggleView}
465
465
  aria-disabled={forceVertical}
466
+ tabIndex={-1}
466
467
  >
467
468
  <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
468
469
  <mask id="mask-toggle" style={{maskType: "alpha"}} maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16">
@@ -46,6 +46,38 @@ describe("blocksToMarkdown", () => {
46
46
  expect(blocksToMarkdown(blocks)).toBe("Hello **world**_!_");
47
47
  });
48
48
 
49
+ it("preserves HTML comments without escaping", () => {
50
+ const blocks: CustomEditorBlock[] = [
51
+ {
52
+ id: "c1",
53
+ type: "paragraph",
54
+ props: baseProps,
55
+ content: [
56
+ { type: "text", text: "<!-- ai/agent generated description -->", styles: {} },
57
+ ],
58
+ children: [],
59
+ },
60
+ ];
61
+
62
+ expect(blocksToMarkdown(blocks)).toBe("<!-- ai/agent generated description -->");
63
+ });
64
+
65
+ it("preserves HTML comments inline among text and still escapes stray angle brackets", () => {
66
+ const blocks: CustomEditorBlock[] = [
67
+ {
68
+ id: "c2",
69
+ type: "paragraph",
70
+ props: baseProps,
71
+ content: [
72
+ { type: "text", text: "before <!-- note --> after <div>", styles: {} },
73
+ ],
74
+ children: [],
75
+ },
76
+ ];
77
+
78
+ expect(blocksToMarkdown(blocks)).toBe("before <!-- note --> after \\<div>");
79
+ });
80
+
49
81
  it("places bold markers outside leading/trailing spaces", () => {
50
82
  const blocks: CustomEditorBlock[] = [
51
83
  {
@@ -1082,6 +1114,11 @@ describe("markdownToBlocks", () => {
1082
1114
  content: [{ type: "text", text: "Preconditions", styles: {} }],
1083
1115
  children: [],
1084
1116
  },
1117
+ {
1118
+ type: "paragraph",
1119
+ content: [],
1120
+ children: [],
1121
+ },
1085
1122
  {
1086
1123
  type: "bulletListItem",
1087
1124
  props: baseProps,
@@ -2525,3 +2562,121 @@ describe("steps require Steps heading", () => {
2525
2562
  expect((stepBlocks[0].props as any).stepTitle).toBe("next 22");
2526
2563
  });
2527
2564
  });
2565
+
2566
+ describe("blank line <-> empty paragraph mapping", () => {
2567
+ const isEmptyParagraph = (block: CustomPartialBlock | CustomEditorBlock) =>
2568
+ block.type === "paragraph" &&
2569
+ (!block.content || (block.content as any[]).length === 0);
2570
+
2571
+ it("parses a single blank line between headings as one empty paragraph", () => {
2572
+ const blocks = markdownToBlocks("### A\n\n### B");
2573
+ expect(blocks).toHaveLength(3);
2574
+ expect(blocks[0].type).toBe("heading");
2575
+ expect(isEmptyParagraph(blocks[1])).toBe(true);
2576
+ expect(blocks[2].type).toBe("heading");
2577
+ });
2578
+
2579
+ it("parses two blank lines between headings as two empty paragraphs", () => {
2580
+ const blocks = markdownToBlocks("### A\n\n\n### B");
2581
+ expect(blocks).toHaveLength(4);
2582
+ expect(blocks[0].type).toBe("heading");
2583
+ expect(isEmptyParagraph(blocks[1])).toBe(true);
2584
+ expect(isEmptyParagraph(blocks[2])).toBe(true);
2585
+ expect(blocks[3].type).toBe("heading");
2586
+ });
2587
+
2588
+ it("drops leading and trailing blank lines", () => {
2589
+ const blocks = markdownToBlocks("\n\n### A\n\n");
2590
+ expect(blocks).toHaveLength(1);
2591
+ expect(blocks[0].type).toBe("heading");
2592
+ });
2593
+
2594
+ it("serializes an empty paragraph between two blocks as one blank line", () => {
2595
+ const blocks: CustomEditorBlock[] = [
2596
+ {
2597
+ id: "h1",
2598
+ type: "heading",
2599
+ props: { ...baseProps, level: 3 } as any,
2600
+ content: [{ type: "text", text: "A", styles: {} }],
2601
+ children: [],
2602
+ },
2603
+ {
2604
+ id: "p1",
2605
+ type: "paragraph",
2606
+ props: baseProps,
2607
+ content: [],
2608
+ children: [],
2609
+ },
2610
+ {
2611
+ id: "h2",
2612
+ type: "heading",
2613
+ props: { ...baseProps, level: 3 } as any,
2614
+ content: [{ type: "text", text: "B", styles: {} }],
2615
+ children: [],
2616
+ },
2617
+ ];
2618
+ expect(blocksToMarkdown(blocks)).toBe("### A\n\n### B");
2619
+ });
2620
+
2621
+ it("round-trips a single blank line between headings", () => {
2622
+ const markdown = "### A\n\n### B";
2623
+ const blocks = markdownToBlocks(markdown);
2624
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
2625
+ });
2626
+
2627
+ it("round-trips two blank lines between headings", () => {
2628
+ const markdown = "### A\n\n\n### B";
2629
+ const blocks = markdownToBlocks(markdown);
2630
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
2631
+ });
2632
+
2633
+ it("round-trips headings adjacent with no blank line", () => {
2634
+ const markdown = "### A\n### B";
2635
+ const blocks = markdownToBlocks(markdown);
2636
+ expect(blocks.some(isEmptyParagraph)).toBe(false);
2637
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
2638
+ });
2639
+
2640
+ it("deleting an empty paragraph removes the blank line from the output", () => {
2641
+ const blocks = markdownToBlocks("### A\n\n### B") as CustomEditorBlock[];
2642
+ const withoutEmpty = blocks.filter((block) => !isEmptyParagraph(block));
2643
+ expect(withoutEmpty).toHaveLength(2);
2644
+ expect(blocksToMarkdown(withoutEmpty)).toBe("### A\n### B");
2645
+ });
2646
+
2647
+ it("inserting an empty paragraph adds a blank line to the output", () => {
2648
+ const blocks = markdownToBlocks("### A\n### B") as CustomEditorBlock[];
2649
+ const withEmpty: CustomEditorBlock[] = [
2650
+ blocks[0],
2651
+ {
2652
+ id: "inserted",
2653
+ type: "paragraph",
2654
+ props: baseProps,
2655
+ content: [],
2656
+ children: [],
2657
+ },
2658
+ blocks[1],
2659
+ ];
2660
+ expect(blocksToMarkdown(withEmpty)).toBe("### A\n\n### B");
2661
+ });
2662
+
2663
+ it("preserves blank lines between a heading and a bullet list", () => {
2664
+ const markdown = "### Steps\n\n* first\n* second";
2665
+ const blocks = markdownToBlocks(markdown);
2666
+ expect(isEmptyParagraph(blocks[1])).toBe(true);
2667
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
2668
+ });
2669
+
2670
+ it("preserves blank lines across the user-reported screenshot example", () => {
2671
+ const markdown = [
2672
+ "### Requirements",
2673
+ "",
2674
+ "### Steps",
2675
+ "",
2676
+ "* *open* **webiste**",
2677
+ " step data with `image`",
2678
+ ].join("\n");
2679
+ const blocks = markdownToBlocks(markdown);
2680
+ expect(blocksToMarkdown(blocks as CustomEditorBlock[])).toBe(markdown);
2681
+ });
2682
+ });
@@ -61,6 +61,7 @@ const headingPrefixes: Record<number, string> = {
61
61
  };
62
62
 
63
63
  const SPECIAL_CHAR_REGEX = /([*_`~\[\]()<\\])/g;
64
+ const HTML_COMMENT_REGEX = /<!--[\s\S]*?-->/g;
64
65
  const HTML_SPAN_REGEX = /<\/?span[^>]*>/g;
65
66
  const HTML_UNDERLINE_REGEX = /<\/?u>/g;
66
67
  const EXPECTED_LABEL_REGEX = /^(?:[*_`]*\s*)?(expected(?:\s+result)?)\s*(?:[*_`]*\s*)?\s*[:\-–—]\s*/i;
@@ -70,7 +71,17 @@ const STEP_DATA_LINE_REGEX =
70
71
  const NUMBERED_STEP_REGEX = /^\d+[.)]\s+/;
71
72
 
72
73
  function escapeMarkdown(text: string): string {
73
- return text.replace(SPECIAL_CHAR_REGEX, "\\$1");
74
+ let result = "";
75
+ let lastIndex = 0;
76
+ HTML_COMMENT_REGEX.lastIndex = 0;
77
+ let match: RegExpExecArray | null;
78
+ while ((match = HTML_COMMENT_REGEX.exec(text)) !== null) {
79
+ result += text.slice(lastIndex, match.index).replace(SPECIAL_CHAR_REGEX, "\\$1");
80
+ result += match[0];
81
+ lastIndex = match.index + match[0].length;
82
+ }
83
+ result += text.slice(lastIndex).replace(SPECIAL_CHAR_REGEX, "\\$1");
84
+ return result;
74
85
  }
75
86
 
76
87
  function stripHtmlWrappers(text: string): string {
@@ -273,13 +284,6 @@ function serializeChildren(block: CustomEditorBlock, ctx: MarkdownContext): stri
273
284
  return serializeBlocks(block.children, childCtx);
274
285
  }
275
286
 
276
- function flattenWithBlankLine(lines: string[], appendBlank = false): string[] {
277
- if (appendBlank && (lines.length === 0 || lines.at(-1) !== "")) {
278
- return [...lines, ""];
279
- }
280
- return lines;
281
- }
282
-
283
287
  function serializeBlock(
284
288
  block: CustomEditorBlock,
285
289
  ctx: MarkdownContext,
@@ -292,17 +296,21 @@ function serializeBlock(
292
296
  switch (block.type) {
293
297
  case "paragraph": {
294
298
  const text = inlineToMarkdown(block.content);
295
- if (text.length > 0) {
296
- lines.push(ctx.insideQuote ? `> ${text}` : text);
299
+ if (text.length === 0) {
300
+ // Empty paragraph = one blank line in the output. Under the 1:1
301
+ // block model, this is the only mechanism that produces blank lines
302
+ // between top-level blocks.
303
+ return [""];
297
304
  }
298
- return flattenWithBlankLine(lines, !ctx.insideQuote);
305
+ lines.push(ctx.insideQuote ? `> ${text}` : text);
306
+ return lines;
299
307
  }
300
308
  case "heading": {
301
309
  const level = (block.props as any).level ?? 1;
302
310
  const prefix = headingPrefixes[level] ?? headingPrefixes[3];
303
311
  const text = inlineToMarkdown(block.content);
304
312
  lines.push(`${prefix} ${text}`.trimEnd());
305
- return flattenWithBlankLine(lines, true);
313
+ return lines;
306
314
  }
307
315
  case "quote": {
308
316
  const quoteContent = serializeBlocks(block.children ?? [], {
@@ -317,7 +325,7 @@ function serializeBlock(
317
325
  lines.push(...quoteText);
318
326
  }
319
327
  lines.push(...quoteContent.map((line) => (line ? `> ${line}` : ">")));
320
- return flattenWithBlankLine(lines, true);
328
+ return lines;
321
329
  }
322
330
  case "codeBlock": {
323
331
  const language = (block.props as any).language || "";
@@ -328,7 +336,7 @@ function serializeBlock(
328
336
  lines.push(body);
329
337
  }
330
338
  lines.push("```");
331
- return flattenWithBlankLine(lines, true);
339
+ return lines;
332
340
  }
333
341
  case "bulletListItem": {
334
342
  const text = inlineToMarkdown(block.content);
@@ -361,7 +369,7 @@ function serializeBlock(
361
369
  const size = width ? ` =${width}x*` : "";
362
370
  lines.push(`![${caption}](${url}${size})`);
363
371
  }
364
- return flattenWithBlankLine(lines, true);
372
+ return lines;
365
373
  }
366
374
  case "file":
367
375
  case "video":
@@ -373,7 +381,7 @@ function serializeBlock(
373
381
  const displayUrl = caption || resolveFileDisplayUrl(url);
374
382
  lines.push(`[![${name}](${displayUrl})](${url})`);
375
383
  }
376
- return flattenWithBlankLine(lines, true);
384
+ return lines;
377
385
  }
378
386
  case "testStep":
379
387
  case "snippet": {
@@ -405,7 +413,7 @@ function serializeBlock(
405
413
  lines.push(`<!-- end snippet #${snippetId} -->`);
406
414
  }
407
415
 
408
- return flattenWithBlankLine(lines, true);
416
+ return lines;
409
417
  }
410
418
 
411
419
  const normalizedTitle = stepTitle
@@ -453,16 +461,12 @@ function serializeBlock(
453
461
  });
454
462
  }
455
463
 
456
- if (lines.length === 0) {
457
- return lines;
458
- }
459
-
460
- return flattenWithBlankLine(lines, false);
464
+ return lines;
461
465
  }
462
466
  case "table": {
463
467
  const tableContent = block.content as any;
464
468
  if (!tableContent || tableContent.type !== "tableContent") {
465
- return flattenWithBlankLine(lines, true);
469
+ return lines;
466
470
  }
467
471
 
468
472
  const rows: any[] = Array.isArray(tableContent.rows)
@@ -470,7 +474,7 @@ function serializeBlock(
470
474
  : [];
471
475
 
472
476
  if (rows.length === 0) {
473
- return flattenWithBlankLine(lines, true);
477
+ return lines;
474
478
  }
475
479
 
476
480
  const columnCount = rows.reduce((max, row) => {
@@ -479,7 +483,7 @@ function serializeBlock(
479
483
  }, 0);
480
484
 
481
485
  if (columnCount === 0) {
482
- return flattenWithBlankLine(lines, true);
486
+ return lines;
483
487
  }
484
488
 
485
489
  const headerRowCount = rows.length
@@ -570,7 +574,7 @@ function serializeBlock(
570
574
  lines.push(`| ${row.map((cell) => formatCell(cell)).join(" | ")} |`);
571
575
  });
572
576
 
573
- return flattenWithBlankLine(lines, true);
577
+ return lines;
574
578
  }
575
579
  }
576
580
 
@@ -582,7 +586,7 @@ function serializeBlock(
582
586
  }
583
587
  }
584
588
  lines.push(...serializeChildren(fallbackBlock, ctx));
585
- return flattenWithBlankLine(lines, false);
589
+ return lines;
586
590
  }
587
591
 
588
592
  function serializeBlocks(blocks: CustomEditorBlock[], ctx: MarkdownContext): string[] {
@@ -1394,12 +1398,16 @@ export function fixMalformedImageBlocks(blocks: CustomPartialBlock[]): CustomPar
1394
1398
  return result;
1395
1399
  }
1396
1400
 
1401
+ // The `preserveBlankLines` option is retained for backwards compatibility
1402
+ // but is now a no-op: blank lines in the source markdown always produce
1403
+ // empty paragraph blocks (except for leading/trailing blanks, which are
1404
+ // dropped). This gives a 1:1 mapping between blank lines and blocks so the
1405
+ // Rich editor can render and delete each blank line individually.
1397
1406
  export interface MarkdownToBlocksOptions {
1398
- /** When true, every blank line produces an empty paragraph block. */
1399
1407
  preserveBlankLines?: boolean;
1400
1408
  }
1401
1409
 
1402
- export function markdownToBlocks(markdown: string, options?: MarkdownToBlocksOptions): CustomPartialBlock[] {
1410
+ export function markdownToBlocks(markdown: string, _options?: MarkdownToBlocksOptions): CustomPartialBlock[] {
1403
1411
  const normalized = markdown.replace(/\r\n/g, "\n");
1404
1412
  const lines = normalized.split("\n");
1405
1413
  const blocks: CustomPartialBlock[] = [];
@@ -1409,22 +1417,12 @@ export function markdownToBlocks(markdown: string, options?: MarkdownToBlocksOpt
1409
1417
  while (index < lines.length) {
1410
1418
  const line = lines[index];
1411
1419
  if (!line.trim()) {
1412
- if (options?.preserveBlankLines) {
1420
+ // Drop blank lines until we've emitted at least one block, so leading
1421
+ // blanks don't produce a ghost empty paragraph at the top of the doc.
1422
+ if (blocks.length > 0) {
1413
1423
  blocks.push({ type: "paragraph", content: [], children: [] } as CustomPartialBlock);
1414
- index += 1;
1415
- continue;
1416
1424
  }
1417
1425
  index += 1;
1418
- // Count consecutive blank lines
1419
- let blankCount = 1;
1420
- while (index < lines.length && !lines[index].trim()) {
1421
- blankCount++;
1422
- index++;
1423
- }
1424
- // Create empty paragraph for each extra blank line beyond the first
1425
- for (let i = 1; i < blankCount; i++) {
1426
- blocks.push({ type: "paragraph", content: [], children: [] } as CustomPartialBlock);
1427
- }
1428
1426
  continue;
1429
1427
  }
1430
1428
 
@@ -1540,16 +1538,18 @@ export function markdownToBlocks(markdown: string, options?: MarkdownToBlocksOpt
1540
1538
  index = paragraph.nextIndex;
1541
1539
  }
1542
1540
 
1543
- // Insert empty paragraphs between consecutive headings so users can type between them
1544
- const result: CustomPartialBlock[] = [];
1545
- for (let i = 0; i < blocks.length; i++) {
1546
- result.push(blocks[i]);
1547
- if (blocks[i].type === "heading" && blocks[i + 1]?.type === "heading") {
1548
- result.push({ type: "paragraph", content: [], children: [] } as CustomPartialBlock);
1549
- }
1541
+ // Drop trailing empty paragraphs so a trailing blank line in the source
1542
+ // doesn't leave a ghost empty block at the end of the document.
1543
+ while (
1544
+ blocks.length > 0 &&
1545
+ blocks[blocks.length - 1].type === "paragraph" &&
1546
+ (!blocks[blocks.length - 1].content ||
1547
+ (blocks[blocks.length - 1].content as any[]).length === 0)
1548
+ ) {
1549
+ blocks.pop();
1550
1550
  }
1551
1551
 
1552
- return fixMalformedImageBlocks(result);
1552
+ return fixMalformedImageBlocks(blocks);
1553
1553
  }
1554
1554
 
1555
1555
  function splitTableRow(line: string): string[] {