Make headings with links appear in Safari Reader View
Table of contents
TL;DR:
If you’re doing links in headings like this…
<h2 id="introduction">
<a href="#introduction">Introduction</a>
</h2>
…and they’re not showing up in Safari Reader View, try wrapping an extra element like a <span>
around the text content:
<h2 id="introduction">
<a href="#introduction">
<span>Introduction</span>
</a>
</h2>
The longer version
On my site here I recently started adding links to headers so you can link directly to a part of an article, like this link to the header right above here.
For my first pass, I followed this very sound advice from Amber Wilson. I strongly recommend reading the whole article because the process is important, but the final markup pattern was this:
<h2 id="introduction">Introduction</h2>
<a href="#introduction">
<span aria-hidden="true">#</span>
<span class="hidden">Section titled introduction</span>
</a>
The end result is a link after each heading with a clear description for screen readers, but only show a #
(or whatever you want) visually. Makes sense!
So first pass ended up looking like this:
A potential issue with this pattern is that if that markup ends somewhere that you don’t control the styling like an RSS feed or browser reader mode, those links will be visible in their entirety. Here’s an example heading in NetNewsWire:
Or in Safari’s Reader View with only “#” visible, which is a little weird:
None of these are deal-breakers, but I started exploring alternatives.
Turns out after I initially implemented this, Amber updated her original article with an alternative markup pattern where you make the entire content of the heading a link, like so:
<h2 id="introduction">
<a href="#introduction">Introduction</a>
</h2>
Again, it’s worth reading her reasoning and also this thread on Github, particularly this amazing roundup of the pros and cons of assorted header link patterns.
That has simpler markup, a larger click/tap target, no additional elements to worry about in other contexts, and the accessibility seems reasonable. The one drawback is you can’t put another link inside the heading, but I don’t really do that here, so that pattern seemed like a win.
One problem: here that is in Safari Reader View, or more precisely, isn’t there because now it doesn’t render those heading tags:
Well that’s worse! It’s not just me as you can see that same pattern in action on MDN Web Docs or the The Web Almanac and those pages don’t have headings in Reader View either.
(I’m checking in Safari 14.0.3 on macOS 11.2.3, for what it’s worth.)
So… how do we fix it?
Safari’s Reader View is a black box, but there are little hints of how it works floating around.
The top Google hit for structuring content for it is Mandy Michael’s article. There’s a link there to a discussion with Apple developer Ricky Mondello who sheds some light on how it pulls content.
In short, to work with as many websites as possible, it uses a lot of fuzzy logic to try to match content and metadata, like these examples for pulling an author name:
But to work with lots of existing content, we’ll look for stuff like this, too:
— Ricky Mondello (@rmondello) July 20, 2018
`.byline, .article-byline, .entry-meta, .author-name, .byline-dateline, .article-author`
So I had that thought that if I made the headings look more content-ish with some classes, maybe that’d work? First pass with an extra <span>
with a faux-content-y class of header
:
<h2 id="introduction">
<a href="#introduction">
<span class="header">Introduction</span>
</a>
</h2>
That worked! Although it isn’t a link, the heading content is back in Reader View:
I futzed around with it some more and it seems like the class on the span
isn’t necessary. Just so long as the link has any other element around the text, it seems to show up in Reader View. So, the final pattern you want is:
<h2 id="introduction">
<a href="#introduction">
<span>Introduction</span>
</a>
</h2>
I’m using remarkable
for Markdown conversion, so here’s the plugin I threw together to do this automatically for my content:
// https://www.npmjs.com/package/slug
const slugify = require('slug')
function headerLinks(options) {
const appliedOptions = {
levels: [1, 2, 3, 4, 5, 6],
anchorClassName: 'header-anchor',
...options,
}
return function (remarkable) {
const originalOpen = remarkable.renderer.rules.heading_open
remarkable.renderer.rules.heading_open = function (tokens, idx) {
const { hLevel } = tokens[idx]
// Only anchorize supported header levels
// the extra span seems necessary for Safari reader mode
// if you have links in headers, they disappear
// but then having a span around the content seems to clue Safari back in to grab it
// ¯\_(ツ)_/¯
if (appliedOptions.levels.indexOf(hLevel) !== -1) {
const { content } = tokens[idx + 1]
const slug = slugify(content)
const id = `${slug}`
const href = `#${slug}`
return `<h${hLevel} id="${id}"><a class="${appliedOptions.anchorClassName}" href="${href}"><span>`
}
return originalOpen(tokens, idx)
}
const originalClose = remarkable.renderer.rules.heading_close
remarkable.renderer.rules.heading_close = function (tokens, idx) {
const { hLevel } = tokens[idx]
// Only anchorize supported header levels
if (appliedOptions.levels.indexOf(hLevel) !== -1) {
return `</span></a></h${hLevel}>`
}
return originalClose(tokens, idx)
}
}
}
And you’d use it in Remarkable like so:
import { Remarkable } from 'remarkable'
const renderer = new Remarkable().use(
headerLinks({
levels: [2, 3],
anchorClassName: 'my-special-anchor-class',
})
)
const html = renderer.render('## hello')
Note that this isn’t super robust because if you happen to have multiple headings with identical text content, you’ll have multiple identical IDs, but that isn’t a problem for me here.