diff --git a/packages/transformers/src/index.ts b/packages/transformers/src/index.ts index 7bc630870..bca62b669 100644 --- a/packages/transformers/src/index.ts +++ b/packages/transformers/src/index.ts @@ -6,6 +6,7 @@ export * from './transformers/meta-highlight-word' export * from './transformers/notation-diff' export * from './transformers/notation-error-level' export * from './transformers/notation-focus' +export * from './transformers/notation-fold' export * from './transformers/notation-highlight' export * from './transformers/notation-highlight-word' export * from './transformers/notation-map' diff --git a/packages/transformers/src/transformers/notation-fold.ts b/packages/transformers/src/transformers/notation-fold.ts new file mode 100644 index 000000000..16c62956c --- /dev/null +++ b/packages/transformers/src/transformers/notation-fold.ts @@ -0,0 +1,160 @@ +import type { ShikiTransformer } from '@shikijs/types' +import type { Element, Text } from 'hast' +import { parseComments } from '../shared/parse-comments' + +export interface TransformerNotationFoldOptions { + /** + * Class for the start line of a fold + */ + classFoldStart?: string + /** + * Class for the end line of a fold + */ + classFoldEnd?: string + /** + * Class for the content lines of a fold (excluding start and end) + */ + classFoldContent?: string +} + +export function transformerNotationFold( + options: TransformerNotationFoldOptions = {}, +): ShikiTransformer { + const { + classFoldStart = 'fold-start', + classFoldEnd = 'fold-end', + classFoldContent = 'fold-content', + } = options + + return { + name: '@shikijs/transformers:notation-fold', + code(code) { + const lines = code.children.filter(i => i.type === 'element') as Element[] + const linesToRemove: (Element | Text)[] = [] + + code.data ??= {} as any + const data = code.data as { + _shiki_notation?: ReturnType + } + + data._shiki_notation ??= parseComments(lines, ['jsx', 'tsx'].includes(this.options.lang), 'v3') + const parsed = data._shiki_notation + + const stack: { line: Element, type: 'shiki' | 'region' }[] = [] + + for (const comment of parsed) { + if (comment.info[1].length === 0) + continue + + let text = comment.info[1] + let isStart = false + let isEnd = false + let isRegion = false + + // Check shiki syntax + if (text.match(/\[!code fold:start(:.*)?\]/i)) { + isStart = true + // Remove marker + text = text.replace(/\[!code fold:start(:.*)?\]/i, '').trim() + comment.info[1] = text + } + else if (text.match(/\[!code fold:end\]/i)) { + isEnd = true + // Remove marker + text = text.replace(/\[!code fold:end\]/i, '').trim() + comment.info[1] = text + } + // Check region syntax + else if (text.match(/^\s*#region\b/)) { + isStart = true + isRegion = true + } + else if (text.match(/^\s*#endregion\b/)) { + isEnd = true + isRegion = true + } + + if (!isStart && !isEnd) + continue + + // Update the AST if we modified the text + if (isStart || isEnd) { + const head = comment.token.children[0] + if (head.type === 'text') { + head.value = text + } + } + + const line = comment.line + // Find line index in lines array for logical navigation + const lineIdx = lines.indexOf(line) + + // If text is empty (and it wasn't a region), remove the line + if (text.length === 0 && !isRegion) { + linesToRemove.push(line) + } + + if (isStart) { + if (isRegion) { + stack.push({ line, type: 'region' }) + this.addClassToHast(line, classFoldStart) + } + else { + // Shiki syntax + // Target next line + const nextLineIdx = lineIdx + 1 + if (nextLineIdx < lines.length) { + const nextLine = lines[nextLineIdx] + stack.push({ line: nextLine, type: 'shiki' }) + this.addClassToHast(nextLine, classFoldStart) + } + } + } + else if (isEnd) { + const start = stack.pop() + if (start) { + let endLine: Element | undefined + + if (isRegion) { + endLine = line + this.addClassToHast(endLine, classFoldEnd) + } + else { + // Shiki syntax + // Target PREVIOUS line + const prevLineIdx = lineIdx - 1 + if (prevLineIdx >= 0) { + endLine = lines[prevLineIdx] + this.addClassToHast(endLine, classFoldEnd) + } + } + + // Mark content lines + if (start && endLine) { + const startIdx = lines.indexOf(start.line) + const endIdx = lines.indexOf(endLine) + + if (startIdx !== -1 && endIdx !== -1 && startIdx < endIdx) { + for (let i = startIdx + 1; i < endIdx; i++) { + this.addClassToHast(lines[i], classFoldContent) + } + } + } + } + } + } + + // Remove lines + for (const line of linesToRemove) { + const index = code.children.indexOf(line) + if (index !== -1) { + const nextLine = code.children[index + 1] + let removeLength = 1 + if (nextLine?.type === 'text' && nextLine?.value === '\n') + removeLength = 2 + code.children.splice(index, removeLength) + } + } + }, + } +} diff --git a/packages/transformers/test/fixtures/fold/region.js b/packages/transformers/test/fixtures/fold/region.js new file mode 100644 index 000000000..4bf89b39e --- /dev/null +++ b/packages/transformers/test/fixtures/fold/region.js @@ -0,0 +1,10 @@ +// #region variables +let a = 1 + +// #region variable b +let b = 1 +// #endregion + +// #endregion + +console.log(a, b) diff --git a/packages/transformers/test/fixtures/fold/region.output.html b/packages/transformers/test/fixtures/fold/region.output.html new file mode 100644 index 000000000..2308d0e6e --- /dev/null +++ b/packages/transformers/test/fixtures/fold/region.output.html @@ -0,0 +1,10 @@ +
 #region variables
+let a = 1
+
+ #region variable b
+let b = 1
+ #endregion
+
+ #endregion
+
+console.log(a, b)
\ No newline at end of file diff --git a/packages/transformers/test/fixtures/fold/shiki.js b/packages/transformers/test/fixtures/fold/shiki.js new file mode 100644 index 000000000..7a335adfa --- /dev/null +++ b/packages/transformers/test/fixtures/fold/shiki.js @@ -0,0 +1,10 @@ +// [!code fold:start] variables +let a = 1 + +// [!code fold:start] variable b +let b = 1 +// [!code fold:end] + +// [!code fold:end] + +console.log(a, b) diff --git a/packages/transformers/test/fixtures/fold/shiki.output.html b/packages/transformers/test/fixtures/fold/shiki.output.html new file mode 100644 index 000000000..af044ea63 --- /dev/null +++ b/packages/transformers/test/fixtures/fold/shiki.output.html @@ -0,0 +1,8 @@ +
variables
+let a = 1
+
+variable b
+let b = 1
+
+
+console.log(a, b)
\ No newline at end of file diff --git a/packages/transformers/test/fold.test.ts b/packages/transformers/test/fold.test.ts new file mode 100644 index 000000000..0019daf66 --- /dev/null +++ b/packages/transformers/test/fold.test.ts @@ -0,0 +1,55 @@ +import { codeToHtml } from 'shiki' +import { describe, expect, it } from 'vitest' +import { transformerNotationFold } from '../src' + +describe('transformerNotationFold', () => { + it('shiki', async () => { + const code = ` +// [!code fold:start] variables +let a = 1 + +// [!code fold:start] variable b +let b = 1 +// [!code fold:end] + +// [!code fold:end] + +console.log(a, b) + `.trim() + + const html = await codeToHtml(code, { + lang: 'js', + theme: 'github-dark', + transformers: [ + transformerNotationFold(), + ], + }) + + await expect(html).toMatchFileSnapshot('./fixtures/fold/shiki.output.html') + }) + + it('region', async () => { + const code = ` +// #region variables +let a = 1 + +// #region variable b +let b = 1 +// #endregion + +// #endregion + +console.log(a, b) + `.trim() + + const html = await codeToHtml(code, { + lang: 'js', + theme: 'github-dark', + transformers: [ + transformerNotationFold(), + ], + }) + + await expect(html).toMatchFileSnapshot('./fixtures/fold/region.output.html') + }) +}) diff --git a/test/exports/@shikijs/transformers.yaml b/test/exports/@shikijs/transformers.yaml index c4bf27d20..83d0c22d1 100644 --- a/test/exports/@shikijs/transformers.yaml +++ b/test/exports/@shikijs/transformers.yaml @@ -9,6 +9,7 @@ transformerNotationDiff: function transformerNotationErrorLevel: function transformerNotationFocus: function + transformerNotationFold: function transformerNotationHighlight: function transformerNotationMap: function transformerNotationWordHighlight: function