diff --git a/packages/desktop/test/unit/specs/exportHtml.spec.ts b/packages/desktop/test/unit/specs/exportHtml.spec.ts
index 2677508b96..32ab74c607 100644
--- a/packages/desktop/test/unit/specs/exportHtml.spec.ts
+++ b/packages/desktop/test/unit/specs/exportHtml.spec.ts
@@ -75,9 +75,10 @@ describe('exportStyledHTML — wrapper parity', () => {
describe('exportStyledHTML — [TOC] expansion and slug matching', () => {
// Mirror what `muya.getTOC()` returns ({ lvl, content } per heading, content
- // being the RAW markdown heading text). `getHtmlToc` re-slugs `content`; the
- // engine slugs each heading's rendered textContent — both via the SAME
- // `generateGithubSlug`, so for plain-text headings the anchors line up.
+ // being the heading's rendered plain text — inline markdown stripped (#4811)).
+ // `getHtmlToc` re-slugs `content`; the engine slugs each heading's rendered
+ // textContent — both via the SAME `generateGithubSlug` over the same plain
+ // text, so the anchors line up even for headings authored with formatting.
const MD = [
'# Getting Started',
'',
@@ -98,7 +99,9 @@ describe('exportStyledHTML — [TOC] expansion and slug matching', () => {
{ lvl: 1, content: 'Getting Started' },
{ lvl: 2, content: 'Installation' },
{ lvl: 2, content: 'Installation' },
- { lvl: 2, content: 'Use **bold** and [a link](http://x)' }
+ // getTOC() strips the `**bold**` / `[a link](http://x)` markup to the
+ // rendered text before it reaches the export TOC.
+ { lvl: 2, content: 'Use bold and a link' }
]
it('replaces the rendered
[TOC]
with the toc list', async() => {
@@ -131,22 +134,21 @@ describe('exportStyledHTML — [TOC] expansion and slug matching', () => {
expect(ids).toContain('installation-1')
})
- it('SLUG DIVERGENCE: a heading with inline markup produces a TOC anchor that does NOT match the heading id', async() => {
+ it('a heading with inline markup produces a TOC anchor that matches the heading id (#4811)', async() => {
const toc = getHtmlToc(TOC, {})
const out = await exportStyledHTML(NO_MUYA, MD, { toc })
const hrefs = [...out.matchAll(/href="#([^"]+)"/g)].map((m) => m[1])
const ids = [...out.matchAll(/]*\sid="([^"]+)"/g)].map((m) => m[1])
- // getHtmlToc slugs the RAW markdown content "Use **bold** and [a link](http://x)"
- // → "use-bold-and-a-linkhttpx" (only `*[]():/` etc. stripped as non-\w, so
- // the "httpx" of "http://x" survives). The engine slugs the heading's
- // rendered textContent "Use bold and a link" → "use-bold-and-a-link". The
- // two diverge, so this anchor is a DEAD link in the exported document.
- expect(hrefs).toContain('use-bold-and-a-linkhttpx')
+ // getHtmlToc slugs the plain-text content "Use bold and a link" that
+ // getTOC() now yields → "use-bold-and-a-link", and the engine slugs the
+ // heading's rendered textContent to the same "use-bold-and-a-link". They
+ // converge, so the anchor is a live link in the exported document. The old
+ // raw-markdown slug "use-bold-and-a-linkhttpx" (a dead link) is gone.
+ expect(hrefs).toContain('use-bold-and-a-link')
expect(ids).toContain('use-bold-and-a-link')
- expect(ids).not.toContain('use-bold-and-a-linkhttpx')
- expect(hrefs).not.toContain('use-bold-and-a-link')
+ expect(hrefs).not.toContain('use-bold-and-a-linkhttpx')
})
it('does not inject the toc when the document has no [TOC] marker', async() => {
diff --git a/packages/muya/src/__tests__/getTOC.spec.ts b/packages/muya/src/__tests__/getTOC.spec.ts
index e9789a5e39..e2929bd4e4 100644
--- a/packages/muya/src/__tests__/getTOC.spec.ts
+++ b/packages/muya/src/__tests__/getTOC.spec.ts
@@ -10,9 +10,10 @@ import { Muya } from '../muya';
//
// - Only top-level heading blocks are surfaced (matches marktext
// iterating `this.blocks`).
-// - Heading text is the raw inner content — inline markdown markers are
-// NOT stripped. marktext used `block.children[0].text` for the same
-// reason.
+// - Heading text is the rendered plain text: inline markdown markers,
+// link/image URLs and tags are stripped to what a reader sees (#4811),
+// and `githubSlug` is derived from that same plain text so it matches
+// the anchor id the HTML export injects from `heading.textContent`.
// - Slug is a stable per-block identifier (so `getTOC()` returns the
// same slug across multiple invocations on the same document); duplicate
// headings keep distinct slugs but share `githubSlug`. The marktext
@@ -89,16 +90,32 @@ describe('muya.getTOC()', () => {
expect(toc[1]).toMatchObject({ lvl: 2, content: 'Title Two' });
});
- it('keeps raw markdown inline markers in `content` (no inline parsing)', () => {
- // marktext reads `block.children[0].text` — the raw source of the
- // heading line — so `**bold**` and `[link](url)` are preserved as
- // typed. Consumers that want rendered text should run their own
- // inline tokenizer over `content`.
+ it('strips inline markdown to plain text in `content` (#4811)', () => {
+ // Emphasis markers are dropped and a link renders as its label, not
+ // its URL — matching the visible heading rather than the raw source.
const md = `## **bold** and [link](https://example.com)`;
const muya = bootMuya(md);
const toc = muya.getTOC();
expect(toc).toHaveLength(1);
- expect(toc[0].content).toBe('**bold** and [link](https://example.com)');
+ expect(toc[0].content).toBe('bold and link');
+ });
+
+ it('renders an image heading as its alt text, not the raw `![]()`', () => {
+ const md = `##  Title`;
+ const muya = bootMuya(md);
+ const toc = muya.getTOC();
+ expect(toc).toHaveLength(1);
+ expect(toc[0].content).toBe('Logo Title');
+ });
+
+ it('derives githubSlug from the stripped text so anchors match the export', () => {
+ // The HTML export slugs `heading.textContent` (rendered plain text);
+ // a raw-markdown slug (`linkhttpsexamplecom`) would never resolve.
+ const md = `## a [link](https://example.com) here`;
+ const muya = bootMuya(md);
+ const toc = muya.getTOC();
+ expect(toc[0].content).toBe('a link here');
+ expect(toc[0].githubSlug).toBe('a-link-here');
});
it('strips the leading hash run robustly (9cb2cbe8 \\s regex fix)', () => {
diff --git a/packages/muya/src/inlineRenderer/lexer.ts b/packages/muya/src/inlineRenderer/lexer.ts
index ef460320bb..018603ae7b 100644
--- a/packages/muya/src/inlineRenderer/lexer.ts
+++ b/packages/muya/src/inlineRenderer/lexer.ts
@@ -5,6 +5,7 @@ import type {
Labels,
Token,
} from './types';
+import escapeCharactersMap from '../config/escapeCharacter';
import { isLengthEven, union } from '../utils';
import { beginRules, inlineRules, linkValidateRules, validateRules } from './rules';
import {
@@ -924,3 +925,79 @@ export function generator(tokens: Token[], rebuildWrappers = false) {
return result;
}
+
+// The reader-facing text of inline tokens with every marker, delimiter, URL and
+// tag dropped — `**bold**` → `bold`, `[text](url)` → `text`, `` →
+// `alt`. Mirrors the visible `textContent` a rendered heading yields, so a slug
+// derived from this matches the anchor id the HTML export injects
+// (state/markdownToHtml.ts injects ids from `heading.textContent`). The TOC uses
+// it to show and slug headings by their rendered text instead of raw source
+// (#4811).
+export function tokensToPlainText(tokens: Token[]): string {
+ let result = '';
+
+ for (const token of tokens) {
+ switch (token.type) {
+ case 'text':
+ case 'inline_code':
+ case 'inline_math':
+ case 'emoji':
+ case 'super_sub_script':
+ case 'footnote_identifier':
+ result += token.content;
+ break;
+
+ case 'strong':
+ case 'em':
+ case 'del':
+ case 'link':
+ case 'reference_link':
+ result += tokensToPlainText(token.children);
+ break;
+
+ case 'image':
+ case 'reference_image':
+ result += token.alt;
+ break;
+
+ case 'html_tag':
+ if (token.children)
+ result += tokensToPlainText(token.children);
+ else if (token.content)
+ result += token.content;
+ break;
+
+ case 'backlash':
+ // `content` is empty; the escaped char is `raw` minus its leading `\`.
+ result += token.raw.replace(/^\\/, '');
+ break;
+
+ case 'html_escape':
+ result += escapeCharactersMap[token.escapeCharacter] ?? token.raw;
+ break;
+
+ case 'auto_link':
+ // `` / `` show verbatim between the
+ // angle brackets — `href` may carry an added `mailto:` scheme.
+ result += token.raw.replace(/^<|>$/g, '');
+ break;
+
+ case 'auto_link_extension':
+ result += token.raw;
+ break;
+
+ case 'soft_line_break':
+ case 'hard_line_break':
+ result += ' ';
+ break;
+
+ // header / hr / code_fence / multiple_math begin markers, the
+ // reference_definition line, and an atx heading's tail `#`s carry no
+ // reader-facing text.
+ default:
+ break;
+ }
+ }
+
+ return result;
+}
diff --git a/packages/muya/src/state/getTOC.ts b/packages/muya/src/state/getTOC.ts
index a4e0d514a9..bda61ed583 100644
--- a/packages/muya/src/state/getTOC.ts
+++ b/packages/muya/src/state/getTOC.ts
@@ -1,6 +1,7 @@
import type Content from '../block/base/content';
import type Parent from '../block/base/parent';
import type { Muya } from '../muya';
+import { tokenizer, tokensToPlainText } from '../inlineRenderer/lexer';
import { getUniqueId } from '../utils';
import { generateGithubSlug } from '../utils/slug';
@@ -42,10 +43,23 @@ export function getTOC(muya: Muya): ITocItem[] {
const head = block.children.head as Content | null;
const text = head?.text ?? '';
- const content = blockName === 'setext-heading'
+ const source = blockName === 'setext-heading'
? text.trim()
: text.replace(/^\s*#{1,6}\s+/, '').trim();
+ // Show and slug the heading by its rendered text — inline markdown
+ // (`**bold**`, `[label](url)`, images) stripped to what a reader sees —
+ // instead of the raw source (#4811). Slugging the same plain text keeps
+ // `githubSlug` in step with the anchor id the HTML export injects from
+ // `heading.textContent` (state/markdownToHtml.ts).
+ const { superSubScript, footnote } = muya.options;
+ const content = tokensToPlainText(
+ tokenizer(source, {
+ hasBeginRules: false,
+ options: { superSubScript, footnote },
+ }),
+ ).trim();
+
items.push({
content,
lvl: block.meta.level,