diff --git a/packages/docusaurus-init/templates/bootstrap/package.json b/packages/docusaurus-init/templates/bootstrap/package.json
index f052f6488f..180eda2763 100644
--- a/packages/docusaurus-init/templates/bootstrap/package.json
+++ b/packages/docusaurus-init/templates/bootstrap/package.json
@@ -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",
diff --git a/packages/docusaurus-init/templates/classic/package.json b/packages/docusaurus-init/templates/classic/package.json
index 24f338dfe4..e26273845f 100644
--- a/packages/docusaurus-init/templates/classic/package.json
+++ b/packages/docusaurus-init/templates/classic/package.json
@@ -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",
diff --git a/packages/docusaurus-init/templates/facebook/package.json b/packages/docusaurus-init/templates/facebook/package.json
index abd540bf82..8a699a824b 100644
--- a/packages/docusaurus-init/templates/facebook/package.json
+++ b/packages/docusaurus-init/templates/facebook/package.json
@@ -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}\"",
diff --git a/packages/docusaurus-mdx-loader/src/index.js b/packages/docusaurus-mdx-loader/src/index.js
index 814171de56..51432c6217 100644
--- a/packages/docusaurus-mdx-loader/src/index.js
+++ b/packages/docusaurus-mdx-loader/src/index.js
@@ -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) {
diff --git a/packages/docusaurus-mdx-loader/src/remark/slug/__tests__/index.test.js b/packages/docusaurus-mdx-loader/src/remark/headings/__tests__/index.test.js
similarity index 78%
rename from packages/docusaurus-mdx-loader/src/remark/slug/__tests__/index.test.js
rename to packages/docusaurus-mdx-loader/src/remark/headings/__tests__/index.test.js
index 887f897d70..90153a2da2 100644
--- a/packages/docusaurus-mdx-loader/src/remark/slug/__tests__/index.test.js
+++ b/packages/docusaurus-mdx-loader/src/remark/headings/__tests__/index.test.js
@@ -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('# \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',
+ },
+ ]);
+ });
});
diff --git a/packages/docusaurus-mdx-loader/src/remark/headings/index.js b/packages/docusaurus-mdx-loader/src/remark/headings/index.js
new file mode 100644
index 0000000000..15e713a907
--- /dev/null
+++ b/packages/docusaurus-mdx-loader/src/remark/headings/index.js
@@ -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;
diff --git a/packages/docusaurus-mdx-loader/src/remark/slug/index.js b/packages/docusaurus-mdx-loader/src/remark/slug/index.js
deleted file mode 100644
index ad8cb51f88..0000000000
--- a/packages/docusaurus-mdx-loader/src/remark/slug/index.js
+++ /dev/null
@@ -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;
diff --git a/packages/docusaurus-mdx-loader/src/remark/toc/__tests__/index.test.js b/packages/docusaurus-mdx-loader/src/remark/toc/__tests__/index.test.js
index f55c0be063..e9df4502b2 100644
--- a/packages/docusaurus-mdx-loader/src/remark/toc/__tests__/index.test.js
+++ b/packages/docusaurus-mdx-loader/src/remark/toc/__tests__/index.test.js
@@ -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);
diff --git a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/index.test.js b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/index.test.js
index 90498b8430..5a9492a41a 100644
--- a/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/index.test.js
+++ b/packages/docusaurus-mdx-loader/src/remark/transformImage/__tests__/index.test.js
@@ -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);
diff --git a/packages/docusaurus-utils/package.json b/packages/docusaurus-utils/package.json
index 15afbce49e..72c2da6674 100644
--- a/packages/docusaurus-utils/package.json
+++ b/packages/docusaurus-utils/package.json
@@ -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",
diff --git a/packages/docusaurus-utils/src/__tests__/index.test.ts b/packages/docusaurus-utils/src/__tests__/index.test.ts
index f0ddf80f96..0b24c87052 100644
--- a/packages/docusaurus-utils/src/__tests__/index.test.ts
+++ b/packages/docusaurus-utils/src/__tests__/index.test.ts
@@ -35,6 +35,7 @@ import {
getFolderContainingFile,
updateTranslationFileMessages,
readDefaultCodeTranslationMessages,
+ parseMarkdownHeadingId,
} from '../index';
import {sum} from 'lodash';
@@ -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',
+ });
+ });
+});
diff --git a/packages/docusaurus-utils/src/index.ts b/packages/docusaurus-utils/src/index.ts
index 77016cf1e0..adc92a863c 100644
--- a/packages/docusaurus-utils/src/index.ts
+++ b/packages/docusaurus-utils/src/index.ts
@@ -642,3 +642,23 @@ export function getDateTimeFormat(locale: string) {
: // eslint-disable-next-line @typescript-eslint/no-var-requires
require('intl').DateTimeFormat;
}
+
+// Input: ## Some heading {#some-heading}
+// Output: {text: "## Some heading", id: "some-heading"}
+export function parseMarkdownHeadingId(
+ heading: string,
+): {
+ text: string;
+ id?: string;
+} {
+ const customHeadingIdRegex = /^(.*?)\s*\{#([\w-]+)\}$/;
+ const matches = customHeadingIdRegex.exec(heading);
+ if (matches) {
+ return {
+ text: matches[1],
+ id: matches[2],
+ };
+ } else {
+ return {text: heading, id: undefined};
+ }
+}
diff --git a/packages/docusaurus/bin/docusaurus.js b/packages/docusaurus/bin/docusaurus.js
index d77327de6e..57fbf4429e 100755
--- a/packages/docusaurus/bin/docusaurus.js
+++ b/packages/docusaurus/bin/docusaurus.js
@@ -23,6 +23,7 @@ const {
serve,
clear,
writeTranslations,
+ writeHeadingIds,
} = require('../lib');
const {
name,
@@ -284,6 +285,13 @@ cli
},
);
+cli
+ .command('write-heading-ids [contentDir]')
+ .description('Generate heading ids in Markdown content')
+ .action((siteDir = '.') => {
+ wrapCommand(writeHeadingIds)(siteDir);
+ });
+
cli.arguments('').action((cmd) => {
cli.outputHelp();
console.log(` ${chalk.red(`\n Unknown command ${chalk.yellow(cmd)}.`)}`);
@@ -299,6 +307,7 @@ function isInternalCommand(command) {
'serve',
'clear',
'write-translations',
+ 'write-heading-ids',
].includes(command);
}
diff --git a/packages/docusaurus/package.json b/packages/docusaurus/package.json
index 6babfcdcb9..5ab0afa4f6 100644
--- a/packages/docusaurus/package.json
+++ b/packages/docusaurus/package.json
@@ -73,6 +73,7 @@
"express": "^4.17.1",
"file-loader": "^6.2.0",
"fs-extra": "^9.1.0",
+ "github-slugger": "^1.3.0",
"globby": "^11.0.2",
"html-minifier-terser": "^5.1.1",
"html-tags": "^3.1.0",
diff --git a/packages/docusaurus/src/commands/__tests__/writeHeadingIds.test.ts b/packages/docusaurus/src/commands/__tests__/writeHeadingIds.test.ts
new file mode 100644
index 0000000000..382d6d9261
--- /dev/null
+++ b/packages/docusaurus/src/commands/__tests__/writeHeadingIds.test.ts
@@ -0,0 +1,118 @@
+/**
+ * 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.
+ */
+
+import {
+ transformMarkdownHeadingLine,
+ transformMarkdownContent,
+} from '../writeHeadingIds';
+import GithubSlugger from 'github-slugger';
+
+describe('transformMarkdownHeadingLine', () => {
+ test('throws when not a heading', () => {
+ expect(() =>
+ transformMarkdownHeadingLine('ABC', new GithubSlugger()),
+ ).toThrowErrorMatchingInlineSnapshot(
+ `"Line is not a markdown heading: ABC"`,
+ );
+ });
+
+ test('works for simple level-2 heading', () => {
+ expect(transformMarkdownHeadingLine('## ABC', new GithubSlugger())).toEqual(
+ '## ABC {#abc}',
+ );
+ });
+
+ test('works for simple level-3 heading', () => {
+ expect(transformMarkdownHeadingLine('###ABC', new GithubSlugger())).toEqual(
+ '###ABC {#abc}',
+ );
+ });
+
+ test('works for simple level-4 heading', () => {
+ expect(
+ transformMarkdownHeadingLine('#### ABC', new GithubSlugger()),
+ ).toEqual('#### ABC {#abc}');
+ });
+
+ test('works for simple level-2 heading', () => {
+ expect(transformMarkdownHeadingLine('## ABC', new GithubSlugger())).toEqual(
+ '## ABC {#abc}',
+ );
+ });
+
+ test('unwraps markdown links', () => {
+ const input = `## hello [facebook](https://facebook.com) [crowdin](https://crowdin.com/translate/docusaurus-v2/126/en-fr?filter=basic&value=0)`;
+ expect(transformMarkdownHeadingLine(input, new GithubSlugger())).toEqual(
+ `${input} {#hello-facebook-crowdin}`,
+ );
+ });
+
+ test('can slugify complex headings', () => {
+ const input = '## abc [Hello] How are you %Sébastien_-_$)( ## -56756';
+ expect(transformMarkdownHeadingLine(input, new GithubSlugger())).toEqual(
+ `${input} {#abc-hello-how-are-you-sébastien_-_---56756}`,
+ );
+ });
+
+ test('does not duplicate duplicate id', () => {
+ expect(
+ transformMarkdownHeadingLine(
+ '# hello world {#hello-world}',
+ new GithubSlugger(),
+ ),
+ ).toEqual('# hello world {#hello-world}');
+ });
+});
+
+describe('transformMarkdownContent', () => {
+ test('transform the headings', () => {
+ const input = `
+
+# Hello world
+
+## abc
+
+\`\`\`
+# Heading in code block
+\`\`\`
+
+## Hello world
+
+ \`\`\`
+ # Heading in escaped code block
+ \`\`\`
+
+### abc {#abc}
+
+ `;
+
+ // TODO the first heading should probably rather be slugified to abc-1
+ // otherwise we end up with 2 x "abc" anchors
+ // not sure how to implement that atm
+ const expected = `
+
+# Hello world {#hello-world}
+
+## abc {#abc}
+
+\`\`\`
+# Heading in code block
+\`\`\`
+
+## Hello world {#hello-world-1}
+
+ \`\`\`
+ # Heading in escaped code block
+ \`\`\`
+
+### abc {#abc}
+
+ `;
+
+ expect(transformMarkdownContent(input)).toEqual(expected);
+ });
+});
diff --git a/packages/docusaurus/src/commands/writeHeadingIds.ts b/packages/docusaurus/src/commands/writeHeadingIds.ts
new file mode 100644
index 0000000000..4661ccb4b3
--- /dev/null
+++ b/packages/docusaurus/src/commands/writeHeadingIds.ts
@@ -0,0 +1,132 @@
+/**
+ * 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.
+ */
+
+import globby from 'globby';
+import fs from 'fs-extra';
+import GithubSlugger from 'github-slugger';
+import chalk from 'chalk';
+import {loadContext, loadPluginConfigs} from '../server';
+import initPlugins from '../server/plugins/init';
+
+import {flatten} from 'lodash';
+import {parseMarkdownHeadingId} from '@docusaurus/utils';
+
+export function unwrapMarkdownLinks(line) {
+ return line.replace(/\[([^\]]+)\]\([^)]+\)/g, (match, p1) => p1);
+}
+
+function addHeadingId(line, slugger) {
+ let headingLevel = 0;
+ while (line.charAt(headingLevel) === '#') {
+ headingLevel += 1;
+ }
+
+ const headingText = line.slice(headingLevel).trimEnd();
+ const headingHashes = line.slice(0, headingLevel);
+ const slug = slugger.slug(unwrapMarkdownLinks(headingText));
+
+ return `${headingHashes}${headingText} {#${slug}}`;
+}
+
+export function transformMarkdownHeadingLine(
+ line: string,
+ slugger: GithubSlugger,
+) {
+ if (!line.startsWith('#')) {
+ throw new Error(`Line is not a markdown heading: ${line}`);
+ }
+
+ const parsedHeading = parseMarkdownHeadingId(line);
+
+ // Do not process if id is already therer
+ if (parsedHeading.id) {
+ return line;
+ }
+ return addHeadingId(line, slugger);
+}
+
+export function transformMarkdownLine(
+ line: string,
+ slugger: GithubSlugger,
+): string {
+ if (line.startsWith('#')) {
+ return transformMarkdownHeadingLine(line, slugger);
+ } else {
+ return line;
+ }
+}
+
+function transformMarkdownLines(lines: string[]): string[] {
+ let inCode = false;
+ const slugger = new GithubSlugger();
+
+ return lines.map((line) => {
+ if (line.startsWith('```')) {
+ inCode = !inCode;
+ return line;
+ } else {
+ if (inCode) {
+ return line;
+ }
+ return transformMarkdownLine(line, slugger);
+ }
+ });
+}
+
+export function transformMarkdownContent(content: string): string {
+ return transformMarkdownLines(content.split('\n')).join('\n');
+}
+
+async function transformMarkdownFile(
+ filepath: string,
+): Promise {
+ const content = await fs.readFile(filepath, 'utf8');
+ const updatedContent = transformMarkdownLines(content.split('\n')).join('\n');
+ if (content !== updatedContent) {
+ await fs.writeFile(filepath, updatedContent);
+ return filepath;
+ }
+ return undefined;
+}
+
+// We only handle the "paths to watch" because these are the paths where the markdown files are
+// Also we don't want to transform the site md docs that do not belong to a content plugin
+// For example ./README.md should not be transformed
+async function getPathsToWatch(siteDir: string): Promise {
+ const context = await loadContext(siteDir);
+ const pluginConfigs = loadPluginConfigs(context);
+ const plugins = await initPlugins({
+ pluginConfigs,
+ context,
+ });
+ return flatten(plugins.map((plugin) => plugin?.getPathsToWatch?.() ?? []));
+}
+
+export default async function writeHeadingIds(siteDir: string): Promise {
+ const markdownFiles = await globby(await getPathsToWatch(siteDir), {
+ expandDirectories: ['**/*.{md,mdx}'],
+ });
+
+ const result = await Promise.all(markdownFiles.map(transformMarkdownFile));
+
+ const pathsModified = result.filter(Boolean) as string[];
+
+ if (pathsModified.length) {
+ console.log(
+ chalk.green(`Heading ids added to markdown files (${
+ pathsModified.length
+ }/${markdownFiles.length} files):
+- ${pathsModified.join('\n- ')}`),
+ );
+ } else {
+ console.log(
+ chalk.yellow(
+ `${markdownFiles.length} markdown files already have explicit heading ids`,
+ ),
+ );
+ }
+}
diff --git a/packages/docusaurus/src/index.ts b/packages/docusaurus/src/index.ts
index f2c15ce978..fba0d2bb06 100644
--- a/packages/docusaurus/src/index.ts
+++ b/packages/docusaurus/src/index.ts
@@ -13,3 +13,4 @@ export {default as externalCommand} from './commands/external';
export {default as serve} from './commands/serve';
export {default as clear} from './commands/clear';
export {default as writeTranslations} from './commands/writeTranslations';
+export {default as writeHeadingIds} from './commands/writeHeadingIds';
diff --git a/website/docs/cli.md b/website/docs/cli.md
index 1821b53aa0..4d0e125083 100644
--- a/website/docs/cli.md
+++ b/website/docs/cli.md
@@ -11,11 +11,15 @@ Once your website is bootstrapped, the website source will contain the Docusauru
{
// ...
"scripts": {
+ "docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
- "clear": "docusaurus clear"
+ "clear": "docusaurus clear",
+ "serve": "docusaurus serve",
+ "write-translations": "docusaurus write-translations",
+ "write-heading-ids": "docusaurus write-heading-ids"
}
}
```
@@ -177,3 +181,7 @@ By default, the files are written in `website/i18n//...`.
| `--override` | `false` | Override existing translation messages |
| `--config` | `undefined` | Path to docusaurus config file, default to `[siteDir]/docusaurus.config.js` |
| `--messagePrefix` | `''` | Allows to add a prefix to each translation message, to help you highlight untranslated strings |
+
+### `docusaurus write-heading-ids [siteDir]`
+
+Add [explicit heading ids](./guides/markdown-features/markdown-features-headings.mdx#explicit-ids) to the Markdown documents of your site.
diff --git a/website/docs/guides/docs/docs-create-doc.mdx b/website/docs/guides/docs/docs-create-doc.mdx
index b1856efcd4..49841e7158 100644
--- a/website/docs/guides/docs/docs-create-doc.mdx
+++ b/website/docs/guides/docs/docs-create-doc.mdx
@@ -44,6 +44,10 @@ The headers are well-spaced so that the hierarchy is clear.
- that you want your users to remember
- and you may nest them
- multiple times
+
+### Custom id headers {#custom-id}
+
+With `{#custom-id}` syntax you can set your own header id.
```
This will render in the browser as follows:
diff --git a/website/docs/guides/markdown-features/markdown-features-headings.mdx b/website/docs/guides/markdown-features/markdown-features-headings.mdx
new file mode 100644
index 0000000000..abc6fca0f9
--- /dev/null
+++ b/website/docs/guides/markdown-features/markdown-features-headings.mdx
@@ -0,0 +1,59 @@
+---
+id: headings
+title: Headings
+description: Using Markdown headings
+slug: /markdown-features/headings
+---
+
+## Markdown headings
+
+You can use regular Markdown headings.
+
+```
+## Level 2 title
+
+### Level 3 title
+
+### Level 4 title
+```
+
+Markdown headings appear as a table-of-contents entry.
+
+## Heading ids
+
+Each heading has an id that can be generated, or explicitly specified.
+
+Heading ids allow you to link to a specific document heading in Markdown or JSX:
+
+```md
+[link](#heading-id)
+```
+
+```jsx
+link
+```
+
+### Generated ids
+
+By default, Docusaurus will generate heading ids for you, based on the heading text.
+
+`### Hello World` will have id `hello-world`.
+
+Generated ids have **some limits**:
+
+- The id might not look good
+- You might want to **change or translate** the text without updating the existing id
+
+### Explicit ids
+
+A special Markdown syntax lets you set an **explicit heading id**:
+
+```md
+### Hello World {#my-explicit-id}
+```
+
+:::tip
+
+Use the **[write-heading-ids](../../cli.md#docusaurus-write-heading-ids-sitedir)** CLI command to add explicit ids to all your Markdown documents.
+
+:::
diff --git a/website/docs/i18n/i18n-introduction.md b/website/docs/i18n/i18n-introduction.md
index 6e33a99283..229c0172c5 100644
--- a/website/docs/i18n/i18n-introduction.md
+++ b/website/docs/i18n/i18n-introduction.md
@@ -36,14 +36,6 @@ The goals of the Docusaurus i18n system are:
- **RTL support**: locales reading right-to-left (Arabic, Hebrew...) should be easy to use.
- **Default translations**: theme labels are translated for you in [many languages](https://github.com/facebook/docusaurus/tree/master/packages/docusaurus-theme-classic/codeTranslations).
-### i18n goals (TODO)
-
-Features that are **not yet implemented**:
-
-- **Contextual translations**: reduce friction to contribute to the translation effort.
-- **Anchor links**: linking should not break when you localize headings.
-- **Advanced configuration options**: customize route paths, file-system paths.
-
### i18n non-goals
We don't provide support for:
diff --git a/website/docs/i18n/i18n-tutorial.md b/website/docs/i18n/i18n-tutorial.md
index 3f9d2618ab..3d7bf2fdf2 100644
--- a/website/docs/i18n/i18n-tutorial.md
+++ b/website/docs/i18n/i18n-tutorial.md
@@ -254,6 +254,27 @@ We only copy `.md` and `.mdx` files, as pages React components are translated th
:::
+### Use explicit heading ids
+
+By default, a Markdown heading `### Hello World` will have a generated id `hello-world`.
+
+Other documents can target it with `[link](#hello-world)`.
+
+The translated heading becomes `### Bonjour le Monde`, with id `bonjour-le-monde`.
+
+Generated ids are not always a good fit for localized sites, as it requires you to localize all the anchor links:
+
+```diff
+- [link](#hello-world).
++ [link](#bonjour-le-monde)
+```
+
+:::tip
+
+For localized sites, it is recommended to use **[explicit heading ids](../guides/markdown-features/markdown-features-headings.mdx#explicit-ids)**.
+
+:::
+
## Deploy your site
You can choose to deploy your site under a **single domain**, or use **multiple (sub)domains**.
diff --git a/website/package.json b/website/package.json
index b7a883af39..eaac341679 100644
--- a/website/package.json
+++ b/website/package.json
@@ -11,6 +11,7 @@
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
+ "write-heading-ids": "docusaurus write-heading-ids",
"start:baseUrl": "cross-env BASE_URL='/build/' yarn start",
"build:baseUrl": "cross-env BASE_URL='/build/' yarn build",
"start:bootstrap": "cross-env DOCUSAURUS_PRESET=bootstrap yarn start",
diff --git a/website/sidebars.js b/website/sidebars.js
index 5d3c538285..24461ff0cd 100644
--- a/website/sidebars.js
+++ b/website/sidebars.js
@@ -43,6 +43,7 @@ module.exports = {
'guides/markdown-features/tabs',
'guides/markdown-features/code-blocks',
'guides/markdown-features/admonitions',
+ 'guides/markdown-features/headings',
'guides/markdown-features/inline-toc',
'guides/markdown-features/assets',
'guides/markdown-features/plugins',
diff --git a/website/src/pages/examples/markdownPageExample.md b/website/src/pages/examples/markdownPageExample.md
index a25ddf4582..78f642ab09 100644
--- a/website/src/pages/examples/markdownPageExample.md
+++ b/website/src/pages/examples/markdownPageExample.md
@@ -187,3 +187,5 @@ function Clock(props) {
test
+
+## Custom heading id {#custom}
diff --git a/yarn.lock b/yarn.lock
index af30368a9b..21db311672 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3326,6 +3326,11 @@
dependencies:
"@types/node" "*"
+"@types/github-slugger@^1.3.0":
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/@types/github-slugger/-/github-slugger-1.3.0.tgz#16ab393b30d8ae2a111ac748a015ac05a1fc5524"
+ integrity sha512-J/rMZa7RqiH/rT29TEVZO4nBoDP9XJOjnbbIofg7GQKs4JIduEO3WLpte+6WeUz/TcrXKlY+bM7FYrp8yFB+3g==
+
"@types/glob@*", "@types/glob@^7.1.1":
version "7.1.1"
resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575"