From 82d043d4605db3caf2976c381f6fc044e03cde2e Mon Sep 17 00:00:00 2001 From: PathGao <42336971+PathGao@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:31:42 +0900 Subject: [PATCH] fix: key section folds by unique heading id instead of heading text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit comrak puts the deduplicated heading id on an empty inner anchor, so h.id was always empty and fold state fell back to heading-text keys: duplicate titles shared one fold state after any re-render, and the ToC fold button's id-based key never matched the preview's text key (so folding from the ToC didn't collapse the section unless the title happened to equal its own slug). Promoting the anchor id onto the heading unifies all four consumers — preview chevron, render restore, ToC fold button, and toggleFold's id lookup — on one unique key, with no change to any of their logic. Co-Authored-By: Claude Fable 5 --- scripts/foldKeys.test.ts | 29 +++++++++++++++++++++++++++++ src/lib/utils/markdown.ts | 12 ++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 scripts/foldKeys.test.ts diff --git a/scripts/foldKeys.test.ts b/scripts/foldKeys.test.ts new file mode 100644 index 0000000..7509108 --- /dev/null +++ b/scripts/foldKeys.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +// Fold state is keyed by `h.id || textContent`. comrak emits the +// deduplicated heading id on an empty inner , not on the +// heading element, so without promotion every fold consumer falls back to +// heading text: duplicate titles share one fold state, and the ToC's +// id-based fold keys never match the preview's text-based ones. + +test('processMarkdownHtml promotes the anchor id onto the heading element', () => { + const source = readFileSync('src/lib/utils/markdown.ts', 'utf8'); + + const headingLoop = source.slice(source.indexOf('querySelectorAll("h1, h2, h3, h4, h5, h6")')); + assert.match(headingLoop, /querySelector\("a\.anchor"\)/, 'heading loop looks up the comrak anchor'); + assert.match(headingLoop, /h\.id = \w+\.id/, 'anchor id is promoted onto the heading'); + assert.match(headingLoop, /removeAttribute\("id"\)/, 'anchor id is removed so document ids stay unique'); +}); + +test('fold restore keys by heading id before falling back to text', () => { + const source = readFileSync('src/lib/utils/markdown.ts', 'utf8'); + assert.match(source, /const key = h\.id \|\| h\.textContent/, 'restore key prefers the (now populated) heading id'); +}); + +test('viewer fold handlers key by heading id before falling back to text', () => { + const viewer = readFileSync('src/lib/MarkdownViewer.svelte', 'utf8'); + assert.match(viewer, /foldableHeader\.id \|\| foldableHeader\.textContent/, 'preview chevron keys by heading id first'); + assert.match(viewer, /\[id="\$\{CSS\.escape\(key\)\}"\]\.foldable-header/, 'toggleFold resolves the heading by id'); +}); diff --git a/src/lib/utils/markdown.ts b/src/lib/utils/markdown.ts index f834d70..e742f80 100644 --- a/src/lib/utils/markdown.ts +++ b/src/lib/utils/markdown.ts @@ -683,6 +683,18 @@ export function processMarkdownHtml( const headings = Array.from(doc.querySelectorAll("h1, h2, h3, h4, h5, h6")); for (const h of headings) { + // comrak puts the deduplicated heading id ("title", "title-1", ...) + // on an empty inner , so h.id is empty and every + // consumer that keys folds by `h.id || textContent` falls back to + // the heading text — which collides for duplicate titles and never + // matches the ToC's id-based keys. Promote the id onto the heading + // itself (and off the anchor, so the document keeps unique ids). + const headingAnchor = h.querySelector("a.anchor"); + if (headingAnchor && headingAnchor.id && !h.id) { + h.id = headingAnchor.id; + headingAnchor.removeAttribute("id"); + } + const chevron = doc.createElement("span"); chevron.className = "header-fold-icon"; chevron.innerHTML = ``;