fix(replace-links): preserve unclosed fenced code blocks

Why:
- Markdown treats an opening fenced code block without a closing fence as code through the end of the document.
- Automatic linking was still processing that content as normal text, which could turn code block contents into wiki links.

What:
- Extract fenced code blocks before heading, callout, table, and link replacement processing.
- Preserve unmatched fenced blocks through EOF and restore them unchanged after replacement.
- Add regression coverage for unclosed code blocks, including when heading protection is enabled.
This commit is contained in:
Kodai Nakamura 2026-05-30 10:32:24 +09:00
parent 504e37a8d1
commit 846ebda9ce
2 changed files with 105 additions and 2 deletions

View file

@ -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")
})
})

View file

@ -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
}