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>
unmerged-prs-triage
Divyansh Singh 2 months 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 { import {
dedent, createMarkdownRenderer,
findRegion, disposeMdItInstance,
rawPathToToken type MarkdownOptions
} from 'node/markdown/plugins/snippet' } 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) => { const removeEmptyKeys = <T extends Record<string, unknown>>(obj: T) => {
return Object.fromEntries( return Object.fromEntries(
@ -11,7 +16,8 @@ const removeEmptyKeys = <T extends Record<string, unknown>>(obj: T) => {
} }
/* prettier-ignore */ /* 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' }], ['./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', { 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.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}', { 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#' }], ['/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 [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.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}', { 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#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 {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('node/markdown/plugins/snippet', () => {
describe('dedent', () => { describe('parseSnippetPath', () => {
test('when 0-level is minimal, do not remove spaces', () => { test.each(parseSnippetPathMap)('%s', (rawPath, parsed) => {
expect( expect(removeEmptyKeys(parseSnippetPath(rawPath))).toEqual(parsed)
dedent( })
[
//
'fn main() {',
' println!("Hello");',
'}'
].join('\n')
)
).toMatchInlineSnapshot(`
"fn main() {
println!("Hello");
}"
`)
}) })
test('when 4-level is minimal, remove 4 spaces', () => { describe('rendering', () => {
expect( let root: string
dedent( let warnings: string[]
[
// const logger = {
' let a = {', warn: (msg: string) => {
' value: 42', warnings.push(msg)
' };' }
].join('\n') }
)
).toMatchInlineSnapshot(` beforeEach(async () => {
"let a = { root = await mkdtemp(path.join(tmpdir(), 'vitepress-snippet-'))
value: 42 warnings = []
};"
`)
}) })
test('when only 1 line is passed, dedent it', () => { afterEach(async () => {
expect(dedent(' let a = 42;')).toEqual('let a = 42;') await rm(root, { recursive: true, force: true })
}) })
test('handle tabs as well', () => { async function write(name: string, src: string) {
expect( const file = path.join(root, name)
dedent( await mkdir(path.dirname(file), { recursive: true })
[ await writeFile(file, src)
// }
' let a = {',
' value: 42', async function render(
' };' src: string,
].join('\n') options: MarkdownOptions = {},
env: Partial<MarkdownEnv> = {}
) {
disposeMdItInstance()
const md = await createMarkdownRenderer(
root,
{ highlight: (code) => code, ...options },
'/',
logger
) )
).toMatchInlineSnapshot(` const fullEnv: MarkdownEnv = {
"let a = { path: path.join(root, 'index.md'),
value: 42 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('resolves @ against srcDir', async () => {
test.each(rawPathTokenMap)('%s', (rawPath, token) => { await write('nested/snip.js', 'const nested = 1\n')
expect(removeEmptyKeys(rawPathToToken(rawPath))).toEqual(token)
const { html } = await render(
'<<< @/nested/snip.js',
{},
{ path: path.join(root, 'sub/dir/index.md') }
)
expect(html).toContain('const nested = 1')
}) })
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')
}) })
describe('findRegion', () => { test('parses a snippet without a space after the marker', async () => {
it('returns null when no region markers are present', () => { await write('snip.ts', 'const a = 1\n')
const lines = ['function foo() {', ' console.log("hello");', '}']
expect(findRegion(lines, 'foo')).toBeNull() const { html } = await render('<<<./snip.ts')
expect(html).toContain('const a = 1')
}) })
it('ignores non-matching region names', () => { test('does not dedent whole-file imports', async () => {
const lines = [ await write('indented.ts', ' const a = 1\n const b = 2\n')
'// #region regionA',
'some code here', const { html } = await render('<<< ./indented.ts')
'// #endregion regionA' expect(html).toContain(' const a = 1')
]
expect(findRegion(lines, 'regionC')).toBeNull()
}) })
it('returns null if a region start marker exists without a matching end marker', () => { test('passes attrs to the highlighter and keeps them out of the title', async () => {
const lines = [ await write('snip.ts', 'const a = 1\nconst b = 2\n')
'// #region missingEnd',
'console.log("inside region");', const calls: { lang: string; attrs: string }[] = []
'console.log("still inside");' await render('<<< ./snip.ts{1 ts twoslash} [my title]', {
] highlight: (code, lang, attrs) => {
expect(findRegion(lines, 'missingEnd')).toBeNull() calls.push({ lang, attrs })
return code
}
}) })
it('returns null if an end marker exists without a preceding start marker', () => { expect(calls).toHaveLength(1)
const lines = [ expect(calls[0].lang).toBe('ts')
'// #endregion ghostRegion', expect(calls[0].attrs).toContain('twoslash')
'console.log("stray end marker");' expect(calls[0].attrs).toContain('{1}')
] expect(calls[0].attrs).not.toContain('my title')
expect(findRegion(lines, 'ghostRegion')).toBeNull()
}) })
it('detects C#/JavaScript style region markers with matching tags', () => { test('resolves relative paths against the real file path', async () => {
const lines = [ await write('sub/snip.js', 'const real = 1\n')
'Console.WriteLine("Before region");',
'#region hello', const { html } = await render(
'Console.WriteLine("Hello, World!");', '<<< ./snip.js',
'#endregion hello', {},
'Console.WriteLine("After region");' {
] path: path.join(root, 'rewritten/index.md'),
const result = findRegion(lines, 'hello') realPath: path.join(root, 'sub/index.md')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
'Console.WriteLine("Hello, World!");'
)
} }
)
expect(html).toContain('const real = 1')
}) })
it('detects region markers even when the end marker omits the region name', () => { test('concatenates all regions with the requested name', async () => {
const lines = [ await write(
'Console.WriteLine("Before region");', 'regions.ts',
'#region hello', [
'Console.WriteLine("Hello, World!");', '// #region one',
'#endregion', 'const a = 1',
'Console.WriteLine("After region");' '// #endregion one',
] 'const outside = 2',
const result = findRegion(lines, 'hello') '// #region one',
expect(result).not.toBeNull() 'const b = 3',
if (result) { '// #endregion',
expect(lines.slice(result.start, result.end).join('\n')).toBe( ''
'Console.WriteLine("Hello, World!");' ].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', () => { test('dedents extracted regions', async () => {
const lines = [ await write(
' Console.WriteLine("Before region");', 'indent.ts',
' #region hello', [
' Console.WriteLine("Hello, World!");', 'function f() {',
' #endregion hello', ' // #region inner',
' Console.WriteLine("After region");' ' const x = 1',
] ' // #endregion inner',
const result = findRegion(lines, 'hello') '}',
expect(result).not.toBeNull() ''
if (result) { ].join('\n')
expect(lines.slice(result.start, result.end).join('\n')).toBe(
' Console.WriteLine("Hello, World!");'
) )
}
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 nested = [
const lines = [ '// #region outer',
'let regexp: RegExp[] = [];', 'let a = 1',
'// #region foo', '// #region nested',
'let start = -1;', 'let b = 2',
'// #endregion foo' '// #endregion nested',
] '/* #region css */',
const result = findRegion(lines, 'foo') 'let c = 3',
expect(result).not.toBeNull() '/* #endregion css */',
if (result) { '// #endregion outer',
expect(lines.slice(result.start, result.end).join('\n')).toBe( ''
'let start = -1;' ].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', () => { test('strips every marker style with stripRegionMarkers: all', async () => {
const lines = [ await write('nested.ts', nested)
'.body-content {',
'/* #region foo */', const region = await render('<<< ./nested.ts#outer', {
' padding-left: 15px;', snippet: { stripRegionMarkers: 'all' }
'/* #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;'
)
}
}) })
expect(region.html).not.toContain('#region')
expect(region.html).toContain('let b = 2')
it('detects HTML style region markers', () => { const whole = await render('<<< ./nested.ts', {
const lines = [ snippet: { stripRegionMarkers: 'all' }
'<div>Some content</div>', })
'<!-- #region foo -->', expect(whole.html).not.toContain('#region')
' <h1>Hello world</h1>', expect(whole.html).toContain('let a = 1')
'<!-- #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>'
)
}
}) })
it('detects Visual Basic style region markers (with case-insensitive "End")', () => { test('keeps marker lines with stripRegionMarkers: false', async () => {
const lines = [ await write('nested.ts', nested)
'Console.WriteLine("VB")',
'#Region VBRegion', const region = await render('<<< ./nested.ts#outer', {
' Console.WriteLine("Inside region")', snippet: { stripRegionMarkers: false }
'#End Region VBRegion', })
'Console.WriteLine("Done")' expect(region.html).toContain('#region nested')
] expect(region.html).toContain('#region css')
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")'
)
}
}) })
it('detects Bat style region markers', () => { test('applies lang, highlight lines, attrs and title to the fence', async () => {
const lines = ['::#region foo', 'echo off', '::#endregion foo'] await write('snip.ts', 'const a = 1\nconst b = 2\n')
const result = findRegion(lines, 'foo')
expect(result).not.toBeNull() const { html } = await render(
if (result) { '::: code-group\n\n<<< ./snip.ts{1 js twoslash} [custom title]\n\n:::'
expect(lines.slice(result.start, result.end).join('\n')).toBe(
'echo off'
) )
} expect(html).toContain('language-js')
expect(html).toContain('custom title')
expect(html).not.toContain('twoslash')
}) })
it('detects C/C++ style region markers using #pragma', () => { test('throws when the file is missing', async () => {
const lines = [ await expect(render('<<< ./missing.ts')).rejects.toThrow(
'#pragma region foo', /Code snippet path not found/
'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;'
) )
}
}) })
it('returns the first complete region when multiple regions exist', () => { test('throws when the path is a directory', async () => {
const lines = [ await write('dir/file.ts', 'const a = 1\n')
'// #region foo', await expect(render('<<< ./dir')).rejects.toThrow(/directory/)
'first region content', })
'// #endregion foo',
'// #region foo', test('throws when the region is missing', async () => {
'second region content', await write('snip.ts', 'const a = 1\n')
'// #endregion foo' await expect(render('<<< ./snip.ts#nope')).rejects.toThrow(
] /region "nope" not found/i
const result = findRegion(lines, 'foo')
expect(result).not.toBeNull()
if (result) {
expect(lines.slice(result.start, result.end).join('\n')).toBe(
'first region content'
) )
}
}) })
it('handles nested regions with different names properly', () => { test('silent mode renders nothing and warns', async () => {
const lines = [ await write('snip.ts', 'const a = 1\n')
'// #region foo',
"console.log('line before nested');", const missingFile = await render('<<< ./missing.ts', {
'// #region bar', snippet: { silent: true }
"console.log('nested content');", })
'// #endregion bar', expect(missingFile.html).not.toContain('<pre')
'// #endregion foo' expect(missingFile.env.includes).toEqual([path.join(root, 'missing.ts')])
]
const result = findRegion(lines, 'foo') const missingRegion = await render('<<< ./snip.ts#nope', {
expect(result).not.toBeNull() snippet: { silent: true }
if (result) { })
const extracted = lines.slice(result.start, result.end).join('\n') expect(missingRegion.html).not.toContain('<pre')
const expected = [
"console.log('line before nested');", expect(warnings).toHaveLength(2)
'// #region bar', expect(warnings[0]).toContain('missing.ts')
"console.log('nested content');", expect(warnings[1]).toContain('nope')
'// #endregion bar' })
].join('\n')
expect(extracted).toBe(expected) 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 { linkPlugin } from './plugins/link'
import { preWrapperPlugin } from './plugins/preWrapper' import { preWrapperPlugin } from './plugins/preWrapper'
import { restoreEntities } from './plugins/restoreEntities' import { restoreEntities } from './plugins/restoreEntities'
import { snippetPlugin } from './plugins/snippet' import {
snippetPlugin,
type Options as SnippetPluginOptions
} from './plugins/snippet'
import { tablePlugin } from './plugins/table' import { tablePlugin } from './plugins/table'
export type { Header } from '../shared' export type { Header } from '../shared'
@ -200,11 +203,11 @@ export interface MarkdownOptions extends MarkdownItAsyncOptions {
*/ */
lineNumbers?: boolean lineNumbers?: boolean
/** /**
* Enables importing code snippets from files with `<<<`. * Options for importing code snippets from files with `<<<`. Set to
* @default true * `false` to disable.
* @see https://vitepress.dev/guide/markdown#import-code-snippets * @see https://vitepress.dev/guide/markdown#import-code-snippets
*/ */
snippet?: boolean snippet?: SnippetPluginOptions | boolean
/* ==================== Markdown Extensions ==================== */ /* ==================== Markdown Extensions ==================== */
@ -391,7 +394,7 @@ export async function createMarkdownRenderer(
lineNumberPlugin(md, options.lineNumbers) lineNumberPlugin(md, options.lineNumbers)
} }
if (options.snippet !== false) { if (options.snippet !== false) {
snippetPlugin(md, srcDir) snippetPlugin(md, srcDir, normalizePluginOptions(options.snippet), logger)
} }
const containerOptions = normalizePluginOptions(options.container) const containerOptions = normalizePluginOptions(options.container)
if (options.container !== false) { if (options.container !== false) {

@ -1,140 +1,52 @@
import type { MarkdownItAsync } from 'markdown-it-async' import type { MarkdownItAsync } from 'markdown-it-async'
import type { RuleBlock } from 'markdown-it/lib/parser_block.mjs' import type { RuleBlock } from 'markdown-it/lib/parser_block.mjs'
import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import type { Logger } from 'vite'
import type { MarkdownEnv } from '../../shared' import type { MarkdownEnv } from '../../shared'
import { readTextFileSync } from '../../utils/fs'
type FenceRenderer = NonNullable<MarkdownItAsync['renderer']['rules']['fence']> import {
type SnippetToken = ReturnType<Parameters<RuleBlock>[0]['push']> & { dedent,
src?: [path: string, regionName: string] findRegions,
} stripRegionMarkers,
type RegionMarker
} from '../regions'
export interface Options {
/** /**
* raw path format: "/path/to/file.extension#region {meta} [title]" * Log a warning and render nothing when the snippet file or region is
* where #region, {meta} and [title] are optional * missing, instead of throwing.
* meta can be like '1,2,4-6 lang', 'lang' or '1,2,4-6' * @default false
* 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 = silent?: boolean
/^(.+?(?:(?:\.([a-z0-9]+))?))(?:(#[\w-]+))?(?: ?(?:{(\d+(?:[,-]\d+)*)? ?(\S+)? ?(\S+)?}))? ?(?:\[(.+)\])?$/ /**
* Which region marker lines to remove from snippet output: `true` removes
const regionMarkers = [ * only markers of the style(s) that matched the requested region, so
{ * whole-file imports keep theirs, `'all'` removes markers of every style
start: /^\s*\/\/\s*#?region\b\s*(.*?)\s*$/, * and name, and `false` keeps all of them.
end: /^\s*\/\/\s*#?endregion\b\s*(.*?)\s*$/ * @default true
}, */
{ stripRegionMarkers?: boolean | 'all'
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*\*\)/
}
]
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 type FenceRenderer = NonNullable<MarkdownItAsync['renderer']['rules']['fence']>
// 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 const snippetMarker = '<<<'
}
export function snippetPlugin(md: MarkdownItAsync, srcDir: string) { const titleRE = /\s*\[(.+)\]$/
const renderFence = md.renderer.rules.fence! const regionRE = /#([\w.-]+)$/
md.renderer.rules.fence = createSnippetRenderer(renderFence) 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)) 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 { function createSnippetParser(srcDir: string): RuleBlock {
@ -156,26 +68,23 @@ function createSnippetParser(srcDir: string): RuleBlock {
const start = pos + snippetMarker.length const start = pos + snippetMarker.length
const end = state.skipSpacesBack(max, pos) 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 } = const { filepath, extension, region, lines, lang, attrs, title } =
rawPathToToken(rawPath) parseSnippetPath(state.src.slice(start, end))
state.line = startLine + 1 state.line = startLine + 1
const token = state.push('fence', 'code', 0) as SnippetToken const token = state.push('fence', 'code', 0)
token.info = `${lang || extension}${lines ? `{${lines}}` : ''}${ token.info = (
title ? `[${title}]` : '' `${lang || extension}${lines ? ` {${lines}}` : ''}` +
} ${attrs}` `${attrs ? ` ${attrs}` : ''}${title ? ` [${title}]` : ''}`
).trim()
const { realPath, path: _path } = state.env as MarkdownEnv 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.markup = '```'
token.map = [startLine, startLine + 1] token.map = [startLine, startLine + 1]
@ -183,56 +92,145 @@ function createSnippetParser(srcDir: string): RuleBlock {
} }
} }
function getFileOrError(src: string): { content: string; error?: string } { /**
try { * Parses the raw path of a snippet import:
const content = fs.readFileSync(src, 'utf8').replace(/\r\n/g, '\n') * `path[#region][{[lines] [lang] [attrs...]}][ [title]]`
return { content } *
} catch (error) { * The suffixes are peeled off right to left, so the path itself may contain
switch ((error as NodeJS.ErrnoException).code) { * spaces and dots. `lines` is the highlight specifier (e.g. `1,2,4-6`),
case 'ENOENT': * `lang` overrides the extension-derived language and everything after it
return { content: '', error: `Code snippet path not found: ${src}` } * inside the braces is passed through to the fence info verbatim (e.g.
case 'EISDIR': * `twoslash`). The title defaults to the file name.
return { content: '', error: 'Invalid code snippet option' } */
default: export function parseSnippetPath(rawPath: string) {
throw error 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 { function parseSnippetMeta(meta: string) {
if (!regionName) return content let lines = ''
let lang = ''
let attrs = ''
if (!meta) return { lines, lang, attrs }
// 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 lines = content.split('\n') const first = /^\S+/.exec(meta)![0]
const region = findRegion(lines, regionName) 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( return { lines, lang, attrs }
lines
.slice(region.start, region.end)
.filter((l) => !(region.re.start.test(l) || region.re.end.test(l)))
.join('\n')
)
} }
function createSnippetRenderer(renderFence: FenceRenderer): FenceRenderer { function createSnippetRenderer(
renderFence: FenceRenderer,
options: Options,
logger: Pick<Logger, 'warn'>
): FenceRenderer {
return (...args) => { return (...args) => {
const [tokens, idx, , { includes }] = args const [tokens, idx, , env] = args
const token = tokens[idx] as SnippetToken const token = tokens[idx]
const [src, regionName = ''] = token.src ?? []
const { src, region } = (token.meta ?? {}) as {
src?: string
region?: string
}
if (!src) return renderFence(...args) if (!src) return renderFence(...args)
const { includes, relativePath } = (env ?? {}) as MarkdownEnv
includes?.push(src) includes?.push(src)
const { content, error } = getFileOrError(src) const fail = (message: string): string => {
if (error) { if (!options.silent) throw new Error(message)
token.content = error logger.warn(relativePath ? `${message} (in ${relativePath})` : message)
token.info = '' return ''
return renderFence(...args) }
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) return renderFence(...args)
} }
} }

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

Loading…
Cancel
Save