diff --git a/frontend/src/lib/services/numericListFenceNesting.test.ts b/frontend/src/lib/services/numericListFenceNesting.test.ts index 84202028..efdde3fb 100644 --- a/frontend/src/lib/services/numericListFenceNesting.test.ts +++ b/frontend/src/lib/services/numericListFenceNesting.test.ts @@ -92,14 +92,111 @@ describe("numeric list fence nesting", () => { for (const isStreaming of [false, true]) { const renderedContainer = renderMarkdown(markdown, isStreaming); + const nestedCodeBlocks = renderedContainer.querySelectorAll("ol > li pre > code"); + + expect(nestedCodeBlocks).toHaveLength(1); + expect(nestedCodeBlocks[0].textContent).toBe("{{name}}\n"); + expect(renderedContainer.querySelectorAll(":scope > pre")).toHaveLength(0); + } + } + } + }); + + it("keeps column-zero and continuation-aligned fences nested with intact body", () => { + for (const fenceMarker of ["```", "~~~"]) { + for (const bodyIndent of [0, 1, 2, 3]) { + const indent = " ".repeat(bodyIndent); + const markdown = [ + "1. Run this:", + `${indent}${fenceMarker}bash`, + `${indent}npm install`, + `${indent}${fenceMarker}`, + ].join("\n"); + + for (const isStreaming of [false, true]) { + const renderedContainer = renderMarkdown(markdown, isStreaming); + const nestedCodeBlocks = renderedContainer.querySelectorAll("ol > li pre > code"); + + expect(nestedCodeBlocks).toHaveLength(1); + expect(nestedCodeBlocks[0].textContent).toBe("npm install\n"); + expect(renderedContainer.querySelectorAll(":scope > pre")).toHaveLength(0); + } + } + } + }); + + it("renders over-indented fence body without spurious leading spaces", () => { + for (const fenceMarker of ["```", "~~~"]) { + for (const bodyIndent of [4, 5, 6]) { + const indent = " ".repeat(bodyIndent); + const markdown = [ + "1. Run this:", + `${indent}${fenceMarker}bash`, + `${indent}npm install`, + `${indent}${fenceMarker}`, + ].join("\n"); + + for (const isStreaming of [false, true]) { + const renderedContainer = renderMarkdown(markdown, isStreaming); + const nestedCodeBlocks = renderedContainer.querySelectorAll("ol > li pre > code"); + + expect(nestedCodeBlocks).toHaveLength(1); + expect(nestedCodeBlocks[0].textContent).toBe("npm install\n"); + expect(renderedContainer.querySelectorAll(":scope > pre")).toHaveLength(0); + } + } + } + }); + + it("renders over-indented fences under multi-digit list markers without corruption", () => { + for (const numericListMarker of ["12.", "100."]) { + const requiredIndentation = " ".repeat(numericListMarker.length + 1); + for (const bodyIndent of [ + requiredIndentation.length + 1, + requiredIndentation.length + 2, + requiredIndentation.length + 3, + ]) { + const indent = " ".repeat(bodyIndent); + const markdown = [ + `${numericListMarker} Run this:`, + `${indent}\`\`\`bash`, + `${indent}npm install`, + `${indent}\`\`\``, + ].join("\n"); + + for (const isStreaming of [false, true]) { + const renderedContainer = renderMarkdown(markdown, isStreaming); + const nestedCodeBlocks = renderedContainer.querySelectorAll("ol > li pre > code"); - expect(renderedContainer.querySelectorAll("ol > li pre > code")).toHaveLength(1); + expect(nestedCodeBlocks).toHaveLength(1); + expect(nestedCodeBlocks[0].textContent).toBe("npm install\n"); expect(renderedContainer.querySelectorAll(":scope > pre")).toHaveLength(0); } } } }); + it("preserves ragged body line indentation under an over-indented opener", () => { + for (const fenceMarker of ["```", "~~~"]) { + const markdown = [ + "1. Run this:", + ` ${fenceMarker}bash`, + " lineA", + "lineB", + ` ${fenceMarker}`, + ].join("\n"); + + for (const isStreaming of [false, true]) { + const renderedContainer = renderMarkdown(markdown, isStreaming); + const nestedCodeBlocks = renderedContainer.querySelectorAll("ol > li pre > code"); + + expect(nestedCodeBlocks).toHaveLength(1); + expect(nestedCodeBlocks[0].textContent).toBe("lineA\nlineB\n"); + expect(renderedContainer.querySelectorAll(":scope > pre")).toHaveLength(0); + } + } + }); + it("accepts CommonMark closing indentation after a column-zero opening fence", () => { for (const fenceMarker of ["```", "~~~"]) { for (const closingOffset of [1, 2, 3, 4]) { diff --git a/frontend/src/lib/services/numericListFenceNesting.ts b/frontend/src/lib/services/numericListFenceNesting.ts index e8d79c3d..ff387e69 100644 --- a/frontend/src/lib/services/numericListFenceNesting.ts +++ b/frontend/src/lib/services/numericListFenceNesting.ts @@ -209,6 +209,7 @@ export function nestNumericListFences( const fenceState = new ListFenceState(); let awaitedContinuationIndentation: number | null = null; let bodyIndentation = ""; + let bodyDeindentation = 0; let targetIndentation = 0; let maximumSourceIndentation = COMMONMARK_MAX_FENCE_INDENTATION; @@ -228,10 +229,13 @@ export function nestNumericListFences( ); fenceState.close(); bodyIndentation = ""; + bodyDeindentation = 0; targetIndentation = 0; maximumSourceIndentation = COMMONMARK_MAX_FENCE_INDENTATION; } else { - nestedLines.push(`${bodyIndentation}${markdownLine}`); + const leadingSpaces = leadingSpaceCount(markdownLine); + const strippedLine = markdownLine.slice(Math.min(bodyDeindentation, leadingSpaces)); + nestedLines.push(`${bodyIndentation}${strippedLine}`); } continue; } @@ -259,9 +263,9 @@ export function nestNumericListFences( awaitedContinuationIndentation + COMMONMARK_MAX_FENCE_INDENTATION, ); if (fenceCandidate) { - bodyIndentation = " ".repeat( - Math.max(0, awaitedContinuationIndentation - fenceCandidate.markerIndex), - ); + const bodyShift = awaitedContinuationIndentation - fenceCandidate.markerIndex; + bodyIndentation = " ".repeat(Math.max(0, bodyShift)); + bodyDeindentation = Math.max(0, -bodyShift); targetIndentation = awaitedContinuationIndentation; maximumSourceIndentation = awaitedContinuationIndentation + COMMONMARK_MAX_FENCE_INDENTATION;