mirror of https://github.com/vuejs/vitepress
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
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)
|
||||
})
|
||||
})
|
||||
@ -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 }
|
||||
}
|
||||
@ -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]
|
||||
)
|
||||
})
|
||||
}
|
||||
Loading…
Reference in new issue