Skip to content

Commit

Permalink
Fix to match GH for HTML generated for backreferences
Browse files Browse the repository at this point in the history
*   GH previously generated HTML for backreferences to repeated references
    that was not accessible, as it failed [WCAG 2.1 SC 2.4.4 — Link Purpose
    (In Context)](https://www.w3.org/TR/WCAG21/#link-purpose-in-context)
*   GH changed the text content they use in their backreferences from
    `Back to content` to `Back to reference i`, where `i` is either `x` or
    `x-y`, of which `x` is the reference index, and `y` the rereference index

This commit changes all HTML output for users that relied on the defaults, so
that it matches GH again, exactly.
The default handling is exposed as `defaultFootnoteBackLabel`.

Users who set `footnoteBackLabel` are not affected.
But these users can now provide a function instead of a `string`, to also solve
the WCAG issue.
The type for this function is exposed as `FootnoteBackLabelTemplate`.

Additionally, you can now pass `footnoteBackContent` to set the *content* of the
backreference.

Related-to: github/cmark-gfm#307.
Closes remarkjs/remark-rehype#32.
  • Loading branch information
wooorm committed Jul 17, 2023
1 parent 3e300ea commit 56c88e4
Show file tree
Hide file tree
Showing 8 changed files with 548 additions and 94 deletions.
8 changes: 8 additions & 0 deletions index.d.ts
@@ -1,10 +1,18 @@
import type {Data, ElementContent, Literal, Properties} from 'hast'

// Expose types.
export type {
FootnoteBackContentTemplate,
FootnoteBackLabelTemplate
} from './lib/footer.js'
export type {Handler, Handlers, Options, State} from './lib/state.js'

// Expose JS API.
export {handlers as defaultHandlers} from './lib/handlers/index.js'
export {
defaultFootnoteBackContent,
defaultFootnoteBackLabel
} from './lib/footer.js'
export {toHast} from './lib/index.js'

/**
Expand Down
4 changes: 4 additions & 0 deletions index.js
@@ -1,3 +1,7 @@
// Note: types exposed from `index.d.ts`.
export {handlers as defaultHandlers} from './lib/handlers/index.js'
export {toHast} from './lib/index.js'
export {
defaultFootnoteBackContent,
defaultFootnoteBackLabel
} from './lib/footer.js'
167 changes: 138 additions & 29 deletions lib/footer.js
Expand Up @@ -5,9 +5,114 @@
* @typedef {import('./state.js').State} State
*/

/**
* @callback FootnoteBackContentTemplate
* Generate content for the backreference dynamically.
*
* For the following markdown:
*
* ```markdown
* Alpha[^micromark], bravo[^micromark], and charlie[^remark].
*
* [^remark]: things about remark
* [^micromark]: things about micromark
* ```
*
* This function will be called with:
*
* * `0` and `0` for the backreference from `things about micromark` to
* `alpha`, as it is the first used definition, and the first call to it
* * `0` and `1` for the backreference from `things about micromark` to
* `bravo`, as it is the first used definition, and the second call to it
* * `1` and `0` for the backreference from `things about remark` to
* `charlie`, as it is the second used definition
* @param {number} referenceIndex
* Index of the definition in the order that they are first referenced,
* 0-indexed.
* @param {number} rereferenceIndex
* Index of calls to the same definition, 0-indexed.
* @returns {Array<ElementContent> | ElementContent | string}
* Content for the backreference when linking back from definitions to their
* reference.
*
* @callback FootnoteBackLabelTemplate
* Generate a back label dynamically.
*
* For the following markdown:
*
* ```markdown
* Alpha[^micromark], bravo[^micromark], and charlie[^remark].
*
* [^remark]: things about remark
* [^micromark]: things about micromark
* ```
*
* This function will be called with:
*
* * `0` and `0` for the backreference from `things about micromark` to
* `alpha`, as it is the first used definition, and the first call to it
* * `0` and `1` for the backreference from `things about micromark` to
* `bravo`, as it is the first used definition, and the second call to it
* * `1` and `0` for the backreference from `things about remark` to
* `charlie`, as it is the second used definition
* @param {number} referenceIndex
* Index of the definition in the order that they are first referenced,
* 0-indexed.
* @param {number} rereferenceIndex
* Index of calls to the same definition, 0-indexed.
* @returns {string}
* Back label to use when linking back from definitions to their reference.
*/

import structuredClone from '@ungap/structured-clone'
import {normalizeUri} from 'micromark-util-sanitize-uri'

/**
* Generate the default content that GitHub uses on backreferences.
*
* @param {number} _
* Index of the definition in the order that they are first referenced,
* 0-indexed.
* @param {number} rereferenceIndex
* Index of calls to the same definition, 0-indexed.
* @returns {Array<ElementContent>}
* Content.
*/
export function defaultFootnoteBackContent(_, rereferenceIndex) {
/** @type {Array<ElementContent>} */
const result = [{type: 'text', value: '↩'}]

if (rereferenceIndex > 1) {
result.push({
type: 'element',
tagName: 'sup',
properties: {},
children: [{type: 'text', value: String(rereferenceIndex)}]
})
}

return result
}

/**
* Generate the default label that GitHub uses on backreferences.
*
* @param {number} referenceIndex
* Index of the definition in the order that they are first referenced,
* 0-indexed.
* @param {number} rereferenceIndex
* Index of calls to the same definition, 0-indexed.
* @returns {string}
* Label.
*/
export function defaultFootnoteBackLabel(referenceIndex, rereferenceIndex) {
return (
'Back to reference ' +
(referenceIndex + 1) +
(rereferenceIndex > 1 ? '-' + rereferenceIndex : '')
)
}

/**
* Generate a hast footer for called footnote definitions.
*
Expand All @@ -16,23 +121,27 @@ import {normalizeUri} from 'micromark-util-sanitize-uri'
* @returns {Element | undefined}
* `section` element or `undefined`.
*/
// eslint-disable-next-line complexity
export function footer(state) {
const clobberPrefix =
typeof state.options.clobberPrefix === 'string'
? state.options.clobberPrefix
: 'user-content-'
const footnoteBackLabel = state.options.footnoteBackLabel || 'Back to content'
const footnoteBackContent =
state.options.footnoteBackContent || defaultFootnoteBackContent
const footnoteBackLabel =
state.options.footnoteBackLabel || defaultFootnoteBackLabel
const footnoteLabel = state.options.footnoteLabel || 'Footnotes'
const footnoteLabelTagName = state.options.footnoteLabelTagName || 'h2'
const footnoteLabelProperties = state.options.footnoteLabelProperties || {
className: ['sr-only']
}
/** @type {Array<ElementContent>} */
const listItems = []
let index = -1
let referenceIndex = -1

while (++index < state.footnoteOrder.length) {
const def = state.footnoteById.get(state.footnoteOrder[index])
while (++referenceIndex < state.footnoteOrder.length) {
const def = state.footnoteById.get(state.footnoteOrder[referenceIndex])

if (!def) {
continue
Expand All @@ -41,15 +150,27 @@ export function footer(state) {
const content = state.all(def)
const id = String(def.identifier).toUpperCase()
const safeId = normalizeUri(id.toLowerCase())
let referenceIndex = 0
let rereferenceIndex = 0
/** @type {Array<ElementContent>} */
const backReferences = []
const counts = state.footnoteCounts.get(id)

// eslint-disable-next-line no-unmodified-loop-condition
while (counts !== undefined && ++referenceIndex <= counts) {
/** @type {Element} */
const backReference = {
while (counts !== undefined && ++rereferenceIndex <= counts) {
if (backReferences.length > 0) {
backReferences.push({type: 'text', value: ' '})
}

let children =
typeof footnoteBackContent === 'string'
? footnoteBackContent
: footnoteBackContent(referenceIndex, rereferenceIndex)

if (typeof children === 'string') {
children = {type: 'text', value: children}
}

backReferences.push({
type: 'element',
tagName: 'a',
properties: {
Expand All @@ -58,28 +179,16 @@ export function footer(state) {
clobberPrefix +
'fnref-' +
safeId +
(referenceIndex > 1 ? '-' + referenceIndex : ''),
dataFootnoteBackref: true,
className: ['data-footnote-backref'],
ariaLabel: footnoteBackLabel
(rereferenceIndex > 1 ? '-' + rereferenceIndex : ''),
dataFootnoteBackref: '',
ariaLabel:
typeof footnoteBackLabel === 'string'
? footnoteBackLabel
: footnoteBackLabel(referenceIndex, rereferenceIndex),
className: ['data-footnote-backref']
},
children: [{type: 'text', value: '↩'}]
}

if (referenceIndex > 1) {
backReference.children.push({
type: 'element',
tagName: 'sup',
properties: {},
children: [{type: 'text', value: String(referenceIndex)}]
})
}

if (backReferences.length > 0) {
backReferences.push({type: 'text', value: ' '})
}

backReferences.push(backReference)
children: Array.isArray(children) ? children : [children]
})
}

const tail = content[content.length - 1]
Expand Down

0 comments on commit 56c88e4

Please sign in to comment.