diff --git a/__tests__/unit/node/markdown/regions.test.ts b/__tests__/unit/node/markdown/regions.test.ts
new file mode 100644
index 00000000..f14930bc
--- /dev/null
+++ b/__tests__/unit/node/markdown/regions.test.ts
@@ -0,0 +1,529 @@
+import {
+ dedent,
+ findRegions,
+ markers,
+ stripRegionMarkers
+} from 'node/markdown/regions'
+
+const extract = (lines: string[], name: string) =>
+ findRegions(lines, name)
+ .flatMap((r) => lines.slice(r.start, r.end))
+ .join('\n')
+
+describe('node/markdown/regions', () => {
+ describe('dedent', () => {
+ test('keeps lines when 0-level is minimal', () => {
+ expect(dedent(['fn main() {', ' println!("Hello");', '}'])).toEqual([
+ 'fn main() {',
+ ' println!("Hello");',
+ '}'
+ ])
+ })
+
+ test('removes the common minimal indent', () => {
+ expect(dedent([' let a = {', ' value: 42', ' };'])).toEqual([
+ 'let a = {',
+ ' value: 42',
+ '};'
+ ])
+ })
+
+ test('dedents a single line', () => {
+ expect(dedent([' let a = 42;'])).toEqual(['let a = 42;'])
+ })
+
+ test('handles tabs', () => {
+ expect(dedent(['\tlet a = {', '\t\tvalue: 42', '\t};'])).toEqual([
+ 'let a = {',
+ '\tvalue: 42',
+ '};'
+ ])
+ })
+
+ test('ignores blank lines when computing the minimal indent', () => {
+ expect(dedent([' a', '', ' b'])).toEqual(['a', '', 'b'])
+ })
+
+ test('keeps whitespace-only input as is', () => {
+ expect(dedent(['', ' '])).toEqual(['', ' '])
+ })
+ })
+
+ describe('findRegions', () => {
+ it('returns no regions without markers', () => {
+ const lines = ['function foo() {', ' console.log("hello");', '}']
+ expect(findRegions(lines, 'foo')).toHaveLength(0)
+ })
+
+ it('ignores non-matching and prefix-matching region names', () => {
+ const lines = [
+ '// #region regionA',
+ 'some code here',
+ '// #endregion regionA'
+ ]
+ expect(findRegions(lines, 'regionC')).toHaveLength(0)
+ expect(findRegions(lines, 'region')).toHaveLength(0)
+ })
+
+ it('returns no regions for a start marker without a matching end', () => {
+ const lines = [
+ '// #region missingEnd',
+ 'console.log("inside region");',
+ 'console.log("still inside");'
+ ]
+ expect(findRegions(lines, 'missingEnd')).toHaveLength(0)
+ })
+
+ it('returns no regions for an end marker without a preceding start', () => {
+ const lines = [
+ '// #endregion ghostRegion',
+ 'console.log("stray end marker");'
+ ]
+ expect(findRegions(lines, 'ghostRegion')).toHaveLength(0)
+ })
+
+ it('ignores non-marker lines containing the word region', () => {
+ const lines = [
+ 'const region = "region"',
+ '// #region hello',
+ 'const x = 1',
+ '// endregion hello is mentioned here without a comment prefix'
+ ]
+ expect(findRegions(lines, 'hello')).toHaveLength(0)
+ })
+
+ it('detects C#-style markers', () => {
+ const lines = [
+ 'Console.WriteLine("Before region");',
+ '#region hello',
+ 'Console.WriteLine("Hello, World!");',
+ '#endregion hello',
+ 'Console.WriteLine("After region");'
+ ]
+ expect(extract(lines, 'hello')).toBe(
+ 'Console.WriteLine("Hello, World!");'
+ )
+ })
+
+ it('closes a named region with an anonymous end marker', () => {
+ const lines = [
+ '#region hello',
+ 'Console.WriteLine("Hello, World!");',
+ '#endregion',
+ 'Console.WriteLine("After region");'
+ ]
+ expect(extract(lines, 'hello')).toBe(
+ 'Console.WriteLine("Hello, World!");'
+ )
+ })
+
+ it('does not close a region with a differently named end marker', () => {
+ const lines = [
+ '#region hello',
+ 'Console.WriteLine("Hello, World!");',
+ '#endregion world'
+ ]
+ expect(findRegions(lines, 'hello')).toHaveLength(0)
+ })
+
+ it('keeps indentation of indented markers and content', () => {
+ const lines = [
+ ' #region hello',
+ ' Console.WriteLine("Hello, World!");',
+ ' #endregion hello'
+ ]
+ expect(extract(lines, 'hello')).toBe(
+ ' Console.WriteLine("Hello, World!");'
+ )
+ })
+
+ it('detects double-slash markers with and without spacing', () => {
+ const lines = [
+ 'let regexp: RegExp[] = [];',
+ '// #region foo',
+ 'let start = -1;',
+ '//#endregion foo'
+ ]
+ expect(extract(lines, 'foo')).toBe('let start = -1;')
+ })
+
+ it('detects hash-less double-slash markers like VS Code', () => {
+ const lines = ['// region foo', 'let start = -1;', '// endregion foo']
+ expect(extract(lines, 'foo')).toBe('let start = -1;')
+ })
+
+ it('detects CSS-style markers', () => {
+ const lines = [
+ '/* #region foo */',
+ ' padding-left: 15px;',
+ '/*#endregion foo*/'
+ ]
+ expect(extract(lines, 'foo')).toBe(' padding-left: 15px;')
+ })
+
+ it('detects HTML-style markers, with the hash optional', () => {
+ const lines = [
+ '',
+ '
Hello world
',
+ '',
+ '',
+ ' Other
',
+ ''
+ ]
+ expect(extract(lines, 'foo')).toBe(' Hello world
')
+ expect(extract(lines, 'bar')).toBe(' Other
')
+ })
+
+ it('detects Visual Basic-style markers', () => {
+ const lines = [
+ '#Region VBRegion',
+ ' Console.WriteLine("Inside region")',
+ '#End Region VBRegion'
+ ]
+ expect(extract(lines, 'VBRegion')).toBe(
+ ' Console.WriteLine("Inside region")'
+ )
+ })
+
+ it('ignores the quotes around a Visual Basic region name', () => {
+ const lines = [
+ '#Region "Quoted Name"',
+ ' Console.WriteLine("Inside region")',
+ '#End Region',
+ '#Region "Other"',
+ ' Console.WriteLine("Other region")',
+ '#End Region "Other"'
+ ]
+ expect(extract(lines, 'Quoted Name')).toBe(
+ ' Console.WriteLine("Inside region")'
+ )
+ expect(extract(lines, 'Other')).toBe(
+ ' Console.WriteLine("Other region")'
+ )
+ })
+
+ it('detects bat-style markers with case-insensitive REM', () => {
+ const lines = [
+ '@REM #region hello',
+ '@ECHO OFF',
+ '::#endregion hello',
+ 'echo out',
+ 'rem #region hello',
+ 'exit 0',
+ 'Rem #endregion hello'
+ ]
+ expect(extract(lines, 'hello')).toBe('@ECHO OFF\nexit 0')
+ })
+
+ it('detects dash-dash markers, which require the hash', () => {
+ const lines = [
+ '-- #region foo',
+ 'select 1;',
+ '-- #endregion foo',
+ '--#region bar',
+ 'select 2;',
+ '--#endregion bar'
+ ]
+ expect(extract(lines, 'foo')).toBe('select 1;')
+ expect(extract(lines, 'bar')).toBe('select 2;')
+
+ // prose comments are not markers
+ expect(findRegions(['-- region of interest', 'select 1;'], '')).toEqual(
+ []
+ )
+ })
+
+ it('detects pragma markers, allowing space after the hash', () => {
+ const lines = [
+ '#pragma region foo',
+ 'int a = 1;',
+ '#pragma endregion foo',
+ '# pragma region bar',
+ 'int b = 2;',
+ '# pragma endregion bar'
+ ]
+ expect(extract(lines, 'foo')).toBe('int a = 1;')
+ expect(extract(lines, 'bar')).toBe('int b = 2;')
+ })
+
+ it('detects paren-star markers', () => {
+ const lines = ['(* #region foo *)', 'let a = 1', '(* #endregion foo *)']
+ expect(extract(lines, 'foo')).toBe('let a = 1')
+ })
+
+ it('detects shell and python style hash markers', () => {
+ const lines = [
+ '# region hello',
+ 'echo "inside"',
+ '#\tendregion hello',
+ '# #region hello',
+ 'exit 0',
+ '# #endregion'
+ ]
+ expect(extract(lines, 'hello')).toBe('echo "inside"\nexit 0')
+ })
+
+ it('detects JSON key-style markers with two or more slashes', () => {
+ const lines = [
+ '{',
+ ' "// #region hello": "",',
+ ' "one": true,',
+ ' "//#endregion hello": "",',
+ ' "two": false,',
+ ' "/// #region hello": "",',
+ ' "three": true,',
+ ' "//// #endregion hello": ""',
+ '}'
+ ]
+ expect(extract(lines, 'hello')).toBe(' "one": true,\n "three": true,')
+ })
+
+ it('concatenates multiple same-named regions in document order', () => {
+ const lines = [
+ '// #region hello',
+ 'first region content',
+ '// #endregion hello',
+ 'other content',
+ '// #region hello',
+ 'second region content',
+ '// #endregion',
+ '// #region hello',
+ 'third region content',
+ '// #endregion hello'
+ ]
+ expect(extract(lines, 'hello')).toBe(
+ 'first region content\nsecond region content\nthird region content'
+ )
+ })
+
+ it('merges same-named regions across different comment styles', () => {
+ const lines = [
+ '',
+ ' ',
+ ' template part
',
+ ' ',
+ '',
+ '',
+ ''
+ ]
+ const regions = findRegions(lines, 'shared')
+ expect(regions).toHaveLength(4)
+ expect(extract(lines, 'shared')).toBe(
+ [
+ ' template part
',
+ 'const scriptPart = true',
+ 'console.log(scriptPart)',
+ '.style-part {}'
+ ].join('\n')
+ )
+ })
+
+ it('tracks nesting of same-named regions across styles', () => {
+ const lines = [
+ '// #region foo',
+ "console.log('double-slash only');",
+ '/* #region foo */',
+ "console.log('nested in both');",
+ '// #endregion foo',
+ "console.log('still in outer');",
+ '/* #endregion foo */',
+ "console.log('outside');"
+ ]
+ const regions = findRegions(lines, 'foo')
+ expect(regions).toHaveLength(1)
+ expect(regions[0]).toMatchObject({ start: 1, end: 6 })
+ })
+
+ it('closes the innermost open region with an anonymous end marker', () => {
+ const lines = [
+ '',
+ '',
+ '',
+ ''
+ ]
+ expect(extract(lines, 'demo')).toBe(
+ [
+ '',
+ ''
+ ].join('\n')
+ )
+ expect(extract(lines, 'state')).toBe('const count = ref(0)')
+ })
+
+ it('is not closed by an anonymous end marker of a nested region in a fence', () => {
+ const lines = [
+ '',
+ 'Use markers like this:',
+ '',
+ '```js',
+ '// #region foo',
+ 'const a = 1',
+ '// #endregion',
+ '```',
+ ''
+ ]
+ expect(extract(lines, 'sample')).toBe(
+ [
+ 'Use markers like this:',
+ '',
+ '```js',
+ '// #region foo',
+ 'const a = 1',
+ '// #endregion',
+ '```'
+ ].join('\n')
+ )
+ })
+
+ it('is closed by an anonymous end marker of its own style', () => {
+ // a region documenting region syntax: the marker inside the fence is
+ // never closed, and the anonymous end marker belongs to the region
+ const lines = [
+ '',
+ 'How regions work:',
+ '',
+ '```js',
+ '// #region example',
+ 'const a = 1',
+ '```',
+ ''
+ ]
+ expect(extract(lines, 'real')).toBe(
+ [
+ 'How regions work:',
+ '',
+ '```js',
+ '// #region example',
+ 'const a = 1',
+ '```'
+ ].join('\n')
+ )
+ })
+
+ it('ignores an anonymous end marker with nothing open in its style', () => {
+ const lines = [
+ '',
+ '// #endregion',
+ 'body',
+ ''
+ ]
+ expect(extract(lines, 'real')).toBe('// #endregion\nbody')
+ })
+
+ it('tolerates an unclosed region nested inside the requested one', () => {
+ const lines = [
+ '// #region outer',
+ 'const a = 1',
+ '// #region inner',
+ 'const b = 2',
+ '// #endregion outer'
+ ]
+ expect(extract(lines, 'outer')).toBe(
+ ['const a = 1', '// #region inner', 'const b = 2'].join('\n')
+ )
+ })
+
+ it('keeps differently named nested regions verbatim', () => {
+ const lines = [
+ '// #region foo',
+ "console.log('line before nested');",
+ '// #region bar',
+ "console.log('nested content');",
+ '// #endregion bar',
+ '// #endregion foo'
+ ]
+ expect(extract(lines, 'foo')).toBe(
+ [
+ "console.log('line before nested');",
+ '// #region bar',
+ "console.log('nested content');",
+ '// #endregion bar'
+ ].join('\n')
+ )
+ })
+
+ it('supports special characters in region names', () => {
+ const lines = [
+ '// #region complex-name_123',
+ 'const x = 1;',
+ '// #endregion complex-name_123',
+ '// #region my.region',
+ 'const y = 2;',
+ '// #endregion my.region'
+ ]
+ expect(extract(lines, 'complex-name_123')).toBe('const x = 1;')
+ expect(extract(lines, 'my.region')).toBe('const y = 2;')
+ })
+
+ it('reports the marker style that opened each region', () => {
+ const lines = ['// #region foo', 'const x = 1;', '// #endregion foo']
+ const [region] = findRegions(lines, 'foo')
+ expect(markers).toContain(region.marker)
+ expect(region.marker.start.test('// #region other')).toBe(true)
+ expect(region.marker.start.test('# region other')).toBe(false)
+ })
+ })
+
+ describe('stripRegionMarkers', () => {
+ it('strips marker lines of every style and name by default', () => {
+ const lines = [
+ '// #region name',
+ 'const a = 0;',
+ '/* #region HELLO */',
+ 'const b = 0;',
+ '//\t#endregion complex_name-123',
+ 'const c = 0;',
+ '/*#endregion*/'
+ ]
+ expect(stripRegionMarkers(lines)).toEqual([
+ 'const a = 0;',
+ 'const b = 0;',
+ 'const c = 0;'
+ ])
+ })
+
+ it('keeps non-marker lines mentioning regions', () => {
+ const lines = ['const region = "region"', 'let a = 1']
+ expect(stripRegionMarkers(lines)).toEqual(lines)
+ })
+
+ it('strips only the given marker styles when provided', () => {
+ const lines = [
+ '// #region a',
+ 'const x = 1',
+ '// #endregion a',
+ '# region b',
+ 'const y = 2',
+ '# endregion b'
+ ]
+ const [region] = findRegions(lines, 'a')
+ expect(stripRegionMarkers(lines, [region.marker])).toEqual([
+ 'const x = 1',
+ '# region b',
+ 'const y = 2',
+ '# endregion b'
+ ])
+ })
+ })
+})
diff --git a/src/node/markdown/regions.ts b/src/node/markdown/regions.ts
new file mode 100644
index 00000000..8cc130e8
--- /dev/null
+++ b/src/node/markdown/regions.ts
@@ -0,0 +1,159 @@
+export interface RegionMarker {
+ start: RegExp
+ end: RegExp
+}
+
+export interface Region {
+ start: number
+ end: number
+ marker: RegionMarker
+}
+
+// cheap pre-filter so the marker regexes only run on candidate lines
+const maybeMarkerRE = /region/i
+const quotedRE = /^"(.*)"$/
+
+// visual basic names its regions `#Region "Name"`, so the quotes are part of
+// the captured name and have to be dropped to make it referenceable
+function unquote(name: string) {
+ return quotedRE.exec(name)?.[1] ?? name
+}
+
+/**
+ * Region marker styles, derived from the `folding.markers` definitions VS Code
+ * ships per language and merged per comment syntax, keeping the hash optional
+ * where at least one of the merged languages makes it optional. Since a
+ * markdown renderer cannot know the language of an imported file, every style
+ * is tried on every file, which makes this a superset: everything an editor
+ * folds is extracted, but not the reverse. Note that a language service can
+ * be stricter than the marker it ships - TypeScript, for one, requires the
+ * hash that the ts/js marker makes optional.
+ */
+export const markers: RegionMarker[] = [
+ // line comments: js, ts, go, rust, java and json with comments, whose
+ // markers make the hash optional, plus sql and bat, which require it
+ {
+ start: /^\s*(?:\/\/\s*#?|(?:--|::|@?[rR][eE][mM])\s*#)region\b\s*(.*?)\s*$/,
+ end: /^\s*(?:\/\/\s*#?|(?:--|::|@?[rR][eE][mM])\s*#)endregion\b\s*(.*?)\s*$/
+ },
+ // hash comments: c# and coffeescript (`#region`), python, yaml and shell
+ // (`# region`, and `# #region` in shell), visual basic (`#Region` closed by
+ // `#End Region`), powershell (`#EndRegion`) and c/c++ (`#pragma region`)
+ {
+ start: /^\s*#\s*(?:#\s*|pragma\s+)?[rR]egion\b\s*(.*?)\s*$/,
+ end: /^\s*#\s*(?:#\s*|pragma\s+)?[eE]nd ?[rR]egion\b\s*(.*?)\s*$/
+ },
+ // markdown (hash optional) and html (hash required), vue templates
+ {
+ start: /^\s*/,
+ end: /^\s*/
+ },
+ // css, less and scss
+ {
+ start: /^\s*\/\*\s*#region\b\s*(.*?)\s*\*\//,
+ end: /^\s*\/\*\s*#endregion\b\s*(.*?)\s*\*\//
+ },
+ // f# block comments; its `// #region` form is covered by the line comments
+ // above
+ {
+ start: /^\s*\(\*\s*#region\b\s*(.*?)\s*\*\)/,
+ end: /^\s*\(\*\s*#endregion\b\s*(.*?)\s*\*\)/
+ },
+ // json keys, e.g. `"// #region name": "",` - VS Code cannot fold these,
+ // since plain json has no comments to put a marker in
+ {
+ start: /^\s*"\/{2,}\s*#region\b\s*(.*?)":\s*"",?\s*$/,
+ end: /^\s*"\/{2,}\s*#endregion\b\s*(.*?)":\s*"",?\s*$/
+ }
+]
+
+/**
+ * Finds all regions with the given name, in document order. Matching is
+ * name-based across all marker styles, so a region opened by one comment
+ * style may be closed by another - useful in files mixing languages, like
+ * Vue SFCs. All regions are tracked, not just the requested ones, so that an
+ * end marker without a name closes the innermost open region rather than the
+ * requested one. Nested regions with the requested name yield the outermost
+ * span.
+ */
+export function findRegions(lines: string[], name: string): Region[] {
+ const regions: Region[] = []
+ const open: { name: string; start: number; marker: RegionMarker }[] = []
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i]
+ if (!maybeMarkerRE.test(line)) continue
+
+ let isStart = false
+ for (const marker of markers) {
+ const startName = marker.start.exec(line)?.[1]
+ if (startName != null) {
+ open.push({ name: unquote(startName), start: i + 1, marker })
+ isStart = true
+ break
+ }
+ }
+ if (isStart || open.length === 0) continue
+
+ for (const marker of markers) {
+ const rawEndName = marker.end.exec(line)?.[1]
+ if (rawEndName == null) continue
+ const endName = unquote(rawEndName)
+
+ // a named end marker closes the innermost region it names, whichever
+ // comment style opened it, while an unnamed one closes the innermost
+ // region opened in its own comment style - so it can neither be
+ // captured by a region of another language nested inside, nor close
+ // one that was left open there
+ const index = endName
+ ? open.findLastIndex((r) => r.name === endName)
+ : open.findLastIndex((r) => r.marker === marker)
+ if (index === -1) continue
+
+ const [closed] = open.splice(index, open.length - index)
+ if (closed.name === name && !open.some((r) => r.name === name)) {
+ regions.push({ start: closed.start, end: i, marker: closed.marker })
+ }
+ break
+ }
+ }
+
+ return regions
+}
+
+/**
+ * Removes region marker lines of the given styles (all styles by default),
+ * regardless of region name.
+ */
+export function stripRegionMarkers(
+ lines: string[],
+ styles: RegionMarker[] = markers
+): string[] {
+ return lines.filter(
+ (line) =>
+ !maybeMarkerRE.test(line) ||
+ !styles.some((m) => m.start.test(line) || m.end.test(line))
+ )
+}
+
+/**
+ * Removes the common minimal indentation (spaces and tabs counted per
+ * character) from the given lines. Whitespace-only lines don't constrain
+ * the minimum.
+ */
+export function dedent(lines: string[]): string[] {
+ let minIndent = Infinity
+
+ for (const line of lines) {
+ for (let i = 0; i < line.length; i++) {
+ if (line[i] !== ' ' && line[i] !== '\t') {
+ minIndent = Math.min(i, minIndent)
+ break
+ }
+ }
+ if (minIndent === 0) break
+ }
+
+ if (minIndent === Infinity || minIndent === 0) return lines
+ return lines.map((line) => line.slice(minIndent))
+}