From db05c2ee1fd669489399df995042c7ec0a3381f7 Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 03:05:52 +0900 Subject: [PATCH] fix(markdown): protect variable-length fenced code blocks Why: Markdown fence handling regressed when the shared segmenter only recognized backticks and exact triple fences. That let link and URL rewriting leak into supported fenced code blocks. What: Add fenced-code range collection with opening/closing fence length matching, include tilde-only input in the fast path, and keep fenced blocks out of the generic protected matcher. Add regressions for tilde fences, wider backtick fences, and the URL/link consumers that rely on markdown segmentation. --- src/__tests__/markdown-segments.test.ts | 15 +++++++ src/markdown-segments.ts | 42 ++++++++++++++++++- .../__tests__/replace-links.code.test.ts | 42 +++++++++++++++++++ .../__tests__/replace-url-with-title.test.ts | 13 ++++++ .../utils/__tests__/list-up-all-urls.test.ts | 6 +++ 5 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/__tests__/markdown-segments.test.ts b/src/__tests__/markdown-segments.test.ts index 3d9821f..c9adb6f 100644 --- a/src/__tests__/markdown-segments.test.ts +++ b/src/__tests__/markdown-segments.test.ts @@ -32,6 +32,21 @@ describe("segmentMarkdown", () => { ]) }) + it("protects tilde fenced code blocks", () => { + const text = "before\n~~~ts\nTypeScript\n~~~\nafter" + const segments = segmentMarkdown(text) + + expect(segments.map(segment => ({ + kind: segment.kind, + protectedKind: segment.protectedKind, + text: segment.text, + }))).toEqual([ + { kind: "prose", protectedKind: undefined, text: "before\n" }, + { kind: "protected", protectedKind: "fenced-code", text: "~~~ts\nTypeScript\n~~~\n" }, + { kind: "prose", protectedKind: undefined, text: "after" }, + ]) + }) + it("protects headings, tables, and callouts when requested", () => { const text = "# TypeScript\n| TypeScript |\n> [!note]\n> TypeScript\nTypeScript" const segments = segmentMarkdown(text, { diff --git a/src/markdown-segments.ts b/src/markdown-segments.ts index bffa45d..f54cdea 100644 --- a/src/markdown-segments.ts +++ b/src/markdown-segments.ts @@ -92,10 +92,45 @@ const collectTableRowRanges = (text: string): ProtectedRange[] => { return ranges } +const escapeRegExp = (text: string): string => + text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + +const collectFencedCodeRanges = (text: string): ProtectedRange[] => { + if (!text.includes("```") && !text.includes("~~~")) { + return [] + } + + const ranges: ProtectedRange[] = [] + const openingFencePattern = /^ {0,3}(`{3,}|~{3,})[^\r\n]*(?:\r?\n|$)/gm + let openingMatch: RegExpExecArray | null + + while ((openingMatch = openingFencePattern.exec(text)) !== null) { + const openingFence = openingMatch[1] + const fenceChar = openingFence[0] + const fenceLength = openingFence.length + const closingFencePattern = new RegExp( + `^ {0,3}${escapeRegExp(fenceChar)}{${fenceLength},}[ \\t]*(?:\\r?\\n|$)`, + "gm", + ) + closingFencePattern.lastIndex = openingMatch.index + openingMatch[0].length + const closingMatch = closingFencePattern.exec(text) + const end = closingMatch + ? closingMatch.index + closingMatch[0].length + : text.length + + ranges.push({ + start: openingMatch.index, + end, + protectedKind: "fenced-code", + }) + openingFencePattern.lastIndex = end + } + + return ranges +} + const buildProtectedPattern = (protectUrls: boolean): RegExp => { const parts = [ - "```[\\s\\S]*?(?:```|$)", - "~~~[\\s\\S]*?(?:~~~|$)", "`[^`]*`", "\\[\\[[^\\]]+\\]\\]", "\\[[^\\]]+\\]\\([^)]+\\)", @@ -160,6 +195,7 @@ export const segmentMarkdown = ( options: SegmentMarkdownOptions = {}, ): MarkdownSegment[] => { const mayContainProtectedMarkdown = text.includes("`") + || text.includes("~") || text.includes("[") || (options.protectHeadings && text.includes("#")) || (options.protectCallouts && text.includes(">")) @@ -189,6 +225,8 @@ export const segmentMarkdown = ( ranges.push(...collectTableRowRanges(text)) } + ranges.push(...collectFencedCodeRanges(text)) + const protectedPattern = buildProtectedPattern(options.protectUrls ?? false) let match: RegExpExecArray | null diff --git a/src/replace-links/__tests__/replace-links.code.test.ts b/src/replace-links/__tests__/replace-links.code.test.ts index 2d6f0da..a16237b 100644 --- a/src/replace-links/__tests__/replace-links.code.test.ts +++ b/src/replace-links/__tests__/replace-links.code.test.ts @@ -45,6 +45,48 @@ describe("ignore code", () => { expect(result).toBe("```typescript\nexample\n```") }) + it("tilde code block", () => { + const settings = { + scoped: false, + baseDir: undefined, + } + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "example" }, { path: "typescript" }], + settings, + }) + const result = replaceLinks({ + body: "~~~typescript\nexample\n~~~", + linkResolverContext: { + filePath: "journals/2022-01-01", + trie, + candidateMap, + }, + settings, + }) + expect(result).toBe("~~~typescript\nexample\n~~~") + }) + + it("wide code fence with embedded triple backticks", () => { + const settings = { + scoped: false, + baseDir: undefined, + } + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "example" }, { path: "typescript" }], + settings, + }) + const result = replaceLinks({ + body: "````\nnot end ```\nexample\n````", + linkResolverContext: { + filePath: "journals/2022-01-01", + trie, + candidateMap, + }, + settings, + }) + expect(result).toBe("````\nnot end ```\nexample\n````") + }) + it("unclosed code block", () => { const settings = { scoped: false, diff --git a/src/replace-url-with-title/__tests__/replace-url-with-title.test.ts b/src/replace-url-with-title/__tests__/replace-url-with-title.test.ts index 7455cae..d8d488a 100644 --- a/src/replace-url-with-title/__tests__/replace-url-with-title.test.ts +++ b/src/replace-url-with-title/__tests__/replace-url-with-title.test.ts @@ -63,4 +63,17 @@ describe("replaceUrlWithTitle", () => { "Line 1: [Example Title](https://example.com)\nLine 2: [Another Title](https://another.com)", ) }) + + it("should ignore URLs inside tilde fenced code blocks", () => { + const body = "~~~\nhttps://example.com\n~~~" + const result = replaceUrlWithTitle({ + body, + urlTitleMap: new Map( + Object.entries({ + "https://example.com": "Example Title", + }), + ), + }) + expect(result).toBe("~~~\nhttps://example.com\n~~~") + }) }) diff --git a/src/replace-url-with-title/utils/__tests__/list-up-all-urls.test.ts b/src/replace-url-with-title/utils/__tests__/list-up-all-urls.test.ts index 327fa93..18be74a 100644 --- a/src/replace-url-with-title/utils/__tests__/list-up-all-urls.test.ts +++ b/src/replace-url-with-title/utils/__tests__/list-up-all-urls.test.ts @@ -32,6 +32,12 @@ describe("listupAllUrls", () => { expect(result).not.toContain("https://example.com") }) + it("should ignore URLs inside tilde fenced code blocks", () => { + const body = "~~~\nhttps://example.com\n~~~" + const result = listupAllUrls(body) + expect(result).not.toContain("https://example.com") + }) + it("ignore domains", () => { const body = "Check this link: https://example.com" const result = listupAllUrls(body, ["example.com"])