fix(markdown): skip circular includes

Expanding an include that is its own ancestor recursed forever.
Track the ancestor chain and leave such includes unexpanded; the
same file can still be included repeatedly as a sibling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/5341/head
Divyansh Singh 7 days ago
parent 644ad945db
commit 91ced03685

@ -0,0 +1,54 @@
import { createMarkdownItAsync } from 'markdown-it-async'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { processIncludes } from 'node/utils/processIncludes'
describe('node/utils/processIncludes', () => {
let root: string
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-includes-'))
})
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
async function write(name: string, src: string) {
await writeFile(path.join(root, name), src)
}
async function run(name: string) {
const file = path.join(root, name)
const src = await readFile(file, 'utf8')
return processIncludes(createMarkdownItAsync(), root, src, file, [], false)
}
test('leaves a self-include unexpanded', async () => {
await write('a.md', '# A\n\n<!-- @include: ./a.md -->\n')
expect(await run('a.md')).toContain('<!-- @include: ./a.md -->')
})
test('leaves circular includes unexpanded', async () => {
await write('a.md', 'A-content\n\n<!-- @include: ./b.md -->\n')
await write('b.md', 'B-content\n\n<!-- @include: ./a.md -->\n')
const result = await run('a.md')
expect(result).toContain('B-content')
expect(result).toContain('<!-- @include: ./a.md -->')
})
test('expands repeated includes outside the ancestor chain', async () => {
await write(
'a.md',
'<!-- @include: ./b.md -->\n<!-- @include: ./c.md -->\n'
)
await write('b.md', 'B-content\n\n<!-- @include: ./d.md -->\n')
await write('c.md', 'C-content\n\n<!-- @include: ./d.md -->\n')
await write('d.md', 'D-content\n')
expect((await run('a.md')).match(/D-content/g)).toHaveLength(2)
})
})

@ -11,7 +11,8 @@ export function processIncludes(
src: string,
file: string,
includes: string[],
cleanUrls: boolean
cleanUrls: boolean,
ancestors: string[] = []
): Promise<string> {
const includesRE = /<!--\s*@include:\s*(.*?)\s*-->/g
const regionRE = /(#[^\s\{]+)/
@ -36,6 +37,10 @@ export function processIncludes(
? path.join(srcDir, m1.slice(m1[1] === '/' ? 2 : 1))
: path.join(path.dirname(file), m1)
// leave circular includes unexpanded — only repeats along the ancestor
// chain are cycles, the same file may still be included by siblings
if (includePath === file || ancestors.includes(includePath)) return m
let content = await readFile(includePath, 'utf8')
if (region) {
@ -102,7 +107,8 @@ export function processIncludes(
content,
includePath,
includes,
cleanUrls
cleanUrls,
[...ancestors, file]
)
})
}

Loading…
Cancel
Save