From cb8327207488b79b5688bb2c430a796abb866946 Mon Sep 17 00:00:00 2001 From: Miroma Date: Mon, 6 Apr 2026 22:09:03 +0200 Subject: [PATCH] fix(md)!: include all regions with the same name VitePress supports including a part of a file through a VS Code region. However, when multiple regions have the same name, only one is included. The workaround plugin suggested in #3690 is no longer maintained. [^1] Furthermore, it suffers from a memory leak causing huge RAM usage. [^2] While we at Fabric Docs have forked the plugin [^3] to fix some issues, it still feels fragile. Another problem is having to use two different syntaxes (`<<<` vs `@[]()`) for doing the same thing, causing confusion. Since this feature has been requested by VitePress directly, I don't think there's any point in working on the plugin any further. fix #3690 [^1]: [^2]: via [^3]: --- .../node/markdown/plugins/snippet.test.ts | 288 +++++++++++------- docs/en/guide/markdown.md | 8 +- docs/snippets/snippet-with-region.js | 12 +- src/node/markdown/plugins/snippet.ts | 91 +++--- src/node/utils/processIncludes.ts | 14 +- 5 files changed, 257 insertions(+), 156 deletions(-) diff --git a/__tests__/unit/node/markdown/plugins/snippet.test.ts b/__tests__/unit/node/markdown/plugins/snippet.test.ts index ba4b343e1..1ca5d3045 100644 --- a/__tests__/unit/node/markdown/plugins/snippet.test.ts +++ b/__tests__/unit/node/markdown/plugins/snippet.test.ts @@ -1,6 +1,6 @@ import { dedent, - findRegion, + findRegions, rawPathToToken } from 'node/markdown/plugins/snippet' @@ -38,6 +38,7 @@ const rawPathTokenMap: [string, Partial<{ filepath: string, extension: string, t ['./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 {C++}', { filepath: './path/to/file', title: 'file', lang: 'C++' }], ] describe('node/markdown/plugins/snippet', () => { @@ -105,9 +106,14 @@ describe('node/markdown/plugins/snippet', () => { }) describe('findRegion', () => { - it('returns null when no region markers are present', () => { - const lines = ['function foo() {', ' console.log("hello");', '}'] - expect(findRegion(lines, 'foo')).toBeNull() + it('returns empty array when no region markers are present', () => { + const lines = [ + 'function foo() {', + ' console.log("hello");', + ' return "foo";', + '}' + ] + expect(findRegions(lines, 'foo')).toHaveLength(0) }) it('ignores non-matching region names', () => { @@ -116,24 +122,24 @@ describe('node/markdown/plugins/snippet', () => { 'some code here', '// #endregion regionA' ] - expect(findRegion(lines, 'regionC')).toBeNull() + expect(findRegions(lines, 'regionC')).toHaveLength(0) }) - it('returns null if a region start marker exists without a matching end marker', () => { + it('returns empty array 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() + expect(findRegions(lines, 'missingEnd')).toHaveLength(0) }) - it('returns null if an end marker exists without a preceding start marker', () => { + it('returns empty array if an end marker exists without a preceding start marker', () => { const lines = [ '// #endregion ghostRegion', 'console.log("stray end marker");' ] - expect(findRegion(lines, 'ghostRegion')).toBeNull() + expect(findRegions(lines, 'ghostRegion')).toHaveLength(0) }) it('detects C#/JavaScript style region markers with matching tags', () => { @@ -144,13 +150,17 @@ describe('node/markdown/plugins/snippet', () => { '#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!");' - ) - } + const result = findRegions(lines, 'hello') + expect(result).toHaveLength(1) + expect( + result + .flatMap((r) => + lines + .slice(r.start, r.end) + .filter((l) => !(r.re.start.test(l) || r.re.end.test(l))) + ) + .join('\n') + ).toBe('Console.WriteLine("Hello, World!");') }) it('detects region markers even when the end marker omits the region name', () => { @@ -161,13 +171,17 @@ describe('node/markdown/plugins/snippet', () => { '#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!");' - ) - } + const result = findRegions(lines, 'hello') + expect(result).toHaveLength(1) + expect( + result + .flatMap((r) => + lines + .slice(r.start, r.end) + .filter((l) => !(r.re.start.test(l) || r.re.end.test(l))) + ) + .join('\n') + ).toBe('Console.WriteLine("Hello, World!");') }) it('handles indented region markers correctly', () => { @@ -178,125 +192,167 @@ describe('node/markdown/plugins/snippet', () => { ' #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!");' - ) - } + const result = findRegions(lines, 'hello') + expect(result).toHaveLength(1) + expect( + result + .flatMap((r) => + lines + .slice(r.start, r.end) + .filter((l) => !(r.re.start.test(l) || r.re.end.test(l))) + ) + .join('\n') + ).toBe(' Console.WriteLine("Hello, World!");') }) it('detects TypeScript style region markers', () => { const lines = [ 'let regexp: RegExp[] = [];', - '// #region foo', + '// #region hello', 'let start = -1;', - '// #endregion foo' + '// #endregion hello' ] - 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 result = findRegions(lines, 'hello') + expect(result).toHaveLength(1) + expect( + result + .flatMap((r) => + lines + .slice(r.start, r.end) + .filter((l) => !(r.re.start.test(l) || r.re.end.test(l))) + ) + .join('\n') + ).toBe('let start = -1;') }) it('detects CSS style region markers', () => { const lines = [ '.body-content {', - '/* #region foo */', + '/* #region hello */', ' padding-left: 15px;', - '/* #endregion foo */', + '/* #endregion hello */', ' 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;' - ) - } + const result = findRegions(lines, 'hello') + expect(result).toHaveLength(1) + expect( + result + .flatMap((r) => + lines + .slice(r.start, r.end) + .filter((l) => !(r.re.start.test(l) || r.re.end.test(l))) + ) + .join('\n') + ).toBe(' padding-left: 15px;') }) it('detects HTML style region markers', () => { const lines = [ '
Some content
', - '', + '', '

Hello world

', - '', + '', '
Other content
' ] - const result = findRegion(lines, 'foo') - expect(result).not.toBeNull() - if (result) { - expect(lines.slice(result.start, result.end).join('\n')).toBe( - '

Hello world

' - ) - } + const result = findRegions(lines, 'hello') + expect(result).toHaveLength(1) + expect( + result + .flatMap((r) => + lines + .slice(r.start, r.end) + .filter((l) => !(r.re.start.test(l) || r.re.end.test(l))) + ) + .join('\n') + ).toBe('

Hello world

') }) it('detects Visual Basic style region markers (with case-insensitive "End")', () => { const lines = [ 'Console.WriteLine("VB")', - '#Region VBRegion', + '#Region hello', ' Console.WriteLine("Inside region")', - '#End Region VBRegion', + '#End Region hello', '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")' - ) - } + const result = findRegions(lines, 'hello') + expect(result).toHaveLength(1) + expect( + result + .flatMap((r) => + lines + .slice(r.start, r.end) + .filter((l) => !(r.re.start.test(l) || r.re.end.test(l))) + ) + .join('\n') + ).toBe(' Console.WriteLine("Inside region")') }) 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' - ) - } + const lines = ['::#region hello', '@ECHO OFF', 'REM #endregion hello'] + const result = findRegions(lines, 'hello') + expect(result).toHaveLength(1) + expect( + result + .flatMap((r) => + lines + .slice(r.start, r.end) + .filter((l) => !(r.re.start.test(l) || r.re.end.test(l))) + ) + .join('\n') + ).toBe('@ECHO OFF') }) it('detects C/C++ style region markers using #pragma', () => { const lines = [ - '#pragma region foo', + '#pragma region hello', 'int a = 1;', - '#pragma endregion foo' + '#pragma endregion hello' ] - const result = findRegion(lines, 'foo') - expect(result).not.toBeNull() - if (result) { - expect(lines.slice(result.start, result.end).join('\n')).toBe( - 'int a = 1;' - ) - } + const result = findRegions(lines, 'hello') + expect(result).toHaveLength(1) + expect( + result + .flatMap((r) => + lines + .slice(r.start, r.end) + .filter((l) => !(r.re.start.test(l) || r.re.end.test(l))) + ) + .join('\n') + ).toBe('int a = 1;') }) - it('returns the first complete region when multiple regions exist', () => { + it('returns all regions with the same name when multiple exist', () => { const lines = [ - '// #region foo', + '// #region hello', 'first region content', - '// #endregion foo', - '// #region foo', + '// #endregion hello', + 'between regions content', + '// #region hello', 'second region content', - '// #endregion foo' + '// #endregion', + 'between regions content', + '// #region hello', + 'third region content', + '// #endregion hello', + 'below regions content' ] - const result = findRegion(lines, 'foo') - expect(result).not.toBeNull() - if (result) { - expect(lines.slice(result.start, result.end).join('\n')).toBe( - 'first region content' + const result = findRegions(lines, 'hello') + expect(result).toHaveLength(3) + const extracted = result + .flatMap((r) => + lines + .slice(r.start, r.end) + .filter((l) => !(r.re.start.test(l) || r.re.end.test(l))) ) - } + .join('\n') + const expected = [ + 'first region content', + 'second region content', + 'third region content' + ].join('\n') + expect(extracted).toBe(expected) }) it('handles nested regions with different names properly', () => { @@ -308,18 +364,38 @@ describe('node/markdown/plugins/snippet', () => { '// #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) - } + const result = findRegions(lines, 'foo') + expect(result).toHaveLength(1) + const extracted = result + .flatMap((r) => + lines + .slice(r.start, r.end) + .filter((l) => !(r.re.start.test(l) || r.re.end.test(l))) + ) + .join('\n') + const expected = [ + "console.log('line before nested');", + "console.log('nested content');" + ].join('\n') + expect(extracted).toBe(expected) + }) + + it('handles region names with hyphens and special characters', () => { + const lines = [ + '// #region complex-name_123', + 'const x = 1;', + '// #endregion complex-name_123' + ] + const result = findRegions(lines, 'complex-name_123') + expect(result).toHaveLength(1) + const extracted = result + .flatMap((r) => + lines + .slice(r.start, r.end) + .filter((l) => !(r.re.start.test(l) || r.re.end.test(l))) + ) + .join('\n') + expect(extracted).toBe('const x = 1;') }) }) }) diff --git a/docs/en/guide/markdown.md b/docs/en/guide/markdown.md index 193ab8cf5..71736fa48 100644 --- a/docs/en/guide/markdown.md +++ b/docs/en/guide/markdown.md @@ -660,12 +660,12 @@ The value of `@` corresponds to the source root. By default it's the VitePress p ::: -You can also use a [VS Code region](https://code.visualstudio.com/docs/editor/codebasics#_folding) to only include the corresponding part of the code file. You can provide a custom region name after a `#` following the filepath: +You can also use a [VS Code region](https://code.visualstudio.com/docs/editor/codebasics#_folding) to only include the corresponding part of the code file. You can provide a custom region name after a `#` following the filepath, and all regions with that name will be imported: **Input** ```md -<<< @/snippets/snippet-with-region.js#snippet{1} +<<< @/snippets/snippet-with-region.js#snippet{2,5} ``` **Code file** @@ -674,7 +674,7 @@ You can also use a [VS Code region](https://code.visualstudio.com/docs/editor/co **Output** -<<< @/snippets/snippet-with-region.js#snippet{1} +<<< @/snippets/snippet-with-region.js#snippet{2,5} You can also specify the language inside the braces (`{}`) like this: @@ -860,7 +860,7 @@ Can be created using `.foorc.json`. The format of the selected line range can be: `{3,}`, `{,10}`, `{1,10}` -You can also use a [VS Code region](https://code.visualstudio.com/docs/editor/codebasics#_folding) to only include the corresponding part of the code file. You can provide a custom region name after a `#` following the filepath: +You can also use a [VS Code region](https://code.visualstudio.com/docs/editor/codebasics#_folding) to only include the corresponding part of the code file. You can provide a custom region name after a `#` following the filepath, and all regions with that name will be included: **Input** diff --git a/docs/snippets/snippet-with-region.js b/docs/snippets/snippet-with-region.js index 9c7faaebc..fd0878627 100644 --- a/docs/snippets/snippet-with-region.js +++ b/docs/snippets/snippet-with-region.js @@ -1,7 +1,15 @@ // #region snippet function foo() { - // .. + console.log('foo') } // #endregion snippet -export default foo +console.log('this line is not in #region snippet!') + +// #region snippet +function bar() { + console.log('bar') +} +// #endregion snippet + +export { bar, foo } diff --git a/src/node/markdown/plugins/snippet.ts b/src/node/markdown/plugins/snippet.ts index 71e5e76c9..2acbf4c0f 100644 --- a/src/node/markdown/plugins/snippet.ts +++ b/src/node/markdown/plugins/snippet.ts @@ -86,37 +86,44 @@ const markers = [ } ] -export function findRegion(lines: Array, regionName: string) { - let chosen: { re: (typeof markers)[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 re of markers) { - if (re.start.exec(lines[i])?.[1] === regionName) { - chosen = { re, start: i + 1 } - break +export function findRegions(lines: string[], regionName: string) { + const returned: { + re: (typeof markers)[number] + start: number + end: number + }[] = [] + + for (const re of markers) { + let nestedCounter = 0 + let start: number | null = null + + for (let i = 0; i < lines.length; i++) { + // find region start + const startMatch = re.start.exec(lines[i]) + if (startMatch?.[1] === regionName) { + if (nestedCounter === 0) start = i + 1 + nestedCounter++ + continue + } + + if (nestedCounter === 0) continue + + // find region end + const endMatch = re.end.exec(lines[i]) + if (endMatch?.[1] === regionName || endMatch?.[1] === '') { + nestedCounter-- + // if all nested regions ended + if (nestedCounter === 0 && start != null) { + returned.push({ re, start, end: i }) + start = null + } } } - if (chosen) break - } - if (!chosen) return null - - let counter = 1 - // scan the rest of the lines to find the matching end marker, handling nested markers - for (let i = chosen.start; i < lines.length; i++) { - // check for an inner start marker for the same region - if (chosen.re.start.exec(lines[i])?.[1] === regionName) { - counter++ - continue - } - // check for an end marker for the same region - const endRegion = chosen.re.end.exec(lines[i])?.[1] - // allow empty region name on the end marker as a fallback - if (endRegion === regionName || endRegion === '') { - if (--counter === 0) return { ...chosen, end: i } - } + + if (returned.length > 0) break } - return null + return returned } export const snippetPlugin = (md: MarkdownItAsync, srcDir: string) => { @@ -175,7 +182,7 @@ export const snippetPlugin = (md: MarkdownItAsync, srcDir: string) => { const [tokens, idx, , { includes }] = args const token = tokens[idx] // @ts-ignore - const [src, regionName] = token.src ?? [] + const [src, region] = token.src ?? [] if (!src) return fence(...args) @@ -183,26 +190,32 @@ export const snippetPlugin = (md: MarkdownItAsync, srcDir: string) => { includes.push(src) } - const isAFile = fs.statSync(src).isFile() - if (!fs.existsSync(src) || !isAFile) { - token.content = isAFile - ? `Code snippet path not found: ${src}` - : `Invalid code snippet option` + if (!fs.existsSync(src)) { + token.content = `Code snippet path not found: ${src}` + token.info = '' + return fence(...args) + } + + if (!fs.statSync(src).isFile()) { + token.content = `Invalid code snippet option` token.info = '' return fence(...args) } let content = fs.readFileSync(src, 'utf8').replace(/\r\n/g, '\n') - if (regionName) { + if (region) { const lines = content.split('\n') - const region = findRegion(lines, regionName) + const regions = findRegions(lines, region) - if (region) { + if (regions.length > 0) { content = dedent( - lines - .slice(region.start, region.end) - .filter((l) => !(region.re.start.test(l) || region.re.end.test(l))) + regions + .flatMap((r) => + lines + .slice(r.start, r.end) + .filter((l) => !(r.re.start.test(l) || r.re.end.test(l))) + ) .join('\n') ) } diff --git a/src/node/utils/processIncludes.ts b/src/node/utils/processIncludes.ts index 20f6fbfff..97d3d987c 100644 --- a/src/node/utils/processIncludes.ts +++ b/src/node/utils/processIncludes.ts @@ -2,7 +2,7 @@ import matter from 'gray-matter' import type { MarkdownItAsync } from 'markdown-it-async' import fs from 'node:fs' import path from 'node:path' -import { findRegion } from '../markdown/plugins/snippet' +import { findRegions } from '../markdown/plugins/snippet' import { slash, type MarkdownEnv } from '../shared' export function processIncludes( @@ -41,9 +41,9 @@ export function processIncludes( if (region) { const [regionName] = region const lines = content.split(/\r?\n/) - let { start, end } = findRegion(lines, regionName.slice(1)) ?? {} + let regions = findRegions(lines, regionName.slice(1)) - if (start === undefined) { + if (regions.length === 0) { // region not found, it might be a header const tokens = md .parse(content, { @@ -57,18 +57,22 @@ export function processIncludes( ) const token = tokens[idx] if (token) { - start = token.map![1] + const start = token.map![1] const level = parseInt(token.tag.slice(1)) + let end = undefined for (let i = idx + 1; i < tokens.length; i++) { if (parseInt(tokens[i].tag.slice(1)) <= level) { end = tokens[i].map![0] break } } + regions.push({ start, end } as any) } } - content = lines.slice(start, end).join('\n') + content = regions + .flatMap((region) => lines.slice(region.start, region.end)) + .join('\n') } if (range) {