feat(markdown)!: rewrite the snippet plugin

Replaces the single-regex raw path parsing with a structural parser
that peels [title], {meta} and #region off the end, so meta can no
longer be silently swallowed into the file path. Inside the braces,
everything after the language is now passed through to the fence info
verbatim, allowing multiple attributes and quoted values (e.g.
{ts twoslash key="a b"}). Extensions are derived from the file name,
so dotfiles resolve, region references may contain dots, and {1, 2}
style spacing is tolerated.

Region extraction uses the new region engine, so all same-named
regions are concatenated in document order, and which marker lines are
removed from the output is configurable through stripRegionMarkers.

BREAKING CHANGES:
- a missing snippet file or region now throws instead of rendering an
  error message inside the code block (or importing the whole file, in
  the region case); the silent option restores non-fatal behavior by
  logging a warning and rendering nothing
- the language inferred from a file name now includes uppercase
  extensions, which were previously matched as [a-z0-9]+ only, so a
  snippet of e.g. Foo.TS is highlighted where it used to render
  without a language; suffixes that are not alphanumeric, like .c++ or
  .code-snippets, are still not inferred and need the language given
  in the braces
- markdown.snippet now accepts an options object in addition to the
  boolean toggle
- rawPathToToken, findRegion and dedent are no longer exported from
  the plugin module

Co-authored-by: Miroma <its.miroma@proton.me>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/5307/merge
Divyansh Singh 2 days ago
parent 6a4a977ce5
commit 45c5b2ffeb

@ -1,8 +1,13 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import {
dedent,
findRegion,
rawPathToToken
} from 'node/markdown/plugins/snippet'
createMarkdownRenderer,
disposeMdItInstance,
type MarkdownOptions
} from 'node/markdown/markdown'
import { parseSnippetPath } from 'node/markdown/plugins/snippet'
import type { MarkdownEnv } from 'node/shared'
const removeEmptyKeys = <T extends Record<string, unknown>>(obj: T) => {
return Object.fromEntries(
@ -11,7 +16,8 @@ const removeEmptyKeys = <T extends Record<string, unknown>>(obj: T) => {
}
/* prettier-ignore */
const rawPathTokenMap: [string, Partial<{ filepath: string, extension: string, title: string, region: string, lines: string, lang: string }>][] = [
const parseSnippetPathMap: [string, Partial<{ filepath: string, extension: string, title: string, region: string, lines: string, lang: string, attrs: string }>][] = [
// paths may contain spaces and dots, and the title defaults to the file name
['/path/to/file.extension', { filepath: '/path/to/file.extension', extension: 'extension', title: 'file.extension' }],
['./path/to/file.extension', { filepath: './path/to/file.extension', extension: 'extension', title: 'file.extension' }],
['/path to/file.extension', { filepath: '/path to/file.extension', extension: 'extension', title: 'file.extension' }],
@ -28,298 +34,368 @@ const rawPathTokenMap: [string, Partial<{ filepath: string, extension: string, t
['./path.to/file', { filepath: './path.to/file', title: 'file' }],
['/path .to/file', { filepath: '/path .to/file', title: 'file' }],
['./path .to/file', { filepath: './path .to/file', title: 'file' }],
['/path/to/file.extension#region', { filepath: '/path/to/file.extension', extension: 'extension', title: 'file.extension', region: '#region' }],
// the extension comes from the file name, so dots in directories and
// dotfiles resolve, and it is not lowercased
['/path/to/.extension', { filepath: '/path/to/.extension', extension: 'extension', title: '.extension' }],
['/path/.to/file.extension', { filepath: '/path/.to/file.extension', extension: 'extension', title: 'file.extension' }],
['/path/.to/.extension', { filepath: '/path/.to/.extension', extension: 'extension', title: '.extension' }],
['/path/.to/file', { filepath: '/path/.to/file', title: 'file' }],
['./script.ps1', { filepath: './script.ps1', extension: 'ps1', title: 'script.ps1' }],
['./File.TS', { filepath: './File.TS', extension: 'TS', title: 'File.TS' }],
// suffixes that are not alphanumeric are not treated as an extension, so
// the language has to be given explicitly for these
['./main.c++', { filepath: './main.c++', title: 'main.c++' }],
['./main.c++ {c++}', { filepath: './main.c++', title: 'main.c++', lang: 'c++' }],
['@/.vscode/scss.code-snippets', { filepath: '@/.vscode/scss.code-snippets', title: 'scss.code-snippets' }],
// region names may contain dots, dashes, digits and underscores
['/path/to/file.extension#region', { filepath: '/path/to/file.extension', extension: 'extension', title: 'file.extension', region: 'region' }],
['./file.ts#my.region', { filepath: './file.ts', extension: 'ts', title: 'file.ts', region: 'my.region' }],
['./file.ts#complex-name_123', { filepath: './file.ts', extension: 'ts', title: 'file.ts', region: 'complex-name_123' }],
// inside the braces: optional highlight lines, then the language override
// (which may contain special characters), then attributes
['./path/to/file.extension {c#}', { filepath: './path/to/file.extension', extension: 'extension', title: 'file.extension', lang: 'c#' }],
['./path/to/file {C++}', { filepath: './path/to/file', title: 'file', lang: 'C++' }],
['/path to/file.extension {1,2,4-6}', { filepath: '/path to/file.extension', extension: 'extension', title: 'file.extension', lines: '1,2,4-6' }],
['/path to/file.extension {1,2,4-6 c#}', { filepath: '/path to/file.extension', extension: 'extension', title: 'file.extension', lines: '1,2,4-6', lang: 'c#' }],
['./file.ts{1 ts:line-numbers}', { filepath: './file.ts', extension: 'ts', title: 'file.ts', lines: '1', lang: 'ts:line-numbers' }],
// everything after the language is kept verbatim, so several attributes and
// quoted values with spaces reach the fence info
['./file.ts{1,2 ts twoslash}', { filepath: './file.ts', extension: 'ts', title: 'file.ts', lines: '1,2', lang: 'ts', attrs: 'twoslash' }],
['./file.ts{ts twoslash noext}', { filepath: './file.ts', extension: 'ts', title: 'file.ts', lang: 'ts', attrs: 'twoslash noext' }],
['./file.ts{1 ts key="a b" twoslash}', { filepath: './file.ts', extension: 'ts', title: 'file.ts', lines: '1', lang: 'ts', attrs: 'key="a b" twoslash' }],
// a lone word in the braces is the language, not an attribute
['./file.ts{twoslash}', { filepath: './file.ts', extension: 'ts', title: 'file.ts', lang: 'twoslash' }],
// stray whitespace in the braces is tolerated
['./file.ts{ ts twoslash }', { filepath: './file.ts', extension: 'ts', title: 'file.ts', lang: 'ts', attrs: 'twoslash' }],
['./file.ts{1, 2}', { filepath: './file.ts', extension: 'ts', title: 'file.ts', lines: '1,2' }],
['./file.ts{}', { filepath: './file.ts', extension: 'ts', title: 'file.ts' }],
// an explicit title overrides the file name and may itself contain brackets
['/path.to/file.extension [title]', { filepath: '/path.to/file.extension', extension: 'extension', title: 'title' }],
['./path.to/file.extension#region {c#}', { filepath: './path.to/file.extension', extension: 'extension', title: 'file.extension', region: '#region', lang: 'c#' }],
['/path/to/file#region {1,2,4-6}', { filepath: '/path/to/file', title: 'file', region: '#region', lines: '1,2,4-6' }],
['./path/to/file#region {1,2,4-6 c#}', { filepath: './path/to/file', title: 'file', region: '#region', lines: '1,2,4-6', lang: 'c#' }],
['./path.to/file.extension#region {c#}', { filepath: './path.to/file.extension', extension: 'extension', title: 'file.extension', region: 'region', lang: 'c#' }],
['/path/to/file#region {1,2,4-6}', { filepath: '/path/to/file', title: 'file', region: 'region', lines: '1,2,4-6' }],
['./path/to/file#region {1,2,4-6 c#}', { filepath: './path/to/file', title: 'file', region: 'region', lines: '1,2,4-6', lang: 'c#' }],
['/path to/file {1,2,4-6 c#} [title]', { filepath: '/path to/file', title: 'title', lines: '1,2,4-6', lang: 'c#' }],
['./path to/file#region {1,2,4-6 c#} [title]', { filepath: './path to/file', title: 'title', region: '#region', lines: '1,2,4-6', lang: 'c#' }],
['./path to/file#region {1,2,4-6 c#} [title]', { filepath: './path to/file', title: 'title', region: 'region', lines: '1,2,4-6', lang: 'c#' }],
['./file.ts#region{1,2 ts twoslash} [my title]', { filepath: './file.ts', extension: 'ts', title: 'my title', region: 'region', lines: '1,2', lang: 'ts', attrs: 'twoslash' }],
['./snippet.js [title [with brackets]]', { filepath: './snippet.js', extension: 'js', title: 'title [with brackets]' }],
// the space before the title is optional
['./foo.js[custom]', { filepath: './foo.js', extension: 'js', title: 'custom' }],
['./demo.js{1,3}[Demo]', { filepath: './demo.js', extension: 'js', title: 'Demo', lines: '1,3' }],
['./demo.js#region[Demo]', { filepath: './demo.js', extension: 'js', title: 'Demo', region: 'region' }],
['@/src/ExampleMod.java{15-21}[java]', { filepath: '@/src/ExampleMod.java', extension: 'java', title: 'java', lines: '15-21' }],
// windows-style separators resolve the file name the same way
['..\\path to\\file.extension', { filepath: '..\\path to\\file.extension', extension: 'extension', title: 'file.extension' }],
['C:\\path\\file.ts#region {1 ts}', { filepath: 'C:\\path\\file.ts', extension: 'ts', title: 'file.ts', region: 'region', lines: '1', lang: 'ts' }]
]
describe('node/markdown/plugins/snippet', () => {
describe('dedent', () => {
test('when 0-level is minimal, do not remove spaces', () => {
expect(
dedent(
[
//
'fn main() {',
' println!("Hello");',
'}'
].join('\n')
)
).toMatchInlineSnapshot(`
"fn main() {
println!("Hello");
}"
`)
describe('parseSnippetPath', () => {
test.each(parseSnippetPathMap)('%s', (rawPath, parsed) => {
expect(removeEmptyKeys(parseSnippetPath(rawPath))).toEqual(parsed)
})
})
describe('rendering', () => {
let root: string
let warnings: string[]
const logger = {
warn: (msg: string) => {
warnings.push(msg)
}
}
test('when 4-level is minimal, remove 4 spaces', () => {
expect(
dedent(
[
//
' let a = {',
' value: 42',
' };'
].join('\n')
)
).toMatchInlineSnapshot(`
"let a = {
value: 42
};"
`)
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), 'vitepress-snippet-'))
warnings = []
})
test('when only 1 line is passed, dedent it', () => {
expect(dedent(' let a = 42;')).toEqual('let a = 42;')
afterEach(async () => {
await rm(root, { recursive: true, force: true })
})
test('handle tabs as well', () => {
expect(
dedent(
[
//
' let a = {',
' value: 42',
' };'
].join('\n')
)
).toMatchInlineSnapshot(`
"let a = {
value: 42
};"
`)
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('imports a whole file, deriving language and title', async () => {
await write('snip.ts', 'const a = 1\nconst b = 2\n')
const { html, env } = await render('<<< ./snip.ts')
expect(html).toContain('language-ts')
expect(html).toContain('const a = 1')
expect(html).toContain('const b = 2')
expect(env.includes).toEqual([path.join(root, 'snip.ts')])
})
})
describe('rawPathToToken', () => {
test.each(rawPathTokenMap)('%s', (rawPath, token) => {
expect(removeEmptyKeys(rawPathToToken(rawPath))).toEqual(token)
test('resolves @ against srcDir', async () => {
await write('nested/snip.js', 'const nested = 1\n')
const { html } = await render(
'<<< @/nested/snip.js',
{},
{ path: path.join(root, 'sub/dir/index.md') }
)
expect(html).toContain('const nested = 1')
})
})
describe('findRegion', () => {
it('returns null when no region markers are present', () => {
const lines = ['function foo() {', ' console.log("hello");', '}']
expect(findRegion(lines, 'foo')).toBeNull()
test('resolves @ without a slash against srcDir', async () => {
await write('nested/snip.js', 'const nested = 1\n')
const { html } = await render(
'<<< @nested/snip.js',
{},
{ path: path.join(root, 'sub/dir/index.md') }
)
expect(html).toContain('const nested = 1')
})
it('ignores non-matching region names', () => {
const lines = [
'// #region regionA',
'some code here',
'// #endregion regionA'
]
expect(findRegion(lines, 'regionC')).toBeNull()
test('parses a snippet without a space after the marker', async () => {
await write('snip.ts', 'const a = 1\n')
const { html } = await render('<<<./snip.ts')
expect(html).toContain('const a = 1')
})
it('returns null if a region start marker exists without a matching end marker', () => {
const lines = [
'// #region missingEnd',
'console.log("inside region");',
'console.log("still inside");'
]
expect(findRegion(lines, 'missingEnd')).toBeNull()
test('does not dedent whole-file imports', async () => {
await write('indented.ts', ' const a = 1\n const b = 2\n')
const { html } = await render('<<< ./indented.ts')
expect(html).toContain(' const a = 1')
})
it('returns null if an end marker exists without a preceding start marker', () => {
const lines = [
'// #endregion ghostRegion',
'console.log("stray end marker");'
]
expect(findRegion(lines, 'ghostRegion')).toBeNull()
test('passes attrs to the highlighter and keeps them out of the title', async () => {
await write('snip.ts', 'const a = 1\nconst b = 2\n')
const calls: { lang: string; attrs: string }[] = []
await render('<<< ./snip.ts{1 ts twoslash} [my title]', {
highlight: (code, lang, attrs) => {
calls.push({ lang, attrs })
return code
}
})
expect(calls).toHaveLength(1)
expect(calls[0].lang).toBe('ts')
expect(calls[0].attrs).toContain('twoslash')
expect(calls[0].attrs).toContain('{1}')
expect(calls[0].attrs).not.toContain('my title')
})
it('detects C#/JavaScript style region markers with matching tags', () => {
const lines = [
'Console.WriteLine("Before region");',
'#region hello',
'Console.WriteLine("Hello, World!");',
'#endregion hello',
'Console.WriteLine("After region");'
]
const result = findRegion(lines, 'hello')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
'Console.WriteLine("Hello, World!");'
)
}
test('resolves relative paths against the real file path', async () => {
await write('sub/snip.js', 'const real = 1\n')
const { html } = await render(
'<<< ./snip.js',
{},
{
path: path.join(root, 'rewritten/index.md'),
realPath: path.join(root, 'sub/index.md')
}
)
expect(html).toContain('const real = 1')
})
it('detects region markers even when the end marker omits the region name', () => {
const lines = [
'Console.WriteLine("Before region");',
'#region hello',
'Console.WriteLine("Hello, World!");',
'#endregion',
'Console.WriteLine("After region");'
]
const result = findRegion(lines, 'hello')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
'Console.WriteLine("Hello, World!");'
)
}
test('concatenates all regions with the requested name', async () => {
await write(
'regions.ts',
[
'// #region one',
'const a = 1',
'// #endregion one',
'const outside = 2',
'// #region one',
'const b = 3',
'// #endregion',
''
].join('\n')
)
const { html } = await render('<<< ./regions.ts#one')
expect(html).toContain('const a = 1')
expect(html).toContain('const b = 3')
expect(html).not.toContain('const outside')
})
it('handles indented region markers correctly', () => {
const lines = [
' Console.WriteLine("Before region");',
' #region hello',
' Console.WriteLine("Hello, World!");',
' #endregion hello',
' Console.WriteLine("After region");'
]
const result = findRegion(lines, 'hello')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
' Console.WriteLine("Hello, World!");'
)
}
test('dedents extracted regions', async () => {
await write(
'indent.ts',
[
'function f() {',
' // #region inner',
' const x = 1',
' // #endregion inner',
'}',
''
].join('\n')
)
const { html } = await render('<<< ./indent.ts#inner')
expect(html).toContain('const x = 1')
expect(html).not.toContain(' const x = 1')
})
it('detects TypeScript style region markers', () => {
const lines = [
'let regexp: RegExp[] = [];',
'// #region foo',
'let start = -1;',
'// #endregion foo'
]
const result = findRegion(lines, 'foo')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
'let start = -1;'
)
}
const nested = [
'// #region outer',
'let a = 1',
'// #region nested',
'let b = 2',
'// #endregion nested',
'/* #region css */',
'let c = 3',
'/* #endregion css */',
'// #endregion outer',
''
].join('\n')
test('strips markers of the matched style by default', async () => {
await write('nested.ts', nested)
const region = await render('<<< ./nested.ts#outer')
// the double-slash markers matched the region, the css ones did not
expect(region.html).not.toContain('#region nested')
expect(region.html).toContain('#region css')
expect(region.html).toContain('let b = 2')
expect(region.html).toContain('let c = 3')
// whole-file imports keep their markers
const whole = await render('<<< ./nested.ts')
expect(whole.html).toContain('#region outer')
expect(whole.html).toContain('let a = 1')
})
it('detects CSS style region markers', () => {
const lines = [
'.body-content {',
'/* #region foo */',
' padding-left: 15px;',
'/* #endregion foo */',
' padding-right: 15px;',
'}'
]
const result = findRegion(lines, 'foo')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
' padding-left: 15px;'
)
}
test('strips every marker style with stripRegionMarkers: all', async () => {
await write('nested.ts', nested)
const region = await render('<<< ./nested.ts#outer', {
snippet: { stripRegionMarkers: 'all' }
})
expect(region.html).not.toContain('#region')
expect(region.html).toContain('let b = 2')
const whole = await render('<<< ./nested.ts', {
snippet: { stripRegionMarkers: 'all' }
})
expect(whole.html).not.toContain('#region')
expect(whole.html).toContain('let a = 1')
})
it('detects HTML style region markers', () => {
const lines = [
'<div>Some content</div>',
'<!-- #region foo -->',
' <h1>Hello world</h1>',
'<!-- #endregion foo -->',
'<div>Other content</div>'
]
const result = findRegion(lines, 'foo')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
' <h1>Hello world</h1>'
)
}
test('keeps marker lines with stripRegionMarkers: false', async () => {
await write('nested.ts', nested)
const region = await render('<<< ./nested.ts#outer', {
snippet: { stripRegionMarkers: false }
})
expect(region.html).toContain('#region nested')
expect(region.html).toContain('#region css')
})
it('detects Visual Basic style region markers (with case-insensitive "End")', () => {
const lines = [
'Console.WriteLine("VB")',
'#Region VBRegion',
' Console.WriteLine("Inside region")',
'#End Region VBRegion',
'Console.WriteLine("Done")'
]
const result = findRegion(lines, 'VBRegion')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
' Console.WriteLine("Inside region")'
)
}
test('applies lang, highlight lines, attrs and title to the fence', async () => {
await write('snip.ts', 'const a = 1\nconst b = 2\n')
const { html } = await render(
'::: code-group\n\n<<< ./snip.ts{1 js twoslash} [custom title]\n\n:::'
)
expect(html).toContain('language-js')
expect(html).toContain('custom title')
expect(html).not.toContain('twoslash')
})
it('detects Bat style region markers', () => {
const lines = ['::#region foo', 'echo off', '::#endregion foo']
const result = findRegion(lines, 'foo')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
'echo off'
)
}
test('throws when the file is missing', async () => {
await expect(render('<<< ./missing.ts')).rejects.toThrow(
/Code snippet path not found/
)
})
it('detects C/C++ style region markers using #pragma', () => {
const lines = [
'#pragma region foo',
'int a = 1;',
'#pragma endregion foo'
]
const result = findRegion(lines, 'foo')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
'int a = 1;'
)
}
test('throws when the path is a directory', async () => {
await write('dir/file.ts', 'const a = 1\n')
await expect(render('<<< ./dir')).rejects.toThrow(/directory/)
})
it('returns the first complete region when multiple regions exist', () => {
const lines = [
'// #region foo',
'first region content',
'// #endregion foo',
'// #region foo',
'second region content',
'// #endregion foo'
]
const result = findRegion(lines, 'foo')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
'first region content'
)
}
test('throws when the region is missing', async () => {
await write('snip.ts', 'const a = 1\n')
await expect(render('<<< ./snip.ts#nope')).rejects.toThrow(
/region "nope" not found/i
)
})
it('handles nested regions with different names properly', () => {
const lines = [
'// #region foo',
"console.log('line before nested');",
'// #region bar',
"console.log('nested content');",
'// #endregion bar',
'// #endregion foo'
]
const result = findRegion(lines, 'foo')
expect(result).not.toBeNull()
if (result) {
const extracted = lines.slice(result.start, result.end).join('\n')
const expected = [
"console.log('line before nested');",
'// #region bar',
"console.log('nested content');",
'// #endregion bar'
].join('\n')
expect(extracted).toBe(expected)
test('silent mode renders nothing and warns', async () => {
await write('snip.ts', 'const a = 1\n')
const missingFile = await render('<<< ./missing.ts', {
snippet: { silent: true }
})
expect(missingFile.html).not.toContain('<pre')
expect(missingFile.env.includes).toEqual([path.join(root, 'missing.ts')])
const missingRegion = await render('<<< ./snip.ts#nope', {
snippet: { silent: true }
})
expect(missingRegion.html).not.toContain('<pre')
expect(warnings).toHaveLength(2)
expect(warnings[0]).toContain('missing.ts')
expect(warnings[1]).toContain('nope')
})
test('escaped and indented markers are not parsed as snippets', async () => {
const escaped = await render('\\<<< ./snip.ts')
expect(escaped.html).toContain('&lt;&lt;&lt; ./snip.ts')
const indented = await render(' <<< ./snip.ts')
expect(indented.html).toContain('&lt;&lt;&lt; ./snip.ts')
expect(indented.env.includes).toEqual([])
})
test.runIf(process.platform === 'win32')(
'resolves windows-style paths',
async () => {
await write('nested/snip.ts', 'const a = 1\n')
const relative = await render('<<< .\\nested\\snip.ts')
expect(relative.html).toContain('const a = 1')
const rooted = await render('<<< @\\nested\\snip.ts')
expect(rooted.html).toContain('const a = 1')
// the watched path stays platform-native for snippets
expect(relative.env.includes).toEqual([
path.join(root, 'nested/snip.ts')
])
}
)
test('normalizes CRLF in imported files', async () => {
await write('crlf.ts', 'const a = 1\r\nconst b = 2\r\n')
const { html } = await render('<<< ./crlf.ts')
expect(html).toContain('const a = 1\nconst b = 2')
expect(html).not.toContain('\r')
})
})
})

@ -53,7 +53,10 @@ import { lineNumberPlugin } from './plugins/lineNumbers'
import { linkPlugin } from './plugins/link'
import { preWrapperPlugin } from './plugins/preWrapper'
import { restoreEntities } from './plugins/restoreEntities'
import { snippetPlugin } from './plugins/snippet'
import {
snippetPlugin,
type Options as SnippetPluginOptions
} from './plugins/snippet'
import { tablePlugin } from './plugins/table'
export type { Header } from '../shared'
@ -200,11 +203,11 @@ export interface MarkdownOptions extends MarkdownItAsyncOptions {
*/
lineNumbers?: boolean
/**
* Enables importing code snippets from files with `<<<`.
* @default true
* Options for importing code snippets from files with `<<<`. Set to
* `false` to disable.
* @see https://vitepress.dev/guide/markdown#import-code-snippets
*/
snippet?: boolean
snippet?: SnippetPluginOptions | boolean
/* ==================== Markdown Extensions ==================== */
@ -391,7 +394,7 @@ export async function createMarkdownRenderer(
lineNumberPlugin(md, options.lineNumbers)
}
if (options.snippet !== false) {
snippetPlugin(md, srcDir)
snippetPlugin(md, srcDir, normalizePluginOptions(options.snippet), logger)
}
const containerOptions = normalizePluginOptions(options.container)
if (options.container !== false) {

@ -1,140 +1,52 @@
import type { MarkdownItAsync } from 'markdown-it-async'
import type { RuleBlock } from 'markdown-it/lib/parser_block.mjs'
import fs from 'node:fs'
import path from 'node:path'
import type { Logger } from 'vite'
import type { MarkdownEnv } from '../../shared'
type FenceRenderer = NonNullable<MarkdownItAsync['renderer']['rules']['fence']>
type SnippetToken = ReturnType<Parameters<RuleBlock>[0]['push']> & {
src?: [path: string, regionName: string]
import { readTextFileSync } from '../../utils/fs'
import {
dedent,
findRegions,
stripRegionMarkers,
type RegionMarker
} from '../regions'
export interface Options {
/**
* Log a warning and render nothing when the snippet file or region is
* missing, instead of throwing.
* @default false
*/
silent?: boolean
/**
* Which region marker lines to remove from snippet output: `true` removes
* only markers of the style(s) that matched the requested region, so
* whole-file imports keep theirs, `'all'` removes markers of every style
* and name, and `false` keeps all of them.
* @default true
*/
stripRegionMarkers?: boolean | 'all'
}
/**
* raw path format: "/path/to/file.extension#region {meta} [title]"
* where #region, {meta} and [title] are optional
* meta can be like '1,2,4-6 lang', 'lang' or '1,2,4-6'
* lang can contain special characters like C++, C#, F#, etc.
* path can be relative to the current file or absolute
* file extension is optional
* path can contain spaces and dots
*
* captures: ['/path/to/file.extension', 'extension', '#region', '{meta}', '[title]']
*/
export const rawPathRegexp =
/^(.+?(?:(?:\.([a-z0-9]+))?))(?:(#[\w-]+))?(?: ?(?:{(\d+(?:[,-]\d+)*)? ?(\S+)? ?(\S+)?}))? ?(?:\[(.+)\])?$/
const regionMarkers = [
{
start: /^\s*\/\/\s*#?region\b\s*(.*?)\s*$/,
end: /^\s*\/\/\s*#?endregion\b\s*(.*?)\s*$/
},
{
start: /^\s*<!--\s*#?region\b\s*(.*?)\s*-->/,
end: /^\s*<!--\s*#?endregion\b\s*(.*?)\s*-->/
},
{
start: /^\s*\/\*\s*#region\b\s*(.*?)\s*\*\//,
end: /^\s*\/\*\s*#endregion\b\s*(.*?)\s*\*\//
},
{
start: /^\s*#[rR]egion\b\s*(.*?)\s*$/,
end: /^\s*#[eE]nd ?[rR]egion\b\s*(.*?)\s*$/
},
{
start: /^\s*#\s*#?region\b\s*(.*?)\s*$/,
end: /^\s*#\s*#?endregion\b\s*(.*?)\s*$/
},
{
start: /^\s*(?:--|::|@?REM)\s*#region\b\s*(.*?)\s*$/,
end: /^\s*(?:--|::|@?REM)\s*#endregion\b\s*(.*?)\s*$/
},
{
start: /^\s*#pragma\s+region\b\s*(.*?)\s*$/,
end: /^\s*#pragma\s+endregion\b\s*(.*?)\s*$/
},
{
start: /^\s*\(\*\s*#region\b\s*(.*?)\s*\*\)/,
end: /^\s*\(\*\s*#endregion\b\s*(.*?)\s*\*\)/
}
]
type FenceRenderer = NonNullable<MarkdownItAsync['renderer']['rules']['fence']>
const snippetMarker = '<<<'
export function rawPathToToken(rawPath: string) {
const [
filepath = '',
extension = '',
region = '',
lines = '',
lang = '',
attrs = '',
rawTitle = ''
] = (rawPathRegexp.exec(rawPath) || []).slice(1)
const title = rawTitle || filepath.split('/').pop() || ''
return { filepath, extension, region, lines, lang, attrs, title }
}
export function dedent(text: string): string {
const lines = text.split('\n')
const minIndentLength = lines.reduce((acc, line) => {
for (let i = 0; i < line.length; i++) {
if (line[i] !== ' ' && line[i] !== '\t') return Math.min(i, acc)
}
return acc
}, Infinity)
if (minIndentLength < Infinity) {
return lines.map((x) => x.slice(minIndentLength)).join('\n')
}
return text
}
export function findRegion(lines: Array<string>, regionName: string) {
let regionStart: {
re: (typeof regionMarkers)[number]
start: number
} | null = null
// find the regex pair for a start marker that matches the given region name
for (let i = 0; i < lines.length; i++) {
for (const marker of regionMarkers) {
if (marker.start.exec(lines[i])?.[1] === regionName) {
regionStart = { re: marker, start: i + 1 }
break
}
}
if (regionStart) break
}
if (!regionStart) return null
let depth = 1
// scan the rest of the lines to find the matching end marker,
// handling nested markers with the same region name
for (let i = regionStart.start; i < lines.length; i++) {
// check for an inner start marker for the same region
if (regionStart.re.start.exec(lines[i])?.[1] === regionName) {
depth++
continue
}
// check for an end marker for the same region
const endRegion = regionStart.re.end.exec(lines[i])?.[1]
// allow empty region name on the end marker as a fallback
if (endRegion === regionName || endRegion === '') {
if (--depth === 0) return { ...regionStart, end: i }
}
}
return null
}
export function snippetPlugin(md: MarkdownItAsync, srcDir: string) {
const renderFence = md.renderer.rules.fence!
md.renderer.rules.fence = createSnippetRenderer(renderFence)
const titleRE = /\s*\[(.+)\]$/
const regionRE = /#([\w.-]+)$/
const separatorRE = /[\\/]/
const extensionRE = /\.([a-zA-Z0-9]+)$/
const linesRE = /^\d+(?:[,-]\d+)*$/
export function snippetPlugin(
md: MarkdownItAsync,
srcDir: string,
options: Options = {},
logger: Pick<Logger, 'warn'> = console
) {
md.block.ruler.before('fence', 'snippet', createSnippetParser(srcDir))
const renderFence = md.renderer.rules.fence!
md.renderer.rules.fence = createSnippetRenderer(renderFence, options, logger)
}
function createSnippetParser(srcDir: string): RuleBlock {
@ -156,26 +68,23 @@ function createSnippetParser(srcDir: string): RuleBlock {
const start = pos + snippetMarker.length
const end = state.skipSpacesBack(max, pos)
const rawPath = state.src
.slice(start, end)
.trim()
.replace(/^@/, srcDir)
.trim()
const { filepath, extension, region, lines, lang, attrs, title } =
rawPathToToken(rawPath)
parseSnippetPath(state.src.slice(start, end))
state.line = startLine + 1
const token = state.push('fence', 'code', 0) as SnippetToken
token.info = `${lang || extension}${lines ? `{${lines}}` : ''}${
title ? `[${title}]` : ''
} ${attrs}`
const token = state.push('fence', 'code', 0)
token.info = (
`${lang || extension}${lines ? ` {${lines}}` : ''}` +
`${attrs ? ` ${attrs}` : ''}${title ? ` [${title}]` : ''}`
).trim()
const { realPath, path: _path } = state.env as MarkdownEnv
const resolvedPath = path.resolve(path.dirname(realPath ?? _path), filepath)
const src = filepath.startsWith('@')
? path.join(srcDir, filepath.slice(separatorRE.test(filepath[1]) ? 2 : 1))
: path.resolve(path.dirname(realPath ?? _path ?? '.'), filepath)
token.src = [resolvedPath, region.slice(1)]
token.meta = { src, region }
token.markup = '```'
token.map = [startLine, startLine + 1]
@ -183,56 +92,145 @@ function createSnippetParser(srcDir: string): RuleBlock {
}
}
function getFileOrError(src: string): { content: string; error?: string } {
try {
const content = fs.readFileSync(src, 'utf8').replace(/\r\n/g, '\n')
return { content }
} catch (error) {
switch ((error as NodeJS.ErrnoException).code) {
case 'ENOENT':
return { content: '', error: `Code snippet path not found: ${src}` }
case 'EISDIR':
return { content: '', error: 'Invalid code snippet option' }
default:
throw error
}
/**
* Parses the raw path of a snippet import:
* `path[#region][{[lines] [lang] [attrs...]}][ [title]]`
*
* The suffixes are peeled off right to left, so the path itself may contain
* spaces and dots. `lines` is the highlight specifier (e.g. `1,2,4-6`),
* `lang` overrides the extension-derived language and everything after it
* inside the braces is passed through to the fence info verbatim (e.g.
* `twoslash`). The title defaults to the file name.
*/
export function parseSnippetPath(rawPath: string) {
let rest = rawPath.trim()
let title = ''
const titleMatch = titleRE.exec(rest)
if (titleMatch) {
title = titleMatch[1]
rest = rest.slice(0, titleMatch.index).trimEnd()
}
let lines = ''
let lang = ''
let attrs = ''
const braceStart = rest.lastIndexOf('{')
if (rest.endsWith('}') && braceStart > 0) {
;({ lines, lang, attrs } = parseSnippetMeta(
rest.slice(braceStart + 1, -1).trim()
))
rest = rest.slice(0, braceStart).trimEnd()
}
let region = ''
const regionMatch = regionRE.exec(rest)
if (regionMatch) {
region = regionMatch[1]
rest = rest.slice(0, regionMatch.index)
}
const filepath = rest.trim()
const filename = filepath.split(separatorRE).pop() ?? ''
// only alphanumeric suffixes are treated as a language, so that files like
// `scss.code-snippets` don't end up requesting an unknown grammar; anything
// else needs the language given explicitly in the braces
const extension = extensionRE.exec(filename)?.[1] ?? ''
return {
filepath,
extension,
region,
lines,
lang,
attrs,
title: title || filename
}
}
function extractRegion(content: string, regionName: string): string {
if (!regionName) return content
function parseSnippetMeta(meta: string) {
let lines = ''
let lang = ''
let attrs = ''
if (!meta) return { lines, lang, attrs }
const lines = content.split('\n')
const region = findRegion(lines, regionName)
// tolerate whitespace in a lines-only meta, e.g. `{1, 2}`
if (/^[\d\s,-]+$/.test(meta)) {
const collapsed = meta.replace(/\s+/g, '')
if (linesRE.test(collapsed)) return { lines: collapsed, lang, attrs }
}
const first = /^\S+/.exec(meta)![0]
if (linesRE.test(first)) {
lines = first
meta = meta.slice(first.length).trimStart()
}
if (!region) return content
const langMatch = /^\S+/.exec(meta)
if (langMatch) {
lang = langMatch[0]
attrs = meta.slice(langMatch[0].length).trim()
}
return dedent(
lines
.slice(region.start, region.end)
.filter((l) => !(region.re.start.test(l) || region.re.end.test(l)))
.join('\n')
)
return { lines, lang, attrs }
}
function createSnippetRenderer(renderFence: FenceRenderer): FenceRenderer {
function createSnippetRenderer(
renderFence: FenceRenderer,
options: Options,
logger: Pick<Logger, 'warn'>
): FenceRenderer {
return (...args) => {
const [tokens, idx, , { includes }] = args
const token = tokens[idx] as SnippetToken
const [src, regionName = ''] = token.src ?? []
const [tokens, idx, , env] = args
const token = tokens[idx]
const { src, region } = (token.meta ?? {}) as {
src?: string
region?: string
}
if (!src) return renderFence(...args)
const { includes, relativePath } = (env ?? {}) as MarkdownEnv
includes?.push(src)
const { content, error } = getFileOrError(src)
if (error) {
token.content = error
token.info = ''
return renderFence(...args)
const fail = (message: string): string => {
if (!options.silent) throw new Error(message)
logger.warn(relativePath ? `${message} (in ${relativePath})` : message)
return ''
}
let content: string
try {
content = readTextFileSync(src)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
if (code === 'ENOENT') return fail(`Code snippet path not found: ${src}`)
if (code === 'EISDIR')
return fail(`Code snippet path is a directory: ${src}`)
throw error
}
let lines = content.split('\n')
let matchedMarkers: RegionMarker[] | undefined
if (region) {
const regions = findRegions(lines, region)
if (regions.length === 0) {
return fail(`Code snippet region "${region}" not found in ${src}`)
}
lines = regions.flatMap((r) => lines.slice(r.start, r.end))
matchedMarkers = [...new Set(regions.map((r) => r.marker))]
}
const strip = options.stripRegionMarkers ?? true
if (strip === 'all') {
lines = stripRegionMarkers(lines)
} else if (strip === true && matchedMarkers) {
lines = stripRegionMarkers(lines, matchedMarkers)
}
if (region) lines = dedent(lines)
token.content = extractRegion(content, regionName)
token.content = lines.join('\n')
return renderFence(...args)
}
}

@ -1,7 +1,7 @@
import matter from 'gray-matter'
import { replaceAsync, type MarkdownItAsync } from 'markdown-it-async'
import path from 'node:path'
import { findRegion } from '../markdown/plugins/snippet'
import { findRegions } from '../markdown/regions'
import { slash, type MarkdownEnv } from '../shared'
import { readTextFile } from './fs'
@ -47,7 +47,7 @@ export function processIncludes(
const [regionName] = region
const lines = content.split(/\r?\n/)
let selectedLines = lines
let { start, end } = findRegion(lines, regionName.slice(1)) ?? {}
let { start, end } = findRegions(lines, regionName.slice(1))[0] ?? {}
if (start === undefined) {
// region not found, it might be a header

Loading…
Cancel
Save