Skip to content

Commit db31b2d

Browse files
committed
Fixes to Markdown GFM footnotes
1 parent 0a6bac9 commit db31b2d

9 files changed

Lines changed: 208 additions & 9 deletions

File tree

‎src/content/articles/alert-fatigue-reduction-triage-actionable-alerts/index.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ The most visible symptom of alert fatigue is MTTA drift—mean time to acknowled
2929

3030
There's solid research on this from healthcare, where alarm fatigue kills patients. Studies in ICUs found that 72-99% of alarms required no clinical intervention[^1], and staff developed coping mechanisms—turning down volume, disabling non-critical alarms, or simply not rushing to respond. Software operations is no different.
3131

32-
[^1]: Sendelbach S, Funk M. "Alarm fatigue: a patient safety concern." AACN Adv Crit Care. 2013 Oct-Dec;24(4):378-86.
32+
[^1]: Sendelbach S, Funk M. "Alarm fatigue: a patient safety concern." AACN Adv Crit Care. 2013 Oct-Dec: 378-86.
3333

3434
The feedback loop makes it worse. After an incident caused by a missed alert, the instinct is to add more alerts. But if the alert was missed because of volume, adding more alerts makes the next miss more likely, not less. You can't fix what you haven't measured—and most teams have never actually measured their alert usefulness.
3535

‎src/lib/markdown/__tests__/integration/rehype-footnotes-title-astro.spec.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,22 @@ describe('rehype-footnotes-title (Layer 2: With Astro Pipeline)', () => {
1919
expect(html).toContain('data-footnote-backref')
2020
expect(html).toContain('title="Return to footnote 1"')
2121
})
22+
23+
it('should set aria-labelledby on the footnotes section', async () => {
24+
const markdown = `Here is a footnote.[^1]
25+
26+
[^1]: My reference.`
27+
28+
const html = await processWithAstroSettings({
29+
markdown,
30+
plugin: rehypeFootnotesTitle,
31+
pluginOptions: rehypeFootnotesTitleConfig,
32+
stage: 'rehype',
33+
})
34+
35+
expect(html).toContain('data-footnotes')
36+
expect(html).toContain('aria-labelledby="footnote-label"')
37+
expect(html).toContain('id="footnote-label"')
38+
expect(html).not.toContain('class="sr-only"')
39+
})
2240
})

‎src/lib/markdown/__tests__/integration/remark-restore-time-colons-astro.spec.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,18 @@ describe('remark-restore-time-colons (Layer 2: With Astro Pipeline)', () => {
1111
expect(html).toContain('12:00:01')
1212
expect(html).not.toContain('<div></div>')
1313
})
14+
15+
it('preserves citation page ranges inside footnotes', async () => {
16+
const markdown = [
17+
'Alarm fatigue reference[^1].',
18+
'',
19+
'[^1]: Sendelbach S, Funk M. "Alarm fatigue: a patient safety concern." AACN Adv Crit Care. 2013 Oct-Dec;24(4):378-86.',
20+
].join('\n')
21+
22+
const html = await processWithFullPipeline(markdown)
23+
24+
expect(html).toContain('24(4):378-86')
25+
expect(html).not.toContain('<div></div>')
26+
expect(html).not.toContain('</p><div></div>')
27+
})
1428
})

‎src/lib/markdown/plugins/rehype-footnotes-title/__tests__/index.spec.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,4 +37,23 @@ describe('rehype-footnotes-title (Layer 1: Isolated)', () => {
3737
expect(html).toContain('data-footnote-ref')
3838
expect(html).not.toContain('data-footnote-ref" title=')
3939
})
40+
41+
it('should link footnotes section to its Footnotes heading with aria-labelledby', async () => {
42+
const markdown = `Here is a footnote.[^1]
43+
44+
[^1]: My reference.`
45+
46+
const html = await processIsolated({
47+
markdown,
48+
stage: 'rehype',
49+
plugin: rehypeFootnotesTitle,
50+
pluginOptions: rehypeFootnotesTitleConfig,
51+
gfm: true,
52+
})
53+
54+
expect(html).toContain('data-footnotes')
55+
expect(html).toContain('aria-labelledby="footnote-label"')
56+
expect(html).toContain('id="footnote-label"')
57+
expect(html).not.toContain('class="sr-only"')
58+
})
4059
})

‎src/lib/markdown/plugins/rehype-footnotes-title/index.ts‎

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,69 @@
11
import { visit } from 'unist-util-visit'
2-
import type { Root, Element } from 'hast'
2+
import type { Root, Element, ElementContent, Text } from 'hast'
3+
4+
const FOOTNOTES_HEADING_TEXT = 'Footnotes'
5+
const FOOTNOTES_HEADING_ID = 'footnote-label'
6+
7+
function isElementNode(node: ElementContent): node is Element {
8+
return node.type === 'element'
9+
}
10+
11+
function getNodeText(node: Element): string {
12+
let textContent = ''
13+
14+
for (const child of node.children) {
15+
if (child.type === 'text') {
16+
textContent += (child as Text).value
17+
continue
18+
}
19+
20+
if (isElementNode(child)) {
21+
textContent += getNodeText(child)
22+
}
23+
}
24+
25+
return textContent
26+
}
27+
28+
function findFootnotesHeading(section: Element): Element | null {
29+
for (const child of section.children) {
30+
if (!isElementNode(child) || child.tagName !== 'h2') continue
31+
32+
if (getNodeText(child).trim() === FOOTNOTES_HEADING_TEXT) {
33+
return child
34+
}
35+
}
36+
37+
return null
38+
}
39+
40+
function labelFootnotesSection(section: Element): void {
41+
const heading = findFootnotesHeading(section)
42+
if (!heading) return
43+
44+
const headingProperties = heading.properties || {}
45+
46+
const headingClasses = headingProperties['className']
47+
if (Array.isArray(headingClasses)) {
48+
headingProperties['className'] = headingClasses.filter(className => className !== 'sr-only')
49+
}
50+
51+
const headingId = typeof headingProperties['id'] === 'string'
52+
? headingProperties['id']
53+
: FOOTNOTES_HEADING_ID
54+
55+
headingProperties['id'] = headingId
56+
heading.properties = headingProperties
57+
58+
const sectionProperties = section.properties || {}
59+
sectionProperties['aria-labelledby'] = headingId
60+
section.properties = sectionProperties
61+
}
62+
63+
function isFootnotesSection(node: Element): boolean {
64+
const properties = node.properties || {}
65+
return 'dataFootnotes' in properties || 'data-footnotes' in properties
66+
}
367

468
function getIdentifierFromBackrefHref(href: unknown): string | null {
569
if (typeof href !== 'string') return null
@@ -27,6 +91,10 @@ export type RehypeFootnotesTitleOptions = string
2791
export function rehypeFootnotesTitle(titleTemplate: RehypeFootnotesTitleOptions = '') {
2892
return (tree: Root) => {
2993
visit(tree, 'element', (node: Element) => {
94+
if (node.tagName === 'section' && isFootnotesSection(node)) {
95+
labelFootnotesSection(node)
96+
}
97+
3098
if (node.tagName !== 'a') return
3199

32100
const properties = node.properties || {}

‎src/lib/markdown/plugins/rehype-tailwind/index.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,11 @@ export function rehypeTailwindClasses() {
198198
*/
199199
if (['h2', 'h3', 'h4'].includes(node.tagName)) {
200200
node.properties = node.properties || {}
201+
const headingTextClass = 'text-page-inverse'
201202
node.properties['className'] = ((node.properties['className'] as string[]) || []).concat([
202203
'relative',
203204
'my-6',
204-
'text-page-inverse',
205+
headingTextClass,
205206
/**
206207
* Remove top margin from first child element of headings since we're
207208
* controlling bottom margin here.

‎src/lib/markdown/plugins/remark-restore-time-colons/__tests__/index.spec.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,24 @@ describe('remark-restore-time-colons', () => {
110110
})
111111
})
112112

113+
describe('citation page ranges', () => {
114+
it('corrupts citation page ranges without the fix', async () => {
115+
const output = await processWithoutFix(
116+
'AACN Adv Crit Care. 2013 Oct-Dec;24(4):378-86.'
117+
)
118+
expect(output).toContain('<div></div>')
119+
expect(output).not.toContain('24(4):378-86')
120+
})
121+
122+
it('preserves citation page ranges with the fix', async () => {
123+
const output = await processToHtml(
124+
'AACN Adv Crit Care. 2013 Oct-Dec;24(4):378-86.'
125+
)
126+
expect(output).toContain('24(4):378-86')
127+
expect(output).not.toContain('<div></div>')
128+
})
129+
})
130+
113131
describe('non-time directives are unaffected', () => {
114132
it('does not interfere with alphabetic text directives', async () => {
115133
// A real text directive like :abbr[HTML] should not be touched

‎src/lib/markdown/plugins/remark-restore-time-colons/index.ts‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,19 @@ import type { Plugin } from 'unified'
1717
import { visit } from 'unist-util-visit'
1818

1919
/**
20-
* Test whether a directive name is purely numeric (0-9).
21-
* No valid remark directive would use a numeric-only name, so these are
22-
* always false positives created from time-like strings.
20+
* Test whether a directive name is numeric-like and therefore a likely
21+
* false positive from prose (time/citation/page-range text).
22+
*
23+
* Examples we want to restore:
24+
* - :47 (time)
25+
* - :59 (time)
26+
* - :378-86 (citation page range)
27+
* - :378–86 / :378—86 (en/em dash variants)
28+
*
29+
* We intentionally avoid alphabetic names so real directives such as
30+
* `:abbr[...]` remain untouched.
2331
*/
24-
const isNumericName = (name: string): boolean => /^\d+$/.test(name)
32+
const isNumericLikeName = (name: string): boolean => /^\d[\d\-–—]*$/.test(name)
2533

2634
/** Record of a directive node to restore, collected during walk. */
2735
interface PendingRestore {
@@ -43,8 +51,8 @@ const remarkRestoreTimeColons: Plugin<[], Root> = () => {
4351
return
4452
}
4553

46-
// Only restore purely-numeric names; real directives have alphabetic names
47-
if (!isNumericName(node.name)) return
54+
// Restore numeric-like names; real directives have alphabetic names
55+
if (!isNumericLikeName(node.name)) return
4856
if (index === undefined || !parent) return
4957

5058
pending.push({ parent: parent as Parent, index, name: node.name })

‎src/styles/markdown.css‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,59 @@
1212
text-decoration: none;
1313
}
1414

15+
/**
16+
* Footnote backref icon link should not inherit dotted underline decoration.
17+
*/
18+
.markdown-content__prose a[data-footnote-backref],
19+
.markdown-content__prose a[data-footnote-backref]:hover,
20+
.markdown-content__prose a.footnote-backref,
21+
.markdown-content__prose a.footnote-backref:hover {
22+
text-decoration: none;
23+
}
24+
25+
.markdown-content__prose a[data-footnote-backref],
26+
.markdown-content__prose a.footnote-backref {
27+
display: inline-block;
28+
transition: color 150ms ease, transform 150ms ease;
29+
}
30+
31+
.markdown-content__prose a[data-footnote-backref]:hover,
32+
.markdown-content__prose a[data-footnote-backref]:focus-visible,
33+
.markdown-content__prose a.footnote-backref:hover,
34+
.markdown-content__prose a.footnote-backref:focus-visible {
35+
color: var(--color-content-offset);
36+
transform: translateY(-2px);
37+
}
38+
39+
/**
40+
* Add a separator above the generated footnotes section.
41+
*/
42+
.markdown-content__prose section[data-footnotes] {
43+
border-top: 1px solid var(--color-trim);
44+
margin-top: 2rem;
45+
padding-top: 1rem;
46+
}
47+
48+
/**
49+
* Footnote list marker numbers should fit comfortably inside the circular marker.
50+
*/
51+
.markdown-content__prose section[data-footnotes] .markdown-list ol {
52+
margin-left: 4px;
53+
}
54+
55+
.markdown-content__prose section[data-footnotes] .markdown-list ol > li {
56+
padding-left: calc(1.32em + 0.375rem);
57+
}
58+
59+
.markdown-content__prose section[data-footnotes] .markdown-list ol > li::before {
60+
background-color: var(--color-content-active);
61+
font-size: 0.8em;
62+
line-height: 1em;
63+
height: 1.32em;
64+
top: calc(0.15em + 2px);
65+
width: 1.32em;
66+
}
67+
1568
/**
1669
* Details and Summary stylings. Gives a thin line with a "+" symbol,
1770
* and animates the content when opening and closing.

0 commit comments

Comments
 (0)