fix(docs-infra): exclude heading anchor icon text from ToC tooltips (#32418)

The Table of Contents (ToC) is auto-generated based on the content of
heading elements on the page. At the same time, anchor links are
auto-generated and added to each heading element. Note that the Material
Icons used for the anchor icon make use of ligatures, which means that
the icons are specified by using their textual name as text content of
the icon element. As a result, the name of the icon is included in the
parent element's `textContent`.

Previously, the `TocService` used to strip off these anchor elements
when generating the content of ToC items, but not when generating the
content of their tooltips. Thus, tooltips for ToC items would
confusingly include a `link` suffix (`link` is the textual name of the
icon used in heading anchor links).

This commit fixes this by deriving the tooltip content from the
transformed text content (which already has anchor links stripped off),
instead of from the original heading content.

PR Close #32418
This commit is contained in:
George Kalpakas 2019-08-30 22:06:45 +03:00 committed by Matias Niemelä
parent 094538c0ce
commit 007282d2bb
2 changed files with 44 additions and 27 deletions

View File

@ -299,12 +299,11 @@ describe('TocService', () => {
beforeEach(() => { beforeEach(() => {
docId = 'fizz/buzz/'; docId = 'fizz/buzz/';
// An almost-actual <h2> ... with extra whitespace
callGenToc(` callGenToc(`
<h2 id="setup-to-develop-locally"> <h2 id="setup-to-develop-locally">
Setup to <a href="moo">develop</a> <i>locally</i>. Setup to <a href="moo">develop</a> <i>locally</i>.
<a class="header-link" href="tutorial/toh-pt1#setup-to-develop-locally" aria-hidden="true"> <a class="header-link" href="tutorial/toh-pt1#setup-to-develop-locally" aria-hidden="true">
<span class="icon icon-link"></span> <span class="icon">icon-link</span>
</a> </a>
</h2> </h2>
`, docId); `, docId);

View File

@ -34,12 +34,16 @@ export class TocService {
const headings = this.findTocHeadings(docElement); const headings = this.findTocHeadings(docElement);
const idMap = new Map<string, number>(); const idMap = new Map<string, number>();
const tocList = headings.map(heading => ({ const tocList = headings.map(heading => {
content: this.extractHeadingSafeHtml(heading), const {title, content} = this.extractHeadingSafeHtml(heading);
href: `${docId}#${this.getId(heading, idMap)}`,
level: heading.tagName.toLowerCase(), return {
title: (heading.textContent || '').trim(), level: heading.tagName.toLowerCase(),
})); href: `${docId}#${this.getId(heading, idMap)}`,
title,
content,
};
});
this.tocList.next(tocList); this.tocList.next(tocList);
@ -52,29 +56,35 @@ export class TocService {
this.tocList.next([]); this.tocList.next([]);
} }
// This bad boy exists only to strip off the anchor link attached to a heading // Transform the HTML content to be safe to use in the ToC:
// - Strip off the auto-generated heading anchor links).
// - Strip off any anchor links (but keep their content)
// - Mark the HTML as trusted to be used with `[innerHTML]`.
private extractHeadingSafeHtml(heading: HTMLHeadingElement) { private extractHeadingSafeHtml(heading: HTMLHeadingElement) {
const div: HTMLDivElement = this.document.createElement('div'); const div: HTMLDivElement = this.document.createElement('div');
div.innerHTML = heading.innerHTML; div.innerHTML = heading.innerHTML;
const anchorLinks = Array.from(div.querySelectorAll('a'));
for (const anchorLink of anchorLinks) { // Remove any `.header-link` elements (along with their content).
if (!anchorLink.classList.contains('header-link')) { div.querySelectorAll('.header-link').forEach(removeNode);
// this is an anchor that contains actual content that we want to keep
// move the contents of the anchor into its parent // Remove any remaining `a` elements (but keep their content).
const parent = anchorLink.parentNode!; div.querySelectorAll('a').forEach(anchorLink => {
while (anchorLink.childNodes.length) { // We want to keep the content of this anchor, so move it into its parent.
parent.insertBefore(anchorLink.childNodes[0], anchorLink); const parent = anchorLink.parentNode!;
} while (anchorLink.childNodes.length) {
parent.insertBefore(anchorLink.childNodes[0], anchorLink);
} }
// now remove the anchor
if (anchorLink.parentNode !== null) { // Now, remove the anchor.
// We cannot use ChildNode.remove() because of IE11 removeNode(anchorLink);
anchorLink.parentNode.removeChild(anchorLink); });
}
} return {
// security: the document element which provides this heading content // Security: The document element which provides this heading content is always authored by
// is always authored by the documentation team and is considered to be safe // the documentation team and is considered to be safe.
return this.domSanitizer.bypassSecurityTrustHtml(div.innerHTML.trim()); content: this.domSanitizer.bypassSecurityTrustHtml(div.innerHTML.trim()),
title: (div.textContent || '').trim(),
};
} }
private findTocHeadings(docElement: Element): HTMLHeadingElement[] { private findTocHeadings(docElement: Element): HTMLHeadingElement[] {
@ -115,3 +125,11 @@ export class TocService {
} }
} }
} }
// Helpers
function removeNode(node: Node): void {
if (node.parentNode !== null) {
// We cannot use `Node.remove()` because of IE11.
node.parentNode.removeChild(node);
}
}