diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/index.test.ts.snap b/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/index.test.ts.snap index b5fda7366b70..d25aa4d54928 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/__snapshots__/index.test.ts.snap @@ -781,6 +781,7 @@ Object { "dirName": ".", "type": "autogenerated", }, + "numberPrefixParser": [Function], "version": Object { "contentPath": "docs", "versionName": "current", diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/numberPrefix.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/numberPrefix.test.ts index af116cb1dbf4..6ca7081a345b 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/numberPrefix.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/numberPrefix.test.ts @@ -6,53 +6,114 @@ */ import { - extractNumberPrefix, + DefaultNumberPrefixParser, + DisabledNumberPrefixParser, stripNumberPrefix, stripPathNumberPrefixes, } from '../numberPrefix'; -const BadNumberPrefixPatterns = [ +const IgnoredNumberPrefixPatterns = [ + // Patterns without number prefix + 'MyDoc', 'a1-My Doc', 'My Doc-000', + 'My Doc - 1', + 'My Doc - 02', + 'Hey - 03 - My Doc', '00abc01-My Doc', 'My 001- Doc', 'My -001 Doc', + // ignore common date-like patterns: https://github.com/facebook/docusaurus/issues/4640 + '2021-01-31 - Doc', + '31-01-2021 - Doc', + '2021_01_31 - Doc', + '31_01_2021 - Doc', + '2021.01.31 - Doc', + '31.01.2021 - Doc', + '2021-01 - Doc', + '2021_01 - Doc', + '2021.01 - Doc', + '01-2021 - Doc', + '01_2021 - Doc', + '01.2021 - Doc', + // date patterns without suffix + '2021-01-31', + '2021-01', + '21-01-31', + '21-01', + '2021_01_31', + '2021_01', + '21_01_31', + '21_01', + '01_31', + '01', + '2021', + '01', + // ignore common versioning patterns: https://github.com/facebook/docusaurus/issues/4653 + '8.0', + '8.0.0', + '14.2.16', + '18.2', + '8.0 - Doc', + '8.0.0 - Doc', + '8_0', + '8_0_0', + '14_2_16', + '18_2', + '8.0 - Doc', + '8.0.0 - Doc', ]; describe('stripNumberPrefix', () => { + function stripNumberPrefixDefault(str: string) { + return stripNumberPrefix(str, DefaultNumberPrefixParser); + } + test('should strip number prefix if present', () => { - expect(stripNumberPrefix('1-My Doc')).toEqual('My Doc'); - expect(stripNumberPrefix('01-My Doc')).toEqual('My Doc'); - expect(stripNumberPrefix('001-My Doc')).toEqual('My Doc'); - expect(stripNumberPrefix('001 - My Doc')).toEqual('My Doc'); - expect(stripNumberPrefix('001 - My Doc')).toEqual('My Doc'); - expect(stripNumberPrefix('999 - My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('1-My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('01-My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('001-My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('001 - My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('001 - My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('999 - My Doc')).toEqual( + 'My Doc', + ); // - expect(stripNumberPrefix('1---My Doc')).toEqual('My Doc'); - expect(stripNumberPrefix('01---My Doc')).toEqual('My Doc'); - expect(stripNumberPrefix('001---My Doc')).toEqual('My Doc'); - expect(stripNumberPrefix('001 --- My Doc')).toEqual('My Doc'); - expect(stripNumberPrefix('001 --- My Doc')).toEqual('My Doc'); - expect(stripNumberPrefix('999 --- My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('1---My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('01---My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('001---My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('001 --- My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('001 --- My Doc')).toEqual( + 'My Doc', + ); + expect(stripNumberPrefixDefault('999 --- My Doc')).toEqual( + 'My Doc', + ); // - expect(stripNumberPrefix('1___My Doc')).toEqual('My Doc'); - expect(stripNumberPrefix('01___My Doc')).toEqual('My Doc'); - expect(stripNumberPrefix('001___My Doc')).toEqual('My Doc'); - expect(stripNumberPrefix('001 ___ My Doc')).toEqual('My Doc'); - expect(stripNumberPrefix('001 ___ My Doc')).toEqual('My Doc'); - expect(stripNumberPrefix('999 ___ My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('1___My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('01___My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('001___My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('001 ___ My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('001 ___ My Doc')).toEqual( + 'My Doc', + ); + expect(stripNumberPrefixDefault('999 ___ My Doc')).toEqual( + 'My Doc', + ); // - expect(stripNumberPrefix('1.My Doc')).toEqual('My Doc'); - expect(stripNumberPrefix('01.My Doc')).toEqual('My Doc'); - expect(stripNumberPrefix('001.My Doc')).toEqual('My Doc'); - expect(stripNumberPrefix('001 . My Doc')).toEqual('My Doc'); - expect(stripNumberPrefix('001 . My Doc')).toEqual('My Doc'); - expect(stripNumberPrefix('999 . My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('1.My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('01.My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('001.My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('001 . My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('001 . My Doc')).toEqual('My Doc'); + expect(stripNumberPrefixDefault('999 . My Doc')).toEqual( + 'My Doc', + ); }); test('should not strip number prefix if pattern does not match', () => { - BadNumberPrefixPatterns.forEach((badPattern) => { - expect(stripNumberPrefix(badPattern)).toEqual(badPattern); + IgnoredNumberPrefixPatterns.forEach((badPattern) => { + expect(stripNumberPrefixDefault(badPattern)).toEqual(badPattern); }); }); }); @@ -62,51 +123,74 @@ describe('stripPathNumberPrefix', () => { expect( stripPathNumberPrefixes( '0-MyRootFolder0/1 - MySubFolder1/2. MyDeepFolder2/3 _MyDoc3', + DefaultNumberPrefixParser, ), ).toEqual('MyRootFolder0/MySubFolder1/MyDeepFolder2/MyDoc3'); }); + + test('should strip number prefixes in paths with custom parser', () => { + function stripPathNumberPrefixCustom(str: string) { + return { + filename: str.substring(1, str.length), + numberPrefix: 0, + }; + } + + expect( + stripPathNumberPrefixes('aaaa/bbbb/cccc', stripPathNumberPrefixCustom), + ).toEqual('aaa/bbb/ccc'); + }); + + test('should strip number prefixes in paths with disabled parser', () => { + expect( + stripPathNumberPrefixes( + '0-MyRootFolder0/1 - MySubFolder1/2. MyDeepFolder2/3 _MyDoc3', + DisabledNumberPrefixParser, + ), + ).toEqual('0-MyRootFolder0/1 - MySubFolder1/2. MyDeepFolder2/3 _MyDoc3'); + }); }); -describe('extractNumberPrefix', () => { +describe('DefaultNumberPrefixParser', () => { test('should extract number prefix if present', () => { - expect(extractNumberPrefix('0-My Doc')).toEqual({ + expect(DefaultNumberPrefixParser('0-My Doc')).toEqual({ filename: 'My Doc', numberPrefix: 0, }); - expect(extractNumberPrefix('1-My Doc')).toEqual({ + expect(DefaultNumberPrefixParser('1-My Doc')).toEqual({ filename: 'My Doc', numberPrefix: 1, }); - expect(extractNumberPrefix('01-My Doc')).toEqual({ + expect(DefaultNumberPrefixParser('01-My Doc')).toEqual({ filename: 'My Doc', numberPrefix: 1, }); - expect(extractNumberPrefix('001-My Doc')).toEqual({ + expect(DefaultNumberPrefixParser('001-My Doc')).toEqual({ filename: 'My Doc', numberPrefix: 1, }); - expect(extractNumberPrefix('001 - My Doc')).toEqual({ + expect(DefaultNumberPrefixParser('001 - My Doc')).toEqual({ filename: 'My Doc', numberPrefix: 1, }); - expect(extractNumberPrefix('001 - My Doc')).toEqual({ + expect(DefaultNumberPrefixParser('001 - My Doc')).toEqual({ filename: 'My Doc', numberPrefix: 1, }); - expect(extractNumberPrefix('999 - My Doc')).toEqual({ + expect(DefaultNumberPrefixParser('999 - My Doc')).toEqual({ filename: 'My Doc', numberPrefix: 999, }); - expect(extractNumberPrefix('0046036 - My Doc')).toEqual({ + expect(DefaultNumberPrefixParser('0046036 - My Doc')).toEqual({ filename: 'My Doc', numberPrefix: 46036, }); }); test('should not extract number prefix if pattern does not match', () => { - BadNumberPrefixPatterns.forEach((badPattern) => { - expect(extractNumberPrefix(badPattern)).toEqual({ + IgnoredNumberPrefixPatterns.forEach((badPattern) => { + expect(DefaultNumberPrefixParser(badPattern)).toEqual({ filename: badPattern, numberPrefix: undefined, }); diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/options.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/options.test.ts index 1d46b3d84072..5ddd5f72d3b5 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/options.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/options.test.ts @@ -8,6 +8,10 @@ import {OptionsSchema, DEFAULT_OPTIONS} from '../options'; import {normalizePluginOptions} from '@docusaurus/utils-validation'; import {DefaultSidebarItemsGenerator} from '../sidebarItemsGenerator'; +import { + DefaultNumberPrefixParser, + DisabledNumberPrefixParser, +} from '../numberPrefix'; // the type of remark/rehype plugins is function const markdownPluginsFunctionStub = () => {}; @@ -28,6 +32,7 @@ describe('normalizeDocsPluginOptions', () => { include: ['**/*.{md,mdx}'], // Extensions to include. sidebarPath: 'my-sidebar', // Path to sidebar configuration for showing a list of markdown pages. sidebarItemsGenerator: DefaultSidebarItemsGenerator, + numberPrefixParser: DefaultNumberPrefixParser, docLayoutComponent: '@theme/DocPage', docItemComponent: '@theme/DocItem', remarkPlugins: [markdownPluginsObjectStub], @@ -84,6 +89,46 @@ describe('normalizeDocsPluginOptions', () => { expect(error).toBe(undefined); }); + test('should accept numberPrefixParser function', () => { + function customNumberPrefixParser() {} + expect( + normalizePluginOptions(OptionsSchema, { + ...DEFAULT_OPTIONS, + numberPrefixParser: customNumberPrefixParser, + }), + ).toEqual({ + ...DEFAULT_OPTIONS, + id: 'default', + numberPrefixParser: customNumberPrefixParser, + }); + }); + + test('should accept numberPrefixParser false', () => { + expect( + normalizePluginOptions(OptionsSchema, { + ...DEFAULT_OPTIONS, + numberPrefixParser: false, + }), + ).toEqual({ + ...DEFAULT_OPTIONS, + id: 'default', + numberPrefixParser: DisabledNumberPrefixParser, + }); + }); + + test('should accept numberPrefixParser true', () => { + expect( + normalizePluginOptions(OptionsSchema, { + ...DEFAULT_OPTIONS, + numberPrefixParser: true, + }), + ).toEqual({ + ...DEFAULT_OPTIONS, + id: 'default', + numberPrefixParser: DefaultNumberPrefixParser, + }); + }); + test('should reject admonitions true', async () => { const admonitionsTrue = { ...DEFAULT_OPTIONS, diff --git a/packages/docusaurus-plugin-content-docs/src/__tests__/sidebarItemsGenerator.test.ts b/packages/docusaurus-plugin-content-docs/src/__tests__/sidebarItemsGenerator.test.ts index 81f07f4e81ce..69b26ba2ce6e 100644 --- a/packages/docusaurus-plugin-content-docs/src/__tests__/sidebarItemsGenerator.test.ts +++ b/packages/docusaurus-plugin-content-docs/src/__tests__/sidebarItemsGenerator.test.ts @@ -12,12 +12,14 @@ import { import {DefaultCategoryCollapsedValue} from '../sidebars'; import {Sidebar, SidebarItemsGenerator} from '../types'; import fs from 'fs-extra'; +import {DefaultNumberPrefixParser} from '../numberPrefix'; describe('DefaultSidebarItemsGenerator', () => { function testDefaultSidebarItemsGenerator( options: Partial[0]>, ) { return DefaultSidebarItemsGenerator({ + numberPrefixParser: DefaultNumberPrefixParser, item: { type: 'autogenerated', dirName: '.', @@ -60,6 +62,7 @@ describe('DefaultSidebarItemsGenerator', () => { test('generates simple flat sidebar', async () => { const sidebarSlice = await DefaultSidebarItemsGenerator({ + numberPrefixParser: DefaultNumberPrefixParser, item: { type: 'autogenerated', dirName: '.', @@ -127,6 +130,7 @@ describe('DefaultSidebarItemsGenerator', () => { }); const sidebarSlice = await DefaultSidebarItemsGenerator({ + numberPrefixParser: DefaultNumberPrefixParser, item: { type: 'autogenerated', dirName: '.', @@ -234,6 +238,7 @@ describe('DefaultSidebarItemsGenerator', () => { }); const sidebarSlice = await DefaultSidebarItemsGenerator({ + numberPrefixParser: DefaultNumberPrefixParser, item: { type: 'autogenerated', dirName: 'subfolder/subsubfolder', diff --git a/packages/docusaurus-plugin-content-docs/src/docFrontMatter.ts b/packages/docusaurus-plugin-content-docs/src/docFrontMatter.ts index 6ae0fe77b110..4fc9282ddaa8 100644 --- a/packages/docusaurus-plugin-content-docs/src/docFrontMatter.ts +++ b/packages/docusaurus-plugin-content-docs/src/docFrontMatter.ts @@ -16,9 +16,13 @@ type DocFrontMatter = { sidebar_label?: string; sidebar_position?: number; custom_edit_url?: string; - strip_number_prefixes?: boolean; + parse_number_prefixes?: boolean; }; +// NOTE: we don't add any default value on purpose here +// We don't want default values to magically appear in doc metadatas and props +// While the user did not provide those values explicitly +// We use default values in code instead const DocFrontMatterSchema = Joi.object({ id: Joi.string(), title: Joi.string(), @@ -27,11 +31,14 @@ const DocFrontMatterSchema = Joi.object({ sidebar_label: Joi.string(), sidebar_position: Joi.number(), custom_edit_url: Joi.string().allow(null), - strip_number_prefixes: Joi.boolean(), -}).unknown(); + parse_number_prefixes: Joi.boolean(), +}); -export function assertDocFrontMatter( +export function validateDocFrontMatter( frontMatter: Record, -): asserts frontMatter is DocFrontMatter { - Joi.attempt(frontMatter, DocFrontMatterSchema); +): DocFrontMatter { + return Joi.attempt(frontMatter, DocFrontMatterSchema, { + convert: true, + allowUnknown: true, + }); } diff --git a/packages/docusaurus-plugin-content-docs/src/docs.ts b/packages/docusaurus-plugin-content-docs/src/docs.ts index 7cdfc1c724f2..d84a3840245f 100644 --- a/packages/docusaurus-plugin-content-docs/src/docs.ts +++ b/packages/docusaurus-plugin-content-docs/src/docs.ts @@ -30,8 +30,8 @@ import getSlug from './slug'; import {CURRENT_VERSION_NAME} from './constants'; import globby from 'globby'; import {getDocsDirPaths} from './versions'; -import {extractNumberPrefix, stripPathNumberPrefixes} from './numberPrefix'; -import {assertDocFrontMatter} from './docFrontMatter'; +import {stripPathNumberPrefixes} from './numberPrefix'; +import {validateDocFrontMatter} from './docFrontMatter'; type LastUpdateOptions = Pick< PluginOptions, @@ -117,18 +117,22 @@ export function processDocMetadata({ const {homePageId} = options; const {siteDir, i18n} = context; - const {frontMatter, contentTitle, excerpt} = parseMarkdownString(content, { + const { + frontMatter: unsafeFrontMatter, + contentTitle, + excerpt, + } = parseMarkdownString(content, { source, }); - assertDocFrontMatter(frontMatter); + const frontMatter = validateDocFrontMatter(unsafeFrontMatter); const { sidebar_label: sidebarLabel, custom_edit_url: customEditURL, // Strip number prefixes by default (01-MyFolder/01-MyDoc.md => MyFolder/MyDoc) by default, - // but ability to disable this behavior with frontmatterr - strip_number_prefixes: stripNumberPrefixes = true, + // but allow to disable this behavior with frontmatterr + parse_number_prefixes = true, } = frontMatter; // ex: api/plugins/myDoc -> myDoc @@ -142,8 +146,8 @@ export function processDocMetadata({ // ex: myDoc -> . const sourceDirName = path.dirname(source); - const {filename: unprefixedFileName, numberPrefix} = stripNumberPrefixes - ? extractNumberPrefix(sourceFileNameWithoutExtension) + const {filename: unprefixedFileName, numberPrefix} = parse_number_prefixes + ? options.numberPrefixParser(sourceFileNameWithoutExtension) : {filename: sourceFileNameWithoutExtension, numberPrefix: undefined}; const baseID: string = frontMatter.id ?? unprefixedFileName; @@ -170,8 +174,8 @@ export function processDocMetadata({ return undefined; } // Eventually remove the number prefixes from intermediate directories - return stripNumberPrefixes - ? stripPathNumberPrefixes(sourceDirName) + return parse_number_prefixes + ? stripPathNumberPrefixes(sourceDirName, options.numberPrefixParser) : sourceDirName; } @@ -197,7 +201,8 @@ export function processDocMetadata({ baseID, dirName: sourceDirName, frontmatterSlug: frontMatter.slug, - stripDirNumberPrefixes: stripNumberPrefixes, + stripDirNumberPrefixes: parse_number_prefixes, + numberPrefixParser: options.numberPrefixParser, }); // Default title is the id. diff --git a/packages/docusaurus-plugin-content-docs/src/index.ts b/packages/docusaurus-plugin-content-docs/src/index.ts index 72e12b8058a5..afb16d7c5f68 100644 --- a/packages/docusaurus-plugin-content-docs/src/index.ts +++ b/packages/docusaurus-plugin-content-docs/src/index.ts @@ -177,6 +177,7 @@ export default function pluginContentDocs( const sidebars = await processSidebars({ sidebarItemsGenerator: options.sidebarItemsGenerator, + numberPrefixParser: options.numberPrefixParser, unprocessedSidebars, docs: docsBase, version: versionMetadata, diff --git a/packages/docusaurus-plugin-content-docs/src/numberPrefix.ts b/packages/docusaurus-plugin-content-docs/src/numberPrefix.ts index 0caa02709a0d..7e14271e557e 100644 --- a/packages/docusaurus-plugin-content-docs/src/numberPrefix.ts +++ b/packages/docusaurus-plugin-content-docs/src/numberPrefix.ts @@ -5,23 +5,34 @@ * LICENSE file in the root directory of this source tree. */ -const NumberPrefixRegex = /^(?\d+)(?\s*[-_.]+\s*)(?.*)$/; +import {NumberPrefixParser} from './types'; -// 0-myDoc => myDoc -export function stripNumberPrefix(str: string) { - return NumberPrefixRegex.exec(str)?.groups?.suffix ?? str; -} +// Best-effort to avoid parsing some patterns as number prefix +const IgnoredPrefixPatterns = (function () { + // ignore common date-like patterns: https://github.com/facebook/docusaurus/issues/4640 + const DateLikePrefixRegex = /^((\d{2}|\d{4})[-_.]\d{2}([-_.](\d{2}|\d{4}))?)(.*)$/; -// 0-myFolder/0-mySubfolder/0-myDoc => myFolder/mySubfolder/myDoc -export function stripPathNumberPrefixes(path: string) { - return path.split('/').map(stripNumberPrefix).join('/'); -} + // ignore common versioning patterns: https://github.com/facebook/docusaurus/issues/4653 + // note: we could try to parse float numbers in filenames but that is probably not worth it + // as a version such as "8.0" can be interpreted as both a version and a float + // User can configure his own NumberPrefixParser if he wants 8.0 to be interpreted as a float + const VersionLikePrefixRegex = /^(\d+[-_.]\d+)(.*)$/; + + return new RegExp( + `${DateLikePrefixRegex.source}|${VersionLikePrefixRegex.source}`, + ); +})(); + +const NumberPrefixRegex = /^(?\d+)(?\s*[-_.]+\s*)(?.*)$/; // 0-myDoc => {filename: myDoc, numberPrefix: 0} // 003 - myDoc => {filename: myDoc, numberPrefix: 3} -export function extractNumberPrefix( +export const DefaultNumberPrefixParser: NumberPrefixParser = ( filename: string, -): {filename: string; numberPrefix?: number} { +) => { + if (IgnoredPrefixPatterns.exec(filename)) { + return {filename, numberPrefix: undefined}; + } const match = NumberPrefixRegex.exec(filename); const cleanFileName = match?.groups?.suffix ?? filename; const numberPrefixString = match?.groups?.numberPrefix; @@ -32,4 +43,27 @@ export function extractNumberPrefix( filename: cleanFileName, numberPrefix, }; +}; + +export const DisabledNumberPrefixParser: NumberPrefixParser = ( + filename: string, +) => ({filename, numberPrefix: undefined}); + +// 0-myDoc => myDoc +export function stripNumberPrefix( + str: string, + parser: NumberPrefixParser, +): string { + return parser(str).filename; +} + +// 0-myFolder/0-mySubfolder/0-myDoc => myFolder/mySubfolder/myDoc +export function stripPathNumberPrefixes( + path: string, + parser: NumberPrefixParser, +): string { + return path + .split('/') + .map((segment) => stripNumberPrefix(segment, parser)) + .join('/'); } diff --git a/packages/docusaurus-plugin-content-docs/src/options.ts b/packages/docusaurus-plugin-content-docs/src/options.ts index 189824535d67..5ebf6c0342c7 100644 --- a/packages/docusaurus-plugin-content-docs/src/options.ts +++ b/packages/docusaurus-plugin-content-docs/src/options.ts @@ -16,6 +16,10 @@ import {OptionValidationContext, ValidationResult} from '@docusaurus/types'; import chalk from 'chalk'; import admonitions from 'remark-admonitions'; import {DefaultSidebarItemsGenerator} from './sidebarItemsGenerator'; +import { + DefaultNumberPrefixParser, + DisabledNumberPrefixParser, +} from './numberPrefix'; export const DEFAULT_OPTIONS: Omit = { path: 'docs', // Path to data on filesystem, relative to site dir. @@ -24,6 +28,7 @@ export const DEFAULT_OPTIONS: Omit = { include: ['**/*.{md,mdx}'], // Extensions to include. sidebarPath: 'sidebars.json', // Path to the sidebars configuration file sidebarItemsGenerator: DefaultSidebarItemsGenerator, + numberPrefixParser: DefaultNumberPrefixParser, docLayoutComponent: '@theme/DocPage', docItemComponent: '@theme/DocItem', remarkPlugins: [], @@ -66,6 +71,17 @@ export const OptionsSchema = Joi.object({ sidebarItemsGenerator: Joi.function().default( () => DEFAULT_OPTIONS.sidebarItemsGenerator, ), + numberPrefixParser: Joi.alternatives() + .try( + Joi.function(), + // Convert boolean values to functions + Joi.alternatives().conditional(Joi.boolean(), { + then: Joi.custom((val) => + val ? DefaultNumberPrefixParser : DisabledNumberPrefixParser, + ), + }), + ) + .default(() => DEFAULT_OPTIONS.numberPrefixParser), docLayoutComponent: Joi.string().default(DEFAULT_OPTIONS.docLayoutComponent), docItemComponent: Joi.string().default(DEFAULT_OPTIONS.docItemComponent), remarkPlugins: RemarkPluginsSchema.default(DEFAULT_OPTIONS.remarkPlugins), diff --git a/packages/docusaurus-plugin-content-docs/src/sidebarItemsGenerator.ts b/packages/docusaurus-plugin-content-docs/src/sidebarItemsGenerator.ts index f35c52493b0b..d09a2afdbe7b 100644 --- a/packages/docusaurus-plugin-content-docs/src/sidebarItemsGenerator.ts +++ b/packages/docusaurus-plugin-content-docs/src/sidebarItemsGenerator.ts @@ -15,7 +15,6 @@ import { import {sortBy, take, last, orderBy} from 'lodash'; import {addTrailingSlash, posixPath} from '@docusaurus/utils'; import {Joi} from '@docusaurus/utils-validation'; -import {extractNumberPrefix} from './numberPrefix'; import chalk from 'chalk'; import path from 'path'; import fs from 'fs-extra'; @@ -41,20 +40,22 @@ type WithPosition = {position?: number}; type SidebarItemWithPosition = SidebarItem & WithPosition; const CategoryMetadatasFileSchema = Joi.object({ - label: Joi.string().optional(), - position: Joi.number().optional(), - collapsed: Joi.boolean().optional(), + label: Joi.string(), + position: Joi.number(), + collapsed: Joi.boolean(), }); +// TODO I now believe we should read all the category metadata files ahead of time: we may need this metadata to customize docs metadata +// Example use-case being able to disable number prefix parsing at the folder level, or customize the default route path segment for an intermediate directory... // TODO later if there is `CategoryFolder/index.md`, we may want to read the metadata as yaml on it // see https://github.com/facebook/docusaurus/issues/3464#issuecomment-818670449 async function readCategoryMetadatasFile( categoryDirPath: string, ): Promise { - function assertCategoryMetadataFile( + function validateCategoryMetadataFile( content: unknown, - ): asserts content is CategoryMetadatasFile { - Joi.attempt(content, CategoryMetadatasFileSchema); + ): CategoryMetadatasFile { + return Joi.attempt(content, CategoryMetadatasFileSchema); } async function tryReadFile( @@ -69,8 +70,7 @@ async function readCategoryMetadatasFile( const contentString = await fs.readFile(filePath, {encoding: 'utf8'}); const unsafeContent: unknown = parse(contentString); try { - assertCategoryMetadataFile(unsafeContent); - return unsafeContent; + return validateCategoryMetadataFile(unsafeContent); } catch (e) { console.error( chalk.red( @@ -106,6 +106,7 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async functio item, docs: allDocs, version, + numberPrefixParser, }): Promise { // Doc at the root of the autogenerated sidebar dir function isRootDoc(doc: SidebarItemsGeneratorDoc) { @@ -194,7 +195,7 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async functio const {tail} = parseBreadcrumb(breadcrumb); - const {filename, numberPrefix} = extractNumberPrefix(tail); + const {filename, numberPrefix} = numberPrefixParser(tail); const position = categoryMetadatas?.position ?? numberPrefix; diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars.ts b/packages/docusaurus-plugin-content-docs/src/sidebars.ts index 56496e7038fe..53b202e9dae4 100644 --- a/packages/docusaurus-plugin-content-docs/src/sidebars.ts +++ b/packages/docusaurus-plugin-content-docs/src/sidebars.ts @@ -24,6 +24,7 @@ import { SidebarItemsGenerator, SidebarItemsGeneratorDoc, SidebarItemsGeneratorVersion, + NumberPrefixParser, } from './types'; import {mapValues, flatten, flatMap, difference, pick, memoize} from 'lodash'; import {getElementsAround} from '@docusaurus/utils'; @@ -289,11 +290,13 @@ export function toSidebarItemsGeneratorVersion( // Handle the generation of autogenerated sidebar items export async function processSidebar({ sidebarItemsGenerator, + numberPrefixParser, unprocessedSidebar, docs, version, }: { sidebarItemsGenerator: SidebarItemsGenerator; + numberPrefixParser: NumberPrefixParser; unprocessedSidebar: UnprocessedSidebar; docs: DocMetadataBase[]; version: VersionMetadata; @@ -318,6 +321,7 @@ export async function processSidebar({ if (item.type === 'autogenerated') { return sidebarItemsGenerator({ item, + numberPrefixParser, ...getSidebarItemsGeneratorDocsAndVersion(), }); } @@ -329,11 +333,13 @@ export async function processSidebar({ export async function processSidebars({ sidebarItemsGenerator, + numberPrefixParser, unprocessedSidebars, docs, version, }: { sidebarItemsGenerator: SidebarItemsGenerator; + numberPrefixParser: NumberPrefixParser; unprocessedSidebars: UnprocessedSidebars; docs: DocMetadataBase[]; version: VersionMetadata; @@ -342,6 +348,7 @@ export async function processSidebars({ mapValues(unprocessedSidebars, (unprocessedSidebar) => processSidebar({ sidebarItemsGenerator, + numberPrefixParser, unprocessedSidebar, docs, version, diff --git a/packages/docusaurus-plugin-content-docs/src/slug.ts b/packages/docusaurus-plugin-content-docs/src/slug.ts index 32556a8582e1..4ca410cbf118 100644 --- a/packages/docusaurus-plugin-content-docs/src/slug.ts +++ b/packages/docusaurus-plugin-content-docs/src/slug.ts @@ -11,18 +11,24 @@ import { isValidPathname, resolvePathname, } from '@docusaurus/utils'; -import {stripPathNumberPrefixes} from './numberPrefix'; +import { + DefaultNumberPrefixParser, + stripPathNumberPrefixes, +} from './numberPrefix'; +import {NumberPrefixParser} from './types'; export default function getSlug({ baseID, frontmatterSlug, dirName, stripDirNumberPrefixes = true, + numberPrefixParser = DefaultNumberPrefixParser, }: { baseID: string; frontmatterSlug?: string; dirName: string; stripDirNumberPrefixes?: boolean; + numberPrefixParser?: NumberPrefixParser; }): string { const baseSlug = frontmatterSlug || baseID; let slug: string; @@ -30,7 +36,7 @@ export default function getSlug({ slug = baseSlug; } else { const dirNameStripped = stripDirNumberPrefixes - ? stripPathNumberPrefixes(dirName) + ? stripPathNumberPrefixes(dirName, numberPrefixParser) : dirName; const resolveDirname = dirName === '.' diff --git a/packages/docusaurus-plugin-content-docs/src/types.ts b/packages/docusaurus-plugin-content-docs/src/types.ts index 3c1633f0f5fe..b64258612361 100644 --- a/packages/docusaurus-plugin-content-docs/src/types.ts +++ b/packages/docusaurus-plugin-content-docs/src/types.ts @@ -53,6 +53,7 @@ export type MetadataOptions = { editLocalizedFiles: boolean; showLastUpdateTime?: boolean; showLastUpdateAuthor?: boolean; + numberPrefixParser: NumberPrefixParser; }; export type PathOptions = { @@ -154,6 +155,7 @@ export type SidebarItemsGenerator = (generatorArgs: { item: UnprocessedSidebarItemAutogenerated; version: SidebarItemsGeneratorVersion; docs: SidebarItemsGeneratorDoc[]; + numberPrefixParser: NumberPrefixParser; }) => Promise; export type OrderMetadata = { @@ -245,3 +247,7 @@ export type DocsMarkdownOption = { sourceToPermalink: SourceToPermalink; onBrokenMarkdownLink: (brokenMarkdownLink: BrokenMarkdownLink) => void; }; + +export type NumberPrefixParser = ( + filename: string, +) => {filename: string; numberPrefix?: number}; diff --git a/website/docs/api/plugins/plugin-content-docs.md b/website/docs/api/plugins/plugin-content-docs.md index 690c2e279e4d..639d0ac74bb5 100644 --- a/website/docs/api/plugins/plugin-content-docs.md +++ b/website/docs/api/plugins/plugin-content-docs.md @@ -76,10 +76,35 @@ module.exports = { * Function used to replace the sidebar items of type "autogenerated" * by real sidebar items (docs, categories, links...) */ - sidebarItemsGenerator: function ({item, version, docs}) { + sidebarItemsGenerator: function ({ + item, + version, + docs, + numberPrefixParser, + }) { // Use the provided data to create a custom "sidebar slice" return [{type: 'doc', id: 'doc1'}]; }, + /** + * The Docs plugin supports number prefixes like "01-My Folder/02.My Doc.md". + * Number prefixes are extracted and used as position to order autogenerated sidebar items. + * For conveniency, number prefixes are automatically removed from the default doc id, name, title. + * This parsing logic is configurable to allow all possible usecases and filename patterns. + * Use "false" to disable this behavior and leave the docs untouched. + */ + numberPrefixParser: function (filename) { + // Implement your own logic to extract a potential number prefix + const numberPrefix = findNumberPrefix(filename); + // Prefix found: return it with the cleaned filename + if (numberPrefix) { + return { + numberPrefix, + filename: filename.replace(prefix, ''), + }; + } + // No number prefix found + return {numberPrefix: undefined, filename}; + }, /** * Theme components used by the docs pages */ @@ -165,7 +190,7 @@ Markdown documents can use the following markdown frontmatter metadata fields, e - `hide_table_of_contents`: Whether to hide the table of contents to the right. By default it is `false` - `sidebar_label`: The text shown in the document sidebar and in the next/previous button for this document. If this field is not present, the document's `sidebar_label` will default to its `title` - `sidebar_position`: Permits to control the position of a doc inside the generated sidebar slice, when using `autogenerated` sidebar items. Can be Int or Float. -- `strip_number_prefixes`: When a document has a number prefix (`001 - My Doc.md`, `2. MyDoc.md`...), it is automatically removed, and the prefix is used as `sidebar_position`. Use `strip_number_prefixes: false` if you want to disable this behavior +- `parse_number_prefixes`: When a document has a number prefix (`001 - My Doc.md`, `2. MyDoc.md`...), it is automatically parsed and extracted by the plugin `numberPrefixParser`, and the number prefix is used as `sidebar_position`. Use `parse_number_prefixes: false` to disable number prefix parsing on this doc - `custom_edit_url`: The URL for editing this document. If this field is not present, the document's edit URL will fall back to `editUrl` from options fields passed to `docusaurus-plugin-content-docs` - `keywords`: Keywords meta tag for the document page, for search engines - `description`: The description of your document, which will become the `` and `` in ``, used by search engines. If this field is not present, it will default to the first line of the contents