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.