Skip to content

Commit 3d11fb9

Browse files
committed
Fix the Table of Contents links of headings with inline formatting
The anchor of a heading was built from the text returned by `serializeNodesToText`, which trims every leaf of the node and joins them with whitespace, because it is meant for indexing. A heading that Slate splits into several leafs, either by inline formatting or by an inline link, therefore produced an anchor that did not match the one the Table of Contents block linked to, and the entry led nowhere. Build the anchor from the text of the heading as it is rendered instead, and report it as the third element of the `tocEntry` of the block, so that the Table of Contents block links to the anchor that the block actually renders, instead of slugging its stored plaintext. Blocks that report no anchor keep the previous behavior. Closes #8340
1 parent 7fd18d6 commit 3d11fb9

15 files changed

Lines changed: 429 additions & 19 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed the anchor of a heading being built from its indexed plaintext, which broke it when the heading contained inline formatting or a link. The anchor is now built from the text of the heading as it is rendered, and is also reported as the third element of the block's `tocEntry`. @Somilg11

packages/volto-slate/src/blocks/Text/TextBlockView.jsx

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,7 @@
1-
import {
2-
serializeNodes,
3-
serializeNodesToText,
4-
} from '@plone/volto-slate/editor/render';
1+
import { serializeNodes } from '@plone/volto-slate/editor/render';
2+
import { getAnchor } from '@plone/volto-slate/utils/toc';
53
import config from '@plone/volto/registry';
64
import isEqual from 'lodash/isEqual';
7-
import Slugger from 'github-slugger';
8-
import { normalizeString } from '@plone/volto/helpers/Utils/Utils';
95

106
const TextBlockView = (props) => {
117
const { id, data, styling = {} } = props;
@@ -17,9 +13,7 @@ const TextBlockView = (props) => {
1713
const res = { ...styling };
1814
if (node.type && isEqual(path, [0])) {
1915
if (topLevelTargetElements.includes(node.type) || override_toc) {
20-
const text = serializeNodesToText(node?.children || []);
21-
const slug = Slugger.slug(normalizeString(text));
22-
res.id = slug || id;
16+
res.id = getAnchor(node) || id;
2317
}
2418
}
2519
return res;
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import React from 'react';
2+
import renderer from 'react-test-renderer';
3+
import config from '@plone/volto/registry';
4+
import TextBlockView from './TextBlockView';
5+
import { getAnchor } from '@plone/volto-slate/utils/toc';
6+
7+
beforeAll(() => {
8+
config.settings = {
9+
slate: {
10+
topLevelTargetElements: ['h2', 'h3'],
11+
elements: {
12+
default: ({ attributes, children }) => (
13+
<p {...attributes}>{children}</p>
14+
),
15+
h2: ({ attributes, children }) => <h2 {...attributes}>{children}</h2>,
16+
link: ({ attributes, children }) => <a {...attributes}>{children}</a>,
17+
},
18+
leafs: {
19+
italic: ({ children }) => <em>{children}</em>,
20+
},
21+
},
22+
};
23+
});
24+
25+
const getHeadingId = (value) => {
26+
const component = renderer.create(
27+
<TextBlockView id="block-id" data={{ value }} />,
28+
);
29+
return component.root.findByType('h2').props.id;
30+
};
31+
32+
test('renders the anchor of a plain heading', () => {
33+
expect(
34+
getHeadingId([
35+
{ type: 'h2', children: [{ text: 'Subtitle 1: Everything is okay' }] },
36+
]),
37+
).toBe('subtitle-1-everything-is-okay');
38+
});
39+
40+
test('renders the same anchor for a heading with inline formatting', () => {
41+
const value = [
42+
{
43+
type: 'h2',
44+
children: [
45+
{ text: 'Subtitle 2: Everything is ' },
46+
{ text: 'not', italic: true },
47+
{ text: ' okay' },
48+
],
49+
},
50+
];
51+
expect(getHeadingId(value)).toBe('subtitle-2-everything-is-not-okay');
52+
// The table of contents entry of the block links to this anchor.
53+
expect(getHeadingId(value)).toBe(getAnchor(value[0]));
54+
});
55+
56+
test('renders the same anchor for a heading containing a link', () => {
57+
const value = [
58+
{
59+
type: 'h2',
60+
children: [
61+
{ text: 'Subtitle 3: Everything is ' },
62+
{
63+
type: 'link',
64+
data: { url: '/some-page' },
65+
children: [{ text: 'okay' }],
66+
},
67+
{ text: '' },
68+
],
69+
},
70+
];
71+
expect(getHeadingId(value)).toBe('subtitle-3-everything-is-okay');
72+
expect(getHeadingId(value)).toBe(getAnchor(value[0]));
73+
});
74+
75+
test('falls back to the block id for an empty heading', () => {
76+
expect(getHeadingId([{ type: 'h2', children: [{ text: '' }] }])).toBe(
77+
'block-id',
78+
);
79+
});

packages/volto-slate/src/blocks/Text/index.jsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
cancelEsc,
2121
} from './keyboard';
2222
import { splitAtSeam } from './keyboard/splitAtSeam';
23+
import { getAnchor, getAnchorText } from '@plone/volto-slate/utils/toc';
2324
import { withDeleteSelectionOnEnter } from '@plone/volto-slate/editor/extensions';
2425
import {
2526
breakList,
@@ -129,10 +130,17 @@ export default function applyConfig(config) {
129130
tocEntry: (block = {}) => {
130131
const { value, override_toc, entry_text, level, plaintext } = block;
131132
const type = value?.[0]?.type;
133+
// The anchor is computed from the current value, and not from the stored
134+
// plaintext, so that it always matches the `id` rendered by the view.
135+
const anchor = getAnchor(value?.[0]);
132136
return override_toc && level
133-
? [parseInt(level.slice(1)), entry_text]
137+
? [parseInt(level.slice(1)), entry_text, anchor]
134138
: config.settings.slate.topLevelTargetElements.includes(type)
135-
? [parseInt(type.slice(1)), plaintext]
139+
? [
140+
parseInt(type.slice(1)),
141+
getAnchorText(value?.[0]) || plaintext,
142+
anchor,
143+
]
136144
: null;
137145
},
138146
};

packages/volto-slate/src/utils/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ export * from './random';
99
export * from './selection';
1010
export * from './volto-blocks';
1111
export * from './mime-types';
12+
export * from './toc';
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { Text } from 'slate';
2+
import Slugger from 'github-slugger';
3+
import { normalizeString } from '@plone/volto/helpers/Utils/Utils';
4+
5+
/**
6+
* Get the text of a node as it reads when rendered.
7+
*
8+
* Unlike `serializeNodesToText`, which is meant for indexing and separates the
9+
* text of every node with whitespace, this concatenates the text of the inline
10+
* children of a node without inserting or dropping any whitespace. This is
11+
* required to build anchors, since a heading split into several leafs by inline
12+
* formatting (bold, italic, links) must produce the same text as the same
13+
* heading written without any formatting.
14+
*
15+
* @function serializeNodeToInlineText
16+
* @param {Object} node A Slate node.
17+
* @returns {string} The text of the node.
18+
*/
19+
export const serializeNodeToInlineText = (node) => {
20+
if (!node) return '';
21+
if (Text.isText(node)) return node.text;
22+
return (node.children || []).map(serializeNodeToInlineText).join('');
23+
};
24+
25+
/**
26+
* Get the anchor text of a node, with the surrounding whitespace removed and
27+
* the inner whitespace collapsed.
28+
*
29+
* The empty text nodes that Slate keeps around inline elements would otherwise
30+
* end up as stray separators in the resulting slug.
31+
*
32+
* @function getAnchorText
33+
* @param {Object} node A Slate node.
34+
* @returns {string} The anchor text of the node.
35+
*/
36+
export const getAnchorText = (node) =>
37+
serializeNodeToInlineText(node).replace(/\s+/g, ' ').trim();
38+
39+
/**
40+
* Get the anchor (the `id` attribute) that the view of a Slate block renders
41+
* for the given node.
42+
*
43+
* Both the block view and the block's table of contents entry use it, so that
44+
* the links of the table of contents always match the anchors in the page.
45+
*
46+
* @function getAnchor
47+
* @param {Object} node A Slate node.
48+
* @returns {string} The anchor of the node.
49+
*/
50+
export const getAnchor = (node) =>
51+
Slugger.slug(normalizeString(getAnchorText(node)));
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { getAnchor, getAnchorText, serializeNodeToInlineText } from './toc';
2+
3+
const plainHeading = {
4+
type: 'h2',
5+
children: [{ text: 'Subtitle 1: Everything is okay' }],
6+
};
7+
8+
const italicHeading = {
9+
type: 'h2',
10+
children: [
11+
{ text: 'Subtitle 2: Everything is ' },
12+
{ text: 'not', italic: true },
13+
{ text: ' okay' },
14+
],
15+
};
16+
17+
const partiallyItalicWordHeading = {
18+
type: 'h2',
19+
children: [
20+
{ text: 'Subtitle 3: n' },
21+
{ text: 'o', italic: true },
22+
{ text: 't okay' },
23+
],
24+
};
25+
26+
const linkHeading = {
27+
type: 'h2',
28+
children: [
29+
{ text: 'Subtitle 4: Everything is ' },
30+
{
31+
type: 'link',
32+
data: { url: '/some-page' },
33+
children: [{ text: 'okay' }],
34+
},
35+
{ text: '' },
36+
],
37+
};
38+
39+
describe('serializeNodeToInlineText', () => {
40+
it('returns the text of a node without a type', () => {
41+
expect(serializeNodeToInlineText({ text: 'Hello' })).toBe('Hello');
42+
});
43+
44+
it('does not add whitespace between the leafs of a node', () => {
45+
expect(serializeNodeToInlineText(partiallyItalicWordHeading)).toBe(
46+
'Subtitle 3: not okay',
47+
);
48+
});
49+
50+
it('includes the text of inline elements', () => {
51+
expect(serializeNodeToInlineText(linkHeading)).toBe(
52+
'Subtitle 4: Everything is okay',
53+
);
54+
});
55+
56+
it('returns an empty string for an empty node', () => {
57+
expect(serializeNodeToInlineText(undefined)).toBe('');
58+
expect(serializeNodeToInlineText({ type: 'h2' })).toBe('');
59+
});
60+
});
61+
62+
describe('getAnchorText', () => {
63+
it('collapses the inner whitespace and trims the outer one', () => {
64+
expect(
65+
getAnchorText({
66+
type: 'h2',
67+
children: [{ text: ' Some ' }, { text: '' }, { text: 'heading ' }],
68+
}),
69+
).toBe('Some heading');
70+
});
71+
});
72+
73+
describe('getAnchor', () => {
74+
it('slugs a heading', () => {
75+
expect(getAnchor(plainHeading)).toBe('subtitle-1-everything-is-okay');
76+
});
77+
78+
it('is not affected by inline formatting', () => {
79+
expect(getAnchor(italicHeading)).toBe('subtitle-2-everything-is-not-okay');
80+
expect(getAnchor(partiallyItalicWordHeading)).toBe('subtitle-3-not-okay');
81+
});
82+
83+
it('is not affected by inline links', () => {
84+
expect(getAnchor(linkHeading)).toBe('subtitle-4-everything-is-okay');
85+
});
86+
87+
it('returns an empty string for an empty heading', () => {
88+
expect(getAnchor({ type: 'h2', children: [{ text: '' }] })).toBe('');
89+
});
90+
});

packages/volto/cypress/tests/core/blocks/block-anchors.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,4 +114,51 @@ describe('Block Tests: Anchors', () => {
114114
).click();
115115
cy.get('h2[id="title-2-u-a"]').scrollIntoView().should('be.visible');
116116
});
117+
118+
it('Add Block: add content to TOC with inline formatting', () => {
119+
// Change page title
120+
cy.clearSlateTitle();
121+
cy.getSlateTitle().type('Slate Heading Anchors with inline formatting');
122+
cy.getSlate().click();
123+
124+
// Add TOC block
125+
cy.get('.ui.basic.icon.button.block-add-button').first().click();
126+
cy.get(".blocks-chooser .ui.form .field.searchbox input[type='text']").type(
127+
'table of contents',
128+
);
129+
cy.get('.button.toc').click();
130+
131+
// Add a heading, and make one of its words italic
132+
cy.get('.ui.basic.icon.button.block-add-button').first().click();
133+
cy.get(".blocks-chooser .ui.form .field.searchbox input[type='text']").type(
134+
'text',
135+
);
136+
cy.get('.button.slate').click();
137+
cy.get('.ui.drag.block.inner.slate')
138+
.click()
139+
.type('Title 1 is not okay')
140+
.click();
141+
cy.get('.ui.drag.block.inner.slate span span span').setSelection(
142+
'Title 1 is not okay',
143+
);
144+
cy.get('.slate-inline-toolbar .button-wrapper a[title="Title"]').click({
145+
force: true,
146+
});
147+
cy.setSlateSelection('not');
148+
cy.clickSlateButton('Italic');
149+
150+
// Save page
151+
cy.get('#toolbar-save').click();
152+
cy.url().should('eq', Cypress.config().baseUrl + '/my-page');
153+
154+
// The anchor of the heading ignores the inline formatting, and the TOC
155+
// entry links to it
156+
cy.get('h2[id="title-1-is-not-okay"] em').contains('not');
157+
cy.get(
158+
`.table-of-contents a[href="${subpathPrefix}/my-page#title-1-is-not-okay"]`,
159+
).click();
160+
cy.get('h2[id="title-1-is-not-okay"]')
161+
.scrollIntoView()
162+
.should('be.visible');
163+
});
117164
});

packages/volto/news/8340.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed the links of the `Table of Contents` block breaking when a heading contains inline formatting or a link. The block now uses the anchor reported by the block that owns the heading, instead of slugging its indexed plaintext. @Somilg11

packages/volto/src/components/manage/Blocks/ToC/View.jsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export const getBlocksTocEntries = (properties, tocData) => {
5555
const i = `${id}-${index}`;
5656
const level = entry[0];
5757
const title = entry[1];
58+
const anchor = entry[2];
5859
const items = [];
5960
if (!level || !levels.includes(level)) return;
6061
tocEntriesLayout.push(i);
@@ -63,6 +64,7 @@ export const getBlocksTocEntries = (properties, tocData) => {
6364
title: title || block.plaintext,
6465
items,
6566
id: i,
67+
anchor,
6668
};
6769
if (level < rootLevel) {
6870
rootLevel = level;
@@ -119,6 +121,7 @@ const View = (props) => {
119121
if (entry) {
120122
const level = entry[0];
121123
const title = entry[1];
124+
const anchor = entry[2];
122125
const items = [];
123126
if (!title?.trim() && !block.plaintext?.trim()) return;
124127
if (!level || !levels.includes(level)) return;
@@ -130,6 +133,7 @@ const View = (props) => {
130133
id,
131134
override_toc: block.override_toc,
132135
plaintext: block.plaintext,
136+
anchor,
133137
};
134138
}
135139
});

0 commit comments

Comments
 (0)