From 9c98142fea416492efebf5a462fc0724551732f6 Mon Sep 17 00:00:00 2001 From: Sviatoslav Date: Fri, 4 May 2018 05:36:12 +0300 Subject: [PATCH] Ensure anchor links are unique per document (#574) --- lib/core/__tests__/__fixtures__/getTOC.md | 20 ++ .../__snapshots__/anchors.tests.js.snap | 5 + .../__snapshots__/getTOC.tests.js.snap | 187 ++++++++++++++++++ lib/core/__tests__/anchors.tests.js | 104 ++++++++++ lib/core/__tests__/getTOC.tests.js | 26 +++ lib/core/__tests__/toSlug.tests.js | 15 ++ lib/core/anchors.js | 29 +++ lib/core/getTOC.js | 23 ++- lib/core/renderMarkdown.js | 20 +- lib/core/toSlug.js | 28 ++- lib/server/generate.js | 13 +- lib/server/server.js | 13 +- 12 files changed, 440 insertions(+), 43 deletions(-) create mode 100644 lib/core/__tests__/__fixtures__/getTOC.md create mode 100644 lib/core/__tests__/__snapshots__/anchors.tests.js.snap create mode 100644 lib/core/__tests__/__snapshots__/getTOC.tests.js.snap create mode 100644 lib/core/__tests__/anchors.tests.js create mode 100644 lib/core/__tests__/getTOC.tests.js create mode 100644 lib/core/anchors.js diff --git a/lib/core/__tests__/__fixtures__/getTOC.md b/lib/core/__tests__/__fixtures__/getTOC.md new file mode 100644 index 000000000000..f56735c17147 --- /dev/null +++ b/lib/core/__tests__/__fixtures__/getTOC.md @@ -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 diff --git a/lib/core/__tests__/__snapshots__/anchors.tests.js.snap b/lib/core/__tests__/__snapshots__/anchors.tests.js.snap new file mode 100644 index 000000000000..70920fd1b15f --- /dev/null +++ b/lib/core/__tests__/__snapshots__/anchors.tests.js.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Anchors rendering 1`] = `"

"`; + +exports[`Anchors rendering 2`] = `"

"`; diff --git a/lib/core/__tests__/__snapshots__/getTOC.tests.js.snap b/lib/core/__tests__/__snapshots__/getTOC.tests.js.snap new file mode 100644 index 000000000000..89176ac5f9f7 --- /dev/null +++ b/lib/core/__tests__/__snapshots__/getTOC.tests.js.snap @@ -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": "bar", + "hashLink": "bar-3", + "rawContent": "\`bar\`", + }, + Object { + "children": Array [], + "content": "bar", + "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": "bar", + "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", + }, +] +`; diff --git a/lib/core/__tests__/anchors.tests.js b/lib/core/__tests__/anchors.tests.js new file mode 100644 index 000000000000..c9530f265b60 --- /dev/null +++ b/lib/core/__tests__/anchors.tests.js @@ -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"' + ); +}); diff --git a/lib/core/__tests__/getTOC.tests.js b/lib/core/__tests__/getTOC.tests.js new file mode 100644 index 000000000000..da0e62a005cc --- /dev/null +++ b/lib/core/__tests__/getTOC.tests.js @@ -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'); +}); diff --git a/lib/core/__tests__/toSlug.tests.js b/lib/core/__tests__/toSlug.tests.js index a21b726732ea..9d472c547c71 100644 --- a/lib/core/__tests__/toSlug.tests.js +++ b/lib/core/__tests__/toSlug.tests.js @@ -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; + }, {}); +}); diff --git a/lib/core/anchors.js b/lib/core/anchors.js new file mode 100644 index 000000000000..f13745cc42fe --- /dev/null +++ b/lib/core/anchors.js @@ -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 ( + '' + ); + }; +} + +module.exports = anchors; diff --git a/lib/core/getTOC.js b/lib/core/getTOC.js index 2facfaca4197..45093c110b94 100644 --- a/lib/core/getTOC.js +++ b/lib/core/getTOC.js @@ -23,20 +23,29 @@ module.exports = (content, headingTags = 'h2', subHeadingTags = 'h3') => { const subHeadingLevels = subHeadingTags ? [].concat(subHeadingTags).map(tagToLevel) : []; + const allowedHeadingLevels = headingLevels.concat(subHeadingLevels); const md = new Remarkable(); - const headings = mdToc(content, { - filter: function(str, ele) { - return headingLevels.concat(subHeadingLevels).includes(ele.lvl); - }, - }).json; + const headings = mdToc(content).json; const toc = []; + const context = {}; let current; + 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 = { - hashLink: toSlug(heading.content), - content: md.renderInline(mdToc.titleize(heading.content)), + hashLink, + rawContent, + content: md.renderInline(rawContent), children: [], }; diff --git a/lib/core/renderMarkdown.js b/lib/core/renderMarkdown.js index a531b51346ca..b219bc2b2d9b 100644 --- a/lib/core/renderMarkdown.js +++ b/lib/core/renderMarkdown.js @@ -7,28 +7,10 @@ const hljs = require('highlight.js'); const Markdown = require('remarkable'); -const toSlug = require('./toSlug.js'); +const anchors = require('./anchors.js'); 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 ( - '' - ); - }; -} - class MarkdownRenderer { constructor() { const siteConfig = require(CWD + '/siteConfig.js'); diff --git a/lib/core/toSlug.js b/lib/core/toSlug.js index 7e7d2edaf426..21bf1ccb229f 100644 --- a/lib/core/toSlug.js +++ b/lib/core/toSlug.js @@ -18,7 +18,16 @@ const exceptAlphanum = new RegExp( '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 = "àáäâèéëêìíïîòóöôùúüûñç"; const accents = '\u00e0\u00e1\u00e4\u00e2\u00e8' + @@ -50,5 +59,22 @@ module.exports = string => { 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; }; diff --git a/lib/server/generate.js b/lib/server/generate.js index 430a04240e72..0f5c4c84efe1 100644 --- a/lib/server/generate.js +++ b/lib/server/generate.js @@ -14,7 +14,7 @@ async function execute() { const readMetadata = require('./readMetadata.js'); const path = require('path'); const color = require('color'); - const toSlug = require('../core/toSlug.js'); + const getTOC = require('../core/getTOC.js'); const React = require('react'); const mkdirp = require('mkdirp'); 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 // contents inserted const insertTableOfContents = rawContent => { - const regexp = /\n###\s+(`.*`.*)\n/g; - let match; - const headers = []; - while ((match = regexp.exec(rawContent))) { - headers.push(match[1]); - } + const filterRe = /^`[^`]*`/; + const headers = getTOC(rawContent, 'h3', null); const tableOfContents = headers - .map(header => ` - [${header}](#${toSlug(header)})`) + .filter(header => filterRe.test(header.rawContent)) + .map(header => ` - [${header.rawContent}](#${header.hashLink})`) .join('\n'); return rawContent.replace(TABLE_OF_CONTENTS_TOKEN, tableOfContents); diff --git a/lib/server/server.js b/lib/server/server.js index 2aff8442802e..b14033e20854 100644 --- a/lib/server/server.js +++ b/lib/server/server.js @@ -17,7 +17,7 @@ function execute(port) { const os = require('os'); const path = require('path'); const color = require('color'); - const toSlug = require('../core/toSlug'); + const getTOC = require('../core/getTOC'); const mkdirp = require('mkdirp'); const glob = require('glob'); const chalk = require('chalk'); @@ -91,15 +91,12 @@ function execute(port) { const TABLE_OF_CONTENTS_TOKEN = ''; const insertTableOfContents = rawContent => { - const regexp = /\n###\s+(`.*`.*)\n/g; - let match; - const headers = []; - while ((match = regexp.exec(rawContent))) { - headers.push(match[1]); - } + const filterRe = /^`[^`]*`/; + const headers = getTOC(rawContent, 'h3', null); const tableOfContents = headers - .map(header => ` - [${header}](#${toSlug(header)})`) + .filter(header => filterRe.test(header.rawContent)) + .map(header => ` - [${header.rawContent}](#${header.hashLink})`) .join('\n'); return rawContent.replace(TABLE_OF_CONTENTS_TOKEN, tableOfContents);