Skip to content

Commit

Permalink
Ensure anchor links are unique per document (#574)
Browse files Browse the repository at this point in the history
  • Loading branch information
SleepWalker authored and yangshun committed May 4, 2018
1 parent 2a83959 commit 9c98142
Show file tree
Hide file tree
Showing 12 changed files with 440 additions and 43 deletions.
20 changes: 20 additions & 0 deletions lib/core/__tests__/__fixtures__/getTOC.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
## foo
### foo
### foo 1
## foo 1
## foo 2
### foo
#### 4th level headings
All 4th level headings should not be shown by default

## bar
### bar
#### bar
4th level heading should be ignored by default, but is should be always taken
into account, when generating slugs
### `bar`
#### `bar`
## bar
### bar
#### bar
## bar
5 changes: 5 additions & 0 deletions lib/core/__tests__/__snapshots__/anchors.tests.js.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Anchors rendering 1`] = `"<h1><a class=\\"anchor\\" aria-hidden=\\"true\\" id=\\"hello-world\\"></a><a href=\\"#hello-world\\" aria-hidden=\\"true\\" class=\\"hash-link\\" ><svg aria-hidden=\\"true\\" height=\\"16\\" version=\\"1.1\\" viewBox=\\"0 0 16 16\\" width=\\"16\\"><path fill-rule=\\"evenodd\\" d=\\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\\"></path></svg></a>"`;
exports[`Anchors rendering 2`] = `"<h2><a class=\\"anchor\\" aria-hidden=\\"true\\" id=\\"hello-small-world\\"></a><a href=\\"#hello-small-world\\" aria-hidden=\\"true\\" class=\\"hash-link\\" ><svg aria-hidden=\\"true\\" height=\\"16\\" version=\\"1.1\\" viewBox=\\"0 0 16 16\\" width=\\"16\\"><path fill-rule=\\"evenodd\\" d=\\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\\"></path></svg></a>"`;
187 changes: 187 additions & 0 deletions lib/core/__tests__/__snapshots__/getTOC.tests.js.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`with custom heading levels 1`] = `
Array [
Object {
"children": Array [
Object {
"children": Array [],
"content": "foo",
"hashLink": "foo-1",
"rawContent": "foo",
},
Object {
"children": Array [],
"content": "foo 1",
"hashLink": "foo-1-1",
"rawContent": "foo 1",
},
],
"content": "foo",
"hashLink": "foo",
"rawContent": "foo",
},
Object {
"children": Array [],
"content": "foo 1",
"hashLink": "foo-1-2",
"rawContent": "foo 1",
},
Object {
"children": Array [
Object {
"children": Array [],
"content": "foo",
"hashLink": "foo-3",
"rawContent": "foo",
},
Object {
"children": Array [],
"content": "4th level headings",
"hashLink": "4th-level-headings",
"rawContent": "4th level headings",
},
],
"content": "foo 2",
"hashLink": "foo-2",
"rawContent": "foo 2",
},
Object {
"children": Array [
Object {
"children": Array [],
"content": "bar",
"hashLink": "bar-1",
"rawContent": "bar",
},
Object {
"children": Array [],
"content": "bar",
"hashLink": "bar-2",
"rawContent": "bar",
},
Object {
"children": Array [],
"content": "<code>bar</code>",
"hashLink": "bar-3",
"rawContent": "\`bar\`",
},
Object {
"children": Array [],
"content": "<code>bar</code>",
"hashLink": "bar-4",
"rawContent": "\`bar\`",
},
],
"content": "bar",
"hashLink": "bar",
"rawContent": "bar",
},
Object {
"children": Array [
Object {
"children": Array [],
"content": "bar",
"hashLink": "bar-6",
"rawContent": "bar",
},
Object {
"children": Array [],
"content": "bar",
"hashLink": "bar-7",
"rawContent": "bar",
},
],
"content": "bar",
"hashLink": "bar-5",
"rawContent": "bar",
},
Object {
"children": Array [],
"content": "bar",
"hashLink": "bar-8",
"rawContent": "bar",
},
]
`;

exports[`with defaults 1`] = `
Array [
Object {
"children": Array [
Object {
"children": Array [],
"content": "foo",
"hashLink": "foo-1",
"rawContent": "foo",
},
Object {
"children": Array [],
"content": "foo 1",
"hashLink": "foo-1-1",
"rawContent": "foo 1",
},
],
"content": "foo",
"hashLink": "foo",
"rawContent": "foo",
},
Object {
"children": Array [],
"content": "foo 1",
"hashLink": "foo-1-2",
"rawContent": "foo 1",
},
Object {
"children": Array [
Object {
"children": Array [],
"content": "foo",
"hashLink": "foo-3",
"rawContent": "foo",
},
],
"content": "foo 2",
"hashLink": "foo-2",
"rawContent": "foo 2",
},
Object {
"children": Array [
Object {
"children": Array [],
"content": "bar",
"hashLink": "bar-1",
"rawContent": "bar",
},
Object {
"children": Array [],
"content": "<code>bar</code>",
"hashLink": "bar-3",
"rawContent": "\`bar\`",
},
],
"content": "bar",
"hashLink": "bar",
"rawContent": "bar",
},
Object {
"children": Array [
Object {
"children": Array [],
"content": "bar",
"hashLink": "bar-6",
"rawContent": "bar",
},
],
"content": "bar",
"hashLink": "bar-5",
"rawContent": "bar",
},
Object {
"children": Array [],
"content": "bar",
"hashLink": "bar-8",
"rawContent": "bar",
},
]
`;
104 changes: 104 additions & 0 deletions lib/core/__tests__/anchors.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
const anchors = require('../anchors');

const md = {
renderer: {
rules: {},
},
};

anchors(md);

const render = md.renderer.rules.heading_open;

test('Anchors rendering', () => {
expect(
render([{hLevel: 1}, {content: 'Hello world'}], 0, {}, {})
).toMatchSnapshot();
expect(
render([{hLevel: 2}, {content: 'Hello small world'}], 0, {}, {})
).toMatchSnapshot();
});

test('Each anchor is unique across rendered document', () => {
const tokens = [
{hLevel: 1},
{content: 'Almost unique heading'},
{hLevel: 1},
{content: 'Almost unique heading'},
{hLevel: 1},
{content: 'Almost unique heading 1'},
{hLevel: 1},
{content: 'Almost unique heading 1'},
{hLevel: 1},
{content: 'Almost unique heading 2'},
{hLevel: 1},
{content: 'Almost unique heading'},
];
const options = {};
const env = {};

expect(render(tokens, 0, options, env)).toContain(
'id="almost-unique-heading"'
);
expect(render(tokens, 2, options, env)).toContain(
'id="almost-unique-heading-1"'
);
expect(render(tokens, 4, options, env)).toContain(
'id="almost-unique-heading-1-1"'
);
expect(render(tokens, 6, options, env)).toContain(
'id="almost-unique-heading-1-2"'
);
expect(render(tokens, 8, options, env)).toContain(
'id="almost-unique-heading-2"'
);
expect(render(tokens, 10, options, env)).toContain(
'id="almost-unique-heading-3"'
);
});

test('Each anchor is unique across rendered document. Case 2', () => {
const tokens = [
{hLevel: 1},
{content: 'foo'},
{hLevel: 1},
{content: 'foo 1'},
{hLevel: 1},
{content: 'foo'},
{hLevel: 1},
{content: 'foo 1'},
];
const options = {};
const env = {};

expect(render(tokens, 0, options, env)).toContain('id="foo"');
expect(render(tokens, 2, options, env)).toContain('id="foo-1"');
expect(render(tokens, 4, options, env)).toContain('id="foo-2"');
expect(render(tokens, 6, options, env)).toContain('id="foo-1-1"');
});

test('Anchor index resets on each render', () => {
const tokens = [
{hLevel: 1},
{content: 'Almost unique heading'},
{hLevel: 1},
{content: 'Almost unique heading'},
];
const options = {};
const env = {};
const env2 = {};

expect(render(tokens, 0, options, env)).toContain(
'id="almost-unique-heading"'
);
expect(render(tokens, 2, options, env)).toContain(
'id="almost-unique-heading-1"'
);

expect(render(tokens, 0, options, env2)).toContain(
'id="almost-unique-heading"'
);
expect(render(tokens, 2, options, env2)).toContain(
'id="almost-unique-heading-1"'
);
});
26 changes: 26 additions & 0 deletions lib/core/__tests__/getTOC.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const path = require('path');
const readFileSync = require('fs').readFileSync;
const getTOC = require('../getTOC');

const mdContents = readFileSync(
path.join(__dirname, '__fixtures__', 'getTOC.md'),
'utf-8'
);

test('with defaults', () => {
const headings = getTOC(mdContents);
const headingsJson = JSON.stringify(headings);

expect(headings).toMatchSnapshot();
expect(headingsJson).toContain('bar-8'); // maximum unique bar index is 8
expect(headingsJson).not.toContain('4th level headings');
});

test('with custom heading levels', () => {
const headings = getTOC(mdContents, 'h2', ['h3', 'h4']);
const headingsJson = JSON.stringify(headings);

expect(headings).toMatchSnapshot();
expect(headingsJson).toContain('bar-8'); // maximum unique bar index is 8
expect(headingsJson).toContain('4th level headings');
});
15 changes: 15 additions & 0 deletions lib/core/__tests__/toSlug.tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,18 @@ const toSlug = require('../toSlug');
expect(toSlug(input)).toBe(output);
});
});

test('unique slugs if `context` argument passed', () => {
[
['foo', 'foo'],
['foo', 'foo-1'],
['foo 1', 'foo-1-1'],
['foo 1', 'foo-1-2'],
['foo 2', 'foo-2'],
['foo', 'foo-3'],
].reduce((context, [input, output]) => {
expect(toSlug(input, context)).toBe(output);

return context;
}, {});
});
29 changes: 29 additions & 0 deletions lib/core/anchors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Copyright (c) 2017-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
const toSlug = require('./toSlug.js');

/**
* The anchors plugin adds GFM-style anchors to headings.
*/
function anchors(md) {
md.renderer.rules.heading_open = function(tokens, idx, options, env) {
const textToken = tokens[idx + 1];
const anchor = toSlug(textToken.content, env);

return (
'<h' +
tokens[idx].hLevel +
'><a class="anchor" aria-hidden="true" id="' +
anchor +
'"></a><a href="#' +
anchor +
'" aria-hidden="true" class="hash-link" ><svg aria-hidden="true" height="16" version="1.1" viewBox="0 0 16 16" width="16"><path fill-rule="evenodd" d="M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z"></path></svg></a>'
);
};
}

module.exports = anchors;
Loading

0 comments on commit 9c98142

Please sign in to comment.