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.
- package/package/editor/blocks/step.js +1 -1
- package/package/editor/customMarkdownConverter.d.ts +1 -2
- package/package/editor/customMarkdownConverter.js +44 -48
- package/package.json +1 -1
- package/src/App.tsx +23 -1
- package/src/editor/blocks/step.tsx +1 -0
- package/src/editor/customMarkdownConverter.test.ts +155 -0
- package/src/editor/customMarkdownConverter.ts +50 -50
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
223
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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(``);
|
|
289
298
|
}
|
|
290
|
-
return
|
|
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(`[](${url})`);
|
|
301
310
|
}
|
|
302
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
1176
|
-
var _a, _b
|
|
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
|
-
|
|
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
|
-
//
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
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(
|
|
1300
|
+
return fixMalformedImageBlocks(blocks);
|
|
1305
1301
|
}
|
|
1306
1302
|
function splitTableRow(line) {
|
|
1307
1303
|
let value = line.trim();
|
package/package.json
CHANGED
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
|
|
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
|
-
|
|
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
|
|
296
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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(``);
|
|
363
371
|
}
|
|
364
|
-
return
|
|
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(`[](${url})`);
|
|
375
383
|
}
|
|
376
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
//
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
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(
|
|
1552
|
+
return fixMalformedImageBlocks(blocks);
|
|
1553
1553
|
}
|
|
1554
1554
|
|
|
1555
1555
|
function splitTableRow(line: string): string[] {
|