Repository navigation
feat: render inline markdown in the TOC (#3110) - #5648
Conversation
The outline serialized every heading to reader-facing plain text (#4811), so inline styling was lost and an emoji shortcode degraded to its bare alias name (`:ok:` -> `ok`). Add `tokensToInlineHtml()`, a case-for-case HTML twin of `tokensToPlainText()`, and expose it as `getTOC().contentHtml` from the engine. The outline panel renders it through a sanitized `v-html` el-tree scoped slot, and the exported `[TOC]` uses the same HTML. `tokensToPlainText()` now emits the emoji glyph rather than the shortcode name, so the TOC slug matches the anchor id the HTML export injects from the rendered heading text.
Toggling a render-affecting preference (superscript/subscript, highlight, math, footnotes) calls `muya.setOptions(..., true)`, which re-renders the headings in place. That path replaces the JSON state directly and never emits `json-change`, so the desktop store's content-change handler never ran and the outline kept the parse from before the toggle. Re-seed the outline from the options watchers via a new `REFRESH_TOC` store action. It keeps `activeHeadingSlug` (a preference toggle does not move the caret, and render-only options leave the heading blocks and their stable slugs intact), unlike `UPDATE_TOC` which clears it on document replacement.
Drop the comments that only restated the code and keep the ones that carry rationale the code cannot: why the outline is re-seeded after a preference toggle, why the label wrapper/truncation is re-declared, the v-html sanitizer scope, and the plain-text/HTML serialization parity.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Reference-style links remain unflattened, and option refreshes retain invalidated active-heading slugs.
Review effort: Balanced
Findings: 2
Open (2)
What changed in this PR
Adds inline Markdown rendering to TOC headings while preserving plain-text slugs and safe export behavior.
Changes:
- Adds token-to-inline-HTML serialization and emoji glyph handling.
- Renders sanitized inline HTML in desktop TOCs and exports.
- Refreshes TOCs after parser-option changes and adds unit/E2E coverage.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| REFRESH_TOC(toc: TocItem[]): void { | ||
| this.listToc = toc ?? [] | ||
| this.toc = listToTree<TocItem>(toc ?? []) |
There was a problem hiding this comment.
Confirmed and fixed in 5211591.
Reproduced first: the new e2e clicks a heading, toggles superscript/subscript, and the outline's is-current entry goes to null. Reading the code confirms your mechanism — ScrollPage.updateState does empty() and then create()s every block, so the WeakMap-backed stableSlug values all change and the retained activeHeadingSlug matches no node.
REFRESH_TOC now remaps the active heading by document position before dropping the old list (a preference toggle does not reorder or drop headings), instead of keeping the stale slug. Covered by keeps the active outline entry highlighted across a toggle in toc-preference-live-update.spec.ts.
There was a problem hiding this comment.
Confirmed and fixed in 5211591.
Reproduced first: a test with ## [Title][ref] plus [ref]: https://example.com returned content === '[Title][ref]', because matchReference requires labels.has(...) and getTOC tokenized with the default empty label map.
Fix: promoted InlineRenderer._collectReferenceDefinitions() to a public collectReferenceDefinitions() (still reused by patch()), and getTOC now collects the document's definitions once and passes them into the heading tokenizer. content/contentHtml are now Title and Alt; the regression test covers both a reference link and a reference image.
- Resolve reference-style links and images in headings. `getTOC` tokenized with an empty label map, so `[Title][ref]` and `![Alt][img]` stayed literal in the outline while the editor rendered them. Extract `InlineRenderer.collectReferenceDefinitions()` and pass the document's definitions to the heading tokenizer. - Actually keep the active outline highlight across a preference toggle. `setOptions(..., true)` rebuilds every block through `ScrollPage.updateState`, so all per-instance heading slugs change and the retained `activeHeadingSlug` matched no node, dropping the highlight. Remap it by document position instead.


Closes #3110
Summary
The outline (TOC) serialized every heading to reader-facing plain text (#4811): inline styling was dropped, and an emoji shortcode degraded to its bare alias name (
:ok:->ok). This PR renders a heading's inline markdown in the outline, so headings such as**bold**,`code`and:ok:show up the way they do in the editor.It adds a second serialization of the same token stream:
@muyajs/core—tokensToInlineHtml()is a case-for-case HTML twin oftokensToPlainText(), andgetTOC()now returnscontentHtmlnext tocontent. Both come from one tokenize pass, so the shown text and the slug cannot drift apart.contentHtmlthrough a sanitizedv-htmlin anel-treescoped slot, and the exported[TOC](getHtmlToc) uses the same HTML.Rendered as styles:
**strong**,*em*,~~del~~,==mark==,`code`,^sup^/~sub~, emoji.Flattened to text: links (label only — an outline row is itself a click-to-scroll control), images (alt), math (source text, no KaTeX in the outline), autolinks, footnotes, raw HTML and escapes. Reference-style links and images resolve against the document's definitions, so
[Title][ref]showsTitlejust like the editor.The outline also follows the options that drive that tokenizer: toggling superscript/subscript (or highlight / math / footnotes) re-renders in place via
setOptions(..., true), which emits nojson-change. The options watchers now re-seed the outline through a newREFRESH_TOCaction, and becausesetOptionsrebuilds every block the highlighted entry is remapped by document position so the highlight survives the refresh.Fixed along the way
tokensToPlainText()used an emoji token's shortcode name as its text, so# :ok: Doneproduced the plain textok Done. It now emits the glyph (🆗 Done) — the same text the editor shows and the HTML export slugs. This also makesgetTOC().githubSlugmatch the headingidthe export injects fromheading.textContent.Type of change
Test plan
packages/muya/src/__tests__/getTOC.spec.ts: HTML output,==mark==, acontentHtml-textContent-equals-contentinvariant, emoji glyph + slug, reference-style link/image resolution, link/image flattening, escaping.packages/desktop/test/unit/specs/{toc-keys,pdf}.spec.ts:labelHtmlpass-through; the exported TOC usescontentHtml.packages/desktop/test/e2e/toc-markdown-render.spec.ts: the rendered outline DOM.packages/desktop/test/e2e/toc-preference-live-update.spec.ts: toggling superscript/subscript refreshes the outline both ways, and the active entry stays highlighted.pnpm -C packages/muya test— 278 files / 2402 tests passpnpm lint,pnpm -C packages/muya lint,pnpm --filter marktext typecheck— no errorstoc-*.spec.ts,issue-5094-toc-theming,copy-anchor-link,source-toc-scroll) — passNotes for reviewers
el-treescoped slot replaces Element Plus's default<el-text truncated>label, sotoc.vuere-declares theel-tree-node__labelwrapper and its truncation CSS (nowrap + ellipsis +max-width: 100%) itself. The word-wrap preference keeps working through the same class.TOC_DOMPURIFY_CONFIGthat allows only the inline tags the engine emits (noa,img, styles or event handlers), as defense in depth.REFRESH_TOCremapsactiveHeadingSlugby document position rather than keeping it verbatim:setOptions(..., true)rebuilds every block throughScrollPage.updateState, so each heading gets a fresh per-instance slug.<strong>,<em>,<code>, the emoji glyph, no leaked<a>/<img>, the superscript toggle, and the preserved highlight).By submitting this pull request, I confirm that my contribution is made under the terms of the MIT license.