From 0e50d756f5ea9ef5055b4eb157fbfd765bfeb478 Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Sun, 2 Aug 2026 05:28:22 +0530 Subject: [PATCH] feat(markdown)!: rewrite include processing as a markdown plugin Moves expansion out of markdownToVue and the local search plugin into an include plugin registered on the renderer, which wraps renderAsync and expands includes whenever the env carries a file path. createMarkdownRenderer users and createContentLoader with render or excerpt enabled now get includes expanded too (previously the directives silently came through unexpanded), and the expansion is reachable from the public API without exporting the helper (#4838). Region selection uses the region engine (all same-named regions concatenate, matched across comment styles), frontmatter of markdown files is stripped before locating regions and headings so both share one coordinate space, and the region suffix is anchored to the end of the include path, so paths containing # now work. Files reached by a failed render are still reported, so that creating a missing snippet or include recovers the page instead of requiring an edit of the including file. BREAKING CHANGES: - a missing region or heading anchor now throws instead of silently including the whole file, and out-of-bounds or inverted ranges throw instead of clamping; markdown.include.silent logs a warning and expands to nothing instead - markdown.include is a new option; false disables include processing - custom search _render functions receive the raw source (sync md.render does not expand includes; use renderAsync) - the include-expanded source is exposed as env.src Co-authored-by: Miroma Co-authored-by: Naloam <110604855+Naloam@users.noreply.github.com> Co-Authored-By: Claude Fable 5 --- .../node/markdown/plugins/include.test.ts | 448 ++++++++++++++++++ .../unit/node/utils/processIncludes.test.ts | 54 --- src/node/markdown/markdown.ts | 13 + src/node/markdown/plugins/include.ts | 186 ++++++++ src/node/markdownToVue.ts | 22 +- src/node/plugin.ts | 21 +- src/node/plugins/localSearchPlugin.ts | 6 +- src/node/utils/processIncludes.ts | 114 ----- types/shared.d.ts | 8 +- 9 files changed, 684 insertions(+), 188 deletions(-) create mode 100644 __tests__/unit/node/markdown/plugins/include.test.ts delete mode 100644 __tests__/unit/node/utils/processIncludes.test.ts create mode 100644 src/node/markdown/plugins/include.ts delete mode 100644 src/node/utils/processIncludes.ts diff --git a/__tests__/unit/node/markdown/plugins/include.test.ts b/__tests__/unit/node/markdown/plugins/include.test.ts new file mode 100644 index 00000000..293c3331 --- /dev/null +++ b/__tests__/unit/node/markdown/plugins/include.test.ts @@ -0,0 +1,448 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { + createMarkdownRenderer, + disposeMdItInstance, + type MarkdownOptions +} from 'node/markdown/markdown' +import { slash, type MarkdownEnv } from 'node/shared' + +describe('node/markdown/plugins/include', () => { + let root: string + let warnings: string[] + + const logger = { + warn: (msg: string) => { + warnings.push(msg) + } + } + + beforeEach(async () => { + root = await mkdtemp(path.join(tmpdir(), 'vitepress-include-')) + warnings = [] + }) + + afterEach(async () => { + await rm(root, { recursive: true, force: true }) + }) + + async function write(name: string, src: string) { + const file = path.join(root, name) + await mkdir(path.dirname(file), { recursive: true }) + await writeFile(file, src) + } + + async function render( + src: string, + options: MarkdownOptions = {}, + env: Partial = {} + ) { + disposeMdItInstance() + const md = await createMarkdownRenderer( + root, + { highlight: (code) => code, ...options }, + '/', + logger + ) + const fullEnv: MarkdownEnv = { + path: path.join(root, 'index.md'), + relativePath: 'index.md', + cleanUrls: false, + includes: [], + ...env + } + const html = await md.renderAsync(src, fullEnv) + return { html, env: fullEnv } + } + + test('includes a relative markdown file', async () => { + await write('b.md', 'B-content\n') + + const { html, env } = await render('# A\n\n\n') + expect(html).toContain('B-content') + expect(env.includes).toEqual([slash(path.join(root, 'b.md'))]) + expect(env.src).toContain('B-content') + }) + + test('resolves @ against srcDir', async () => { + await write('dir/c.md', 'C-content\n') + + const { html } = await render( + '\n', + {}, + { path: path.join(root, 'sub/index.md') } + ) + expect(html).toContain('C-content') + }) + + test('resolves @ without a slash against srcDir', async () => { + await write('dir/c.md', 'C-content\n') + + const { html } = await render( + '\n', + {}, + { path: path.join(root, 'sub/index.md') } + ) + expect(html).toContain('C-content') + }) + + test('resolves relative includes against the real file path', async () => { + await write('sub/part.md', 'real-content\n') + + const { html } = await render( + '\n', + {}, + { + path: path.join(root, 'rewritten/index.md'), + realPath: path.join(root, 'sub/index.md') + } + ) + expect(html).toContain('real-content') + }) + + test.runIf(process.platform === 'win32')( + 'resolves windows-style paths', + async () => { + await write('dir/c.md', 'C-content\n') + + const relative = await render('\n') + expect(relative.html).toContain('C-content') + + const rooted = await render('\n') + expect(rooted.html).toContain('C-content') + + // watched paths are posix-style for includes + expect(relative.env.includes).toEqual([ + slash(path.join(root, 'dir/c.md')) + ]) + } + ) + + test('handles CRLF sources', async () => { + await write('b.md', 'B-content\n') + + const { html } = await render('# A\r\n\r\n\r\n') + expect(html).toContain('B-content') + }) + + test('expands nested includes with relative resolution', async () => { + await write( + 'sub/inside.md', + 'inside\n\n\n' + ) + await write('sub/subsub/deep.md', 'deep-content\n') + + const { html, env } = await render('\n') + expect(html).toContain('inside') + expect(html).toContain('deep-content') + expect(env.includes).toEqual([ + slash(path.join(root, 'sub/inside.md')), + slash(path.join(root, 'sub/subsub/deep.md')) + ]) + }) + + test('leaves a self-include unexpanded', async () => { + const src = '# A\n\n\n' + await write('index.md', src) + + const { html } = await render(src) + expect(html).toContain('@include: ./index.md') + }) + + 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 { html } = await render( + 'A-content\n\n\n', + {}, + { path: path.join(root, 'a.md') } + ) + expect(html).toContain('B-content') + expect(html).toContain('@include: ./a.md') + }) + + test('expands repeated includes outside the ancestor chain', async () => { + 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') + + const { html } = await render( + '\n\n' + ) + expect(html.match(/D-content/g)).toHaveLength(2) + }) + + test('strips frontmatter of whole-file markdown includes', async () => { + await write('b.md', '---\ntitle: B\n---\n\nB-content\n') + + const { html } = await render('\n') + expect(html).toContain('B-content') + expect(html).not.toContain('title: B') + }) + + test('keeps frontmatter lines in range-only includes', async () => { + await write('b.md', '---\ntitle: B\n---\nline-4\nline-5\n') + + const { html } = await render('\n') + expect(html).toContain('line-4') + expect(html).not.toContain('line-5') + }) + + test('includes regions and strips frontmatter before locating them', async () => { + await write( + 'b.md', + [ + '---', + 'title: B', + '---', + '', + 'region-content', + '', + 'outside-content', + '' + ].join('\n') + ) + + const { html } = await render('\n') + expect(html).toContain('region-content') + expect(html).not.toContain('outside-content') + }) + + test('concatenates all regions with the requested name', async () => { + await write( + 'b.md', + [ + '', + 'first', + '', + 'outside', + '', + 'second', + '', + '' + ].join('\n') + ) + + const { html } = await render('\n') + expect(html).toContain('first') + expect(html).toContain('second') + expect(html).not.toContain('outside') + }) + + test('applies ranges within the extracted region', async () => { + await write( + 'b.md', + [ + '', + 'one', + 'two', + 'three', + '', + '' + ].join('\n') + ) + + const { html } = await render('\n') + expect(html).toContain('two') + expect(html).not.toContain('one') + expect(html).not.toContain('three') + }) + + test('supports ranges with open ends', async () => { + await write('b.md', 'one\ntwo\nthree\n') + + const from = await render('\n') + expect(from.html).toContain('two') + expect(from.html).toContain('three') + expect(from.html).not.toContain('one') + + const to = await render('\n') + expect(to.html).toContain('one') + expect(to.html).toContain('two') + expect(to.html).not.toContain('three') + + const both = await render('\n') + expect(both.html).toContain('two') + expect(both.html).toContain('three') + expect(both.html).not.toContain('one') + }) + + test('includes heading sections by anchor', async () => { + await write( + 'source.md', + [ + '---', + 'description: Source description', + '---', + '# Intro', + '', + 'intro text', + '', + '## Shared', + '', + 'shared before target', + '', + '## Target', + '', + 'target text', + '', + '### Child', + '', + 'child text', + '', + '## Shared', + '', + 'shared after target', + '' + ].join('\n') + ) + + const { html } = await render('\n') + expect(html).toContain('target text') + expect(html).toContain('child text') + expect(html).not.toContain('Source description') + expect(html).not.toContain('intro text') + expect(html).not.toContain('shared before target') + expect(html).not.toContain('shared after target') + }) + + test('includes heading sections with custom ids up to EOF', async () => { + await write( + 'source.md', + ['## My Section {#custom-id}', '', 'section text', ''].join('\n') + ) + + const { html } = await render('\n') + expect(html).toContain('section text') + }) + + test('includes non-markdown files verbatim, also inside fences', async () => { + await write('code.ts', 'const a = 1\nconst b = 2\nconst c = 3\n') + + const fenced = await render( + '```ts\n\n```\n' + ) + expect(fenced.html).toContain('language-ts') + expect(fenced.html).toContain('const b = 2') + expect(fenced.html).toContain('const c = 3') + expect(fenced.html).not.toContain('const a = 1') + }) + + test('includes regions of non-markdown files', async () => { + await write( + 'code.ts', + [ + '// #region part', + 'region line', + '// #endregion part', + 'outside line', + '' + ].join('\n') + ) + + const { html } = await render( + '```ts\n\n```\n' + ) + expect(html).toContain('region line') + expect(html).not.toContain('outside line') + }) + + test('leaves empty include paths untouched', async () => { + const { html } = await render('\n') + expect(html).toContain('@include:') + }) + + test('skips expansion without a file path in env', async () => { + disposeMdItInstance() + const md = await createMarkdownRenderer( + root, + { highlight: (code) => code }, + '/', + logger + ) + const html = await md.renderAsync('\n') + expect(html).toContain('@include: ./b.md') + }) + + test('can be disabled', async () => { + await write('b.md', 'B-content\n') + + const { html } = await render('\n', { + include: false + }) + expect(html).not.toContain('B-content') + expect(html).toContain('@include: ./b.md') + }) + + test('throws when the file is missing, recording it as a dependency', async () => { + const env: MarkdownEnv = { + path: path.join(root, 'index.md'), + relativePath: 'index.md', + cleanUrls: false, + includes: [] + } + disposeMdItInstance() + const md = await createMarkdownRenderer( + root, + { highlight: (code) => code }, + '/', + logger + ) + await expect( + md.renderAsync('\n', env) + ).rejects.toThrow(/Include file not found/) + // the missing file is watched so that creating it recovers the page + expect(env.includes).toEqual([slash(path.join(root, 'missing.md'))]) + }) + + test('throws when neither region nor heading matches', async () => { + await write('b.md', '## Some Heading\n\ncontent\n') + + await expect(render('\n')).rejects.toThrow( + /region or heading "nope" not found/i + ) + }) + + test('throws when the range is out of bounds', async () => { + await write('b.md', 'one\ntwo\nthree\n') + + await expect(render('\n')).rejects.toThrow( + /range/i + ) + await expect(render('\n')).rejects.toThrow( + /range/i + ) + await expect(render('\n')).rejects.toThrow( + /range/i + ) + }) + + test('silent mode renders nothing on errors and warns', async () => { + await write('b.md', 'one\ntwo\n') + + const missing = await render( + 'before\n\n\n\nafter\n', + { include: { silent: true } } + ) + expect(missing.html).toContain('before') + expect(missing.html).toContain('after') + expect(missing.html).not.toContain('@include') + + const region = await render('\n', { + include: { silent: true } + }) + expect(region.html).not.toContain('@include') + + const range = await render('\n', { + include: { silent: true } + }) + expect(range.html).not.toContain('@include') + + expect(warnings).toHaveLength(3) + expect(warnings[0]).toContain('missing.md') + expect(warnings[1]).toContain('nope') + expect(warnings[2]).toContain('b.md') + }) +}) diff --git a/__tests__/unit/node/utils/processIncludes.test.ts b/__tests__/unit/node/utils/processIncludes.test.ts deleted file mode 100644 index 64a95fbf..00000000 --- a/__tests__/unit/node/utils/processIncludes.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -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/markdown/markdown.ts b/src/node/markdown/markdown.ts index af4bb609..24c225ca 100644 --- a/src/node/markdown/markdown.ts +++ b/src/node/markdown/markdown.ts @@ -53,6 +53,10 @@ import { lineNumberPlugin } from './plugins/lineNumbers' import { linkPlugin } from './plugins/link' import { preWrapperPlugin } from './plugins/preWrapper' import { restoreEntities } from './plugins/restoreEntities' +import { + includePlugin, + type Options as IncludePluginOptions +} from './plugins/include' import { snippetPlugin, type Options as SnippetPluginOptions @@ -208,6 +212,12 @@ export interface MarkdownOptions extends MarkdownItAsyncOptions { * @see https://vitepress.dev/guide/markdown#import-code-snippets */ snippet?: SnippetPluginOptions | boolean + /** + * Options for including markdown files with ``. + * Set to `false` to disable. + * @see https://vitepress.dev/guide/markdown#markdown-file-inclusion + */ + include?: IncludePluginOptions | boolean /* ==================== Markdown Extensions ==================== */ @@ -396,6 +406,9 @@ export async function createMarkdownRenderer( if (options.snippet !== false) { snippetPlugin(md, srcDir, normalizePluginOptions(options.snippet), logger) } + if (options.include !== false) { + includePlugin(md, srcDir, normalizePluginOptions(options.include), logger) + } const containerOptions = normalizePluginOptions(options.container) if (options.container !== false) { containerPlugin(md, containerOptions, { locales: options.locales }) diff --git a/src/node/markdown/plugins/include.ts b/src/node/markdown/plugins/include.ts new file mode 100644 index 00000000..b2f7d144 --- /dev/null +++ b/src/node/markdown/plugins/include.ts @@ -0,0 +1,186 @@ +import matter from 'gray-matter' +import { replaceAsync, type MarkdownItAsync } from 'markdown-it-async' +import path from 'node:path' +import type { Logger } from 'vite' +import { slash, type MarkdownEnv } from '../../shared' +import { readTextFile } from '../../utils/fs' +import { findRegions } from '../regions' + +export interface Options { + /** + * Log a warning and expand to nothing when the included file, region, + * heading or range is missing, instead of throwing. + * @default false + */ + silent?: boolean +} + +const includeRE = //g +const rangeRE = /\{(\d*),(\d*)\}$/ +const regionRE = /#([^\s{]+)$/ +const separatorRE = /[\\/]/ + +/** + * Expands `` directives before rendering. Wraps + * `renderAsync` so every consumer of the renderer (page rendering, local + * search indexing, the content loader and `createMarkdownRenderer` users) + * gets the same expansion. Included files are recorded in `env.includes` + * and the expanded source is exposed as `env.src`. + */ +export function includePlugin( + md: MarkdownItAsync, + srcDir: string, + options: Options = {}, + logger: Pick = console +) { + const renderAsync = md.renderAsync.bind(md) + md.renderAsync = async (src, env?) => { + const mdEnv = env as MarkdownEnv | undefined + const file = mdEnv?.realPath ?? mdEnv?.path + if (file == null) return renderAsync(src, env) + + mdEnv!.includes ??= [] + src = await processIncludes(md, srcDir, src, file, mdEnv!, options, logger) + mdEnv!.src = src + return renderAsync(src, env) + } +} + +async function processIncludes( + md: MarkdownItAsync, + srcDir: string, + src: string, + file: string, + env: MarkdownEnv, + options: Options, + logger: Pick, + ancestors: string[] = [] +): Promise { + return replaceAsync(src, includeRE, async (m: string, m1: string) => { + if (!m1.length) return m + + const fail = (message: string): string => { + if (!options.silent) throw new Error(message) + logger.warn(`${message} (in ${file})`) + return '' + } + + const range = rangeRE.exec(m1) + if (range) m1 = m1.slice(0, range.index) + const region = regionRE.exec(m1) + if (region) m1 = m1.slice(0, region.index) + + const includePath = m1.startsWith('@') + ? path.join(srcDir, m1.slice(separatorRE.test(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 + + // record the dependency before reading it, so that creating a missing + // file is picked up by the watcher + env.includes!.push(slash(includePath)) + + let content: string + try { + content = await readTextFile(includePath) + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ENOENT') { + return fail(`Include file not found: ${includePath}`) + } + if (code === 'EISDIR') { + return fail(`Include path is a directory: ${includePath}`) + } + throw error + } + + // for markdown files, if a range is used without a region, the line + // numbers must account for the frontmatter, so it is kept; otherwise + // it is stripped before selecting content + if (path.extname(includePath) === '.md' && (region || !range)) { + content = matter(content, {}).content + } + + let lines = content.split('\n') + + if (region) { + const name = region[1] + const regions = findRegions(lines, name) + if (regions.length > 0) { + lines = regions.flatMap((r) => lines.slice(r.start, r.end)) + } else { + // no editor-style region matched — try heading anchors + const section = findHeadingSection(md, content, includePath, name, { + srcDir, + cleanUrls: env.cleanUrls + }) + if (!section) { + return fail( + `Include region or heading "${name}" not found in ${includePath}` + ) + } + lines = lines.slice(section.start, section.end) + } + } + + if (range) { + const start = range[1] ? parseInt(range[1]) : 1 + const end = range[2] ? parseInt(range[2]) : lines.length + if (start < 1 || end < start || end > lines.length) { + return fail( + `Include range ${range[0]} is out of bounds in ${includePath}` + ) + } + lines = lines.slice(start - 1, end) + } + + // recursively process includes in the content + const expanded = await processIncludes( + md, + srcDir, + lines.join('\n'), + includePath, + env, + options, + logger, + [...ancestors, file] + ) + + return expanded + }) +} + +function findHeadingSection( + md: MarkdownItAsync, + content: string, + includePath: string, + anchor: string, + { srcDir, cleanUrls }: { srcDir: string; cleanUrls: boolean } +) { + const headings = md + .parse(content, { + path: includePath, + relativePath: slash(path.relative(srcDir, includePath)), + cleanUrls + } satisfies MarkdownEnv) + .filter((t) => t.type === 'heading_open' && t.map) + + const idx = headings.findIndex((t) => t.attrGet('id') === anchor) + const heading = headings[idx] + if (!heading) return null + + // the section spans from below the heading to the next heading of the + // same or a higher level, or to the end of the file + let end: number | undefined + const level = parseInt(heading.tag.slice(1)) + for (let i = idx + 1; i < headings.length; i++) { + if (parseInt(headings[i].tag.slice(1)) <= level) { + end = headings[i].map![0] + break + } + } + + return { start: heading.map![1], end } +} diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index 9cbdbd8c..dd0f993a 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -22,7 +22,6 @@ import { type PageData } from './shared' import { getGitTimestamp } from './utils/getGitTimestamp' -import { processIncludes } from './utils/processIncludes' const debug = createDebug('vitepress:md') const cache = new LRUCache({ @@ -149,31 +148,38 @@ export async function createMarkdownToVueRenderFn( } ) - // resolve includes - let includes: string[] = [] - src = await processIncludes(md, srcDir, src, fileOrig, includes, cleanUrls) - const localeIndex = getLocaleForPath(siteConfig?.site, relativePath) - // reset env before render + // reset env before render; the include plugin fills `includes` and + // exposes the include-expanded source as `env.src` const env: MarkdownEnv = { path: file, relativePath, cleanUrls, - includes, + includes: [], realPath: fileOrig, localeIndex } - const html = await md.renderAsync(src, env) + let html: string + try { + html = await md.renderAsync(src, env) + } catch (e) { + // surface the dependencies collected so far, so that the caller can + // watch them and a missing snippet or include recovers once created + ;(e as { includes?: string[] }).includes = env.includes + throw e + } const { content, frontmatter = {}, headers = [], + includes = [], linkLines = [], links = [], sfcBlocks, title = '' } = env + src = env.src ?? src const contentLineOffset = countLineBreaks( content && src.endsWith(content) ? src.slice(0, -content.length) : '' ) diff --git a/src/node/plugin.ts b/src/node/plugin.ts index c97b9494..00003729 100644 --- a/src/node/plugin.ts +++ b/src/node/plugin.ts @@ -212,23 +212,30 @@ export async function createVitePressPlugin( return processClientJS(code, id) } if (id.endsWith('.md')) { + const watchIncludes = (files: string[] = []) => { + files.forEach((i) => { + ;(importerMap[slash(i)] ??= new Set()).add(slash(id)) + this.addWatchFile(i) + }) + } + // transform .md files into vueSrc so plugin-vue can handle it const { vueSrc, deadLinks, includes, pageData } = await markdownToVue( code, id - ) + ).catch((e: { includes?: string[] }) => { + // watch the files the failed render did reach, so that creating a + // missing snippet or include recovers the page + watchIncludes(e.includes) + throw e + }) if (pageMetaMap) { pageMetaMap[pageData.relativePath] = { lastUpdated: pageData.lastUpdated } } allDeadLinks.push(...deadLinks) - if (includes.length) { - includes.forEach((i) => { - ;(importerMap[slash(i)] ??= new Set()).add(slash(id)) - this.addWatchFile(i) - }) - } + watchIncludes(includes) if ( this.environment.mode === 'dev' && this.environment.name === 'client' diff --git a/src/node/plugins/localSearchPlugin.ts b/src/node/plugins/localSearchPlugin.ts index bdeaa4b9..13198eec 100644 --- a/src/node/plugins/localSearchPlugin.ts +++ b/src/node/plugins/localSearchPlugin.ts @@ -8,7 +8,6 @@ import type { DefaultTheme } from '../defaultTheme' import { createMarkdownRenderer } from '../markdown/markdown' import { getLocaleForPath, slash, type MarkdownEnv } from '../shared' import { readTextFile } from '../utils/fs' -import { processIncludes } from '../utils/processIncludes' const debug = createDebug('vitepress:local-search') @@ -62,11 +61,10 @@ export async function localSearchPlugin( } throw e }) - const src = await processIncludes(md, srcDir, raw, file, [], cleanUrls) if (options._render) { - return options._render(src, env, md) + return options._render(raw, env, md) } else { - const html = await md.renderAsync(src, env) + const html = await md.renderAsync(raw, env) return env.frontmatter?.search === false ? '' : html } } diff --git a/src/node/utils/processIncludes.ts b/src/node/utils/processIncludes.ts deleted file mode 100644 index 415686e8..00000000 --- a/src/node/utils/processIncludes.ts +++ /dev/null @@ -1,114 +0,0 @@ -import matter from 'gray-matter' -import { replaceAsync, type MarkdownItAsync } from 'markdown-it-async' -import path from 'node:path' -import { findRegions } from '../markdown/regions' -import { slash, type MarkdownEnv } from '../shared' -import { readTextFile } from './fs' - -export function processIncludes( - md: MarkdownItAsync, - srcDir: string, - src: string, - file: string, - includes: string[], - cleanUrls: boolean, - ancestors: string[] = [] -): Promise { - const includesRE = //g - const regionRE = /(#[^\s\{]+)/ - const rangeRE = /\{(\d*),(\d*)\}$/ - - return replaceAsync(src, includesRE, async (m: string, m1: string) => { - if (!m1.length) return m - - const range = m1.match(rangeRE) - const region = m1.match(regionRE) - - const hasMeta = !!(region || range) - - if (hasMeta) { - const len = (region?.[0].length || 0) + (range?.[0].length || 0) - m1 = m1.slice(0, -len) // remove meta info from the include path - } - - const atPresent = m1[0] === '@' - - const includePath = atPresent - ? 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 readTextFile(includePath) - - if (region) { - const [regionName] = region - const lines = content.split(/\r?\n/) - let selectedLines = lines - let { start, end } = findRegions(lines, regionName.slice(1))[0] ?? {} - - if (start === undefined) { - // region not found, it might be a header - const headerContent = - path.extname(includePath) === '.md' - ? matter(content, {}).content - : content - const headerLines = headerContent.split(/\r?\n/) - const tokens = md - .parse(headerContent, { - path: includePath, - relativePath: slash(path.relative(srcDir, includePath)), - cleanUrls - } satisfies MarkdownEnv) - .filter((t) => t.type === 'heading_open' && t.map) - const idx = tokens.findIndex( - (t) => t.attrGet('id') === regionName.slice(1) - ) - const token = tokens[idx] - if (token) { - selectedLines = headerLines - start = token.map![1] - const level = parseInt(token.tag.slice(1)) - for (let i = idx + 1; i < tokens.length; i++) { - if (parseInt(tokens[i].tag.slice(1)) <= level) { - end = tokens[i].map![0] - break - } - } - } - } - - content = selectedLines.slice(start, end).join('\n') - } - - if (range) { - const [, startLine, endLine] = range - const lines = content.split(/\r?\n/) - content = lines - .slice( - startLine ? parseInt(startLine) - 1 : undefined, - endLine ? parseInt(endLine) : undefined - ) - .join('\n') - } - - if (!hasMeta && path.extname(includePath) === '.md') { - content = matter(content, {}).content - } - - includes.push(slash(includePath)) - - // recursively process includes in the content - return processIncludes( - md, - srcDir, - content, - includePath, - includes, - cleanUrls, - [...ancestors, file] - ) - }) -} diff --git a/types/shared.d.ts b/types/shared.d.ts index a4afccd1..c1e280b2 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -595,9 +595,15 @@ export interface MarkdownEnv { */ linkLines?: number[] /** - * The absolute paths of the files inlined via ``. + * The absolute paths of the files inlined via `` and + * imported via `<<<` code snippets, used for watch invalidation. */ includes?: string[] + /** + * The markdown source with includes expanded, set during rendering when + * include processing is enabled. + */ + src?: string /** * The absolute path of the actual source file on disk: the route template * for dynamic routes, or the original file when rewrites are in use.