Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 15 additions & 13 deletions packages/desktop/test/unit/specs/exportHtml.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
'',
Expand All @@ -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 <p>[TOC]</p> with the toc list', async() => {
Expand Down Expand Up @@ -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(/<h[1-6][^>]*\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() => {
Expand Down
35 changes: 26 additions & 9 deletions packages/muya/src/__tests__/getTOC.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)', () => {
Expand Down
77 changes: 77 additions & 0 deletions packages/muya/src/inlineRenderer/lexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
Labels,
Token,
} from './types';
import escapeCharactersMap from '../config/escapeCharacter';
import { isLengthEven, union } from '../utils';
import { beginRules, inlineRules, linkValidateRules, validateRules } from './rules';
import {
Expand Down Expand Up @@ -924,3 +925,79 @@

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 {

Check warning on line 936 in packages/muya/src/inlineRenderer/lexer.ts

View workflow job for this annotation

GitHub Actions / lint

Function 'tokensToPlainText' has a complexity of 25. Maximum allowed is 20
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':
// `<http://x>` / `<foo@bar.com>` 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;
}
16 changes: 15 additions & 1 deletion packages/muya/src/state/getTOC.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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,
Expand Down
Loading