refactor(content-docs): read category metadata files before autogenerating (#6586)

* refactor(content-docs): read category metadata files before autogenerating

* fix tests

* fix Windows...

* warn user when behavior is undetermined

* oops

* fix typo
This commit is contained in:
Joshua Chen 2022-02-03 16:16:19 +08:00 committed by GitHub
parent b03431f139
commit 1ca07f8466
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 120 additions and 128 deletions

View file

@ -1361,6 +1361,17 @@ Object {
exports[`site with custom sidebar items generator sidebarItemsGenerator is called with appropriate data 1`] = ` exports[`site with custom sidebar items generator sidebarItemsGenerator is called with appropriate data 1`] = `
Object { Object {
"categoriesMetadata": Object {
"3-API": Object {
"label": "API (label from _category_.json)",
},
"3-API/02_Extension APIs": Object {
"label": "Extension APIs (label from _category_.yml)",
},
"Guides": Object {
"position": 2,
},
},
"defaultSidebarItemsGenerator": [Function], "defaultSidebarItemsGenerator": [Function],
"docs": Array [ "docs": Array [
Object { Object {

View file

@ -5,12 +5,8 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import { import {DefaultSidebarItemsGenerator} from '../generator';
DefaultSidebarItemsGenerator,
type CategoryMetadataFile,
} from '../generator';
import type {Sidebar, SidebarItemsGenerator} from '../types'; import type {Sidebar, SidebarItemsGenerator} from '../types';
import fs from 'fs-extra';
import {DefaultNumberPrefixParser} from '../../numberPrefix'; import {DefaultNumberPrefixParser} from '../../numberPrefix';
import {isCategoryIndex} from '../../docs'; import {isCategoryIndex} from '../../docs';
@ -34,26 +30,11 @@ describe('DefaultSidebarItemsGenerator', () => {
sidebarCollapsed: true, sidebarCollapsed: true,
sidebarCollapsible: true, sidebarCollapsible: true,
}, },
categoriesMetadata: {},
...params, ...params,
}); });
} }
function mockCategoryMetadataFiles(
categoryMetadataFiles: Record<string, Partial<CategoryMetadataFile>>,
) {
jest
.spyOn(fs, 'pathExists')
.mockImplementation(
(metadataFilePath) =>
typeof categoryMetadataFiles[metadataFilePath] !== 'undefined',
);
jest.spyOn(fs, 'readFile').mockImplementation(
// @ts-expect-error: annoying TS error due to overrides
async (metadataFilePath: string) =>
JSON.stringify(categoryMetadataFiles[metadataFilePath]),
);
}
test('generates empty sidebar slice when no docs and emit a warning', async () => { test('generates empty sidebar slice when no docs and emit a warning', async () => {
const consoleWarn = jest.spyOn(console, 'warn'); const consoleWarn = jest.spyOn(console, 'warn');
const sidebarSlice = await testDefaultSidebarItemsGenerator({ const sidebarSlice = await testDefaultSidebarItemsGenerator({
@ -133,19 +114,6 @@ describe('DefaultSidebarItemsGenerator', () => {
}); });
test('generates complex nested sidebar', async () => { test('generates complex nested sidebar', async () => {
mockCategoryMetadataFiles({
'02-Guides/_category_.json': {collapsed: false} as CategoryMetadataFile,
'02-Guides/01-SubGuides/_category_.yml': {
label: 'SubGuides (metadata file label)',
link: {
type: 'generated-index',
slug: 'subguides-generated-index-slug',
title: 'subguides-title',
description: 'subguides-description',
},
},
});
const sidebarSlice = await DefaultSidebarItemsGenerator({ const sidebarSlice = await DefaultSidebarItemsGenerator({
numberPrefixParser: DefaultNumberPrefixParser, numberPrefixParser: DefaultNumberPrefixParser,
isCategoryIndex, isCategoryIndex,
@ -157,6 +125,18 @@ describe('DefaultSidebarItemsGenerator', () => {
versionName: 'current', versionName: 'current',
contentPath: '', contentPath: '',
}, },
categoriesMetadata: {
'02-Guides': {collapsed: false},
'02-Guides/01-SubGuides': {
label: 'SubGuides (metadata file label)',
link: {
type: 'generated-index',
slug: 'subguides-generated-index-slug',
title: 'subguides-title',
description: 'subguides-description',
},
},
},
docs: [ docs: [
{ {
id: 'intro', id: 'intro',
@ -279,24 +259,6 @@ describe('DefaultSidebarItemsGenerator', () => {
test('generates subfolder sidebar', async () => { test('generates subfolder sidebar', async () => {
// Ensure that category metadata file is correctly read // Ensure that category metadata file is correctly read
// fix edge case found in https://github.com/facebook/docusaurus/issues/4638 // fix edge case found in https://github.com/facebook/docusaurus/issues/4638
mockCategoryMetadataFiles({
'subfolder/subsubfolder/subsubsubfolder2/_category_.yml': {
position: 2,
label: 'subsubsubfolder2 (_category_.yml label)',
className: 'bar',
},
'subfolder/subsubfolder/subsubsubfolder3/_category_.json': {
position: 1,
label: 'subsubsubfolder3 (_category_.json label)',
collapsible: false,
collapsed: false,
link: {
type: 'doc',
id: 'doc1', // This is a "fully-qualified" ID that can't be found locally
},
},
});
const sidebarSlice = await DefaultSidebarItemsGenerator({ const sidebarSlice = await DefaultSidebarItemsGenerator({
numberPrefixParser: DefaultNumberPrefixParser, numberPrefixParser: DefaultNumberPrefixParser,
isCategoryIndex, isCategoryIndex,
@ -308,6 +270,23 @@ describe('DefaultSidebarItemsGenerator', () => {
versionName: 'current', versionName: 'current',
contentPath: '', contentPath: '',
}, },
categoriesMetadata: {
'subfolder/subsubfolder/subsubsubfolder2': {
position: 2,
label: 'subsubsubfolder2 (_category_.yml label)',
className: 'bar',
},
'subfolder/subsubfolder/subsubsubfolder3': {
position: 1,
label: 'subsubsubfolder3 (_category_.json label)',
collapsible: false,
collapsed: false,
link: {
type: 'doc',
id: 'doc1', // This is a "fully-qualified" ID that can't be found locally
},
},
},
docs: [ docs: [
{ {
id: 'doc1', id: 'doc1',
@ -408,20 +387,6 @@ describe('DefaultSidebarItemsGenerator', () => {
}); });
test('uses explicit link over the index/readme.{md,mdx} naming convention', async () => { test('uses explicit link over the index/readme.{md,mdx} naming convention', async () => {
mockCategoryMetadataFiles({
'Category/_category_.yml': {
label: 'Category label',
link: {
type: 'doc',
id: 'doc3', // Using a "local doc id" ("doc1" instead of "parent/doc1") on purpose
},
},
'Category2/_category_.yml': {
label: 'Category 2 label',
link: null,
},
});
const sidebarSlice = await DefaultSidebarItemsGenerator({ const sidebarSlice = await DefaultSidebarItemsGenerator({
numberPrefixParser: DefaultNumberPrefixParser, numberPrefixParser: DefaultNumberPrefixParser,
item: { item: {
@ -432,6 +397,19 @@ describe('DefaultSidebarItemsGenerator', () => {
versionName: 'current', versionName: 'current',
contentPath: '', contentPath: '',
}, },
categoriesMetadata: {
Category: {
label: 'Category label',
link: {
type: 'doc',
id: 'doc3', // Using a "local doc id" ("doc1" instead of "parent/doc1") on purpose
},
},
Category2: {
label: 'Category 2 label',
link: null,
},
},
docs: [ docs: [
{ {
id: 'parent/doc1', id: 'parent/doc1',
@ -541,6 +519,7 @@ describe('DefaultSidebarItemsGenerator', () => {
versionName: 'current', versionName: 'current',
contentPath: '', contentPath: '',
}, },
categoriesMetadata: {},
docs: [ docs: [
{ {
id: 'intro', id: 'intro',

View file

@ -12,19 +12,11 @@ import type {
SidebarItemsGenerator, SidebarItemsGenerator,
SidebarItemsGeneratorDoc, SidebarItemsGeneratorDoc,
SidebarItemCategoryLink, SidebarItemCategoryLink,
SidebarItemCategoryLinkConfig,
} from './types'; } from './types';
import {sortBy, last} from 'lodash'; import {sortBy, last} from 'lodash';
import { import {addTrailingSlash, posixPath} from '@docusaurus/utils';
addTrailingSlash,
posixPath,
findAsyncSequential,
} from '@docusaurus/utils';
import logger from '@docusaurus/logger'; import logger from '@docusaurus/logger';
import path from 'path'; import path from 'path';
import fs from 'fs-extra';
import Yaml from 'js-yaml';
import {validateCategoryMetadataFile} from './validation';
import {createDocsByIdIndex, toCategoryIndexMatcherParam} from '../docs'; import {createDocsByIdIndex, toCategoryIndexMatcherParam} from '../docs';
const BreadcrumbSeparator = '/'; const BreadcrumbSeparator = '/';
@ -39,20 +31,6 @@ function getLocalDocId(docId: string): string {
export const CategoryMetadataFilenameBase = '_category_'; export const CategoryMetadataFilenameBase = '_category_';
export const CategoryMetadataFilenamePattern = '_category_.{json,yml,yaml}'; export const CategoryMetadataFilenamePattern = '_category_.{json,yml,yaml}';
export type CategoryMetadataFile = {
label?: string;
position?: number;
collapsed?: boolean;
collapsible?: boolean;
className?: string;
link?: SidebarItemCategoryLinkConfig | null;
// TODO should we allow "items" here? how would this work? would an
// "autogenerated" type be allowed?
// This mkdocs plugin do something like that: https://github.com/lukasgeiter/mkdocs-awesome-pages-plugin/
// cf comment: https://github.com/facebook/docusaurus/issues/3464#issuecomment-784765199
};
type WithPosition<T> = T & {position?: number}; type WithPosition<T> = T & {position?: number};
/** /**
@ -65,37 +43,6 @@ type Dir = {
[item: string]: Dir | null; [item: string]: Dir | null;
}; };
// 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 base slug for an intermediate directory
// TODO later if there is `CategoryFolder/with-category-name-doc.md`, we may
// want to read the metadata as yaml on it
// see https://github.com/facebook/docusaurus/issues/3464#issuecomment-818670449
async function readCategoryMetadataFile(
categoryDirPath: string,
): Promise<CategoryMetadataFile | null> {
async function tryReadFile(filePath: string): Promise<CategoryMetadataFile> {
const contentString = await fs.readFile(filePath, {encoding: 'utf8'});
const unsafeContent = Yaml.load(contentString);
try {
return validateCategoryMetadataFile(unsafeContent);
} catch (e) {
logger.error`The docs sidebar category metadata file path=${filePath} looks invalid!`;
throw e;
}
}
const filePath = await findAsyncSequential(
['.json', '.yml', '.yaml'].map((ext) =>
posixPath(
path.join(categoryDirPath, `${CategoryMetadataFilenameBase}${ext}`),
),
),
fs.pathExists,
);
return filePath ? tryReadFile(filePath) : null;
}
// Comment for this feature: https://github.com/facebook/docusaurus/issues/3464#issuecomment-818670449 // Comment for this feature: https://github.com/facebook/docusaurus/issues/3464#issuecomment-818670449
export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
numberPrefixParser, numberPrefixParser,
@ -103,7 +50,7 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
docs: allDocs, docs: allDocs,
options, options,
item: {dirName: autogenDir}, item: {dirName: autogenDir},
version, categoriesMetadata,
}) => { }) => {
const docsById = createDocsByIdIndex(allDocs); const docsById = createDocsByIdIndex(allDocs);
const findDoc = (docId: string): SidebarItemsGeneratorDoc | undefined => const findDoc = (docId: string): SidebarItemsGeneratorDoc | undefined =>
@ -199,8 +146,8 @@ export const DefaultSidebarItemsGenerator: SidebarItemsGenerator = async ({
fullPath: string, fullPath: string,
folderName: string, folderName: string,
): Promise<WithPosition<SidebarItemCategory>> { ): Promise<WithPosition<SidebarItemCategory>> {
const categoryPath = path.join(version.contentPath, autogenDir, fullPath); const categoryMetadata =
const categoryMetadata = await readCategoryMetadataFile(categoryPath); categoriesMetadata[posixPath(path.join(autogenDir, fullPath))];
const className = categoryMetadata?.className; const className = categoryMetadata?.className;
const {filename, numberPrefix} = numberPrefixParser(folderName); const {filename, numberPrefix} = numberPrefixParser(folderName);
const allItems = await Promise.all( const allItems = await Promise.all(

View file

@ -9,12 +9,16 @@ import fs from 'fs-extra';
import importFresh from 'import-fresh'; import importFresh from 'import-fresh';
import type {SidebarsConfig, Sidebars, NormalizedSidebars} from './types'; import type {SidebarsConfig, Sidebars, NormalizedSidebars} from './types';
import type {NormalizeSidebarsParams} from '../types'; import type {NormalizeSidebarsParams} from '../types';
import {validateSidebars} from './validation'; import {validateSidebars, validateCategoryMetadataFile} from './validation';
import {normalizeSidebars} from './normalization'; import {normalizeSidebars} from './normalization';
import {processSidebars, type SidebarProcessorParams} from './processor'; import {processSidebars, type SidebarProcessorParams} from './processor';
import path from 'path'; import path from 'path';
import {createSlugger} from '@docusaurus/utils'; import {createSlugger, Globby} from '@docusaurus/utils';
import logger from '@docusaurus/logger';
import type {PluginOptions} from '@docusaurus/plugin-content-docs'; import type {PluginOptions} from '@docusaurus/plugin-content-docs';
import Yaml from 'js-yaml';
import {groupBy, mapValues} from 'lodash';
import combinePromises from 'combine-promises';
export const DefaultSidebars: SidebarsConfig = { export const DefaultSidebars: SidebarsConfig = {
defaultSidebar: [ defaultSidebar: [
@ -38,6 +42,33 @@ export function resolveSidebarPathOption(
: sidebarPathOption; : sidebarPathOption;
} }
async function readCategoriesMetadata(contentPath: string) {
const categoryFiles = await Globby('**/_category_.{json,yml,yaml}', {
cwd: contentPath,
});
const categoryToFile = groupBy(categoryFiles, path.dirname);
return combinePromises(
mapValues(categoryToFile, async (files, folder) => {
const [filePath] = files;
if (files.length > 1) {
logger.warn`There are more than one category metadata files for path=${folder}: ${files.join(
', ',
)}. The behavior is undetermined.`;
}
const content = await fs.readFile(
path.join(contentPath, filePath),
'utf-8',
);
try {
return validateCategoryMetadataFile(Yaml.load(content));
} catch (e) {
logger.error`The docs sidebar category metadata file path=${filePath} looks invalid!`;
throw e;
}
}),
);
}
async function loadSidebarsFileUnsafe( async function loadSidebarsFileUnsafe(
sidebarFilePath: string | false | undefined, sidebarFilePath: string | false | undefined,
): Promise<SidebarsConfig> { ): Promise<SidebarsConfig> {
@ -80,7 +111,7 @@ export async function loadNormalizedSidebars(
// Note: sidebarFilePath must be absolute, use resolveSidebarPathOption // Note: sidebarFilePath must be absolute, use resolveSidebarPathOption
export async function loadSidebars( export async function loadSidebars(
sidebarFilePath: string | false | undefined, sidebarFilePath: string | false | undefined,
options: SidebarProcessorParams, options: Omit<SidebarProcessorParams, 'categoriesMetadata'>,
): Promise<Sidebars> { ): Promise<Sidebars> {
const normalizeSidebarsParams: NormalizeSidebarsParams = { const normalizeSidebarsParams: NormalizeSidebarsParams = {
...options.sidebarOptions, ...options.sidebarOptions,
@ -91,5 +122,8 @@ export async function loadSidebars(
sidebarFilePath, sidebarFilePath,
normalizeSidebarsParams, normalizeSidebarsParams,
); );
return processSidebars(normalizedSidebars, options); const categoriesMetadata = await readCategoriesMetadata(
options.version.contentPath,
);
return processSidebars(normalizedSidebars, {...options, categoriesMetadata});
} }

View file

@ -19,6 +19,7 @@ import type {
NormalizedSidebarItemCategory, NormalizedSidebarItemCategory,
SidebarItemCategory, SidebarItemCategory,
SidebarItemAutogenerated, SidebarItemAutogenerated,
CategoryMetadataFile,
} from './types'; } from './types';
import {transformSidebarItems} from './utils'; import {transformSidebarItems} from './utils';
import {DefaultSidebarItemsGenerator} from './generator'; import {DefaultSidebarItemsGenerator} from './generator';
@ -39,6 +40,7 @@ export type SidebarProcessorParams = {
version: VersionMetadata; version: VersionMetadata;
categoryLabelSlugger: Slugger; categoryLabelSlugger: Slugger;
sidebarOptions: SidebarOptions; sidebarOptions: SidebarOptions;
categoriesMetadata: Record<string, CategoryMetadataFile>;
}; };
function toSidebarItemsGeneratorDoc( function toSidebarItemsGeneratorDoc(
@ -72,6 +74,7 @@ async function processSidebar(
docs, docs,
version, version,
sidebarOptions, sidebarOptions,
categoriesMetadata,
} = params; } = params;
// Just a minor lazy transformation optimization // Just a minor lazy transformation optimization
@ -101,6 +104,7 @@ async function processSidebar(
isCategoryIndex, isCategoryIndex,
...getSidebarItemsGeneratorDocsAndVersion(), ...getSidebarItemsGeneratorDocsAndVersion(),
options: sidebarOptions, options: sidebarOptions,
categoriesMetadata,
}); });
// TODO validate generated items: user can generate bad items // TODO validate generated items: user can generate bad items

View file

@ -188,6 +188,20 @@ export type PropVersionDocs = {
[docId: string]: PropVersionDoc; [docId: string]: PropVersionDoc;
}; };
export type CategoryMetadataFile = {
label?: string;
position?: number;
collapsed?: boolean;
collapsible?: boolean;
className?: string;
link?: SidebarItemCategoryLinkConfig | null;
// TODO should we allow "items" here? how would this work? would an
// "autogenerated" type be allowed?
// This mkdocs plugin do something like that: https://github.com/lukasgeiter/mkdocs-awesome-pages-plugin/
// cf comment: https://github.com/facebook/docusaurus/issues/3464#issuecomment-784765199
};
// Reduce API surface for options.sidebarItemsGenerator // Reduce API surface for options.sidebarItemsGenerator
// The user-provided generator fn should receive only a subset of metadata // The user-provided generator fn should receive only a subset of metadata
// A change to any of these metadata can be considered as a breaking change // A change to any of these metadata can be considered as a breaking change
@ -211,6 +225,7 @@ export type SidebarItemsGeneratorArgs = {
docs: SidebarItemsGeneratorDoc[]; docs: SidebarItemsGeneratorDoc[];
numberPrefixParser: NumberPrefixParser; numberPrefixParser: NumberPrefixParser;
isCategoryIndex: CategoryIndexMatcher; isCategoryIndex: CategoryIndexMatcher;
categoriesMetadata: Record<string, CategoryMetadataFile>;
options: SidebarOptions; options: SidebarOptions;
}; };
export type SidebarItemsGenerator = ( export type SidebarItemsGenerator = (

View file

@ -19,9 +19,9 @@ import type {
SidebarsConfig, SidebarsConfig,
SidebarItemCategoryLinkDoc, SidebarItemCategoryLinkDoc,
SidebarItemCategoryLinkGeneratedIndex, SidebarItemCategoryLinkGeneratedIndex,
CategoryMetadataFile,
} from './types'; } from './types';
import {isCategoriesShorthand} from './utils'; import {isCategoriesShorthand} from './utils';
import type {CategoryMetadataFile} from './generator';
// NOTE: we don't add any default values during validation on purpose! // NOTE: we don't add any default values during validation on purpose!
// Config types are exposed to users for typechecking and we use the same type // Config types are exposed to users for typechecking and we use the same type

View file

@ -94,6 +94,7 @@ type SidebarGenerator = (generatorArgs: {
sidebarPosition?: number | undefined; sidebarPosition?: number | undefined;
}>; // all the docs of that version (unfiltered) }>; // all the docs of that version (unfiltered)
numberPrefixParser: PrefixParser; // numberPrefixParser configured for this plugin numberPrefixParser: PrefixParser; // numberPrefixParser configured for this plugin
categoriesMetadata: Record<string, CategoryMetadata>; // key is the path relative to the doc directory, value is the category metadata file's content
isCategoryIndex: CategoryIndexMatcher; // the default category index matcher, that you can override isCategoryIndex: CategoryIndexMatcher; // the default category index matcher, that you can override
defaultSidebarItemsGenerator: SidebarGenerator; // useful to re-use/enhance default sidebar generation logic from Docusaurus defaultSidebarItemsGenerator: SidebarGenerator; // useful to re-use/enhance default sidebar generation logic from Docusaurus
}) => Promise<SidebarItem[]>; }) => Promise<SidebarItem[]>;

View file

@ -428,6 +428,7 @@ module.exports = {
item, item,
version, version,
docs, docs,
categoriesMetadata,
isCategoryIndex, isCategoryIndex,
}) { }) {
// Example: return an hardcoded list of static sidebar items // Example: return an hardcoded list of static sidebar items