Skip to content

Commit

Permalink
feat(v2): add ability to set custom heading id (#4222)
Browse files Browse the repository at this point in the history
* feat(v2): add ability to set custom heading id

* Add cli command

* Fix slugger

* write-heading-ids doc + add in commands/templates

* refactor + add tests for writeHeadingIds

* polish writeHeadingIds

* polish writeHeadingIds

* remove i18n goals todo section as the  remaining items are quite abstract/useless

* fix edge case with 2 md links in heading

* extract parseMarkdownHeadingId helper function

* refactor using the shared parseMarkdownHeadingId utility fn

* change logic of edge case

* Handle edge case

* Document explicit ids feature

Co-authored-by: slorber <lorber.sebastien@gmail.com>
  • Loading branch information
lex111 and slorber committed Mar 5, 2021
1 parent 6be0bd4 commit 96e7fce
Show file tree
Hide file tree
Showing 26 changed files with 594 additions and 71 deletions.
5 changes: 3 additions & 2 deletions packages/docusaurus-init/templates/bootstrap/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"serve": "docusaurus serve",
"clear": "docusaurus clear",
"write-translations": "write-translations"
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids"
},
"dependencies": {
"@docusaurus/core": "2.0.0-alpha.70",
Expand Down
5 changes: 3 additions & 2 deletions packages/docusaurus-init/templates/classic/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"serve": "docusaurus serve",
"clear": "docusaurus clear",
"write-translations": "write-translations"
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids"
},
"dependencies": {
"@docusaurus/core": "2.0.0-alpha.70",
Expand Down
5 changes: 3 additions & 2 deletions packages/docusaurus-init/templates/facebook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"serve": "docusaurus serve",
"clear": "docusaurus clear",
"write-translations": "write-translations",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"ci": "yarn lint && yarn prettier:diff",
"lint": "eslint --cache \"**/*.js\" && stylelint \"**/*.css\"",
"prettier": "prettier --config .prettierrc --write \"**/*.{js,jsx,ts,tsx,md,mdx}\"",
Expand Down
4 changes: 2 additions & 2 deletions packages/docusaurus-mdx-loader/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ const mdx = require('@mdx-js/mdx');
const emoji = require('remark-emoji');
const matter = require('gray-matter');
const stringifyObject = require('stringify-object');
const slug = require('./remark/slug');
const headings = require('./remark/headings');
const toc = require('./remark/toc');
const unwrapMdxCodeBlocks = require('./remark/unwrapMdxCodeBlocks');
const transformImage = require('./remark/transformImage');
const transformLinks = require('./remark/transformLinks');

const DEFAULT_OPTIONS = {
rehypePlugins: [],
remarkPlugins: [unwrapMdxCodeBlocks, emoji, slug, toc],
remarkPlugins: [unwrapMdxCodeBlocks, emoji, headings, toc],
};

module.exports = async function docusaurusMdxLoader(fileString) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@
* LICENSE file in the root directory of this source tree.
*/

/* Based on remark-slug (https://github.com/remarkjs/remark-slug) */
/* Based on remark-slug (https://github.com/remarkjs/remark-slug) and gatsby-remark-autolink-headers (https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby-remark-autolink-headers) */

/* eslint-disable no-param-reassign */

import remark from 'remark';
import u from 'unist-builder';
import removePosition from 'unist-util-remove-position';
import toString from 'mdast-util-to-string';
import visit from 'unist-util-visit';
import slug from '../index';

function process(doc, plugins = []) {
Expand All @@ -27,7 +29,7 @@ function heading(label, id) {
);
}

describe('slug plugin', () => {
describe('headings 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', [
Expand Down Expand Up @@ -157,7 +159,7 @@ describe('slug plugin', () => {
expect(result).toEqual(expected);
});

test('should create GitHub slugs', () => {
test('should create GitHub-style headings ids', () => {
const result = process(
[
'## I ♥ unicode',
Expand Down Expand Up @@ -225,7 +227,7 @@ describe('slug plugin', () => {
expect(result).toEqual(expected);
});

test('should generate slug from only text contents of headings if they contains HTML tags', () => {
test('should generate id from only text contents of headings if they contains HTML tags', () => {
const result = process('# <span class="normal-header">Normal</span>\n');
const expected = u('root', [
u(
Expand All @@ -244,4 +246,70 @@ describe('slug plugin', () => {

expect(result).toEqual(expected);
});

test('should create custom headings ids', () => {
const result = process(`
# Heading One {#custom_h1}
## Heading Two {#custom-heading-two}
# With *Bold* {#custom-withbold}
# With *Bold* hello{#custom-withbold-hello}
# With *Bold* hello2 {#custom-withbold-hello2}
# Snake-cased ID {#this_is_custom_id}
# No custom ID
# {#id-only}
# {#text-after} custom ID
`);

const headers = [];
visit(result, 'heading', (node) => {
headers.push({text: toString(node), id: node.data.id});
});

expect(headers).toEqual([
{
id: 'custom_h1',
text: 'Heading One',
},
{
id: 'custom-heading-two',
text: 'Heading Two',
},
{
id: 'custom-withbold',
text: 'With Bold',
},
{
id: 'custom-withbold-hello',
text: 'With Bold hello',
},
{
id: 'custom-withbold-hello2',
text: 'With Bold hello2',
},
{
id: 'this_is_custom_id',
text: 'Snake-cased ID',
},
{
id: 'no-custom-id',
text: 'No custom ID',
},
{
id: 'id-only',
text: '',
},
{
id: 'text-after-custom-id',
text: '{#text-after} custom ID',
},
]);
});
});
74 changes: 74 additions & 0 deletions packages/docusaurus-mdx-loader/src/remark/headings/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* 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) and gatsby-remark-autolink-headers (https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby-remark-autolink-headers) */

const {parseMarkdownHeadingId} = require('@docusaurus/utils');
const visit = require('unist-util-visit');
const toString = require('mdast-util-to-string');
const slugs = require('github-slugger')();

function headings() {
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.filter(
({type}) => !['html', 'jsx'].includes(type),
);
const heading = toString(
headingTextNodes.length > 0
? {children: headingTextNodes}
: headingNode,
);

// Support explicit heading IDs
const parsedHeading = parseMarkdownHeadingId(heading);

id = parsedHeading.id || slugs.slug(heading);

if (parsedHeading.id) {
// When there's an id, it is always in the last child node
// Sometimes heading is in multiple "parts" (** syntax creates a child node):
// ## part1 *part2* part3 {#id}
const lastNode =
headingNode.children[headingNode.children.length - 1];

if (headingNode.children.length > 1) {
const lastNodeText = parseMarkdownHeadingId(lastNode.value).text;
// When last part contains test+id, remove the id
if (lastNodeText) {
lastNode.value = lastNodeText;
}
// When last part contains only the id: completely remove that node
else {
headingNode.children.pop();
}
} else {
lastNode.value = parsedHeading.text;
}
}
}

data.id = id;
properties.id = id;
}

visit(ast, 'heading', visitor);
};

return transformer;
}

module.exports = headings;
46 changes: 0 additions & 46 deletions packages/docusaurus-mdx-loader/src/remark/slug/index.js

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +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';
import headings from '../../headings/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(headings)
.use(mdx)
.use(plugin, options)
.process(file);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +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';
import headings from '../../headings/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(headings)
.use(mdx)
.use(plugin, {...options, filePath: path})
.process(file);
Expand Down
1 change: 1 addition & 0 deletions packages/docusaurus-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"license": "MIT",
"dependencies": {
"@docusaurus/types": "2.0.0-alpha.70",
"@types/github-slugger": "^1.3.0",
"chalk": "^4.1.0",
"escape-string-regexp": "^4.0.0",
"fs-extra": "^9.1.0",
Expand Down
49 changes: 49 additions & 0 deletions packages/docusaurus-utils/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
getFolderContainingFile,
updateTranslationFileMessages,
readDefaultCodeTranslationMessages,
parseMarkdownHeadingId,
} from '../index';
import {sum} from 'lodash';

Expand Down Expand Up @@ -806,3 +807,51 @@ describe('readDefaultCodeTranslationMessages', () => {
).resolves.toEqual(await readAsJSON('en.json'));
});
});

describe('parseMarkdownHeadingId', () => {
test('can parse simple heading without id', () => {
expect(parseMarkdownHeadingId('## Some heading')).toEqual({
text: '## Some heading',
id: undefined,
});
});

test('can parse simple heading with id', () => {
expect(parseMarkdownHeadingId('## Some heading {#custom-_id}')).toEqual({
text: '## Some heading',
id: 'custom-_id',
});
});

test('can parse heading not ending with the id', () => {
expect(parseMarkdownHeadingId('## {#custom-_id} Some heading')).toEqual({
text: '## {#custom-_id} Some heading',
id: undefined,
});
});

test('can parse heading with multiple id', () => {
expect(parseMarkdownHeadingId('## Some heading {#id1} {#id2}')).toEqual({
text: '## Some heading {#id1}',
id: 'id2',
});
});

test('can parse heading with link and id', () => {
expect(
parseMarkdownHeadingId(
'## Some heading [facebook](https://facebook.com) {#id}',
),
).toEqual({
text: '## Some heading [facebook](https://facebook.com)',
id: 'id',
});
});

test('can parse heading with only id', () => {
expect(parseMarkdownHeadingId('## {#id}')).toEqual({
text: '##',
id: 'id',
});
});
});
Loading

0 comments on commit 96e7fce

Please sign in to comment.