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/once

js/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

Command icon 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

w01f created an issue. See original summary.

w01f’s picture

Issue summary: View changes
mably’s picture

Thanks @w01f!

It looks like a really nice feature to have.

Let's see what we can do.

mably’s picture

Status: Active » Needs review

@w01f could you give a try to this issue's MR please?

w01f’s picture

@mably tested and seems to work great! Awesome, and thanks for adapting my hodgepodge to get it in so quickly!

mably’s picture

Status: Needs review » Fixed

@w01f thanks for the review!

If all the tickets I handle were as detailed as yours, that would be fantastic.

Let's merge this!

Now that this issue is closed, please review the contribution record.

As a contributor, attribute any organization that helped you, or if you volunteered your own time.

Maintainers, please credit people who helped resolve this issue.

  • mably committed a9bd8f07 on 3.x
    Issue #3546324 by w01f, mably: External link text in header included in...
w01f’s picture

Thanks again! Looking forward to updating on the sites I have the hack fixes for and ripping those out =).

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.