Skip to content

Commit e88f1aa

Browse files
authored
fix(mdx-loader): refactor and fix heading to toc html value serialization (#11004)
* refactor with iso behavior * Add unit tests * change behavior for <img> tags
1 parent 1d4d17d commit e88f1aa

File tree

3 files changed

+207
-76
lines changed

3 files changed

+207
-76
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* Copyright (c) Facebook, Inc. and its affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
import {toHeadingHTMLValue} from '../utils';
9+
import type {Heading} from 'mdast';
10+
11+
describe('toHeadingHTMLValue', () => {
12+
async function convert(heading: Heading): Promise<string> {
13+
const {toString} = await import('mdast-util-to-string');
14+
return toHeadingHTMLValue(heading, toString);
15+
}
16+
17+
it('converts a simple heading', async () => {
18+
const heading: Heading = {
19+
type: 'heading',
20+
depth: 2,
21+
children: [
22+
{
23+
type: 'text',
24+
value: 'Some heading text',
25+
},
26+
],
27+
};
28+
29+
await expect(convert(heading)).resolves.toMatchInlineSnapshot(
30+
`"Some heading text"`,
31+
);
32+
});
33+
34+
it('converts a heading with b tag', async () => {
35+
const heading: Heading = {
36+
type: 'heading',
37+
depth: 2,
38+
children: [
39+
{
40+
type: 'mdxJsxTextElement',
41+
name: 'b',
42+
attributes: [],
43+
children: [
44+
{
45+
type: 'text',
46+
value: 'Some title',
47+
},
48+
],
49+
},
50+
],
51+
};
52+
53+
await expect(convert(heading)).resolves.toMatchInlineSnapshot(
54+
`"<b>Some title</b>"`,
55+
);
56+
});
57+
58+
it('converts a heading with span tag + className', async () => {
59+
const heading: Heading = {
60+
type: 'heading',
61+
depth: 2,
62+
children: [
63+
{
64+
type: 'mdxJsxTextElement',
65+
name: 'span',
66+
attributes: [
67+
{
68+
type: 'mdxJsxAttribute',
69+
name: 'className',
70+
value: 'my-class',
71+
},
72+
],
73+
children: [
74+
{
75+
type: 'text',
76+
value: 'Some title',
77+
},
78+
],
79+
},
80+
],
81+
};
82+
83+
await expect(convert(heading)).resolves.toMatchInlineSnapshot(
84+
`"<span class="my-class">Some title</span>"`,
85+
);
86+
});
87+
88+
it('converts a heading - remove img tag', async () => {
89+
const heading: Heading = {
90+
type: 'heading',
91+
depth: 2,
92+
children: [
93+
{
94+
type: 'mdxJsxTextElement',
95+
name: 'img',
96+
attributes: [
97+
{
98+
type: 'mdxJsxAttribute',
99+
name: 'src',
100+
value: '/img/slash-introducing.svg',
101+
},
102+
{
103+
type: 'mdxJsxAttribute',
104+
name: 'height',
105+
value: '32',
106+
},
107+
{
108+
type: 'mdxJsxAttribute',
109+
name: 'alt',
110+
value: 'test',
111+
},
112+
],
113+
children: [],
114+
},
115+
{
116+
type: 'text',
117+
value: ' Some title',
118+
},
119+
],
120+
};
121+
122+
await expect(convert(heading)).resolves.toMatchInlineSnapshot(
123+
`"Some title"`,
124+
);
125+
});
126+
});

packages/docusaurus-mdx-loader/src/remark/toc/utils.ts

+79-4
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,21 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8-
import {toValue} from '../utils';
9-
import type {Node} from 'unist';
10-
import type {MdxjsEsm} from 'mdast-util-mdx';
8+
import escapeHtml from 'escape-html';
9+
import type {Node, Parent} from 'unist';
10+
import type {
11+
MdxjsEsm,
12+
MdxJsxAttribute,
13+
MdxJsxTextElement,
14+
} from 'mdast-util-mdx';
1115
import type {TOCHeading, TOCItem, TOCItems, TOCSlice} from './types';
1216
import type {
1317
Program,
1418
SpreadElement,
1519
ImportDeclaration,
1620
ImportSpecifier,
1721
} from 'estree';
22+
import type {Heading, PhrasingContent} from 'mdast';
1823

1924
export function getImportDeclarations(program: Program): ImportDeclaration[] {
2025
return program.body.filter(
@@ -118,7 +123,7 @@ export async function createTOCExportNodeAST({
118123
const {toString} = await import('mdast-util-to-string');
119124
const {valueToEstree} = await import('estree-util-value-to-estree');
120125
const value: TOCItem = {
121-
value: toValue(heading, toString),
126+
value: toHeadingHTMLValue(heading, toString),
122127
id: heading.data!.id!,
123128
level: heading.depth,
124129
};
@@ -172,3 +177,73 @@ export async function createTOCExportNodeAST({
172177
},
173178
};
174179
}
180+
181+
function stringifyChildren(
182+
node: Parent,
183+
toString: (param: unknown) => string, // TODO temporary, due to ESM
184+
): string {
185+
return (node.children as PhrasingContent[])
186+
.map((item) => toHeadingHTMLValue(item, toString))
187+
.join('')
188+
.trim();
189+
}
190+
191+
// TODO This is really a workaround, and not super reliable
192+
// For now we only support serializing tagName, className and content
193+
// Can we implement the TOC with real JSX nodes instead of html strings later?
194+
function mdxJsxTextElementToHtml(
195+
element: MdxJsxTextElement,
196+
toString: (param: unknown) => string, // TODO temporary, due to ESM
197+
): string {
198+
const tag = element.name;
199+
200+
// See https://github.com/facebook/docusaurus/issues/11003#issuecomment-2733925363
201+
if (tag === 'img') {
202+
return '';
203+
}
204+
205+
const attributes = element.attributes.filter(
206+
(child): child is MdxJsxAttribute => child.type === 'mdxJsxAttribute',
207+
);
208+
209+
const classAttribute =
210+
attributes.find((attr) => attr.name === 'className') ??
211+
attributes.find((attr) => attr.name === 'class');
212+
213+
const classAttributeString = classAttribute
214+
? `class="${escapeHtml(String(classAttribute.value))}"`
215+
: ``;
216+
217+
const allAttributes = classAttributeString ? ` ${classAttributeString}` : '';
218+
219+
const content = stringifyChildren(element, toString);
220+
221+
return `<${tag}${allAttributes}>${content}</${tag}>`;
222+
}
223+
224+
export function toHeadingHTMLValue(
225+
node: PhrasingContent | Heading | MdxJsxTextElement,
226+
toString: (param: unknown) => string, // TODO temporary, due to ESM
227+
): string {
228+
switch (node.type) {
229+
case 'mdxJsxTextElement': {
230+
return mdxJsxTextElementToHtml(node as MdxJsxTextElement, toString);
231+
}
232+
case 'text':
233+
return escapeHtml(node.value);
234+
case 'heading':
235+
return stringifyChildren(node, toString);
236+
case 'inlineCode':
237+
return `<code>${escapeHtml(node.value)}</code>`;
238+
case 'emphasis':
239+
return `<em>${stringifyChildren(node, toString)}</em>`;
240+
case 'strong':
241+
return `<strong>${stringifyChildren(node, toString)}</strong>`;
242+
case 'delete':
243+
return `<del>${stringifyChildren(node, toString)}</del>`;
244+
case 'link':
245+
return stringifyChildren(node, toString);
246+
default:
247+
return toString(node);
248+
}
249+
}

packages/docusaurus-mdx-loader/src/remark/utils/index.ts

+2-72
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,8 @@
55
* LICENSE file in the root directory of this source tree.
66
*/
77

8-
import escapeHtml from 'escape-html';
9-
import type {Parent, Node} from 'unist';
10-
import type {PhrasingContent, Heading} from 'mdast';
11-
import type {
12-
MdxJsxAttribute,
13-
MdxJsxAttributeValueExpression,
14-
MdxJsxTextElement,
15-
} from 'mdast-util-mdx';
8+
import type {Node} from 'unist';
9+
import type {MdxJsxAttributeValueExpression} from 'mdast-util-mdx';
1610

1711
/**
1812
* Util to transform one node type to another node type
@@ -35,70 +29,6 @@ export function transformNode<NewNode extends Node>(
3529
return node as NewNode;
3630
}
3731

38-
export function stringifyContent(
39-
node: Parent,
40-
toString: (param: unknown) => string, // TODO weird but works
41-
): string {
42-
return (node.children as PhrasingContent[])
43-
.map((item) => toValue(item, toString))
44-
.join('');
45-
}
46-
47-
// TODO This is really a workaround, and not super reliable
48-
// For now we only support serializing tagName, className and content
49-
// Can we implement the TOC with real JSX nodes instead of html strings later?
50-
function mdxJsxTextElementToHtml(
51-
element: MdxJsxTextElement,
52-
toString: (param: unknown) => string, // TODO weird but works
53-
): string {
54-
const tag = element.name;
55-
56-
const attributes = element.attributes.filter(
57-
(child): child is MdxJsxAttribute => child.type === 'mdxJsxAttribute',
58-
);
59-
60-
const classAttribute =
61-
attributes.find((attr) => attr.name === 'className') ??
62-
attributes.find((attr) => attr.name === 'class');
63-
64-
const classAttributeString = classAttribute
65-
? `class="${escapeHtml(String(classAttribute.value))}"`
66-
: ``;
67-
68-
const allAttributes = classAttributeString ? ` ${classAttributeString}` : '';
69-
70-
const content = stringifyContent(element, toString);
71-
72-
return `<${tag}${allAttributes}>${content}</${tag}>`;
73-
}
74-
75-
export function toValue(
76-
node: PhrasingContent | Heading | MdxJsxTextElement,
77-
toString: (param: unknown) => string, // TODO weird but works
78-
): string {
79-
switch (node.type) {
80-
case 'mdxJsxTextElement': {
81-
return mdxJsxTextElementToHtml(node as MdxJsxTextElement, toString);
82-
}
83-
case 'text':
84-
return escapeHtml(node.value);
85-
case 'heading':
86-
return stringifyContent(node, toString);
87-
case 'inlineCode':
88-
return `<code>${escapeHtml(node.value)}</code>`;
89-
case 'emphasis':
90-
return `<em>${stringifyContent(node, toString)}</em>`;
91-
case 'strong':
92-
return `<strong>${stringifyContent(node, toString)}</strong>`;
93-
case 'delete':
94-
return `<del>${stringifyContent(node, toString)}</del>`;
95-
case 'link':
96-
return stringifyContent(node, toString);
97-
default:
98-
return toString(node);
99-
}
100-
}
101-
10232
export function assetRequireAttributeValue(
10333
requireString: string,
10434
hash: string,

0 commit comments

Comments
 (0)