diff --git a/packages/core/src/transformer-decorations.ts b/packages/core/src/transformer-decorations.ts index 577e86546..083b4c50c 100644 --- a/packages/core/src/transformer-decorations.ts +++ b/packages/core/src/transformer-decorations.ts @@ -66,7 +66,10 @@ export function transformerDecorations(): ShikiTransformer { end: normalizePosition(d.end), })) - verifyIntersections(decorations) + // Only verify intersections in 'wrap' mode (default) + // In 'flatten' mode, skip verification to allow overlapping decorations + if (shiki.options.decorationsStyle !== 'flatten') + verifyIntersections(decorations) map.set(shiki.meta, { decorations, @@ -100,7 +103,7 @@ export function transformerDecorations(): ShikiTransformer { function applyLineSection(line: number, start: number, end: number, decoration: DecorationItem): void { const lineEl = lines[line] - let text = '' + let startIndex = -1 let endIndex = -1 @@ -112,12 +115,44 @@ export function transformerDecorations(): ShikiTransformer { endIndex = lineEl.children.length if (startIndex === -1 || endIndex === -1) { - for (let i = 0; i < lineEl.children.length; i++) { - text += stringify(lineEl.children[i]) - if (startIndex === -1 && text.length === start) - startIndex = i + 1 - if (endIndex === -1 && text.length === end) - endIndex = i + 1 + let cumLength = 0 + let i = 0 + while (i < lineEl.children.length) { + const child = lineEl.children[i] + const childLength = stringify(child).length + + if (startIndex === -1) { + if (cumLength + childLength === start) { + startIndex = i + 1 + } + else if (cumLength < start && cumLength + childLength > start) { + const offset = start - cumLength + const [head, tail] = splitElement(child, offset) + lineEl.children.splice(i, 1, head, tail) + startIndex = i + 1 + cumLength += stringify(head).length + i++ + continue + } + } + + if (endIndex === -1) { + if (cumLength + childLength === end) { + endIndex = i + 1 + } + else if (cumLength < end && cumLength + childLength > end) { + const offset = end - cumLength + const [head, tail] = splitElement(child, offset) + lineEl.children.splice(i, 1, head, tail) + endIndex = i + 1 + cumLength += stringify(head).length + i++ + continue + } + } + + cumLength += childLength + i++ } } @@ -138,15 +173,34 @@ export function transformerDecorations(): ShikiTransformer { } // Create a wrapper for the decoration else { - const wrapper: Element = { - type: 'element', - tagName: 'span', - properties: {}, - children, + if (children.length === 0) { + const wrapper: Element = { + type: 'element', + tagName: 'span', + properties: {}, + children: [], + } + applyDecoration(wrapper, decoration, 'wrapper') + lineEl.children.splice(startIndex, 0, wrapper) + } + else { + const newChildren: ElementContent[] = [] + for (const child of children) { + if (child.type === 'element') { + newChildren.push(applyDecoration(child, decoration, 'token')) + } + else if (child.type === 'text') { + const wrapper: Element = { + type: 'element', + tagName: 'span', + properties: {}, + children: [child], + } + newChildren.push(applyDecoration(wrapper, decoration, 'token')) + } + } + lineEl.children.splice(startIndex, children.length, ...newChildren) } - - applyDecoration(wrapper, decoration, 'wrapper') - lineEl.children.splice(startIndex, children.length, wrapper) } } @@ -226,3 +280,30 @@ function stringify(el: ElementContent): string { return el.children.map(stringify).join('') return '' } + +function splitElement(element: ElementContent, offset: number): [ElementContent, ElementContent] { + if (element.type === 'text') { + return [ + { type: 'text', value: element.value.slice(0, offset) }, + { type: 'text', value: element.value.slice(offset) }, + ] + } + if (element.type === 'element') { + let cumLength = 0 + for (let i = 0; i < element.children.length; i++) { + const child = element.children[i] + const childLength = stringify(child).length + if (cumLength + childLength > offset) { + const [head, tail] = splitElement(child, offset - cumLength) + const headChildren = [...element.children.slice(0, i), head] + const tailChildren = [tail, ...element.children.slice(i + 1)] + return [ + { ...element, children: headChildren }, + { ...element, children: tailChildren }, + ] + } + cumLength += childLength + } + } + throw new ShikiError(`Failed to split element at offset ${offset}`) +} diff --git a/packages/core/test/decorations-intersections.test.ts b/packages/core/test/decorations-intersections.test.ts new file mode 100644 index 000000000..8d0fb0e1d --- /dev/null +++ b/packages/core/test/decorations-intersections.test.ts @@ -0,0 +1,18 @@ +import { codeToHtml } from 'shiki/bundle/full' +import { describe, expect, it } from 'vitest' + +const code = `const a = 1` + +describe('decorations intersections', () => { + it('should not throw when checkIntersections is false', async () => { + await expect(codeToHtml(code, { + theme: 'vitesse-light', + lang: 'ts', + decorations: [ + { start: 0, end: 10 }, + { start: 1, end: 11 }, + ], + checkIntersections: false, + })).resolves.not.toThrow() + }) +}) diff --git a/packages/core/test/decorations.test.ts b/packages/core/test/decorations.test.ts index 0ad68a193..69463c106 100644 --- a/packages/core/test/decorations.test.ts +++ b/packages/core/test/decorations.test.ts @@ -211,6 +211,43 @@ const z = 3` await expect(style + html) .toMatchFileSnapshot('./out/decorations/inline-multiline.html') }) + + it('should not throw on intersecting decorations with flatten mode', async () => { + const html = await codeToHtml(code, { + theme: 'vitesse-light', + lang: 'ts', + decorationsStyle: 'flatten', + decorations: [ + // Deeply nested decorations that would cause problems in wrap mode + // These are fully nested (not intersecting) + { + start: { line: 3, character: 0 }, + end: { line: 3, character: 20 }, + properties: { class: 'highlighted' }, + }, + { + start: { line: 3, character: 2 }, + end: { line: 3, character: 18 }, + properties: { class: 'highlighted-body' }, + }, + { + start: { line: 3, character: 4 }, + end: { line: 3, character: 16 }, + properties: { class: 'highlighted-border' }, + }, + { + start: { line: 3, character: 6 }, + end: { line: 3, character: 14 }, + properties: { class: 'highlighted' }, + }, + ], + }) + + // Should not throw an error + expect(html).toBeTruthy() + await expect(style + html) + .toMatchFileSnapshot('./out/decorations/flatten.html') + }) }) describe('decorations errors', () => { diff --git a/packages/core/test/out/decorations/adjacent.html b/packages/core/test/out/decorations/adjacent.html index c23ef9b94..f7edd649e 100644 --- a/packages/core/test/out/decorations/adjacent.html +++ b/packages/core/test/out/decorations/adjacent.html @@ -10,4 +10,4 @@ .highlighted-border { border: 1px solid #ff0000; } -
hello
\ No newline at end of file +
hello
\ No newline at end of file diff --git a/packages/core/test/out/decorations/basic.html b/packages/core/test/out/decorations/basic.html index 1a7986fbf..1c717a6b2 100644 --- a/packages/core/test/out/decorations/basic.html +++ b/packages/core/test/out/decorations/basic.html @@ -18,7 +18,7 @@ code: string, options: CodeToHastOptions, ): string { - let result = hastToHtml(codeToHast(internal, code, options, context)) + let result = hastToHtml(codeToHast(internal, code, options, context)) return result } // final \ No newline at end of file diff --git a/packages/core/test/out/decorations/flatten.html b/packages/core/test/out/decorations/flatten.html new file mode 100644 index 000000000..5b58b9888 --- /dev/null +++ b/packages/core/test/out/decorations/flatten.html @@ -0,0 +1,24 @@ + +
/**
+ * Get highlighted code in HTML.
+ */
+export function codeToHtml(
+  internal: ShikiInternal,
+  code: string,
+  options: CodeToHastOptions,
+): string {
+  let result = hastToHtml(codeToHast(internal, code, options, context))
+  return result
+}
+// final
\ No newline at end of file diff --git a/packages/core/test/out/decorations/inline.html b/packages/core/test/out/decorations/inline.html index 381483e0d..df6b96098 100644 --- a/packages/core/test/out/decorations/inline.html +++ b/packages/core/test/out/decorations/inline.html @@ -10,4 +10,4 @@ .highlighted-border { border: 1px solid #ff0000; } -const foo = "bar" \ No newline at end of file +const foo = "bar" \ No newline at end of file diff --git a/packages/types/src/decorations.ts b/packages/types/src/decorations.ts index 34c6272fb..0f64813fa 100644 --- a/packages/types/src/decorations.ts +++ b/packages/types/src/decorations.ts @@ -5,6 +5,18 @@ export interface DecorationOptions { * Custom decorations to wrap highlighted tokens with. */ decorations?: DecorationItem[] + /** + * The style of applying decorations. + * + * - `wrap`: Apply decorations with nested wrappers (default). + * This checks for intersecting decorations and throws an error if found. + * - `flatten`: Apply decorations without nested wrappers. + * This skips the intersection check and allows overlapping decorations. + * Useful for deeply nested or complex decoration scenarios. + * + * @default 'wrap' + */ + decorationsStyle?: 'wrap' | 'flatten' } export interface DecorationItem {