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 = `## ![Logo](https://example.com/logo.png) 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](src)` → +// `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,