diff --git a/src/replace-links/__tests__/replace-links.code.test.ts b/src/replace-links/__tests__/replace-links.code.test.ts index 8a90d4b..2d6f0da 100644 --- a/src/replace-links/__tests__/replace-links.code.test.ts +++ b/src/replace-links/__tests__/replace-links.code.test.ts @@ -44,4 +44,47 @@ describe("ignore code", () => { }) expect(result).toBe("```typescript\nexample\n```") }) + + it("unclosed code block", () => { + const settings = { + scoped: false, + baseDir: undefined, + } + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "example" }, { path: "typescript" }], + settings, + }) + const result = replaceLinks({ + body: "```typescript\nexample", + linkResolverContext: { + filePath: "journals/2022-01-01", + trie, + candidateMap, + }, + settings, + }) + expect(result).toBe("```typescript\nexample") + }) + + it("unclosed code block when headings are ignored", () => { + const settings = { + scoped: false, + baseDir: undefined, + ignoreHeadings: true, + } + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "example" }, { path: "typescript" }], + settings, + }) + const result = replaceLinks({ + body: "```typescript\nexample", + linkResolverContext: { + filePath: "journals/2022-01-01", + trie, + candidateMap, + }, + settings, + }) + expect(result).toBe("```typescript\nexample") + }) }) diff --git a/src/replace-links/replace-links.ts b/src/replace-links/replace-links.ts index 1b58587..83d6eaf 100644 --- a/src/replace-links/replace-links.ts +++ b/src/replace-links/replace-links.ts @@ -165,6 +165,58 @@ const extractLinkParts = ( return { linkPath: canonicalPath, alias: "", hasAlias } } +const escapeRegExp = (text: string): string => + text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + +const extractFencedCodeBlocks = ( + body: string, +): { body: string, codeBlocks: Array<{ placeholder: string, content: string }> } => { + if (!body.includes("```") && !body.includes("~~~")) { + return { body, codeBlocks: [] } + } + + const codeBlocks: Array<{ placeholder: string, content: string }> = [] + let result = "" + let cursor = 0 + let codeBlockIndex = 0 + + const openingFencePattern = /^ {0,3}(`{3,}|~{3,})[^\r\n]*(?:\r?\n|$)/gm + let openingMatch: RegExpExecArray | null + + while ((openingMatch = openingFencePattern.exec(body)) !== null) { + const start = openingMatch.index + if (start < cursor) { + continue + } + + 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(body) + const end = closingMatch + ? closingMatch.index + closingMatch[0].length + : body.length + + const placeholder = `__CODE_BLOCK_${codeBlockIndex}__` + codeBlocks.push({ + placeholder, + content: body.slice(start, end), + }) + result += body.slice(cursor, start) + placeholder + cursor = end + codeBlockIndex++ + openingFencePattern.lastIndex = end + } + + result += body.slice(cursor) + return { body: result, codeBlocks } +} + // Self-linking Prevention const isSelfLink = ( candidateData: CandidateData, @@ -945,14 +997,17 @@ export const replaceLinks = ({ }) } + // Extract and protect fenced code blocks before any other block-level rules. + const { body: bodyAfterCodeBlocks, codeBlocks } = extractFencedCodeBlocks(body) + // Extract and protect headings first const headingPattern = /^#{1,6}\s+.*$/gm const headings: Array<{ placeholder: string, content: string }> = [] let headingIndex = 0 - let bodyAfterHeadings = body + let bodyAfterHeadings = bodyAfterCodeBlocks if (settings.ignoreHeadings) { - bodyAfterHeadings = body.replace(headingPattern, (match) => { + bodyAfterHeadings = bodyAfterCodeBlocks.replace(headingPattern, (match) => { const placeholder = `__HEADING_${headingIndex}__` headings.push({ placeholder, content: match }) headingIndex++ @@ -1044,5 +1099,10 @@ export const replaceLinks = ({ resultBody = resultBody.replace(placeholder, content) } + // Restore fenced code blocks + for (const { placeholder, content } of codeBlocks) { + resultBody = resultBody.replace(placeholder, content) + } + return resultBody }