Ensure anchor links are unique per document (#574)

This commit is contained in:
Sviatoslav 2018-05-04 05:36:12 +03:00 committed by Yangshun Tay
parent 2a83959ac1
commit 9c98142fea
12 changed files with 440 additions and 43 deletions

View file

@ -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

View file

@ -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>"`;

View file

@ -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",
},
]
`;

View file

@ -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"'
);
});

View file

@ -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');
});

View file

@ -12,3 +12,18 @@ const toSlug = require('../toSlug');
expect(toSlug(input)).toBe(output); 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
lib/core/anchors.js Normal file
View file

@ -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;

View file

@ -23,20 +23,29 @@ module.exports = (content, headingTags = 'h2', subHeadingTags = 'h3') => {
const subHeadingLevels = subHeadingTags const subHeadingLevels = subHeadingTags
? [].concat(subHeadingTags).map(tagToLevel) ? [].concat(subHeadingTags).map(tagToLevel)
: []; : [];
const allowedHeadingLevels = headingLevels.concat(subHeadingLevels);
const md = new Remarkable(); const md = new Remarkable();
const headings = mdToc(content, { const headings = mdToc(content).json;
filter: function(str, ele) {
return headingLevels.concat(subHeadingLevels).includes(ele.lvl);
},
}).json;
const toc = []; const toc = [];
const context = {};
let current; let current;
headings.forEach(heading => { headings.forEach(heading => {
// we need always generate slugs to ensure, that we will have consistent
// slug indexes for headings with the same names
const hashLink = toSlug(heading.content, context);
if (!allowedHeadingLevels.includes(heading.lvl)) {
return;
}
const rawContent = mdToc.titleize(heading.content);
const entry = { const entry = {
hashLink: toSlug(heading.content), hashLink,
content: md.renderInline(mdToc.titleize(heading.content)), rawContent,
content: md.renderInline(rawContent),
children: [], children: [],
}; };

View file

@ -7,28 +7,10 @@
const hljs = require('highlight.js'); const hljs = require('highlight.js');
const Markdown = require('remarkable'); const Markdown = require('remarkable');
const toSlug = require('./toSlug.js'); const anchors = require('./anchors.js');
const CWD = process.cwd(); const CWD = process.cwd();
/**
* 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];
return (
'<h' +
tokens[idx].hLevel +
'><a class="anchor" aria-hidden="true" id="' +
toSlug(textToken.content) +
'"></a><a href="#' +
toSlug(textToken.content) +
'" 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>'
);
};
}
class MarkdownRenderer { class MarkdownRenderer {
constructor() { constructor() {
const siteConfig = require(CWD + '/siteConfig.js'); const siteConfig = require(CWD + '/siteConfig.js');

View file

@ -18,7 +18,16 @@ const exceptAlphanum = new RegExp(
'g' 'g'
); );
module.exports = string => { /**
* Converts a string to a slug, that can be used in heading anchors
*
* @param {string} string
* @param {Object} [context={}] - an optional context to track used slugs and
* ensure that new slug will be unique
*
* @return {string}
*/
module.exports = (string, context = {}) => {
// var accents = "àáäâèéëêìíïîòóöôùúüûñç"; // var accents = "àáäâèéëêìíïîòóöôùúüûñç";
const accents = const accents =
'\u00e0\u00e1\u00e4\u00e2\u00e8' + '\u00e0\u00e1\u00e4\u00e2\u00e8' +
@ -50,5 +59,22 @@ module.exports = string => {
slug += '-'; slug += '-';
} }
if (!context.slugStats) {
context.slugStats = {};
}
if (typeof context.slugStats[slug] === 'number') {
// search for an index, that will not clash with an existing headings
while (
typeof context.slugStats[slug + '-' + ++context.slugStats[slug]] ===
'number'
);
slug += '-' + context.slugStats[slug];
}
// we are tracking both original anchors and suffixed to avoid future name
// clashing with headings with numbers e.g. `#Foo 1` may clash with the second `#Foo`
context.slugStats[slug] = 0;
return slug; return slug;
}; };

View file

@ -14,7 +14,7 @@ async function execute() {
const readMetadata = require('./readMetadata.js'); const readMetadata = require('./readMetadata.js');
const path = require('path'); const path = require('path');
const color = require('color'); const color = require('color');
const toSlug = require('../core/toSlug.js'); const getTOC = require('../core/getTOC.js');
const React = require('react'); const React = require('react');
const mkdirp = require('mkdirp'); const mkdirp = require('mkdirp');
const glob = require('glob'); const glob = require('glob');
@ -42,15 +42,12 @@ async function execute() {
// takes the content of a doc article and returns the content with a table of // takes the content of a doc article and returns the content with a table of
// contents inserted // contents inserted
const insertTableOfContents = rawContent => { const insertTableOfContents = rawContent => {
const regexp = /\n###\s+(`.*`.*)\n/g; const filterRe = /^`[^`]*`/;
let match; const headers = getTOC(rawContent, 'h3', null);
const headers = [];
while ((match = regexp.exec(rawContent))) {
headers.push(match[1]);
}
const tableOfContents = headers const tableOfContents = headers
.map(header => ` - [${header}](#${toSlug(header)})`) .filter(header => filterRe.test(header.rawContent))
.map(header => ` - [${header.rawContent}](#${header.hashLink})`)
.join('\n'); .join('\n');
return rawContent.replace(TABLE_OF_CONTENTS_TOKEN, tableOfContents); return rawContent.replace(TABLE_OF_CONTENTS_TOKEN, tableOfContents);

View file

@ -17,7 +17,7 @@ function execute(port) {
const os = require('os'); const os = require('os');
const path = require('path'); const path = require('path');
const color = require('color'); const color = require('color');
const toSlug = require('../core/toSlug'); const getTOC = require('../core/getTOC');
const mkdirp = require('mkdirp'); const mkdirp = require('mkdirp');
const glob = require('glob'); const glob = require('glob');
const chalk = require('chalk'); const chalk = require('chalk');
@ -91,15 +91,12 @@ function execute(port) {
const TABLE_OF_CONTENTS_TOKEN = '<AUTOGENERATED_TABLE_OF_CONTENTS>'; const TABLE_OF_CONTENTS_TOKEN = '<AUTOGENERATED_TABLE_OF_CONTENTS>';
const insertTableOfContents = rawContent => { const insertTableOfContents = rawContent => {
const regexp = /\n###\s+(`.*`.*)\n/g; const filterRe = /^`[^`]*`/;
let match; const headers = getTOC(rawContent, 'h3', null);
const headers = [];
while ((match = regexp.exec(rawContent))) {
headers.push(match[1]);
}
const tableOfContents = headers const tableOfContents = headers
.map(header => ` - [${header}](#${toSlug(header)})`) .filter(header => filterRe.test(header.rawContent))
.map(header => ` - [${header.rawContent}](#${header.hashLink})`)
.join('\n'); .join('\n');
return rawContent.replace(TABLE_OF_CONTENTS_TOKEN, tableOfContents); return rawContent.replace(TABLE_OF_CONTENTS_TOKEN, tableOfContents);