feat: render inline markdown in the TOC (#3110) by Jocs · Pull Request #5648 · marktext/marktext · GitHub
Skip to content

feat: render inline markdown in the TOC (#3110) - #5648

Merged
Jocs merged 4 commits into
developfrom
feat/toc-render-inline-markdown-3110
Oct 7, 2026
Merged

Jocs merged 4 commits into
developfrom
feat/toc-render-inline-markdown-3110

Conversation

@Jocs

@Jocs Jocs commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

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 of tokensToPlainText(), and getTOC() now returns contentHtml next to content. Both come from one tokenize pass, so the shown text and the slug cannot drift apart.
  • desktop — the outline panel renders contentHtml through a sanitized v-html in an el-tree scoped 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] shows Title just 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 no json-change. The options watchers now re-seed the outline through a new REFRESH_TOC action, and because setOptions rebuilds 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: Done produced the plain text ok Done. It now emits the glyph (🆗 Done) — the same text the editor shows and the HTML export slugs. This also makes getTOC().githubSlug match the heading id the export injects from heading.textContent.

Type of change

  • New feature (non-breaking, adds functionality)

Test plan

  • New tests added
    • packages/muya/src/__tests__/getTOC.spec.ts: HTML output, ==mark==, a contentHtml-textContent-equals-content invariant, emoji glyph + slug, reference-style link/image resolution, link/image flattening, escaping.
    • packages/desktop/test/unit/specs/{toc-keys,pdf}.spec.ts: labelHtml pass-through; the exported TOC uses contentHtml.
    • 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.
  • Manually tested on: macOS
    • pnpm -C packages/muya test — 278 files / 2402 tests pass
    • pnpm lint, pnpm -C packages/muya lint, pnpm --filter marktext typecheck — no errors
    • TOC e2e group (toc-*.spec.ts, issue-5094-toc-theming, copy-anchor-link, source-toc-scroll) — pass

Notes for reviewers

  • The el-tree scoped slot replaces Element Plus's default <el-text truncated> label, so toc.vue re-declares the el-tree-node__label wrapper and its truncation CSS (nowrap + ellipsis + max-width: 100%) itself. The word-wrap preference keeps working through the same class.
  • The label HTML is sanitized with a dedicated TOC_DOMPURIFY_CONFIG that allows only the inline tags the engine emits (no a, img, styles or event handlers), as defense in depth.
  • REFRESH_TOC remaps activeHeadingSlug by document position rather than keeping it verbatim: setOptions(..., true) rebuilds every block through ScrollPage.updateState, so each heading gets a fresh per-instance slug.
  • Screenshot/recording of the outline is not attached in this automated run; the two e2e specs assert the rendered DOM (<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.

Jocs added 2 commits October 7, 2026 22:41
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.
@Jocs
Jocs requested a balanced review from Copilot October 7, 2026 14:47
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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Reference-style links remain unflattened, and option refreshes retain invalidated active-heading slugs.

Review effort: Balanced
Findings: 2 Medium severity

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.
File Description
packages/​muya/​src/​state/​getTOC.ts Adds contentHtml to TOC entries.
packages/​muya/​src/​inlineRenderer/​lexer.ts Serializes inline tokens as HTML.
packages/​muya/​src/​__tests__/​getTOC.spec.ts Tests HTML serialization and escaping.
packages/​desktop/​test/​unit/​specs/​toc-keys.spec.ts Tests rendered-label propagation.
packages/​desktop/​test/​unit/​specs/​pdf.spec.ts Tests rendered export labels.
packages/​desktop/​test/​e2e/​toc-preference-live-update.spec.ts Tests preference-driven refreshes.
packages/​desktop/​test/​e2e/​toc-markdown-render.spec.ts Tests rendered TOC DOM.
packages/​desktop/​src/​renderer/​src/​util/​tocKeys.ts Preserves HTML labels through keying.
packages/​desktop/​src/​renderer/​src/​util/​pdf.ts Uses rendered labels in exported TOCs.
packages/​desktop/​src/​renderer/​src/​util/​listToTree.ts Propagates HTML labels into tree nodes.
packages/​desktop/​src/​renderer/​src/​util/​dompurify.ts Adds restricted TOC sanitization.
packages/​desktop/​src/​renderer/​src/​store/​editor.ts Adds TOC refresh action.
packages/​desktop/​src/​renderer/​src/​components/​sideBar/​toc.vue Renders and styles sanitized labels.
packages/​desktop/​src/​renderer/​src/​components/​editorWithTabs/​editor.vue Refreshes TOC after option changes.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +1484 to +1486
REFRESH_TOC(toc: TocItem[]): void {
this.listToc = toc ?? []
this.toc = listToTree<TocItem>(toc ?? [])

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +60 to +63

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@Jocs
Jocs merged commit 67ea037 into develop Oct 7, 2026
20 of 21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

support Markdown formatting/styling in TOC

2 participants