From 96e7fcef252460778c532531d08556f1af2bcce1 Mon Sep 17 00:00:00 2001 From: Alexey Pyltsyn Date: Fri, 5 Mar 2021 21:36:14 +0300 Subject: [PATCH] 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 --- .../templates/bootstrap/package.json | 5 +- .../templates/classic/package.json | 5 +- .../templates/facebook/package.json | 5 +- packages/docusaurus-mdx-loader/src/index.js | 4 +- .../__tests__/index.test.js | 76 +++++++++- .../src/remark/headings/index.js | 74 ++++++++++ .../src/remark/slug/index.js | 46 ------ .../src/remark/toc/__tests__/index.test.js | 4 +- .../transformImage/__tests__/index.test.js | 4 +- packages/docusaurus-utils/package.json | 1 + .../src/__tests__/index.test.ts | 49 +++++++ packages/docusaurus-utils/src/index.ts | 20 +++ packages/docusaurus/bin/docusaurus.js | 9 ++ packages/docusaurus/package.json | 1 + .../__tests__/writeHeadingIds.test.ts | 118 ++++++++++++++++ .../src/commands/writeHeadingIds.ts | 132 ++++++++++++++++++ packages/docusaurus/src/index.ts | 1 + website/docs/cli.md | 10 +- website/docs/guides/docs/docs-create-doc.mdx | 4 + .../markdown-features-headings.mdx | 59 ++++++++ website/docs/i18n/i18n-introduction.md | 8 -- website/docs/i18n/i18n-tutorial.md | 21 +++ website/package.json | 1 + website/sidebars.js | 1 + .../src/pages/examples/markdownPageExample.md | 2 + yarn.lock | 5 + 26 files changed, 594 insertions(+), 71 deletions(-) rename packages/docusaurus-mdx-loader/src/remark/{slug => headings}/__tests__/index.test.js (78%) create mode 100644 packages/docusaurus-mdx-loader/src/remark/headings/index.js delete mode 100644 packages/docusaurus-mdx-loader/src/remark/slug/index.js create mode 100644 packages/docusaurus/src/commands/__tests__/writeHeadingIds.test.ts create mode 100644 packages/docusaurus/src/commands/writeHeadingIds.ts create mode 100644 website/docs/guides/markdown-features/markdown-features-headings.mdx 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-mdx-loader/src/index.js b/packages/docusaurus-mdx-loader/src/index.js index 814171de56d6..51432c62172a 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 887f897d709a..90153a2da299 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,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 000000000000..15e713a90707 --- /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 ad8cb51f88e1..000000000000 --- 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 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/packages/docusaurus-utils/package.json b/packages/docusaurus-utils/package.json index 15afbce49ed0..72c2da667444 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 f0ddf80f9664..0b24c8705225 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 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/bin/docusaurus.js b/packages/docusaurus/bin/docusaurus.js index d77327de6e86..57fbf4429e42 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 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/__tests__/writeHeadingIds.test.ts b/packages/docusaurus/src/commands/__tests__/writeHeadingIds.test.ts new file mode 100644 index 000000000000..382d6d9261cc --- /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 000000000000..4661ccb4b34a --- /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 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/website/docs/cli.md b/website/docs/cli.md index 1821b53aa069..4d0e12508369 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 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/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-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: 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/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", 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', diff --git a/website/src/pages/examples/markdownPageExample.md b/website/src/pages/examples/markdownPageExample.md index a25ddf4582c6..78f642ab0919 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 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"