From 5d1dfc086460bd95fd33bfacca287ef3618bc229 Mon Sep 17 00:00:00 2001 From: Miroma Date: Mon, 3 Nov 2025 09:49:48 +0100 Subject: [PATCH] feat(markdown): include all regions with 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 including files, which caused confusion. Since this feature has been requested by VitePress directly, I don't think there's any point in trying to fix the plugin any further. fix #3690 [^1]: [^2]: via [^3]: --- .../node/markdown/plugins/snippet.test.ts | 241 ++++++++++++------ docs/en/guide/markdown.md | 8 +- docs/snippets/snippet-with-region.js | 12 +- src/node/markdown/plugins/snippet.ts | 87 ++++--- src/node/utils/processIncludes.ts | 18 +- 5 files changed, 234 insertions(+), 132 deletions(-) diff --git a/__tests__/unit/node/markdown/plugins/snippet.test.ts b/__tests__/unit/node/markdown/plugins/snippet.test.ts index aa940784a0f3..c285c833dd79 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' import { expect } from 'vitest' @@ -106,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', () => { @@ -117,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', () => { @@ -145,12 +150,18 @@ describe('node/markdown/plugins/snippet', () => { '#endregion hello', 'Console.WriteLine("After region");' ] - const result = findRegion(lines, 'hello') - expect(result).not.toBeNull() + const result = findRegions(lines, 'hello') + expect(result).toHaveLength(1) if (result) { - expect(lines.slice(result.start, result.end).join('\n')).toBe( - 'Console.WriteLine("Hello, World!");' - ) + 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!");') } }) @@ -162,12 +173,18 @@ describe('node/markdown/plugins/snippet', () => { '#endregion', 'Console.WriteLine("After region");' ] - const result = findRegion(lines, 'hello') - expect(result).not.toBeNull() + const result = findRegions(lines, 'hello') + expect(result).toHaveLength(1) if (result) { - expect(lines.slice(result.start, result.end).join('\n')).toBe( - 'Console.WriteLine("Hello, World!");' - ) + 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!");') } }) @@ -179,124 +196,182 @@ describe('node/markdown/plugins/snippet', () => { ' #endregion hello', ' Console.WriteLine("After region");' ] - const result = findRegion(lines, 'hello') - expect(result).not.toBeNull() + const result = findRegions(lines, 'hello') + expect(result).toHaveLength(1) if (result) { - expect(lines.slice(result.start, result.end).join('\n')).toBe( - ' Console.WriteLine("Hello, World!");' - ) + 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() + const result = findRegions(lines, 'hello') + expect(result).toHaveLength(1) if (result) { - expect(lines.slice(result.start, result.end).join('\n')).toBe( - 'let start = -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() + const result = findRegions(lines, 'hello') + expect(result).toHaveLength(1) if (result) { - expect(lines.slice(result.start, result.end).join('\n')).toBe( - ' padding-left: 15px;' - ) + 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() + const result = findRegions(lines, 'hello') + expect(result).toHaveLength(1) if (result) { - expect(lines.slice(result.start, result.end).join('\n')).toBe( - '

Hello world

' - ) + 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() + const result = findRegions(lines, 'hello') + expect(result).toHaveLength(1) if (result) { - expect(lines.slice(result.start, result.end).join('\n')).toBe( - ' Console.WriteLine("Inside region")' - ) + 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() + const lines = ['::#region hello', '@ECHO OFF', 'REM #endregion hello'] + const result = findRegions(lines, 'hello') + expect(result).toHaveLength(1) if (result) { - expect(lines.slice(result.start, result.end).join('\n')).toBe( - 'echo off' - ) + 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() + const result = findRegions(lines, 'hello') + expect(result).toHaveLength(1) if (result) { - expect(lines.slice(result.start, result.end).join('\n')).toBe( - 'int a = 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() + const result = findRegions(lines, 'hello') + expect(result).toHaveLength(3) if (result) { - expect(lines.slice(result.start, result.end).join('\n')).toBe( - 'first region content' - ) + 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) } }) @@ -309,15 +384,19 @@ describe('node/markdown/plugins/snippet', () => { '// #endregion bar', '// #endregion foo' ] - const result = findRegion(lines, 'foo') - expect(result).not.toBeNull() + const result = findRegions(lines, 'foo') + expect(result).toHaveLength(1) if (result) { - const extracted = lines.slice(result.start, result.end).join('\n') + 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');", - '// #region bar', - "console.log('nested content');", - '// #endregion bar' + "console.log('nested content');" ].join('\n') expect(extracted).toBe(expected) } diff --git a/docs/en/guide/markdown.md b/docs/en/guide/markdown.md index e1be3e6b366f..7ccf94d4fae3 100644 --- a/docs/en/guide/markdown.md +++ b/docs/en/guide/markdown.md @@ -656,12 +656,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** @@ -670,7 +670,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: @@ -856,7 +856,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 9c7faaebca19..fd0878627433 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 71e5e76c9c16..756da390d76e 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) => { @@ -183,11 +190,14 @@ 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) } @@ -196,13 +206,16 @@ export const snippetPlugin = (md: MarkdownItAsync, srcDir: string) => { if (regionName) { const lines = content.split('\n') - const region = findRegion(lines, regionName) + const regions = findRegions(lines, regionName) - 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 aa98ccbd5c9e..c13c2809573f 100644 --- a/src/node/utils/processIncludes.ts +++ b/src/node/utils/processIncludes.ts @@ -3,7 +3,7 @@ import matter from 'gray-matter' import type { MarkdownItAsync } from 'markdown-it-async' import path from 'node:path' import c from 'picocolors' -import { findRegion } from '../markdown/plugins/snippet' +import { findRegions } from '../markdown/plugins/snippet' import { slash, type MarkdownEnv } from '../shared' export function processIncludes( @@ -42,9 +42,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, { @@ -58,18 +58,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) { @@ -97,11 +101,9 @@ export function processIncludes( includes, cleanUrls ) - - // } catch (error) { if (process.env.DEBUG) { - process.stderr.write(c.yellow(`\nInclude file not found: ${m1}`)) + process.stderr.write(c.yellow(`Include file not found: ${m1}\n`)) } return m // silently ignore error if file is not present