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
Fix dropped and duplicated lines for nested collapse ranges
`parseSections` in packages/@expressive-code/plugin-collapsible-sections/src/utils.ts skips any `collapse` range that overlaps a section it already collected, because the `<details>`-based rendering cannot handle overlapping sections. The check only tested whether the new range's `from` or `to` falls inside an existing section, so a range that fully contains an existing one, e.g. `collapse={3-5, 1-8}`, was not recognized as overlapping and got added anyway.

Both sections then reached `sectionizeAst` in src/ast.ts, which splices sections into the line array from last to first while reading their content from the unmodified `lines` array. With `collapse={3-5, 1-8}` on a 10-line block, splicing the 1-8 section shrinks the array, so the following splice for 3-5 removes line 10 and appends copies of lines 3 to 5: rendered code that no longer matches the source.

Replaces the two partial checks with the standard interval overlap test `from <= existingTo && existingFrom <= to`, which covers containment in both directions and keeps the previous results for every range combination that was already handled.
  • Loading branch information
MaxFreedomPollard committed Sep 7, 2026
commit 34ba418d3d05b0f12344f7d797df79a15b6de2e4
5 changes: 5 additions & 0 deletions .changeset/sour-lions-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@expressive-code/plugin-collapsible-sections': patch
---

Fixes code lines being dropped and duplicated when a `collapse` range fully contains a range that was listed before it, e.g. `collapse={3-5, 1-8}`. Ranges like this are now skipped, just like any other range that overlaps an already added section.
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@ export function parseSections(value: string): Section[] {
if (from > to) return

// skip any entries that overlap existing sections, since our <details>-based approach can't overlap
// (this includes entries that fully contain an existing section, e.g. '3-5, 1-8')
for (const { from: existingFrom, to: existingTo } of sections) {
if (from >= existingFrom && from <= existingTo) return
if (to >= existingFrom && to <= existingTo) return
if (from <= existingTo && existingFrom <= to) return
}

sections.push({ from, to, lines: [] })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,14 @@ test('Merges overlapping sections', async () => {
await expectMetaResult('collapse={2-5,1-2}', {
sections: [{ from: 2, to: 5 }],
})

// A range that fully contains an already added section overlaps it, too,
// so it must be skipped no matter which of the two ranges comes first
await expectMetaResult('collapse={3-5,1-8}', {
sections: [{ from: 3, to: 5 }],
})

await expectMetaResult('collapse={1-8,3-5}', {
sections: [{ from: 1, to: 8 }],
})
})
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, test, expect } from 'vitest'
import type { Element } from '@expressive-code/core/hast'
import { getClassNames, select, selectAll, toText } from '@expressive-code/core/hast'
import { pluginShiki } from '@expressive-code/plugin-shiki'
import { pluginTextMarkers } from '@expressive-code/plugin-text-markers'
Expand Down Expand Up @@ -97,6 +98,24 @@ describe('Renders collapsed sections', async () => {
})
})

test(`Ignores sections that fully contain an already added section`, async ({ task: { name: testName } }) => {
const validateSections = buildBlockValidationFn([{ from: 5, to: 7, text: '3 collapsed lines' }])
await renderAndOutputHtmlSnapshot({
testName,
testBaseDir: __dirname,
fixtures: buildThemeFixtures(themes, {
code: lineMarkerTestText,
meta: `collapse={5-7, 1-9}`,
plugins: [pluginCollapsibleSections()],
blockValidationFn: (context) => {
validateSections(context)
// Also ensure that no code lines were dropped or duplicated
expect(getRenderedCodeLines(context.renderedGroupAst)).toEqual(lineMarkerTestText.split('\n'))
},
}),
})
})

test(`Correctly handles code with text-markers and syntax highlighting`, { timeout: 5 * 1000 }, async ({ task: { name: testName } }) => {
await renderAndOutputHtmlSnapshot({
testName,
Expand Down Expand Up @@ -177,6 +196,18 @@ describe('Renders collapsed sections', async () => {
})
})

/** Returns the text of all rendered code lines, including the lines hidden inside collapsed sections */
function getRenderedCodeLines(renderedGroupAst: Element) {
const codeAst = select('pre > code', renderedGroupAst)
if (!codeAst) throw new Error("Couldn't find code AST when collecting rendered code lines")

// Replace each section with the lines it contains (= all of its children except the summary)
const lines = codeAst.children.flatMap((child) => ('tagName' in child && child.tagName.toLowerCase() === 'details' ? child.children.slice(1) : [child]))

// Empty code lines are rendered as a single line break, so strip it to get the source text back
return lines.map((line) => toText(line, { whitespace: 'pre' }).replace(/\n$/, ''))
}

type ExpectedSection = Omit<Section, 'lines'> & {
text: string
}
Expand Down