feat(markdown)!: rewrite include processing as a markdown plugin

Moves <!-- @include: --> 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 <its.miroma@proton.me>
Co-authored-by: Naloam <110604855+Naloam@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/5307/merge
Divyansh Singh 2 days ago
parent 45c5b2ffeb
commit 0e50d756f5

@ -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<MarkdownEnv> = {}
) {
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<!-- @include: ./b.md -->\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(
'<!-- @include: @/dir/c.md -->\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(
'<!-- @include: @dir/c.md -->\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(
'<!-- @include: ./part.md -->\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('<!-- @include: .\\dir\\c.md -->\n')
expect(relative.html).toContain('C-content')
const rooted = await render('<!-- @include: @\\dir\\c.md -->\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<!-- @include: ./b.md -->\r\n')
expect(html).toContain('B-content')
})
test('expands nested includes with relative resolution', async () => {
await write(
'sub/inside.md',
'inside\n\n<!-- @include: ./subsub/deep.md -->\n'
)
await write('sub/subsub/deep.md', 'deep-content\n')
const { html, env } = await render('<!-- @include: ./sub/inside.md -->\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<!-- @include: ./index.md -->\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<!-- @include: ./b.md -->\n')
await write('b.md', 'B-content\n\n<!-- @include: ./a.md -->\n')
const { html } = await render(
'A-content\n\n<!-- @include: ./b.md -->\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<!-- @include: ./d.md -->\n')
await write('c.md', 'C-content\n\n<!-- @include: ./d.md -->\n')
await write('d.md', 'D-content\n')
const { html } = await render(
'<!-- @include: ./b.md -->\n<!-- @include: ./c.md -->\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('<!-- @include: ./b.md -->\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('<!-- @include: ./b.md{4,4} -->\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 part -->',
'region-content',
'<!-- #endregion part -->',
'outside-content',
''
].join('\n')
)
const { html } = await render('<!-- @include: ./b.md#part -->\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',
[
'<!-- #region part -->',
'first',
'<!-- #endregion part -->',
'outside',
'<!-- #region part -->',
'second',
'<!-- #endregion -->',
''
].join('\n')
)
const { html } = await render('<!-- @include: ./b.md#part -->\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',
[
'<!-- #region part -->',
'one',
'two',
'three',
'<!-- #endregion part -->',
''
].join('\n')
)
const { html } = await render('<!-- @include: ./b.md#part{2,2} -->\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('<!-- @include: ./b.md{2,} -->\n')
expect(from.html).toContain('two')
expect(from.html).toContain('three')
expect(from.html).not.toContain('one')
const to = await render('<!-- @include: ./b.md{,2} -->\n')
expect(to.html).toContain('one')
expect(to.html).toContain('two')
expect(to.html).not.toContain('three')
const both = await render('<!-- @include: ./b.md{2,3} -->\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('<!-- @include: ./source.md#target -->\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('<!-- @include: ./source.md#custom-id -->\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<!-- @include: ./code.ts{2,3} -->\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<!-- @include: ./code.ts#part -->\n```\n'
)
expect(html).toContain('region line')
expect(html).not.toContain('outside line')
})
test('leaves empty include paths untouched', async () => {
const { html } = await render('<!-- @include: -->\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('<!-- @include: ./b.md -->\n')
expect(html).toContain('@include: ./b.md')
})
test('can be disabled', async () => {
await write('b.md', 'B-content\n')
const { html } = await render('<!-- @include: ./b.md -->\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('<!-- @include: ./missing.md -->\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('<!-- @include: ./b.md#nope -->\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('<!-- @include: ./b.md{10,20} -->\n')).rejects.toThrow(
/range/i
)
await expect(render('<!-- @include: ./b.md{3,1} -->\n')).rejects.toThrow(
/range/i
)
await expect(render('<!-- @include: ./b.md{0,2} -->\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<!-- @include: ./missing.md -->\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('<!-- @include: ./b.md#nope -->\n', {
include: { silent: true }
})
expect(region.html).not.toContain('@include')
const range = await render('<!-- @include: ./b.md{5,9} -->\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')
})
})

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

@ -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 `<!-- @include: path -->`.
* 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 })

@ -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 = /<!--\s*@include:\s*(.*?)\s*-->/g
const rangeRE = /\{(\d*),(\d*)\}$/
const regionRE = /#([^\s{]+)$/
const separatorRE = /[\\/]/
/**
* Expands `<!-- @include: path -->` 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<Logger, 'warn'> = 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<Logger, 'warn'>,
ancestors: string[] = []
): Promise<string> {
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 }
}

@ -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<string, MarkdownCompileResult>({
@ -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) : ''
)

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

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

@ -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<string> {
const includesRE = /<!--\s*@include:\s*(.*?)\s*-->/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]
)
})
}

8
types/shared.d.ts vendored

@ -595,9 +595,15 @@ export interface MarkdownEnv {
*/
linkLines?: number[]
/**
* The absolute paths of the files inlined via `<!--@include-->`.
* The absolute paths of the files inlined via `<!--@include-->` 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.

Loading…
Cancel
Save