Skip to content

Commit 3ebeca8

Browse files
authored
Fix Markdown table-of-contents links (#1613)
* Fix Markdown table-of-contents links Preserve fragment links and generate safe GitHub-compatible heading IDs in package READMEs and Markdown Preview. Support both preview parsers and scope scrolling to the current view. * Reuse and reset Markdown heading slugger
1 parent 48ef2d8 commit 3ebeca8

10 files changed

Lines changed: 416 additions & 3 deletions

File tree

packages/markdown-preview/lib/markdown-preview-view.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ module.exports = class MarkdownPreviewView {
2323
this.loaded = false
2424
this.disposables = new CompositeDisposable()
2525
this.registerScrollCommands()
26+
this.registerAnchorScrolling()
2627
if (this.editorId != null) {
2728
this.resolveEditor(this.editorId)
2829
} else if (atom.packages.hasActivatedInitialPackages()) {
@@ -83,6 +84,40 @@ module.exports = class MarkdownPreviewView {
8384
)
8485
}
8586

87+
// Pulsar's global link handler prevents native fragment navigation.
88+
registerAnchorScrolling() {
89+
const handleClick = event => this.scrollToAnchor(event)
90+
this.element.addEventListener('click', handleClick)
91+
this.disposables.add(
92+
new Disposable(() =>
93+
this.element.removeEventListener('click', handleClick)
94+
)
95+
)
96+
}
97+
98+
scrollToAnchor(event) {
99+
const anchor = event.target.closest('a[href^="#"]')
100+
if (anchor == null) return
101+
102+
let id = anchor.getAttribute('href').slice(1)
103+
try {
104+
id = decodeURIComponent(id)
105+
} catch (error) {
106+
// Fall back to the raw fragment.
107+
}
108+
if (!id) return
109+
110+
// Prefer generated heading ids over colliding raw ids.
111+
const prefixedId = `user-content-${id}`
112+
const target =
113+
this.element.querySelector(`[id="${CSS.escape(prefixedId)}"]`) ??
114+
this.element.querySelector(`[id="${CSS.escape(id)}"]`)
115+
if (target == null) return
116+
117+
event.preventDefault()
118+
target.scrollIntoView()
119+
}
120+
86121
onDidChangeTitle(callback) {
87122
return this.emitter.on('did-change-title', callback)
88123
}

packages/markdown-preview/lib/renderer.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ const createDOMPurify = require('dompurify')
44
const emoji = require('emoji-images')
55
const fs = require('fs-plus')
66
let marked = null // Defer until used
7+
let githubSlugger = null
8+
let innertext = null
79
let renderer = null
810
let cheerio = null
911
let yamlFrontMatter = null
@@ -108,6 +110,7 @@ function chooseRender(text, filePath) {
108110
filePath: filePath,
109111
breaks: atom.config.get('markdown-preview.breakOnSingleNewline'),
110112
useDefaultEmoji: true,
113+
useGitHubHeadings: true,
111114
sanitizeAllowUnknownProtocols: atom.config.get('markdown-preview.allowUnsafeProtocols')
112115
})
113116
return atom.ui.markdown.convertToDOM(html)
@@ -171,17 +174,26 @@ exports.toHTML = async function (text, filePath, grammar) {
171174
function render(text, filePath) {
172175
if (marked == null || yamlFrontMatter == null || cheerio == null) {
173176
marked = require('marked')
177+
const GithubSlugger = require('github-slugger')
178+
innertext = require('innertext')
174179
yamlFrontMatter = require('yaml-front-matter')
175180
cheerio = require('cheerio')
176181

177182
renderer = new marked.Renderer()
183+
githubSlugger = new GithubSlugger()
178184
renderer.listitem = function (text, isTask) {
179185
const listAttributes = isTask ? ' class="task-list-item"' : ''
180186

181187
return `<li ${listAttributes}>${text}</li>\n`
182188
}
189+
renderer.heading = function (text, level) {
190+
const headingText = innertext(text)
191+
const id = `user-content-${githubSlugger.slug(headingText)}`
192+
return `<h${level} id="${id}">${text}</h${level}>\n`
193+
}
183194
}
184195

196+
githubSlugger.reset()
185197
marked.setOptions({
186198
breaks: atom.config.get('markdown-preview.breakOnSingleNewline'),
187199
renderer

packages/markdown-preview/package-lock.json

Lines changed: 41 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/markdown-preview/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
"emoji-images": "^0.1.1",
2020
"fs-plus": "^3.0.0",
2121
"github-markdown-css": "^5.5.1",
22+
"github-slugger": "^1.1.1",
23+
"innertext": "^1.0.1",
2224
"marked": "5.0.3",
2325
"morphdom": "^2.7.2",
2426
"underscore-plus": "^1.0.0",

packages/markdown-preview/spec/markdown-preview-view-spec.js

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const temp = require('temp').track()
1010
const url = require('url')
1111
const { TextEditor } = require('atom')
1212
const MarkdownPreviewView = require('../lib/markdown-preview-view')
13+
const renderer = require('../lib/renderer')
1314
const TextMateLanguageMode = new TextEditor().getBuffer().getLanguageMode()
1415
.constructor
1516
const { conditionPromise } = require('./async-spec-helpers')
@@ -64,6 +65,136 @@ describe('MarkdownPreviewView', function () {
6465
})
6566
})
6667

68+
describe('in-page anchor links', function () {
69+
it('uses GitHub-compatible heading ids with the original parser', async function () {
70+
atom.config.set('markdown-preview.useOriginalParser', true)
71+
const html = await renderer.toHTML(
72+
[
73+
'[Launch](#-launch)',
74+
'',
75+
'## 🚀 Launch',
76+
'',
77+
'[Title](#title)',
78+
'',
79+
'## Title',
80+
'',
81+
'[HTML](#hello-world)',
82+
'',
83+
'## Hello<span>world</span>',
84+
'',
85+
'[Café](#caf%C3%A9)',
86+
'',
87+
'## Caf&eacute;'
88+
].join('\n')
89+
)
90+
preview.element.innerHTML = html
91+
92+
const launchLink = preview.element.querySelector('a[href="#-launch"]')
93+
const launchTarget = preview.element.querySelector(
94+
'#user-content--launch'
95+
)
96+
const titleTarget = preview.element.querySelector('#user-content-title')
97+
const htmlTarget = preview.element.querySelector(
98+
'#user-content-hello-world'
99+
)
100+
const entityLink = preview.element.querySelector(
101+
'a[href="#caf%C3%A9"]'
102+
)
103+
const entityTarget = preview.element.querySelector('#user-content-café')
104+
spyOn(launchTarget, 'scrollIntoView')
105+
spyOn(entityTarget, 'scrollIntoView')
106+
107+
launchLink.dispatchEvent(
108+
new MouseEvent('click', { bubbles: true, cancelable: true })
109+
)
110+
entityLink.dispatchEvent(
111+
new MouseEvent('click', { bubbles: true, cancelable: true })
112+
)
113+
114+
expect(launchTarget.scrollIntoView).toHaveBeenCalled()
115+
expect(entityTarget.scrollIntoView).toHaveBeenCalled()
116+
expect(titleTarget).not.toBeNull()
117+
expect(htmlTarget).not.toBeNull()
118+
})
119+
120+
it('resets original parser heading ids between renders', async function () {
121+
atom.config.set('markdown-preview.useOriginalParser', true)
122+
const markdown = ['## Repeated', '', '## Repeated'].join('\n')
123+
124+
const firstRender = await renderer.toHTML(markdown)
125+
const secondRender = await renderer.toHTML(markdown)
126+
127+
for (const html of [firstRender, secondRender]) {
128+
expect(html).toContain('id="user-content-repeated"')
129+
expect(html).toContain('id="user-content-repeated-1"')
130+
expect(html).not.toContain('id="user-content-repeated-2"')
131+
}
132+
})
133+
134+
it('prefers the generated prefixed id over a colliding raw id', function () {
135+
preview.element.innerHTML = [
136+
'<a href="#user-content-foo">User content foo</a>',
137+
'<h2 id="user-content-foo">Foo</h2>',
138+
'<h2 id="user-content-user-content-foo">User content foo</h2>'
139+
].join('')
140+
const anchor = preview.element.querySelector(
141+
'a[href="#user-content-foo"]'
142+
)
143+
const rawTarget = preview.element.querySelector('#user-content-foo')
144+
const prefixedTarget = preview.element.querySelector(
145+
'#user-content-user-content-foo'
146+
)
147+
spyOn(rawTarget, 'scrollIntoView')
148+
spyOn(prefixedTarget, 'scrollIntoView')
149+
150+
anchor.dispatchEvent(
151+
new MouseEvent('click', { bubbles: true, cancelable: true })
152+
)
153+
154+
expect(prefixedTarget.scrollIntoView).toHaveBeenCalled()
155+
expect(rawTarget.scrollIntoView).not.toHaveBeenCalled()
156+
})
157+
158+
it('scrolls to the matching heading when a fragment link is clicked', function () {
159+
preview.element.innerHTML =
160+
'<p><a href="#install">Install</a></p><h2 id="install">Install</h2>'
161+
const anchor = preview.element.querySelector('a[href="#install"]')
162+
const target = preview.element.querySelector('#install')
163+
spyOn(target, 'scrollIntoView')
164+
165+
anchor.dispatchEvent(
166+
new MouseEvent('click', { bubbles: true, cancelable: true })
167+
)
168+
169+
expect(target.scrollIntoView).toHaveBeenCalled()
170+
})
171+
172+
it('scrolls to a safely prefixed GitHub heading', function () {
173+
preview.element.innerHTML =
174+
'<p><a href="#title">Title</a></p><h2 id="user-content-title">Title</h2>'
175+
const anchor = preview.element.querySelector('a[href="#title"]')
176+
const target = preview.element.querySelector('#user-content-title')
177+
spyOn(target, 'scrollIntoView')
178+
179+
anchor.dispatchEvent(
180+
new MouseEvent('click', { bubbles: true, cancelable: true })
181+
)
182+
183+
expect(target.scrollIntoView).toHaveBeenCalled()
184+
})
185+
186+
it('does nothing when the fragment has no matching target', function () {
187+
preview.element.innerHTML = '<p><a href="#missing">Missing</a></p>'
188+
const anchor = preview.element.querySelector('a[href="#missing"]')
189+
190+
expect(() =>
191+
anchor.dispatchEvent(
192+
new MouseEvent('click', { bubbles: true, cancelable: true })
193+
)
194+
).not.toThrow()
195+
})
196+
})
197+
67198
describe('serialization', function () {
68199
let newPreview = null
69200

packages/settings-view/lib/package-readme-view.js

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ export default class PackageReadmeView {
2525

2626
const markdownOpts = {
2727
breaks: false,
28-
taskCheckboxDisabled: true
28+
taskCheckboxDisabled: true,
29+
useGitHubHeadings: true
2930
};
3031

3132
if (readmeIsLocal) {
@@ -39,9 +40,35 @@ export default class PackageReadmeView {
3940
} catch(err) {
4041
this.packageReadme.innerHTML = "<h3>Error parsing README</h3>";
4142
}
43+
44+
// Pulsar's global link handler prevents native fragment navigation.
45+
this.handleAnchorClick = (event) => {
46+
const anchor = event.target.closest('a[href^="#"]');
47+
if (anchor == null) return;
48+
49+
let id = anchor.getAttribute('href').slice(1);
50+
try {
51+
id = decodeURIComponent(id);
52+
} catch (error) {
53+
// Fall back to the raw fragment.
54+
}
55+
if (!id) return;
56+
57+
// Prefer generated heading ids over colliding raw ids.
58+
const prefixedId = `user-content-${id}`;
59+
const target =
60+
this.packageReadme.querySelector(`[id="${CSS.escape(prefixedId)}"]`) ??
61+
this.packageReadme.querySelector(`[id="${CSS.escape(id)}"]`);
62+
if (target == null) return;
63+
64+
event.preventDefault();
65+
target.scrollIntoView();
66+
};
67+
this.packageReadme.addEventListener('click', this.handleAnchorClick);
4268
}
4369

4470
destroy () {
71+
this.packageReadme.removeEventListener('click', this.handleAnchorClick);
4572
this.element.remove()
4673
}
4774
}

0 commit comments

Comments
 (0)