Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 97 additions & 16 deletions packages/core/src/transformer-decorations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand All @@ -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++
}
}

Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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}`)
}
18 changes: 18 additions & 0 deletions packages/core/test/decorations-intersections.test.ts
Original file line number Diff line number Diff line change
@@ -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,

Check failure on line 15 in packages/core/test/decorations-intersections.test.ts

View workflow job for this annotation

GitHub Actions / typecheck

Object literal may only specify known properties, and 'checkIntersections' does not exist in type 'CodeToHastOptions<BundledLanguage, BundledTheme>'.
})).resolves.not.toThrow()
})
})
37 changes: 37 additions & 0 deletions packages/core/test/decorations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/test/out/decorations/adjacent.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/core/test/out/decorations/basic.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 24 additions & 0 deletions packages/core/test/out/decorations/flatten.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/core/test/out/decorations/inline.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions packages/types/src/decorations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading