When used alongside the Link Purpose module, headings that contain links get an additional span (screen-reader hint) injected inside the heading, e.g.:
<h3 id="...">
<a href="...">Fun exciting header text</a>
<span class="link-purpose-text">(Link is external)</span>
</h3>toc_js (via the underlying heading text extraction) currently reads the entire heading’s textContent, so the TOC label becomes:
Fun exciting header text Link is external
This is noisy for readers, clutters the TOC UI, and can also bleed into generated anchors/IDs if those are derived from the full text.
Why this is needed (Link Purpose + accessibility patterns)
Accessibility/helper modules often inject visually-hidden or assistive text into headings (external-link cues, icon labels, badges). Editors also sometimes embed icons or decorative spans. When these are included verbatim in the TOC:
- TOC labels become verbose and repetitive.
- Navigation fidelity drops (labels don’t match the “visual” heading).
- Anchor slugs risk including assistive text.
Proposed resolution
Introduce a new configuration option (or API hook) that strips specific sub-elements before toc_js extracts heading text. Two implementation patterns could work:
1. Configurable selector(s):
Add a setting (textarea) like “Ignore selectors within headings” where site builders list CSS selectors (comma-separated). At TOC build time, clone each heading, remove nodes matching those selectors, then derive the label from the cleaned clone’s text.
2. Pluggable text extractor (callback):
Expose a getText(headingElement) or similar option passed to the TOC builder. The default uses textContent; advanced users/modules can alter this via hook or setting to strip elements first.
Backwards compatibility: default is empty/no-op, so existing behavior remains unchanged until a selector is provided.
Example default removal most sites would set:
.link-purpose-text, .visually-hidden, .sr-only, .icon, .badge
Workaround (what we used successfully)
Until the feature exists, this tiny behavior cleans the TOC labels client-side by removing .link-purpose-text from the referenced headings and updating the TOC link text. It’s loop-safe (no MutationObserver) and only rewrites anchors if the text actually changes. I've currently implemented it inside an existing custom module called cascades_bg, which is where that reference comes from.
cascades_bg.libraries.yml
toc_cleanup:
version: 1.x
js:
js/toc-cleanup.js:
footer: true
dependencies:
- core/drupal
- core/oncejs/toc-cleanup.js
(function (Drupal, once) {
Drupal.behaviors.cascadesBgCleanToc = {
attach(context) {
const containers = ['.toc', '.tocjs', '.toc-js', '.block-toc-js', '[data-toc]'];
const cleanAnchors = (root) => {
const anchors = root.querySelectorAll('a[href^="#"]:not([data-cbg-cleaned])');
anchors.forEach((a) => {
const href = a.getAttribute('href');
if (!href || href.length < 2) return;
const id = decodeURIComponent(href.slice(1));
const heading = context.getElementById?.(id) || document.getElementById(id);
if (!heading) return;
// Clone heading and remove assistive/decorative spans.
const clone = heading.cloneNode(true);
clone.querySelectorAll('.link-purpose-text').forEach((el) => el.remove());
// Optional: trim trailing parenthetical hints.
const clean = (clone.textContent || '')
.replace(/\s*\((?:Link|Opens|Downloads)[^)]+\)\s*$/i, '')
.trim();
if (clean && a.textContent.trim() !== clean) {
// Replace children to avoid partial innerHTML structures.
a.replaceChildren(document.createTextNode(clean));
}
// Mark so we don’t process this anchor again.
a.dataset.cbgCleaned = '1';
});
};
containers.forEach((sel) => {
once('cascades-bg-clean-toc', context.querySelectorAll(sel)).forEach((toc) => {
cleanAnchors(toc);
setTimeout(() => cleanAnchors(toc), 0);
setTimeout(() => cleanAnchors(toc), 200);
});
});
},
};
})(Drupal, once);
Conditional attach (only on Article nodes, optional)
use Drupal\node\NodeInterface;
/**
* Implements hook_page_attachments().
*/
function cascades_bg_page_attachments(array &$attachments) {
$route_match = \Drupal::routeMatch();
if ($route_match->getRouteName() !== 'entity.node.canonical') {
return;
}
$node = $route_match->getParameter('node');
if (is_numeric($node)) {
$node = \Drupal\node\Entity\Node::load($node);
}
if ($node instanceof NodeInterface && $node->bundle() === 'article') {
$attachments['#attached']['library'][] = 'cascades_bg/toc_cleanup';
}
}Expected outcome
- With the proposed feature, admins can declare selectors (e.g., .link-purpose-text) to be ignored when generating labels (and optionally slugs).
- TOC labels match the intended visible heading text.
- Accessibility/helper spans remain in the page content but not in the TOC.
If possible it would also be nice to have:
- Apply the same stripping when generating IDs/slugs, so anchors are stable and clean.
- Provide a sensible default list: .visually-hidden, .sr-only, .link-purpose-text, .icon, .badge.
A live version of my patch solution above can be seen at https://www.timbers.dev/article/september-round-exciting-new-modules-wat....
Issue fork toc_js-3546324
Show commands
Start within a Git clone of the project using the version control instructions.
Or, if you do not have SSH keys set up on git.drupalcode.org:
Comments
Comment #2
w01f commentedComment #3
mably commentedThanks @w01f!
It looks like a really nice feature to have.
Let's see what we can do.
Comment #5
mably commented@w01f could you give a try to this issue's MR please?
Comment #6
w01f commented@mably tested and seems to work great! Awesome, and thanks for adapting my hodgepodge to get it in so quickly!
Comment #7
mably commented@w01f thanks for the review!
If all the tickets I handle were as detailed as yours, that would be fantastic.
Let's merge this!
Comment #10
w01f commentedThanks again! Looking forward to updating on the sites I have the hack fixes for and ripping those out =).