diff --git a/packages/docusaurus-mdx-loader/package.json b/packages/docusaurus-mdx-loader/package.json index 6bb336d4f2..091a22adfe 100644 --- a/packages/docusaurus-mdx-loader/package.json +++ b/packages/docusaurus-mdx-loader/package.json @@ -19,14 +19,15 @@ "loader-utils": "^1.2.3", "mdast-util-to-string": "^1.0.7", "remark-emoji": "^2.0.2", - "remark-slug": "^5.1.2", "stringify-object": "^3.3.0", "unist-util-visit": "^2.0.1" }, "devDependencies": { "remark": "^11.0.2", "remark-mdx": "^1.5.1", - "to-vfile": "^6.0.0" + "to-vfile": "^6.0.0", + "unist-builder": "^2.0.3", + "unist-util-remove-position": "^2.0.1" }, "engines": { "node": ">=10.9.0" diff --git a/packages/docusaurus-mdx-loader/src/index.js b/packages/docusaurus-mdx-loader/src/index.js index f89e34af90..55a78a2ba4 100644 --- a/packages/docusaurus-mdx-loader/src/index.js +++ b/packages/docusaurus-mdx-loader/src/index.js @@ -9,9 +9,9 @@ const {getOptions} = require('loader-utils'); const {readFile} = require('fs-extra'); const mdx = require('@mdx-js/mdx'); const emoji = require('remark-emoji'); -const slug = require('remark-slug'); const matter = require('gray-matter'); const stringifyObject = require('stringify-object'); +const slug = require('./remark/slug'); const rightToc = require('./remark/rightToc'); const DEFAULT_OPTIONS = { diff --git a/packages/docusaurus-mdx-loader/src/remark/rightToc/__tests__/__snapshots__/index.test.js.snap b/packages/docusaurus-mdx-loader/src/remark/rightToc/__tests__/__snapshots__/index.test.js.snap index 56cfc085d5..bdc25c3df4 100644 --- a/packages/docusaurus-mdx-loader/src/remark/rightToc/__tests__/__snapshots__/index.test.js.snap +++ b/packages/docusaurus-mdx-loader/src/remark/rightToc/__tests__/__snapshots__/index.test.js.snap @@ -62,7 +62,7 @@ exports[`non text phrasing content 1`] = ` }, { value: 'HTML', - id: 'ihtmli', + id: 'html', children: [] }, { diff --git a/packages/docusaurus-mdx-loader/src/remark/rightToc/__tests__/index.test.js b/packages/docusaurus-mdx-loader/src/remark/rightToc/__tests__/index.test.js index 85f7106b47..c5931ff5bb 100644 --- a/packages/docusaurus-mdx-loader/src/remark/rightToc/__tests__/index.test.js +++ b/packages/docusaurus-mdx-loader/src/remark/rightToc/__tests__/index.test.js @@ -10,11 +10,13 @@ import remark from 'remark'; import mdx from 'remark-mdx'; import vfile from 'to-vfile'; import plugin from '../index'; +import slug from '../../slug/index'; const processFixture = async (name, options) => { const path = join(__dirname, 'fixtures', `${name}.md`); const file = await vfile.read(path); const result = await remark() + .use(slug) .use(mdx) .use(plugin, options) .process(file); diff --git a/packages/docusaurus-mdx-loader/src/remark/rightToc/search.js b/packages/docusaurus-mdx-loader/src/remark/rightToc/search.js index 2619ac6def..5c89b314a2 100644 --- a/packages/docusaurus-mdx-loader/src/remark/rightToc/search.js +++ b/packages/docusaurus-mdx-loader/src/remark/rightToc/search.js @@ -8,7 +8,6 @@ const toString = require('mdast-util-to-string'); const visit = require('unist-util-visit'); const escapeHtml = require('escape-html'); -const slugs = require('github-slugger')(); // https://github.com/syntax-tree/mdast#heading function toValue(node) { @@ -40,19 +39,18 @@ function search(node) { let current = -1; let currentDepth = 0; - slugs.reset(); - const onHeading = (child, index, parent) => { const value = toString(child); - const id = - child.data && child.data.hProperties && child.data.hProperties.id; - const slug = slugs.slug(id || value); if (parent !== node || !value || child.depth > 3 || child.depth < 2) { return; } - const entry = {value: toValue(child), id: slug, children: []}; + const entry = { + value: toValue(child), + id: child.data.id, + children: [], + }; if (!headings.length || currentDepth >= child.depth) { headings.push(entry); diff --git a/packages/docusaurus-mdx-loader/src/remark/slug/__tests__/index.test.js b/packages/docusaurus-mdx-loader/src/remark/slug/__tests__/index.test.js new file mode 100644 index 0000000000..5070253260 --- /dev/null +++ b/packages/docusaurus-mdx-loader/src/remark/slug/__tests__/index.test.js @@ -0,0 +1,247 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/* Based on remark-slug (https://github.com/remarkjs/remark-slug) */ + +/* eslint-disable no-param-reassign */ + +import remark from 'remark'; +import u from 'unist-builder'; +import removePosition from 'unist-util-remove-position'; +import slug from '../index'; + +function process(doc, plugins = []) { + const processor = remark().use({plugins: [...plugins, slug]}); + return removePosition(processor.runSync(processor.parse(doc)), true); +} + +function heading(label, id) { + return u( + 'heading', + {depth: 2, data: {id, hProperties: {id}}}, + label ? [u('text', label)] : [], + ); +} + +describe('slug plugin', () => { + test('should patch `id`s and `data.hProperties.id', () => { + const result = process('# Normal\n\n## Table of Contents\n\n# Baz\n'); + const expected = u('root', [ + u( + 'heading', + {depth: 1, data: {hProperties: {id: 'normal'}, id: 'normal'}}, + [u('text', 'Normal')], + ), + u( + 'heading', + { + depth: 2, + data: { + hProperties: {id: 'table-of-contents'}, + id: 'table-of-contents', + }, + }, + [u('text', 'Table of Contents')], + ), + u('heading', {depth: 1, data: {hProperties: {id: 'baz'}, id: 'baz'}}, [ + u('text', 'Baz'), + ]), + ]); + + expect(result).toEqual(expected); + }); + + test('should not overwrite `data` on headings', () => { + const result = process('# Normal\n', [ + function() { + function transform(tree) { + tree.children[0].data = {foo: 'bar'}; + } + return transform; + }, + ]); + const expected = u('root', [ + u( + 'heading', + { + depth: 1, + data: {foo: 'bar', hProperties: {id: 'normal'}, id: 'normal'}, + }, + [u('text', 'Normal')], + ), + ]); + + expect(result).toEqual(expected); + }); + + test('should not overwrite `data.hProperties` on headings', () => { + const result = process('# Normal\n', [ + function() { + function transform(tree) { + tree.children[0].data = {hProperties: {className: ['foo']}}; + } + return transform; + }, + ]); + const expected = u('root', [ + u( + 'heading', + { + depth: 1, + data: {hProperties: {className: ['foo'], id: 'normal'}, id: 'normal'}, + }, + [u('text', 'Normal')], + ), + ]); + + expect(result).toEqual(expected); + }); + + test('should generate `id`s and `hProperties.id`s, based on `hProperties.id` if they exist', () => { + const result = process( + [ + '## Something', + '## Something here', + '## Something there', + '## Something also', + ].join('\n\n'), + [ + function() { + function transform(tree) { + tree.children[1].data = {hProperties: {id: 'here'}}; + tree.children[3].data = {hProperties: {id: 'something'}}; + } + return transform; + }, + ], + ); + const expected = u('root', [ + u( + 'heading', + { + depth: 2, + data: {hProperties: {id: 'something'}, id: 'something'}, + }, + [u('text', 'Something')], + ), + u( + 'heading', + { + depth: 2, + data: {hProperties: {id: 'here'}, id: 'here'}, + }, + [u('text', 'Something here')], + ), + u( + 'heading', + { + depth: 2, + data: {hProperties: {id: 'something-there'}, id: 'something-there'}, + }, + [u('text', 'Something there')], + ), + u( + 'heading', + { + depth: 2, + data: {hProperties: {id: 'something-1'}, id: 'something-1'}, + }, + [u('text', 'Something also')], + ), + ]); + + expect(result).toEqual(expected); + }); + + test('should create GitHub slugs', () => { + const result = process( + [ + '## I โ™ฅ unicode', + '', + '## Dash-dash', + '', + '## enโ€“dash', + '', + '## emโ€“dash', + '', + '## ๐Ÿ˜„ unicode emoji', + '', + '## ๐Ÿ˜„-๐Ÿ˜„ unicode emoji', + '', + '## ๐Ÿ˜„_๐Ÿ˜„ unicode emoji', + '', + '##', + '', + '## ', + '', + '## Initial spaces', + '', + '## Final spaces ', + '', + '## Duplicate', + '', + '## Duplicate', + '', + '## :ok: No underscore', + '', + '## :ok_hand: Single', + '', + '## :ok_hand::hatched_chick: Two in a row with no spaces', + '', + '## :ok_hand: :hatched_chick: Two in a row', + '', + ].join('\n'), + ); + const expected = u('root', [ + heading('I โ™ฅ unicode', 'i--unicode'), + heading('Dash-dash', 'dash-dash'), + heading('enโ€“dash', 'endash'), + heading('emโ€“dash', 'emdash'), + heading('๐Ÿ˜„ unicode emoji', '-unicode-emoji'), + heading('๐Ÿ˜„-๐Ÿ˜„ unicode emoji', '--unicode-emoji'), + heading('๐Ÿ˜„_๐Ÿ˜„ unicode emoji', '_-unicode-emoji'), + heading(null, ''), + heading(null, '-1'), + heading('Initial spaces', 'initial-spaces'), + heading('Final spaces', 'final-spaces'), + heading('Duplicate', 'duplicate'), + heading('Duplicate', 'duplicate-1'), + heading(':ok: No underscore', 'ok-no-underscore'), + heading(':ok_hand: Single', 'ok_hand-single'), + heading( + ':ok_hand::hatched_chick: Two in a row with no spaces', + 'ok_handhatched_chick-two-in-a-row-with-no-spaces', + ), + heading( + ':ok_hand: :hatched_chick: Two in a row', + 'ok_hand-hatched_chick-two-in-a-row', + ), + ]); + + expect(result).toEqual(expected); + }); + + test('should generate slug from only text contents of headings if they contains HTML tags', () => { + const result = process('# Normal\n'); + const expected = u('root', [ + u( + 'heading', + { + depth: 1, + data: {hProperties: {id: 'normal'}, id: 'normal'}, + }, + [ + u('html', ''), + u('text', 'Normal'), + u('html', ''), + ], + ), + ]); + + expect(result).toEqual(expected); + }); +}); diff --git a/packages/docusaurus-mdx-loader/src/remark/slug/index.js b/packages/docusaurus-mdx-loader/src/remark/slug/index.js new file mode 100644 index 0000000000..61d72de6ef --- /dev/null +++ b/packages/docusaurus-mdx-loader/src/remark/slug/index.js @@ -0,0 +1,43 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/* Based on remark-slug (https://github.com/remarkjs/remark-slug) */ + +const visit = require('unist-util-visit'); +const toString = require('mdast-util-to-string'); +const slugs = require('github-slugger')(); + +function slug() { + const transformer = ast => { + slugs.reset(); + + function visitor(headingNode) { + const data = headingNode.data || (headingNode.data = {}); // eslint-disable-line + const properties = data.hProperties || (data.hProperties = {}); + let {id} = properties; + + if (id) { + id = slugs.slug(id, true); + } else { + const headingTextNodes = + headingNode.children.find( + ({type}) => !['html', 'jsx'].includes(type), + ) || headingNode; + id = slugs.slug(toString(headingTextNodes)); + } + + data.id = id; + properties.id = id; + } + + visit(ast, 'heading', visitor); + }; + + return transformer; +} + +module.exports = slug;