diff --git a/packages/docusaurus-1.x/lib/core/BlogPostLayout.js b/packages/docusaurus-1.x/lib/core/BlogPostLayout.js index 9970fcf8ca2b..43dfc5940358 100644 --- a/packages/docusaurus-1.x/lib/core/BlogPostLayout.js +++ b/packages/docusaurus-1.x/lib/core/BlogPostLayout.js @@ -30,7 +30,7 @@ class BlogPostLayout extends React.Component { renderSocialButtons() { const post = this.props.metadata; - post.path = utils.getPath(post.path, this.props.config.cleanUrl); + post.dirPath = utils.getPath(post.path, this.props.config.cleanUrl); const fbComment = this.props.config.facebookAppId && this.props.config.facebookComments && ( @@ -93,7 +93,7 @@ class BlogPostLayout extends React.Component { render() { const hasOnPageNav = this.props.config.onPageNav === 'separate'; const post = this.props.metadata; - post.path = utils.getPath(post.path, this.props.config.cleanUrl); + post.dirPath = utils.getPath(post.path, this.props.config.cleanUrl); const blogSidebarTitleConfig = this.props.config.blogSidebarTitle || {}; return ( { + 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(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(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(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'); + }); + + test('should not strip number prefix if pattern does not match', () => { + const badPatterns = [ + 'a1-My Doc', + 'My Doc-000', + '00abc01-My Doc', + 'My 001- Doc', + 'My -001 Doc', + ]; + + badPatterns.forEach((badPattern) => { + expect(stripNumberPrefix(badPattern)).toEqual(badPattern); + }); + }); +}); diff --git a/packages/docusaurus-plugin-content-docs/src/docs.ts b/packages/docusaurus-plugin-content-docs/src/docs.ts index 592b99843999..91428985f5d3 100644 --- a/packages/docusaurus-plugin-content-docs/src/docs.ts +++ b/packages/docusaurus-plugin-content-docs/src/docs.ts @@ -30,6 +30,7 @@ import getSlug from './slug'; import {CURRENT_VERSION_NAME} from './constants'; import globby from 'globby'; import {getDocsDirPaths} from './versions'; +import {stripNumberPrefix} from './numberPrefix'; type LastUpdateOptions = Pick< PluginOptions, @@ -115,9 +116,15 @@ export function processDocMetadata({ const {homePageId} = options; const {siteDir, i18n} = context; - // ex: api/myDoc -> api + // ex: api/plugins/myDoc -> api/plugins // ex: myDoc -> . - const docsFileDirName = path.dirname(source); + const sourceDirName = path.dirname(source); + // ex: api/plugins/myDoc -> myDoc + // ex: myDoc -> myDoc + const sourceFileNameWithoutExtension = path.basename( + source, + path.extname(source), + ); const {frontMatter = {}, excerpt} = parseMarkdownString(content, source); const { @@ -126,7 +133,7 @@ export function processDocMetadata({ } = frontMatter; const baseID: string = - frontMatter.id || path.basename(source, path.extname(source)); + frontMatter.id || stripNumberPrefix(sourceFileNameWithoutExtension); if (baseID.includes('/')) { throw new Error(`Document id [${baseID}] cannot include "/".`); } @@ -141,7 +148,7 @@ export function processDocMetadata({ // TODO legacy retrocompatibility // I think it's bad to affect the frontmatter id with the dirname - const dirNameIdPart = docsFileDirName === '.' ? '' : `${docsFileDirName}/`; + const dirNameIdPart = sourceDirName === '.' ? '' : `${sourceDirName}/`; // TODO legacy composite id, requires a breaking change to modify this const id = `${versionIdPart}${dirNameIdPart}${baseID}`; @@ -160,7 +167,7 @@ export function processDocMetadata({ ? '/' : getSlug({ baseID, - dirName: docsFileDirName, + dirName: sourceDirName, frontmatterSlug: frontMatter.slug, }); @@ -207,6 +214,7 @@ export function processDocMetadata({ title, description, source: aliasedSitePath(filePath, siteDir), + sourceDirName, slug: docSlug, permalink, editUrl: customEditURL !== undefined ? customEditURL : getDocEditUrl(), diff --git a/packages/docusaurus-plugin-content-docs/src/numberPrefix.ts b/packages/docusaurus-plugin-content-docs/src/numberPrefix.ts new file mode 100644 index 000000000000..d83f8cef4a45 --- /dev/null +++ b/packages/docusaurus-plugin-content-docs/src/numberPrefix.ts @@ -0,0 +1,12 @@ +/** + * 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. + */ + +export function stripNumberPrefix(str: string) { + const numberPrefixPattern = /(?:^(\d)+(\s)*([-_.])+(\s)*)(?.*)/; + const result = numberPrefixPattern.exec(str); + return result?.groups?.suffix ?? str; +} diff --git a/packages/docusaurus-plugin-content-docs/src/sidebars.ts b/packages/docusaurus-plugin-content-docs/src/sidebars.ts index b4925ce33d90..2113f5486739 100644 --- a/packages/docusaurus-plugin-content-docs/src/sidebars.ts +++ b/packages/docusaurus-plugin-content-docs/src/sidebars.ts @@ -22,9 +22,18 @@ import { DocMetadataBase, SidebarItemAutogenerated, } from './types'; -import {mapValues, flatten, flatMap, difference} from 'lodash'; -import {getElementsAround} from '@docusaurus/utils'; +import { + mapValues, + flatten, + flatMap, + difference, + sortBy, + take, + last, +} from 'lodash'; +import {addTrailingSlash, getElementsAround} from '@docusaurus/utils'; import combinePromises from 'combine-promises'; +import {stripNumberPrefix} from './numberPrefix'; type SidebarItemCategoryJSON = SidebarItemBase & { type: 'category'; @@ -35,7 +44,7 @@ type SidebarItemCategoryJSON = SidebarItemBase & { type SidebarItemAutogeneratedJSON = { type: 'autogenerated'; - path: string; + dirPath: string; }; type SidebarItemJSON = @@ -130,10 +139,17 @@ function assertIsCategory( function assertIsAutogenerated( item: Record, ): asserts item is SidebarItemAutogeneratedJSON { - assertItem(item, ['path']); - if (typeof item.path !== 'string') { + assertItem(item, ['dirPath']); + if (typeof item.dirPath !== 'string') { + throw new Error( + `Error loading ${JSON.stringify(item)}. "dirPath" must be a string.`, + ); + } + if (item.dirPath.startsWith('/') || item.dirPath.endsWith('/')) { throw new Error( - `Error loading ${JSON.stringify(item)}. "path" must be a string.`, + `Error loading ${JSON.stringify( + item, + )}. "dirPath" must be a dir path relative to the docs folder root, and should not start or end with /`, ); } } @@ -251,28 +267,140 @@ export function loadSidebars(sidebarFilePath: string): UnprocessedSidebars { return normalizeSidebars(sidebarJson); } +async function transformAutogeneratedSidebarItem( + autogeneratedItem: SidebarItemAutogenerated, + allDocs: DocMetadataBase[], +): Promise { + // Doc at the root of the autogenerated sidebar slice + function isRootDoc(doc: DocMetadataBase) { + return doc.sourceDirName === autogeneratedItem.dirPath; + } + + // Doc inside a subfolder of the autogenerated sidebar slice + const categoryDirNameSuffix = addTrailingSlash(autogeneratedItem.dirPath); + function isCategoryDoc(doc: DocMetadataBase) { + // "api/plugins" startsWith "api/" (but "api2/" docs are excluded) + return doc.sourceDirName.startsWith(categoryDirNameSuffix); + } + + const docsUnsorted: DocMetadataBase[] = allDocs.filter( + (doc) => isRootDoc(doc) || isCategoryDoc(doc), + ); + // Sort by folder+filename at once + const docs = sortBy(docsUnsorted, (d) => d.source); + + console.log( + 'autogenDocsSorted', + docs.map((d) => ({ + source: d.source, + dir: d.sourceDirName, + permalin: d.permalink, + })), + ); + + function createDocSidebarItem(doc: DocMetadataBase): SidebarItemDoc { + return { + type: 'doc', + id: doc.id, + ...(doc.frontMatter.sidebar_label && { + label: doc.frontMatter.sidebar_label, + }), + }; + } + + async function createCategorySidebarItem({ + dirName, + }: { + dirName: string; + }): Promise { + // TODO read metadata file from the directory for additional config? + return { + type: 'category', + label: stripNumberPrefix(dirName), + items: [], + collapsed: true, // TODO use default value + }; + } + + // Not sure how to simplify this algorithm :/ + async function autogenerateSidebarItems(): Promise { + const BreadcrumbSeparator = '/'; + + const sidebarItems: SidebarItem[] = []; // mutable result + + const categoriesByBreadcrumb: Record = {}; // mutable cache of categories already created + + async function getOrCreateCategoriesForBreadcrumb( + breadcrumb: string[], + ): Promise { + if (breadcrumb.length === 0) { + return null; + } + const parentBreadcrumb = take(breadcrumb, breadcrumb.length - 1); + const lastBreadcrumbElement = last(breadcrumb)!; + const parentCategory = await getOrCreateCategoriesForBreadcrumb( + parentBreadcrumb, + ); + const existingCategory = + categoriesByBreadcrumb[breadcrumb.join(BreadcrumbSeparator)]; + + if (existingCategory) { + return existingCategory; + } else { + const newCategory = await createCategorySidebarItem({ + dirName: lastBreadcrumbElement, + }); + if (parentCategory) { + parentCategory.items.push(newCategory); + } else { + sidebarItems.push(newCategory); + } + categoriesByBreadcrumb[ + breadcrumb.join(BreadcrumbSeparator) + ] = newCategory; + return newCategory; + } + } + + // Get the category breadcrumb of a doc (relative to the dir of the autogenerated sidebar item) + function getBreadcrumb(doc: DocMetadataBase): string[] { + return isCategoryDoc(doc) + ? doc.sourceDirName + .replace(categoryDirNameSuffix, '') + .split(BreadcrumbSeparator) + : []; + } + + async function handleDocItem(doc: DocMetadataBase): Promise { + const breadcrumb = getBreadcrumb(doc); + const category = await getOrCreateCategoriesForBreadcrumb(breadcrumb); + + const docSidebarItem = createDocSidebarItem(doc); + if (category) { + category.items.push(docSidebarItem); + } else { + sidebarItems.push(docSidebarItem); + } + } + + // async process made sequential on purpose! order matters + for (const doc of docs) { + // eslint-disable-next-line no-await-in-loop + await handleDocItem(doc); + } + + console.log({sidebarItems}); + + return sidebarItems; + } + + return autogenerateSidebarItems(); +} + export async function processSidebar( unprocessedSidebar: UnprocessedSidebar, - _docs: DocMetadataBase[], + allDocs: DocMetadataBase[], ): Promise { - async function transformAutogeneratedItem( - _item: SidebarItemAutogenerated, - ): Promise { - // TODO temp: perform real sidebars processing here! - return [ - { - type: 'link', - href: 'https://docusaurus.io', - label: 'DOCUSAURUS_TEST 1', - }, - { - type: 'link', - href: 'https://docusaurus.io', - label: 'DOCUSAURUS_TEST 2', - }, - ]; - } - async function processRecursive( item: UnprocessedSidebarItem, ): Promise { @@ -285,13 +413,14 @@ export async function processSidebar( ]; } if (item.type === 'autogenerated') { - return transformAutogeneratedItem(item); + return transformAutogeneratedSidebarItem(item, allDocs); } return [item]; } return (await Promise.all(unprocessedSidebar.map(processRecursive))).flat(); } + export async function processSidebars( unprocessedSidebars: UnprocessedSidebars, docs: DocMetadataBase[], diff --git a/packages/docusaurus-plugin-content-docs/src/types.ts b/packages/docusaurus-plugin-content-docs/src/types.ts index 6e71ffa48c14..fd8555aabd6f 100644 --- a/packages/docusaurus-plugin-content-docs/src/types.ts +++ b/packages/docusaurus-plugin-content-docs/src/types.ts @@ -110,7 +110,7 @@ export type SidebarItemCategory = SidebarItemBase & { export type SidebarItemAutogenerated = { type: 'autogenerated'; - path: string; + dirPath: string; }; export type UnprocessedSidebarItemCategory = SidebarItemBase & { @@ -162,6 +162,7 @@ export type DocMetadataBase = LastUpdateData & { title: string; description: string; source: string; + sourceDirName: string; // relative to the docs folder (can be ".") slug: string; permalink: string; // eslint-disable-next-line camelcase diff --git a/website/docs/autogenerated-folder/000-My First Category/001- Some Subfolder/Subfolder Doc.md b/website/docs/autogenerated-folder/000-My First Category/001- Some Subfolder/Subfolder Doc.md new file mode 100644 index 000000000000..dc73ada4e96c --- /dev/null +++ b/website/docs/autogenerated-folder/000-My First Category/001- Some Subfolder/Subfolder Doc.md @@ -0,0 +1 @@ +This doc is in a subfolder diff --git a/website/docs/autogenerated-folder/000-My First Category/001-installation.md b/website/docs/autogenerated-folder/000-My First Category/002-installation.md similarity index 100% rename from website/docs/autogenerated-folder/000-My First Category/001-installation.md rename to website/docs/autogenerated-folder/000-My First Category/002-installation.md diff --git a/website/sidebars.js b/website/sidebars.js index d7f3332f7311..bc0848995247 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -9,7 +9,7 @@ module.exports = { docs: [ { type: 'autogenerated', - path: 'autogenerated-folder', + dirPath: 'autogenerated-folder', }, { type: 'category',