diff --git a/package-lock.json b/package-lock.json index 2d32fe4..7dbb887 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "testomatio-editor-blocks", - "version": "0.4.73", + "version": "0.4.93", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "testomatio-editor-blocks", - "version": "0.4.73", + "version": "0.4.93", "dependencies": { "overtype": "^2.0.6" }, @@ -23,6 +23,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", + "mdq-cli": "^0.1.3", "react": "^19.1.1", "react-dom": "^19.1.1", "typescript": "~5.9.3", @@ -2840,6 +2841,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -4102,6 +4113,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "dev": true, + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -4337,6 +4361,24 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdq-cli": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/mdq-cli/-/mdq-cli-0.1.3.tgz", + "integrity": "sha512-mJiwoP6Tf8z/nsS1CKrDyCfNNEamHnSo7KSLH1+Pj9gSl87nrKqyzTituZRDfJ5SDNkGwcXEv7B3ezL7vhgbvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^14.0.1", + "marked": "^16.2.0", + "yaml": "^2.8.3" + }, + "bin": { + "mdq-cli": "bin/mdq.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -6952,6 +6994,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yjs": { "version": "13.6.27", "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz", diff --git a/package.json b/package.json index 634ce4c..5dc9e27 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.22", "globals": "^16.4.0", + "mdq-cli": "^0.1.3", "react": "^19.1.1", "react-dom": "^19.1.1", "typescript": "~5.9.3", diff --git a/src/editor/customMarkdownConverter.test.ts b/src/editor/customMarkdownConverter.test.ts index 4531f81..767edaa 100644 --- a/src/editor/customMarkdownConverter.test.ts +++ b/src/editor/customMarkdownConverter.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { mdq } from "mdq-cli"; import { blocksToMarkdown, markdownToBlocks, @@ -1467,6 +1468,98 @@ describe("markdownToBlocks", () => { ); }); + describe("expected result that starts with a block", () => { + const stepWithExpected = (id: string, stepTitle: string, expectedResult: string) => ({ + id, + type: "testStep", + props: { stepTitle, stepData: "", expectedResult, listStyle: "bullet" }, + content: undefined, + children: [], + }); + + // mdq selects top-level list items only, so a step's nested blocks are + // queried by re-parsing the item body without its marker line. + const stepBody = (markdown: string, stepIndex: number) => + mdq( + mdq(markdown).section("Steps").item().at(stepIndex).text().split("\n").slice(1).join("\n"), + ); + + it("puts a bullet list on its own line under the label", () => { + const markdown = blocksToMarkdown([ + stepWithExpected("step1", "step", "- expected result 1\n- expected result 2"), + stepWithExpected("step2", "step number 2", ""), + ] as CustomEditorBlock[]); + + expect(markdown).toBe( + [ + "* step", + " *Expected result*:", + " - expected result 1", + " - expected result 2", + "* step number 2", + ].join("\n"), + ); + }); + + it("renders as one nested list per step for a CommonMark parser", () => { + const markdown = [ + "### Steps", + "", + blocksToMarkdown([ + stepWithExpected("step1", "step", "- expected result 1\n- expected result 2"), + stepWithExpected("step2", "step number 2", ""), + ] as CustomEditorBlock[]), + ].join("\n"); + + expect(mdq(markdown).section("Steps").item().count()).toBe(2); + const body = stepBody(markdown, 0); + expect(body.list().count()).toBe(1); + expect(body.item().each().map((item) => item.text().trim())).toEqual([ + "- expected result 1", + "- expected result 2", + ]); + }); + + it("keeps a fenced code block out of the label line", () => { + const markdown = blocksToMarkdown([ + stepWithExpected("step1", "Run the script", "```\nok\n```"), + ] as CustomEditorBlock[]); + + expect(markdown).toBe( + ["* Run the script", " *Expected result*:", " ```", " ok", " ```"].join("\n"), + ); + expect(stepBody(["### Steps", "", markdown].join("\n"), 0).code().count()).toBe(1); + }); + + it("round-trips the list back into the expected result", () => { + const expectedResult = "- expected result 1\n- expected result 2"; + const markdown = blocksToMarkdown([ + stepWithExpected("step1", "step", expectedResult), + ] as CustomEditorBlock[]); + + const blocks = markdownToBlocks(["### Steps", "", markdown].join("\n")); + const step = blocks.find((block) => block.type === "testStep"); + + expect((step?.props as { expectedResult?: string }).expectedResult).toBe(expectedResult); + }); + + it("still parses the legacy form with the first item glued to the label", () => { + const blocks = markdownToBlocks( + [ + "### Steps", + "* step", + " *Expected result*: - expected result 1", + " - expected result 2", + "* step number 2", + ].join("\n"), + ); + const steps = blocks.filter((block) => block.type === "testStep"); + + expect(steps).toHaveLength(2); + expect((steps[0].props as { expectedResult?: string }).expectedResult).toBe("- expected result 1\n- expected result 2"); + }); + }); + it("does not escape dots when the only ordered list marker sits inside code", () => { const markdown = blocksToMarkdown([ { diff --git a/src/editor/customMarkdownConverter.ts b/src/editor/customMarkdownConverter.ts index 0678085..27a63d4 100644 --- a/src/editor/customMarkdownConverter.ts +++ b/src/editor/customMarkdownConverter.ts @@ -71,6 +71,8 @@ const EXPECTED_LABEL_REGEX = /^(?:[*_`]*\s*)?(expected(?:\s+result)?)\s*(?:[*_`] const STEP_DATA_LINE_REGEX = /^(?!\s*(?:[*_`]*\s*)?(?:expected(?:\s+result)?)\b).+/i; const NUMBERED_STEP_REGEX = /^\d+[.)]\s+/; +// Block constructs that cannot start mid-paragraph: a bullet marker or a code fence. +const EXPECTED_BLOCK_START_REGEX = /^(?:[-*+]\s|```)/; // An ordered list marker in step content: digits, a dot, then whitespace or the // end of a line. Anchored to the start of a line (leading indent still forms a // list), because Markdown only reads a marker there — "Response is 200. See …" @@ -626,15 +628,20 @@ function serializeBlock( if (normalizedExpected.length > 0) { const escaped = renderStepSegments(expectedSegments, escapeDots); const label = "*Expected result*"; - let isFirst = true; - escaped.split(/\r?\n/).forEach((expectedLine: string) => { - const trimmedLine = expectedLine.trim(); - if (trimmedLine.length === 0) { - return; - } - lines.push(isFirst ? ` ${label}: ${trimmedLine}` : ` ${trimmedLine}`); - isFirst = false; - }); + const expectedLines = escaped + .split(/\r?\n/) + .map((expectedLine: string) => expectedLine.trim()) + .filter((expectedLine: string) => expectedLine.length > 0); + // A list or fence glued after the label is plain paragraph text in + // CommonMark, so it gets a line of its own to render as a block. + if (EXPECTED_BLOCK_START_REGEX.test(expectedLines[0] ?? "")) { + lines.push(` ${label}:`); + expectedLines.forEach((expectedLine: string) => lines.push(` ${expectedLine}`)); + } else { + expectedLines.forEach((expectedLine: string, lineIndex: number) => { + lines.push(lineIndex === 0 ? ` ${label}: ${expectedLine}` : ` ${expectedLine}`); + }); + } } return lines;