diff --git a/__tests__/unit/node/markdown/plugins/snippet.test.ts b/__tests__/unit/node/markdown/plugins/snippet.test.ts index aa940784..15f72fc5 100644 --- a/__tests__/unit/node/markdown/plugins/snippet.test.ts +++ b/__tests__/unit/node/markdown/plugins/snippet.test.ts @@ -1,7 +1,8 @@ import { dedent, - findRegion, - rawPathToToken + findRegions, + rawPathToToken, + stripMarkers } from 'node/markdown/plugins/snippet' import { expect } from 'vitest' @@ -39,6 +40,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', () => { @@ -97,6 +99,10 @@ describe('node/markdown/plugins/snippet', () => { };" `) }) + + test('empty string remains empty', () => { + expect(dedent('')).toBe('') + }) }) describe('rawPathToToken', () => { @@ -106,9 +112,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 +128,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 +156,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 +179,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 +202,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,18 +390,102 @@ 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) } }) + + 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) + if (result) { + 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;') + } + }) + }) + + describe('stripRegionMarkers', () => { + it('removes #region and #endregion lines', () => { + const src = [ + '// #region A', + '// #region B', + 'console.log("Hello, World!");', + '// #endregion B', + '// #endregion A' + ] + expect(stripMarkers(src, true)).toBe('console.log("Hello, World!");') + }) + + it('does not remove any marker if stripRegionMarkers is false', () => { + const src = [ + '// #region A', + '// #region B', + 'console.log("Hello, World!");', + '// #endregion B', + '// #endregion A' + ] + expect(stripMarkers(src, false)).toBe(src.join('\n')) + }) + + it('removes region markers for various syntaxes', () => { + const src = [ + '', + '
hi
', + '', + '/* #region css */', + 'body {}', + '/* #endregion css */', + '#pragma region cpp', + 'int main(){}', + '#pragma endregion cpp', + '::#region bat', + 'ECHO ON', + 'REM #endregion bat' + ] + const out = stripMarkers(src, true) + expect(out).not.toContain('#region') + expect(out).not.toContain('#endregion') + expect(out).toContain('
hi
') + expect(out).toContain('body {}') + expect(out).toContain('int main(){}') + expect(out).toContain('ECHO ON') + }) + + it('removes markers even if indented or with extra spaces', () => { + const src = [ + ' // #region spaced ', + '\t/* #region */', + 'code();', + ' // #endregion spaced', + '/* #endregion */' + ] + const out = stripMarkers(src, true) + expect(out.trim()).toBe('code();') + }) }) }) diff --git a/docs/en/guide/markdown.md b/docs/en/guide/markdown.md index 7249fac2..47ed2b76 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 9c7faaeb..fd087862 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/markdown.ts b/src/node/markdown/markdown.ts index fbe92b30..de817b7f 100644 --- a/src/node/markdown/markdown.ts +++ b/src/node/markdown/markdown.ts @@ -140,6 +140,11 @@ export interface MarkdownOptions extends MarkdownItAsyncOptions { * @default 'Copy Code' */ codeCopyButtonTitle?: string + /** + * Remove all #region markers when including snippets + * @default false + */ + stripMarkersFromSnippets?: boolean /* ==================== Markdown It Plugins ==================== */ @@ -274,7 +279,7 @@ export async function createMarkdownRenderer( codeCopyButtonTitle, languageLabel: options.languageLabel }) - snippetPlugin(md, srcDir) + snippetPlugin(md, srcDir, options.stripMarkersFromSnippets) containerPlugin(md, options.container) imagePlugin(md, options.image) linkPlugin( diff --git a/src/node/markdown/plugins/snippet.ts b/src/node/markdown/plugins/snippet.ts index 71e5e76c..87ba8da1 100644 --- a/src/node/markdown/plugins/snippet.ts +++ b/src/node/markdown/plugins/snippet.ts @@ -86,40 +86,63 @@ 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 function stripMarkers(lines: string[], stripMarkers: boolean): string { + if (!stripMarkers) return lines.join('\n') + return lines + .filter((l) => { + for (const m of markers) { + if (m.start.test(l) || m.end.test(l)) return false + } + return true + }) + .join('\n') } -export const snippetPlugin = (md: MarkdownItAsync, srcDir: string) => { +export const snippetPlugin = ( + md: MarkdownItAsync, + srcDir: string, + stripMarkersFromSnippets = false +) => { const parser: RuleBlock = (state, startLine, endLine, silent) => { const CH = '<'.charCodeAt(0) const pos = state.bMarks[startLine] + state.tShift[startLine] @@ -175,7 +198,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,29 +206,42 @@ 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))) - .join('\n') + stripMarkers( + regions.flatMap((r) => + lines + .slice(r.start, r.end) + .filter((l) => !(r.re.start.test(l) || r.re.end.test(l))) + ), + stripMarkersFromSnippets + ) ) + } else { + token.content = `No region #${region} found in path: ${src}` + token.info = '' + return fence(...args) } + } else { + content = stripMarkers(content.split('\n'), stripMarkersFromSnippets) } token.content = content diff --git a/src/node/utils/processIncludes.ts b/src/node/utils/processIncludes.ts index aa98ccbd..bf4bcf22 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,26 @@ 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') + if (regions.length > 0) { + content = regions + .flatMap((region) => lines.slice(region.start, region.end)) + .join('\n') + } else { + content = `No region or heading #${region} found in path: ${includePath}` + } } if (range) { @@ -97,11 +105,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