Skip to content
This repository was archived by the owner on Mar 19, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,16 @@ what they are.

If the footnote content (2nd argument) is omitted: when another footnote with the same `id` already exists on the page, the reference is rendered and links to that existing footnote (so you can reference the same footnote multiple times). When no footnote with that `id` exists yet, the reference is not rendered and a warning is logged.

To reference the same footnote multiple times (e.g. one explanation for several terms), define the footnote on the first reference and omit the content on later references:
You can reference the same footnote multiple times in either order:

```html
In this article, {% footnoteref "pseudonyms", "All names used here are pseudonyms." %}Alice{% endfootnoteref %} and {% footnoteref "pseudonyms" %}Bob{% endfootnoteref %} are interviewees.
```

```html
In this article, {% footnoteref "pseudonyms" %}Alice{% endfootnoteref %} and {% footnoteref "pseudonyms", "All names used here are pseudonyms." %}Bob{% endfootnoteref %} are interviewees.
```

## Customisation

- `title`: The `title` option is the content of the title of the footnotes section. This title *can* be [visually hidden](https://kittygiraudel.com/2016/10/13/css-hide-and-seek/) if desired but it should not be removed or emptied.
Expand Down
106 changes: 95 additions & 11 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
const clsx = require('clsx');

// Internal map storing the footnotes for every page. Keys are page file paths
// mapped to objects holding footnotes. Each footnote has refCount for unique ref ids.
// mapped to objects holding footnotes. Each footnote has refCount for unique
// ref ids.
// E.g.
// {
// '_posts/foobar.md': {
Expand All @@ -10,6 +11,14 @@ const clsx = require('clsx');
// }
const FOOTNOTE_MAP = {}

// Internal map storing IDs that were referenced without a definition (so we can
// fix via transform when definition appears later)
const REFERENCED_WITHOUT_DEF = {}

// Internal map storing the count of placeholder refs per (page, id) so we can
// assign correct ref ids to the defining ref
const PENDING_REF_COUNT = {}

/**
* @param {object} config - 11ty config
* @param {object} [options] - Plugin options
Expand Down Expand Up @@ -38,7 +47,9 @@ module.exports = (config, options = {}) => {
const key = this.page.inputPath
FOOTNOTE_MAP[key] = FOOTNOTE_MAP[key] || {}

// No description: reuse existing footnote with this id if present
// If no description is provided, the reference should either be omitted
// entirely, or if it uses an ID of another footnote reference, it should
// render a reference to that existing footnote.
if (!description) {
const existing = FOOTNOTE_MAP[key][id]

Expand All @@ -60,31 +71,104 @@ module.exports = (config, options = {}) => {
})}>${content}</a>`
}

console.log(
`[eleventy-plugin-footnotes] Warning: Footnote reference with id ‘${id}’ has no given description (missing or falsy second argument); footnote omitted entirely.\n`
)

return content
// If we lack a description, we currently do not know if it is because of
// an editorial mistake or because the definition appears later in another
// reference to the same footnote. We therefore need to keep track it and
// render a placeholder that will be replaced in post-processing by the
// actual anchor (or nothing if the definition remains missing).
REFERENCED_WITHOUT_DEF[key] = REFERENCED_WITHOUT_DEF[key] || new Set()
REFERENCED_WITHOUT_DEF[key].add(id)
PENDING_REF_COUNT[key] = PENDING_REF_COUNT[key] || {}
PENDING_REF_COUNT[key][id] = (PENDING_REF_COUNT[key][id] || 0) + 1

return `<span data-footnote-placeholder data-footnote-id="${id}">${content}</span>`
}

// Register the footnote in the map (first ref)
// Assign index so all refs to this footnote show the same number (CSS counters would increment per-ref)
const hadPendingRefs = REFERENCED_WITHOUT_DEF[key]?.has(id)
if (hadPendingRefs) REFERENCED_WITHOUT_DEF[key].delete(id)

// Assign index so all refs to this footnote show the same number (CSS
// counters would increment per-ref, so we provide this count in a separate
// attribute).
const index = Object.keys(FOOTNOTE_MAP[key]).length + 1
const footnote = { id, description, refCount: 1, index }
FOOTNOTE_MAP[key][id] = footnote

// When definition comes after placeholders, defining ref gets ref-N
// (placeholders get ref, ref-2, …).
const pendingCount = PENDING_REF_COUNT[key]?.[id] || 0
const refId = pendingCount > 0 ? `${id}-ref-${pendingCount + 1}` : `${id}-ref`

// Return an anchor tag with all the necessary attributes
return `<a ${attrs({
class: clsx(`${baseClass}__ref`, classes.ref),
href: `#${id}-note`,
id: `${id}-ref`,
id: refId,
'data-footnote-index': index,
'aria-describedby': titleId,
role: 'doc-noteref',
})}>${content}</a>`
}

/** `footnotes` shortcode that renders the footnotes references wherever it's invoked. */
/**
* Transform to replace placeholders (refs before definition) with proper
* anchors.
* @param {string} content - The HTML content to transform
* @returns {string} The transformed HTML content
* @internal
*/
function footnoteTransform(content) {
const inputPath = this?.page?.inputPath
if (!inputPath) return content

const footnoteMap = FOOTNOTE_MAP[inputPath]
if (!footnoteMap) return content

const refCount = {}
const indexMap = Object.fromEntries(
Object.entries(footnoteMap).map(([id, fn]) => [id, fn.index])
)

return content.replace(
/<span(?=[^>]*data-footnote-placeholder)(?=[^>]*data-footnote-id="([^"]+)")[^>]*>([\s\S]*?)<\/span>/g,
(_, fnId, innerContent) => {
const index = indexMap[fnId]
if (!index) {
console.log(
`[eleventy-plugin-footnotes] Warning: Footnote reference with id ‘${fnId}’ has no given description (missing or falsy second argument); footnote omitted entirely.\n`
)
return innerContent
}

refCount[fnId] = (refCount[fnId] || 0) + 1

const refId = refCount[fnId] === 1
? `${fnId}-ref`
: `${fnId}-ref-${refCount[fnId]}`

return `<a ${attrs({
class: clsx(`${baseClass}__ref`, classes.ref),
href: `#${fnId}-note`,
id: refId,
'data-footnote-index': index,
'aria-describedby': titleId,
role: 'doc-noteref',
})}>${innerContent.trim()}</a>`
}
)
}

// Register transform to replace placeholders with anchors
if (config.addTransform) {
config.addTransform('footnote-placeholders', footnoteTransform)
}

/**
* `footnotes` shortcode that renders the footnotes references wherever it is
* invoked.
* @returns {string} The HTML content of the footnotes section
*/
function footnotes() {
const key = this.page.inputPath
const footnotes = Object.values(FOOTNOTE_MAP[key] || {})
Expand Down Expand Up @@ -123,7 +207,7 @@ module.exports = (config, options = {}) => {
config.addShortcode('footnotes', footnotes)

// Returned for testing purposes
return { footnoteref, footnotes }
return { footnoteref, footnotes, footnoteTransform }
}

/** Small utility to convert an object into a string of HTML attributes */
Expand Down
55 changes: 49 additions & 6 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,13 @@ describe('The `footnoteref` paired shortcode', () => {
expect(anchor.textContent).toBe(content)
})

it('should not render an anchor if description is omitted and no footnote with that id exists', () => {
it('should output placeholder when description is omitted and no footnote with that id exists', () => {
const contextWithoutFootnote = { page: { inputPath: 'tests/footnoteref-orphan' } }
const root = document.createElement('div')
root.innerHTML = footnoteref.call(contextWithoutFootnote, content, id)

const anchor = root.querySelector('a')
const result = footnoteref.call(contextWithoutFootnote, content, id)

expect(anchor).toEqual(null)
expect(result).toContain('data-footnote-placeholder')
expect(result).toContain('data-footnote-id="css-counters"')
expect(result).toContain('CSS counters')
})

it('should render an anchor linking to existing footnote when description is omitted but id matches', () => {
Expand Down Expand Up @@ -62,6 +61,50 @@ describe('The `footnoteref` paired shortcode', () => {
expect(anchors[0].getAttribute('data-footnote-index')).toBe('1')
expect(anchors[1].getAttribute('data-footnote-index')).toBe('1')
})

it('should support definition appearing after ref (placeholders + transform)', () => {
const ctx = { page: { inputPath: 'tests/footnoteref-def-second' } }
const { footnoteref, footnoteTransform, footnotes } = plugin(config)
const root = document.createElement('div')
const aliceHtml = footnoteref.call(ctx, 'Alice', 'late-def')
const bobHtml = footnoteref.call(ctx, 'Bob', 'late-def', 'Definition comes second.')
const footnotesHtml = footnotes.call(ctx)

// Before transform: Alice is placeholder span, Bob is anchor
expect(aliceHtml).toContain('data-footnote-placeholder')
expect(aliceHtml).toContain('data-footnote-id="late-def"')
expect(bobHtml).toContain('id="late-def-ref-2"')

// Simulate full page and run transform
const fullHtml = `<div>${aliceHtml}${bobHtml}</div>${footnotesHtml}`
const transformed = footnoteTransform.call(ctx, fullHtml)

const transformedRoot = document.createElement('div')
transformedRoot.innerHTML = transformed
const anchors = transformedRoot.querySelectorAll('a[href="#late-def-note"]')
expect(anchors.length).toBe(2)
expect(anchors[0].getAttribute('id')).toBe('late-def-ref')
expect(anchors[0].textContent).toBe('Alice')
expect(anchors[1].getAttribute('id')).toBe('late-def-ref-2')
expect(anchors[1].textContent).toBe('Bob')
expect(anchors[0].getAttribute('data-footnote-index')).toBe('1')
expect(anchors[1].getAttribute('data-footnote-index')).toBe('1')
})

it('should warn when placeholder never gets a definition', () => {
const ctx = { page: { inputPath: 'tests/footnoteref-orphan-warn' } }
const { footnoteref, footnoteTransform } = plugin(config)
const spy = jest.spyOn(console, 'log').mockImplementation(() => {})
const placeholder = footnoteref.call(ctx, 'Orphan', 'never-defined')
const fullHtml = `<div>${placeholder}</div>`

footnoteTransform.call(ctx, fullHtml)

expect(spy).toHaveBeenCalledWith(
expect.stringMatching(/never-defined.*no given description|no given description.*never-defined/)
)
spy.mockRestore()
})
})

describe('The `footnotes` shortcode', () => {
Expand Down