feat(v2): add ability to set custom heading id (#4222)

* 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>
This commit is contained in:
Alexey Pyltsyn 2021-03-05 21:36:14 +03:00 committed by GitHub
parent 6be0bd41b0
commit 96e7fcef25
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 594 additions and 71 deletions

View file

@ -11,7 +11,7 @@ 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');
@ -19,7 +19,7 @@ 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) {

View file

@ -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 = []) {
@ -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', [
@ -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',
@ -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(
@ -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',
},
]);
});
});

View file

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

View file

@ -1,46 +0,0 @@
/**
* 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.filter(
({type}) => !['html', 'jsx'].includes(type),
);
const normalizedHeadingNode =
headingTextNodes.length > 0
? {children: headingTextNodes}
: headingNode;
id = slugs.slug(toString(normalizedHeadingNode));
}
data.id = id;
properties.id = id;
}
visit(ast, 'heading', visitor);
};
return transformer;
}
module.exports = slug;

View file

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

View file

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