diff --git a/__tests__/unit/node/utils/processIncludes.test.ts b/__tests__/unit/node/utils/processIncludes.test.ts new file mode 100644 index 00000000..64a95fbf --- /dev/null +++ b/__tests__/unit/node/utils/processIncludes.test.ts @@ -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\n') + + expect(await run('a.md')).toContain('') + }) + + test('leaves circular includes unexpanded', async () => { + await write('a.md', 'A-content\n\n\n') + await write('b.md', 'B-content\n\n\n') + + const result = await run('a.md') + expect(result).toContain('B-content') + expect(result).toContain('') + }) + + test('expands repeated includes outside the ancestor chain', async () => { + await write( + 'a.md', + '\n\n' + ) + await write('b.md', 'B-content\n\n\n') + await write('c.md', 'C-content\n\n\n') + await write('d.md', 'D-content\n') + + expect((await run('a.md')).match(/D-content/g)).toHaveLength(2) + }) +}) diff --git a/src/node/utils/processIncludes.ts b/src/node/utils/processIncludes.ts index 09eb62a3..e111648e 100644 --- a/src/node/utils/processIncludes.ts +++ b/src/node/utils/processIncludes.ts @@ -11,7 +11,8 @@ export function processIncludes( src: string, file: string, includes: string[], - cleanUrls: boolean + cleanUrls: boolean, + ancestors: string[] = [] ): Promise { const includesRE = //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] ) }) }