From 5b6a4dc90997d445babc4e72d9744ccde7ac1bc6 Mon Sep 17 00:00:00 2001 From: Alexey Pyltsyn Date: Sat, 13 Feb 2021 04:21:44 +0300 Subject: [PATCH 01/14] feat(v2): add ability to set custom heading id --- packages/docusaurus-mdx-loader/src/index.js | 4 +- .../__tests__/index.test.js | 64 +++++++++++++++++-- .../src/remark/{slug => headings}/index.js | 28 ++++++-- .../src/remark/toc/__tests__/index.test.js | 4 +- .../transformImage/__tests__/index.test.js | 4 +- website/docs/guides/docs/docs-create-doc.mdx | 4 ++ .../src/pages/examples/markdownPageExample.md | 2 + 7 files changed, 96 insertions(+), 14 deletions(-) rename packages/docusaurus-mdx-loader/src/remark/{slug => headings}/__tests__/index.test.js (81%) rename packages/docusaurus-mdx-loader/src/remark/{slug => headings}/index.js (56%) diff --git a/packages/docusaurus-mdx-loader/src/index.js b/packages/docusaurus-mdx-loader/src/index.js index 5023a09e7716..ec5a01cb5797 100644 --- a/packages/docusaurus-mdx-loader/src/index.js +++ b/packages/docusaurus-mdx-loader/src/index.js @@ -11,14 +11,14 @@ 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 transformImage = require('./remark/transformImage'); const transformLinks = require('./remark/transformLinks'); const DEFAULT_OPTIONS = { rehypePlugins: [], - remarkPlugins: [emoji, slug, toc], + remarkPlugins: [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 81% 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 887f897d709a..a127e2ea1eb9 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('# Normal\n'); const expected = u('root', [ u( @@ -244,4 +246,58 @@ 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} + +# 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: 'this_is_custom_id', + text: 'Snake-cased ID', + }, + { + id: 'no-custom-id', + text: 'No custom ID', + }, + { + id: 'id-only', + text: '{#id-only}', + }, + { + id: 'text-after-custom-id', + text: '{#text-after} custom ID', + }, + ]); + }); }); diff --git a/packages/docusaurus-mdx-loader/src/remark/slug/index.js b/packages/docusaurus-mdx-loader/src/remark/headings/index.js similarity index 56% rename from packages/docusaurus-mdx-loader/src/remark/slug/index.js rename to packages/docusaurus-mdx-loader/src/remark/headings/index.js index ad8cb51f88e1..1fad54ee8e3c 100644 --- a/packages/docusaurus-mdx-loader/src/remark/slug/index.js +++ b/packages/docusaurus-mdx-loader/src/remark/headings/index.js @@ -5,12 +5,14 @@ * 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) */ const visit = require('unist-util-visit'); const toString = require('mdast-util-to-string'); const slugs = require('github-slugger')(); +const customHeadingIdRegex = /^(.*?)\s*\{#([\w-]+)\}$/; + function slug() { const transformer = (ast) => { slugs.reset(); @@ -26,11 +28,29 @@ function slug() { const headingTextNodes = headingNode.children.filter( ({type}) => !['html', 'jsx'].includes(type), ); - const normalizedHeadingNode = + const heading = toString( headingTextNodes.length > 0 ? {children: headingTextNodes} - : headingNode; - id = slugs.slug(toString(normalizedHeadingNode)); + : headingNode, + ); + + // Support explicit heading IDs + const customHeadingIdMatches = customHeadingIdRegex.exec(heading); + + if (customHeadingIdMatches) { + id = customHeadingIdMatches[2]; + + // Remove the custom ID part from the text node + if (headingNode.children.length > 1) { + headingNode.children.pop(); + } else { + const lastNode = + headingNode.children[headingNode.children.length - 1]; + lastNode.value = customHeadingIdMatches[1] || heading; + } + } else { + id = slugs.slug(heading); + } } data.id = id; 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 f55c0be063c6..e9df4502b208 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 90498b8430f7..5a9492a41ab6 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/website/docs/guides/docs/docs-create-doc.mdx b/website/docs/guides/docs/docs-create-doc.mdx index b1856efcd46c..49841e715820 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/src/pages/examples/markdownPageExample.md b/website/src/pages/examples/markdownPageExample.md index 1c9e1722de90..3e9b539256a7 100644 --- a/website/src/pages/examples/markdownPageExample.md +++ b/website/src/pages/examples/markdownPageExample.md @@ -119,3 +119,5 @@ import MyComponentSource from '!!raw-loader!@site/src/pages/examples/\_myCompone {MyComponentSource} + +## Custom heading id {#custom} From fc13227a00499a228577129dd9a1a608508c28ae Mon Sep 17 00:00:00 2001 From: Alexey Pyltsyn Date: Fri, 5 Mar 2021 16:48:01 +0300 Subject: [PATCH 02/14] Add cli command --- packages/docusaurus-mdx-loader/package.json | 1 - .../src/remark/headings/index.js | 8 +-- packages/docusaurus-utils/package.json | 2 + packages/docusaurus-utils/src/index.ts | 6 ++ packages/docusaurus/bin/docusaurus.js | 8 +++ .../src/commands/writeHeadingIds.ts | 69 +++++++++++++++++++ packages/docusaurus/src/index.ts | 1 + yarn.lock | 5 ++ 8 files changed, 94 insertions(+), 6 deletions(-) create mode 100644 packages/docusaurus/src/commands/writeHeadingIds.ts diff --git a/packages/docusaurus-mdx-loader/package.json b/packages/docusaurus-mdx-loader/package.json index 3ec7467c84c7..6e0f1b97b157 100644 --- a/packages/docusaurus-mdx-loader/package.json +++ b/packages/docusaurus-mdx-loader/package.json @@ -25,7 +25,6 @@ "escape-html": "^1.0.3", "file-loader": "^6.2.0", "fs-extra": "^9.1.0", - "github-slugger": "^1.3.0", "gray-matter": "^4.0.2", "loader-utils": "^2.0.0", "mdast-util-to-string": "^2.0.0", diff --git a/packages/docusaurus-mdx-loader/src/remark/headings/index.js b/packages/docusaurus-mdx-loader/src/remark/headings/index.js index 1fad54ee8e3c..ca10c503e6b6 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/index.js +++ b/packages/docusaurus-mdx-loader/src/remark/headings/index.js @@ -9,21 +9,19 @@ const visit = require('unist-util-visit'); const toString = require('mdast-util-to-string'); -const slugs = require('github-slugger')(); +const {slugify} = require('@docusaurus/utils'); const customHeadingIdRegex = /^(.*?)\s*\{#([\w-]+)\}$/; 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); + id = slugify(id); } else { const headingTextNodes = headingNode.children.filter( ({type}) => !['html', 'jsx'].includes(type), @@ -49,7 +47,7 @@ function slug() { lastNode.value = customHeadingIdMatches[1] || heading; } } else { - id = slugs.slug(heading); + id = slugify(heading); } } diff --git a/packages/docusaurus-utils/package.json b/packages/docusaurus-utils/package.json index 15afbce49ed0..b3596d134715 100644 --- a/packages/docusaurus-utils/package.json +++ b/packages/docusaurus-utils/package.json @@ -19,9 +19,11 @@ "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", + "github-slugger": "^1.3.0", "gray-matter": "^4.0.2", "intl": "^1.2.5", "intl-locales-supported": "^1.8.12", diff --git a/packages/docusaurus-utils/src/index.ts b/packages/docusaurus-utils/src/index.ts index 77016cf1e0c3..8af7a73eff12 100644 --- a/packages/docusaurus-utils/src/index.ts +++ b/packages/docusaurus-utils/src/index.ts @@ -24,6 +24,7 @@ import { import resolvePathnameUnsafe from 'resolve-pathname'; import {mapValues} from 'lodash'; import areIntlLocalesSupported from 'intl-locales-supported'; +import GithubSlugger from 'github-slugger'; const fileHash = new Map(); export async function generate( @@ -642,3 +643,8 @@ export function getDateTimeFormat(locale: string) { : // eslint-disable-next-line @typescript-eslint/no-var-requires require('intl').DateTimeFormat; } + +export function slugify(text: string) { + const slugger = new GithubSlugger(); + return slugger.slug(text, true); +} diff --git a/packages/docusaurus/bin/docusaurus.js b/packages/docusaurus/bin/docusaurus.js index d77327de6e86..866314320216 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((contentDir = '.') => { + wrapCommand(writeHeadingIds)(contentDir); + }); + cli.arguments('').action((cmd) => { cli.outputHelp(); console.log(` ${chalk.red(`\n Unknown command ${chalk.yellow(cmd)}.`)}`); diff --git a/packages/docusaurus/src/commands/writeHeadingIds.ts b/packages/docusaurus/src/commands/writeHeadingIds.ts new file mode 100644 index 000000000000..2b9677da61d9 --- /dev/null +++ b/packages/docusaurus/src/commands/writeHeadingIds.ts @@ -0,0 +1,69 @@ +/** + * 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 {slugify} from '@docusaurus/utils'; + +function stripLinks(line) { + return line.replace(/\[([^\]]+)\]\([^)]+\)/, (match, p1) => p1); +} + +function addHeaderId(line) { + const headingText = line.slice(line.indexOf(' ')).trim(); + const headingLevel = line.slice(0, line.indexOf(' ')); + return `${headingLevel} ${headingText} {#${slugify( + stripLinks(headingText), + )}}`; +} + +function addHeadingIds(lines) { + let inCode = false; + const results: string[] = []; + + lines.forEach((line) => { + if (line.startsWith('```')) { + inCode = !inCode; + results.push(line); + return; + } + + if (inCode) { + results.push(line); + return; + } + + if (!line.startsWith('#')) { + results.push(line); + return; + } + + if (/\{#[^}]+\}/.test(line)) { + results.push(line); + return; + } + + results.push(addHeaderId(line)); + }); + + return results; +} + +export default async function writeHeadingIds( + contentDir: string, +): Promise { + const markdownFiles = await globby(contentDir.split(' '), { + expandDirectories: ['**/*.{md,mdx}'], + }); + + markdownFiles.forEach((file) => { + const content = fs.readFileSync(file, 'utf8'); + const lines = content.split('\n'); + const updatedLines = addHeadingIds(lines); + fs.writeFileSync(file, updatedLines.join('\n')); + }); +} diff --git a/packages/docusaurus/src/index.ts b/packages/docusaurus/src/index.ts index f2c15ce9789c..fba0d2bb0664 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/yarn.lock b/yarn.lock index af30368a9b70..21db31167209 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" From c7a6a8ed4b42a61611ee589811c86a1be9d8e689 Mon Sep 17 00:00:00 2001 From: Alexey Pyltsyn Date: Fri, 5 Mar 2021 17:27:54 +0300 Subject: [PATCH 03/14] Fix slugger --- packages/docusaurus-mdx-loader/package.json | 1 + .../docusaurus-mdx-loader/src/remark/headings/index.js | 8 +++++--- packages/docusaurus-utils/package.json | 1 - packages/docusaurus-utils/src/index.ts | 6 ------ packages/docusaurus/package.json | 1 + packages/docusaurus/src/commands/writeHeadingIds.ts | 9 +++++---- 6 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/docusaurus-mdx-loader/package.json b/packages/docusaurus-mdx-loader/package.json index 6e0f1b97b157..3ec7467c84c7 100644 --- a/packages/docusaurus-mdx-loader/package.json +++ b/packages/docusaurus-mdx-loader/package.json @@ -25,6 +25,7 @@ "escape-html": "^1.0.3", "file-loader": "^6.2.0", "fs-extra": "^9.1.0", + "github-slugger": "^1.3.0", "gray-matter": "^4.0.2", "loader-utils": "^2.0.0", "mdast-util-to-string": "^2.0.0", diff --git a/packages/docusaurus-mdx-loader/src/remark/headings/index.js b/packages/docusaurus-mdx-loader/src/remark/headings/index.js index ca10c503e6b6..1fad54ee8e3c 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/index.js +++ b/packages/docusaurus-mdx-loader/src/remark/headings/index.js @@ -9,19 +9,21 @@ const visit = require('unist-util-visit'); const toString = require('mdast-util-to-string'); -const {slugify} = require('@docusaurus/utils'); +const slugs = require('github-slugger')(); const customHeadingIdRegex = /^(.*?)\s*\{#([\w-]+)\}$/; 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 = slugify(id); + id = slugs.slug(id, true); } else { const headingTextNodes = headingNode.children.filter( ({type}) => !['html', 'jsx'].includes(type), @@ -47,7 +49,7 @@ function slug() { lastNode.value = customHeadingIdMatches[1] || heading; } } else { - id = slugify(heading); + id = slugs.slug(heading); } } diff --git a/packages/docusaurus-utils/package.json b/packages/docusaurus-utils/package.json index b3596d134715..72c2da667444 100644 --- a/packages/docusaurus-utils/package.json +++ b/packages/docusaurus-utils/package.json @@ -23,7 +23,6 @@ "chalk": "^4.1.0", "escape-string-regexp": "^4.0.0", "fs-extra": "^9.1.0", - "github-slugger": "^1.3.0", "gray-matter": "^4.0.2", "intl": "^1.2.5", "intl-locales-supported": "^1.8.12", diff --git a/packages/docusaurus-utils/src/index.ts b/packages/docusaurus-utils/src/index.ts index 8af7a73eff12..77016cf1e0c3 100644 --- a/packages/docusaurus-utils/src/index.ts +++ b/packages/docusaurus-utils/src/index.ts @@ -24,7 +24,6 @@ import { import resolvePathnameUnsafe from 'resolve-pathname'; import {mapValues} from 'lodash'; import areIntlLocalesSupported from 'intl-locales-supported'; -import GithubSlugger from 'github-slugger'; const fileHash = new Map(); export async function generate( @@ -643,8 +642,3 @@ export function getDateTimeFormat(locale: string) { : // eslint-disable-next-line @typescript-eslint/no-var-requires require('intl').DateTimeFormat; } - -export function slugify(text: string) { - const slugger = new GithubSlugger(); - return slugger.slug(text, true); -} diff --git a/packages/docusaurus/package.json b/packages/docusaurus/package.json index 6babfcdcb926..5ab0afa4f637 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/writeHeadingIds.ts b/packages/docusaurus/src/commands/writeHeadingIds.ts index 2b9677da61d9..6777b4cf1532 100644 --- a/packages/docusaurus/src/commands/writeHeadingIds.ts +++ b/packages/docusaurus/src/commands/writeHeadingIds.ts @@ -7,16 +7,16 @@ import globby from 'globby'; import fs from 'fs-extra'; -import {slugify} from '@docusaurus/utils'; +import GithubSlugger from 'github-slugger'; function stripLinks(line) { return line.replace(/\[([^\]]+)\]\([^)]+\)/, (match, p1) => p1); } -function addHeaderId(line) { +function addHeaderId(line, slugger) { const headingText = line.slice(line.indexOf(' ')).trim(); const headingLevel = line.slice(0, line.indexOf(' ')); - return `${headingLevel} ${headingText} {#${slugify( + return `${headingLevel} ${headingText} {#${slugger.slug( stripLinks(headingText), )}}`; } @@ -24,6 +24,7 @@ function addHeaderId(line) { function addHeadingIds(lines) { let inCode = false; const results: string[] = []; + const slugger = new GithubSlugger(); lines.forEach((line) => { if (line.startsWith('```')) { @@ -47,7 +48,7 @@ function addHeadingIds(lines) { return; } - results.push(addHeaderId(line)); + results.push(addHeaderId(line, slugger)); }); return results; From 3f75cf309eb6d79ab0d81c1d1ee786f2d965e0d7 Mon Sep 17 00:00:00 2001 From: slorber Date: Fri, 5 Mar 2021 16:01:06 +0100 Subject: [PATCH 04/14] write-heading-ids doc + add in commands/templates --- .../docusaurus-init/templates/bootstrap/package.json | 5 +++-- .../docusaurus-init/templates/classic/package.json | 5 +++-- .../docusaurus-init/templates/facebook/package.json | 5 +++-- packages/docusaurus/bin/docusaurus.js | 1 + website/docs/cli.md | 12 +++++++++++- website/package.json | 1 + 6 files changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/docusaurus-init/templates/bootstrap/package.json b/packages/docusaurus-init/templates/bootstrap/package.json index f052f6488f58..180eda2763fa 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 24f338dfe4ec..e26273845f75 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 abd540bf827a..8a699a824b9c 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/bin/docusaurus.js b/packages/docusaurus/bin/docusaurus.js index 866314320216..689a6ecc7b75 100755 --- a/packages/docusaurus/bin/docusaurus.js +++ b/packages/docusaurus/bin/docusaurus.js @@ -307,6 +307,7 @@ function isInternalCommand(command) { 'serve', 'clear', 'write-translations', + 'write-heading-ids', ].includes(command); } diff --git a/website/docs/cli.md b/website/docs/cli.md index 1821b53aa069..4d05f9c4563a 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,9 @@ 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 stable anchor link ids to the Markdown documents of your site, ensuring anchor links don't break when modifying headings and translating them.) + +A heading like `## my heading` will become `## my heading {#my-heading}` diff --git a/website/package.json b/website/package.json index b7a883af3984..eaac3416791b 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", From d8493dfcb9ae902120e36b3391a042fe847cb2f9 Mon Sep 17 00:00:00 2001 From: slorber Date: Fri, 5 Mar 2021 17:05:56 +0100 Subject: [PATCH 05/14] refactor + add tests for writeHeadingIds --- .../__tests__/writeHeadingIds.test.ts | 104 +++++++++++++++ .../src/commands/writeHeadingIds.ts | 120 ++++++++++++------ 2 files changed, 184 insertions(+), 40 deletions(-) create mode 100644 packages/docusaurus/src/commands/__tests__/writeHeadingIds.test.ts 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 000000000000..267d25c1be4a --- /dev/null +++ b/packages/docusaurus/src/commands/__tests__/writeHeadingIds.test.ts @@ -0,0 +1,104 @@ +/** + * 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('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 index 6777b4cf1532..3a82121f32bc 100644 --- a/packages/docusaurus/src/commands/writeHeadingIds.ts +++ b/packages/docusaurus/src/commands/writeHeadingIds.ts @@ -8,63 +8,103 @@ import globby from 'globby'; import fs from 'fs-extra'; import GithubSlugger from 'github-slugger'; +import chalk from 'chalk'; -function stripLinks(line) { +export function stripLinks(line) { return line.replace(/\[([^\]]+)\]\([^)]+\)/, (match, p1) => p1); } -function addHeaderId(line, slugger) { - const headingText = line.slice(line.indexOf(' ')).trim(); - const headingLevel = line.slice(0, line.indexOf(' ')); - return `${headingLevel} ${headingText} {#${slugger.slug( - stripLinks(headingText), - )}}`; +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(stripLinks(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 alreadyHasId = /\{#[^}]+\}/.test(line); + if (alreadyHasId) { + return line; + } + return addHeadingId(line, slugger); } -function addHeadingIds(lines) { +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 results: string[] = []; const slugger = new GithubSlugger(); - lines.forEach((line) => { + return lines.map((line) => { if (line.startsWith('```')) { inCode = !inCode; - results.push(line); - return; - } - - if (inCode) { - results.push(line); - return; - } - - if (!line.startsWith('#')) { - results.push(line); - return; - } - - if (/\{#[^}]+\}/.test(line)) { - results.push(line); - return; + return line; + } else { + if (inCode) { + return line; + } + return transformMarkdownLine(line, slugger); } - - results.push(addHeaderId(line, slugger)); }); +} - return results; +export function transformMarkdownContent(content: string): string { + return transformMarkdownLines(content.split('\n')).join('\n'); } -export default async function writeHeadingIds( - contentDir: string, -): Promise { - const markdownFiles = await globby(contentDir.split(' '), { +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; +} + +export default async function writeHeadingIds(siteDir: string): Promise { + const markdownFiles = await globby(siteDir, { expandDirectories: ['**/*.{md,mdx}'], }); - markdownFiles.forEach((file) => { - const content = fs.readFileSync(file, 'utf8'); - const lines = content.split('\n'); - const updatedLines = addHeadingIds(lines); - fs.writeFileSync(file, updatedLines.join('\n')); - }); + 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}): +- ${pathsModified.join('\n- ')}`), + ); + } else { + console.log( + chalk.yellow(`No heading id had to be added to any markdown file`), + ); + } } From 07f0d039bcc515f886c6689710e54bf1877e8417 Mon Sep 17 00:00:00 2001 From: slorber Date: Fri, 5 Mar 2021 17:17:45 +0100 Subject: [PATCH 06/14] polish writeHeadingIds --- .../__tests__/writeHeadingIds.test.ts | 11 +++++++++++ .../src/commands/writeHeadingIds.ts | 19 ++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/docusaurus/src/commands/__tests__/writeHeadingIds.test.ts b/packages/docusaurus/src/commands/__tests__/writeHeadingIds.test.ts index 267d25c1be4a..a9c7105fcd18 100644 --- a/packages/docusaurus/src/commands/__tests__/writeHeadingIds.test.ts +++ b/packages/docusaurus/src/commands/__tests__/writeHeadingIds.test.ts @@ -44,6 +44,17 @@ describe('transformMarkdownHeadingLine', () => { ); }); + test('can slugify complex headings', () => { + expect( + transformMarkdownHeadingLine( + '## abc [Hello] How are you %Sébastien_-_$)( ## -56756', + new GithubSlugger(), + ), + ).toEqual( + '## abc [Hello] How are you %Sébastien_-_$)( ## -56756 {#abc-hello-how-are-you-sébastien_-_---56756}', + ); + }); + test('does not duplicate duplicate id', () => { expect( transformMarkdownHeadingLine( diff --git a/packages/docusaurus/src/commands/writeHeadingIds.ts b/packages/docusaurus/src/commands/writeHeadingIds.ts index 3a82121f32bc..3e24334150ca 100644 --- a/packages/docusaurus/src/commands/writeHeadingIds.ts +++ b/packages/docusaurus/src/commands/writeHeadingIds.ts @@ -9,6 +9,10 @@ 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'; export function stripLinks(line) { return line.replace(/\[([^\]]+)\]\([^)]+\)/, (match, p1) => p1); @@ -86,8 +90,21 @@ async function transformMarkdownFile( 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(siteDir, { + const markdownFiles = await globby(await getPathsToWatch(siteDir), { expandDirectories: ['**/*.{md,mdx}'], }); From bb3eb0681249338c3b9109fa13704493e706af2e Mon Sep 17 00:00:00 2001 From: slorber Date: Fri, 5 Mar 2021 17:21:40 +0100 Subject: [PATCH 07/14] polish writeHeadingIds --- packages/docusaurus/src/commands/writeHeadingIds.ts | 6 ++++-- website/docs/cli.md | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/docusaurus/src/commands/writeHeadingIds.ts b/packages/docusaurus/src/commands/writeHeadingIds.ts index 3e24334150ca..798cc916eed5 100644 --- a/packages/docusaurus/src/commands/writeHeadingIds.ts +++ b/packages/docusaurus/src/commands/writeHeadingIds.ts @@ -116,12 +116,14 @@ export default async function writeHeadingIds(siteDir: string): Promise { console.log( chalk.green(`Heading ids added to markdown files (${ pathsModified.length - } / ${markdownFiles.length}): + }/${markdownFiles.length} files): - ${pathsModified.join('\n- ')}`), ); } else { console.log( - chalk.yellow(`No heading id had to be added to any markdown file`), + chalk.yellow( + `${markdownFiles.length} markdown files already have explicit heading ids`, + ), ); } } diff --git a/website/docs/cli.md b/website/docs/cli.md index 4d05f9c4563a..1868287c1217 100644 --- a/website/docs/cli.md +++ b/website/docs/cli.md @@ -184,6 +184,6 @@ By default, the files are written in `website/i18n//...`. ### `docusaurus write-heading-ids [siteDir]` -Add stable anchor link ids to the Markdown documents of your site, ensuring anchor links don't break when modifying headings and translating them.) +Add explicit heading ids to the Markdown documents of your site instead of generating the ids from the heading text, ensuring anchor links don't break when modifying or translating heading text. -A heading like `## my heading` will become `## my heading {#my-heading}` +A heading like `## my heading` is transformed into `## my heading {#my-heading}` From d0a09af67dbf073863e99f2fbf0fd5e02aac0b8e Mon Sep 17 00:00:00 2001 From: slorber Date: Fri, 5 Mar 2021 17:23:19 +0100 Subject: [PATCH 08/14] remove i18n goals todo section as the remaining items are quite abstract/useless --- website/docs/i18n/i18n-introduction.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/website/docs/i18n/i18n-introduction.md b/website/docs/i18n/i18n-introduction.md index 6e33a992838e..229c0172c57d 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: From 216696d1f81e2e42ae52d385d9b6662703878f59 Mon Sep 17 00:00:00 2001 From: slorber Date: Fri, 5 Mar 2021 17:31:09 +0100 Subject: [PATCH 09/14] fix edge case with 2 md links in heading --- .../commands/__tests__/writeHeadingIds.test.ts | 17 ++++++++++------- .../docusaurus/src/commands/writeHeadingIds.ts | 6 +++--- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/docusaurus/src/commands/__tests__/writeHeadingIds.test.ts b/packages/docusaurus/src/commands/__tests__/writeHeadingIds.test.ts index a9c7105fcd18..382d6d9261cc 100644 --- a/packages/docusaurus/src/commands/__tests__/writeHeadingIds.test.ts +++ b/packages/docusaurus/src/commands/__tests__/writeHeadingIds.test.ts @@ -44,14 +44,17 @@ describe('transformMarkdownHeadingLine', () => { ); }); + 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', () => { - expect( - transformMarkdownHeadingLine( - '## abc [Hello] How are you %Sébastien_-_$)( ## -56756', - new GithubSlugger(), - ), - ).toEqual( - '## abc [Hello] How are you %Sébastien_-_$)( ## -56756 {#abc-hello-how-are-you-sébastien_-_---56756}', + 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}`, ); }); diff --git a/packages/docusaurus/src/commands/writeHeadingIds.ts b/packages/docusaurus/src/commands/writeHeadingIds.ts index 798cc916eed5..0bfe0743ecc8 100644 --- a/packages/docusaurus/src/commands/writeHeadingIds.ts +++ b/packages/docusaurus/src/commands/writeHeadingIds.ts @@ -14,8 +14,8 @@ import initPlugins from '../server/plugins/init'; import {flatten} from 'lodash'; -export function stripLinks(line) { - return line.replace(/\[([^\]]+)\]\([^)]+\)/, (match, p1) => p1); +export function unwrapMarkdownLinks(line) { + return line.replace(/\[([^\]]+)\]\([^)]+\)/g, (match, p1) => p1); } function addHeadingId(line, slugger) { @@ -26,7 +26,7 @@ function addHeadingId(line, slugger) { const headingText = line.slice(headingLevel).trimEnd(); const headingHashes = line.slice(0, headingLevel); - const slug = slugger.slug(stripLinks(headingText)); + const slug = slugger.slug(unwrapMarkdownLinks(headingText)); return `${headingHashes}${headingText} {#${slug}}`; } From 0d575de748f47aecd32ea8330c3cbaf190747c0b Mon Sep 17 00:00:00 2001 From: slorber Date: Fri, 5 Mar 2021 17:47:40 +0100 Subject: [PATCH 10/14] extract parseMarkdownHeadingId helper function --- .../src/__tests__/index.test.ts | 42 +++++++++++++++++++ packages/docusaurus-utils/src/index.ts | 20 +++++++++ .../src/commands/writeHeadingIds.ts | 7 +++- 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/packages/docusaurus-utils/src/__tests__/index.test.ts b/packages/docusaurus-utils/src/__tests__/index.test.ts index f0ddf80f9664..5c3da75a5ab7 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,44 @@ 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', + }); + }); +}); diff --git a/packages/docusaurus-utils/src/index.ts b/packages/docusaurus-utils/src/index.ts index 77016cf1e0c3..adc92a863c7a 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/src/commands/writeHeadingIds.ts b/packages/docusaurus/src/commands/writeHeadingIds.ts index 0bfe0743ecc8..4661ccb4b34a 100644 --- a/packages/docusaurus/src/commands/writeHeadingIds.ts +++ b/packages/docusaurus/src/commands/writeHeadingIds.ts @@ -13,6 +13,7 @@ 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); @@ -39,8 +40,10 @@ export function transformMarkdownHeadingLine( throw new Error(`Line is not a markdown heading: ${line}`); } - const alreadyHasId = /\{#[^}]+\}/.test(line); - if (alreadyHasId) { + const parsedHeading = parseMarkdownHeadingId(line); + + // Do not process if id is already therer + if (parsedHeading.id) { return line; } return addHeadingId(line, slugger); From c3ee5256aaa550d30895f7169d1a8eacd7acc9ec Mon Sep 17 00:00:00 2001 From: slorber Date: Fri, 5 Mar 2021 17:56:13 +0100 Subject: [PATCH 11/14] refactor using the shared parseMarkdownHeadingId utility fn --- .../src/remark/headings/index.js | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/docusaurus-mdx-loader/src/remark/headings/index.js b/packages/docusaurus-mdx-loader/src/remark/headings/index.js index 1fad54ee8e3c..9aace5cc0d09 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/index.js +++ b/packages/docusaurus-mdx-loader/src/remark/headings/index.js @@ -7,12 +7,11 @@ /* 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')(); -const customHeadingIdRegex = /^(.*?)\s*\{#([\w-]+)\}$/; - function slug() { const transformer = (ast) => { slugs.reset(); @@ -35,21 +34,19 @@ function slug() { ); // Support explicit heading IDs - const customHeadingIdMatches = customHeadingIdRegex.exec(heading); + const parsedHeading = parseMarkdownHeadingId(heading); - if (customHeadingIdMatches) { - id = customHeadingIdMatches[2]; + id = parsedHeading.id || slugs.slug(heading); + if (parsedHeading.id) { // Remove the custom ID part from the text node if (headingNode.children.length > 1) { headingNode.children.pop(); } else { const lastNode = headingNode.children[headingNode.children.length - 1]; - lastNode.value = customHeadingIdMatches[1] || heading; + lastNode.value = parsedHeading.text || heading; } - } else { - id = slugs.slug(heading); } } From 870628feef93605e2d873304aaa94b2caf829f01 Mon Sep 17 00:00:00 2001 From: slorber Date: Fri, 5 Mar 2021 18:03:41 +0100 Subject: [PATCH 12/14] change logic of edge case --- .../src/remark/headings/__tests__/index.test.js | 2 +- .../docusaurus-mdx-loader/src/remark/headings/index.js | 2 +- packages/docusaurus-utils/src/__tests__/index.test.ts | 7 +++++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/docusaurus-mdx-loader/src/remark/headings/__tests__/index.test.js b/packages/docusaurus-mdx-loader/src/remark/headings/__tests__/index.test.js index a127e2ea1eb9..e3139888b360 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/__tests__/index.test.js +++ b/packages/docusaurus-mdx-loader/src/remark/headings/__tests__/index.test.js @@ -292,7 +292,7 @@ describe('headings plugin', () => { }, { id: 'id-only', - text: '{#id-only}', + text: '', }, { id: '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 index 9aace5cc0d09..43121944d97e 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/index.js +++ b/packages/docusaurus-mdx-loader/src/remark/headings/index.js @@ -45,7 +45,7 @@ function slug() { } else { const lastNode = headingNode.children[headingNode.children.length - 1]; - lastNode.value = parsedHeading.text || heading; + lastNode.value = parsedHeading.text; } } } diff --git a/packages/docusaurus-utils/src/__tests__/index.test.ts b/packages/docusaurus-utils/src/__tests__/index.test.ts index 5c3da75a5ab7..0b24c8705225 100644 --- a/packages/docusaurus-utils/src/__tests__/index.test.ts +++ b/packages/docusaurus-utils/src/__tests__/index.test.ts @@ -847,4 +847,11 @@ describe('parseMarkdownHeadingId', () => { id: 'id', }); }); + + test('can parse heading with only id', () => { + expect(parseMarkdownHeadingId('## {#id}')).toEqual({ + text: '##', + id: 'id', + }); + }); }); From c69a45fcf25d6eca00a79bab330cb68e3799e413 Mon Sep 17 00:00:00 2001 From: slorber Date: Fri, 5 Mar 2021 18:25:19 +0100 Subject: [PATCH 13/14] Handle edge case --- .../remark/headings/__tests__/index.test.js | 12 ++++++++++ .../src/remark/headings/index.js | 23 ++++++++++++++----- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/docusaurus-mdx-loader/src/remark/headings/__tests__/index.test.js b/packages/docusaurus-mdx-loader/src/remark/headings/__tests__/index.test.js index e3139888b360..90153a2da299 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/__tests__/index.test.js +++ b/packages/docusaurus-mdx-loader/src/remark/headings/__tests__/index.test.js @@ -255,6 +255,10 @@ describe('headings plugin', () => { # 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 @@ -282,6 +286,14 @@ describe('headings plugin', () => { 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', diff --git a/packages/docusaurus-mdx-loader/src/remark/headings/index.js b/packages/docusaurus-mdx-loader/src/remark/headings/index.js index 43121944d97e..15e713a90707 100644 --- a/packages/docusaurus-mdx-loader/src/remark/headings/index.js +++ b/packages/docusaurus-mdx-loader/src/remark/headings/index.js @@ -12,7 +12,7 @@ const visit = require('unist-util-visit'); const toString = require('mdast-util-to-string'); const slugs = require('github-slugger')(); -function slug() { +function headings() { const transformer = (ast) => { slugs.reset(); @@ -39,12 +39,23 @@ function slug() { id = parsedHeading.id || slugs.slug(heading); if (parsedHeading.id) { - // Remove the custom ID part from the text node + // 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) { - headingNode.children.pop(); + 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 { - const lastNode = - headingNode.children[headingNode.children.length - 1]; lastNode.value = parsedHeading.text; } } @@ -60,4 +71,4 @@ function slug() { return transformer; } -module.exports = slug; +module.exports = headings; From 728d2626cc347d32a5c43d84783c9d859ace7bb7 Mon Sep 17 00:00:00 2001 From: slorber Date: Fri, 5 Mar 2021 19:21:23 +0100 Subject: [PATCH 14/14] Document explicit ids feature --- packages/docusaurus/bin/docusaurus.js | 4 +- website/docs/cli.md | 4 +- .../markdown-features-headings.mdx | 59 +++++++++++++++++++ website/docs/i18n/i18n-tutorial.md | 21 +++++++ website/sidebars.js | 1 + 5 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 website/docs/guides/markdown-features/markdown-features-headings.mdx diff --git a/packages/docusaurus/bin/docusaurus.js b/packages/docusaurus/bin/docusaurus.js index 689a6ecc7b75..57fbf4429e42 100755 --- a/packages/docusaurus/bin/docusaurus.js +++ b/packages/docusaurus/bin/docusaurus.js @@ -288,8 +288,8 @@ cli cli .command('write-heading-ids [contentDir]') .description('Generate heading ids in Markdown content') - .action((contentDir = '.') => { - wrapCommand(writeHeadingIds)(contentDir); + .action((siteDir = '.') => { + wrapCommand(writeHeadingIds)(siteDir); }); cli.arguments('').action((cmd) => { diff --git a/website/docs/cli.md b/website/docs/cli.md index 1868287c1217..4d0e12508369 100644 --- a/website/docs/cli.md +++ b/website/docs/cli.md @@ -184,6 +184,4 @@ By default, the files are written in `website/i18n//...`. ### `docusaurus write-heading-ids [siteDir]` -Add explicit heading ids to the Markdown documents of your site instead of generating the ids from the heading text, ensuring anchor links don't break when modifying or translating heading text. - -A heading like `## my heading` is transformed into `## my heading {#my-heading}` +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/markdown-features/markdown-features-headings.mdx b/website/docs/guides/markdown-features/markdown-features-headings.mdx new file mode 100644 index 000000000000..abc6fca0f9d5 --- /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-tutorial.md b/website/docs/i18n/i18n-tutorial.md index 3f9d2618ab98..3d7bf2fdf292 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/sidebars.js b/website/sidebars.js index 5d3c53828594..24461ff0cd32 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',