feat(docs, blog): add support for tags.yml, predefined list of tags (#10137)

Co-authored-by: Sébastien Lorber <slorber@users.noreply.github.com>
Co-authored-by: OzakIOne <OzakIOne@users.noreply.github.com>
Co-authored-by: sebastien <lorber.sebastien@gmail.com>
Co-authored-by: slorber <slorber@users.noreply.github.com>
This commit is contained in:
ozaki 2024-05-31 17:32:08 +02:00 committed by GitHub
parent 1049294ba6
commit 0eb7b64aac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 2597 additions and 722 deletions

View file

@ -21,10 +21,15 @@
"@docusaurus/logger": "3.3.2",
"@docusaurus/utils": "3.3.2",
"@docusaurus/utils-common": "3.3.2",
"fs-extra": "^11.2.0",
"joi": "^17.9.2",
"js-yaml": "^4.1.0",
"lodash": "^4.17.21",
"tslib": "^2.6.0"
},
"devDependencies": {
"tmp-promise": "^3.0.3"
},
"engines": {
"node": ">=18.0"
}

View file

@ -0,0 +1,538 @@
/**
* 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 * as path from 'path';
import * as fs from 'fs-extra';
import * as tmp from 'tmp-promise';
import * as YAML from 'js-yaml';
import {
ensureUniquePermalinks,
getTagsFile,
getTagsFilePathsToWatch,
normalizeTagsFile,
} from '../tagsFile';
import type {TagsFile, TagsFileInput} from '@docusaurus/utils';
describe('ensureUniquePermalinks', () => {
it('throw when one duplicate permalink found', () => {
const definedTags: TagsFile = {
open: {
label: 'Open Source',
permalink: '/custom-open-source',
description: 'Learn about the open source',
},
closed: {
label: 'Closed Source',
permalink: '/custom-open-source',
description: 'Learn about the closed source',
},
};
expect(() => ensureUniquePermalinks(definedTags))
.toThrowErrorMatchingInlineSnapshot(`
"Duplicate permalinks found in tags file:
- /custom-open-source"
`);
});
it('throw when multiple duplicate permalink found', () => {
const definedTags: TagsFile = {
open: {
label: 'Open Source',
permalink: '/custom-open-source',
description: 'Learn about the open source',
},
closed: {
label: 'Closed Source',
permalink: '/custom-open-source',
description: 'Learn about the closed source',
},
hello: {
label: 'Hello',
permalink: '/hello',
description: 'Learn about the hello',
},
world: {
label: 'Hello',
permalink: '/hello',
description: 'Learn about the world',
},
};
expect(() => ensureUniquePermalinks(definedTags))
.toThrowErrorMatchingInlineSnapshot(`
"Duplicate permalinks found in tags file:
- /custom-open-source
- /hello"
`);
});
it('do not throw when no duplicate permalink found', () => {
const definedTags: TagsFile = {
open: {
label: 'Open Source',
permalink: '/open-source',
description: 'Learn about the open source',
},
closed: {
label: 'Closed Source',
permalink: '/closed-source',
description: 'Learn about the closed source',
},
};
expect(() => ensureUniquePermalinks(definedTags)).not.toThrow();
});
});
describe('normalizeTagsFile', () => {
it('normalize null tag', () => {
const input: TagsFileInput = {
'kebab case test': null,
};
const expectedOutput: TagsFile = {
'kebab case test': {
description: undefined,
label: 'Kebab case test',
permalink: '/kebab-case-test',
},
};
expect(normalizeTagsFile(input)).toEqual(expectedOutput);
});
it('normalize partial tag with label', () => {
const input: TagsFileInput = {
world: {label: 'WORLD'},
};
const expectedOutput: TagsFile = {
world: {
description: undefined,
label: 'WORLD',
permalink: '/world',
},
};
expect(normalizeTagsFile(input)).toEqual(expectedOutput);
});
it('normalize partial tag with description', () => {
const input: TagsFileInput = {
world: {description: 'World description test'},
};
const expectedOutput: TagsFile = {
world: {
description: 'World description test',
label: 'World',
permalink: '/world',
},
};
expect(normalizeTagsFile(input)).toEqual(expectedOutput);
});
it('normalize partial tag with permalink', () => {
const input: TagsFileInput = {
world: {permalink: 'world'},
};
const expectedOutput: TagsFile = {
world: {
description: undefined,
label: 'World',
permalink: 'world',
},
};
expect(normalizeTagsFile(input)).toEqual(expectedOutput);
});
it('does not modify fully defined tags', () => {
const input: TagsFileInput = {
tag1: {
label: 'Custom Label',
description: 'Custom Description',
permalink: 'custom-permalink',
},
};
expect(normalizeTagsFile(input)).toEqual(input);
});
it('handle special characters in keys', () => {
const input: TagsFileInput = {
'special@char$!key': null,
};
const expectedOutput: TagsFile = {
'special@char$!key': {
description: undefined,
label: 'Special@char$!key',
permalink: '/special-char-key',
},
};
expect(normalizeTagsFile(input)).toEqual(expectedOutput);
});
it('handle special characters in keys with chinese characters', () => {
const input: TagsFileInput = {
特殊字符测试: null,
};
const expectedOutput: TagsFile = {
: {
description: undefined,
label: '特殊字符测试',
permalink: '/特殊字符测试',
},
};
expect(normalizeTagsFile(input)).toEqual(expectedOutput);
});
it('normalize test', () => {
const input: TagsFileInput = {
world: {permalink: 'aze'},
hello: {permalink: 'h e l l o'},
};
const expectedOutput = {
world: {
description: undefined,
label: 'World',
permalink: 'aze',
},
hello: {
description: undefined,
label: 'Hello',
permalink: 'h e l l o',
},
};
expect(normalizeTagsFile(input)).toEqual(expectedOutput);
});
});
describe('getTagsFilePathsToWatch', () => {
it('returns tags file paths - tags undefined', () => {
expect(
getTagsFilePathsToWatch({
tags: undefined,
contentPaths: {
contentPath: '/user/blog',
contentPathLocalized: '/i18n/blog',
},
}),
).toEqual(['/i18n/blog/tags.yml', '/user/blog/tags.yml']);
});
it('returns tags file paths - tags.yml', () => {
expect(
getTagsFilePathsToWatch({
tags: 'tags.yml',
contentPaths: {
contentPath: '/user/blog',
contentPathLocalized: '/i18n/blog',
},
}),
).toEqual(['/i18n/blog/tags.yml', '/user/blog/tags.yml']);
});
it('returns tags file paths - customTags.yml', () => {
expect(
getTagsFilePathsToWatch({
tags: 'customTags.yml',
contentPaths: {
contentPath: '/user/blog',
contentPathLocalized: '/i18n/blog',
},
}),
).toEqual(['/i18n/blog/customTags.yml', '/user/blog/customTags.yml']);
});
it('returns [] - tags: null', () => {
expect(
getTagsFilePathsToWatch({
tags: null,
contentPaths: {
contentPath: '/user/blog',
contentPathLocalized: '/i18n/blog',
},
}),
).toEqual([]);
});
it('returns [] - tags: false', () => {
expect(
getTagsFilePathsToWatch({
tags: false,
contentPaths: {
contentPath: '/user/blog',
contentPathLocalized: '/i18n/blog',
},
}),
).toEqual([]);
});
});
describe('getTagsFile', () => {
async function createTestTagsFile({
filePath,
tagsFileInput,
}: {
filePath: string;
tagsFileInput: TagsFileInput;
}): Promise<{dir: string}> {
async function createTmpDir() {
return (
await tmp.dir({
prefix: 'jest-createTmpSiteDir',
})
).path;
}
const contentPath = await createTmpDir();
const finalFilePath = path.join(contentPath, filePath);
const fileContent = YAML.dump(tagsFileInput);
await fs.writeFile(finalFilePath, fileContent);
return {dir: contentPath};
}
type Params = Parameters<typeof getTagsFile>[0];
it('reads tags file - regular', async () => {
const {dir} = await createTestTagsFile({
filePath: 'tags.yml',
tagsFileInput: {
tag1: {label: 'Tag1 Label'},
tag2: {description: 'Tag2 Description'},
tag3: {
label: 'Tag3 Label',
permalink: '/tag-3',
description: 'Tag3 Description',
},
},
});
const params: Params = {
contentPaths: {contentPath: dir, contentPathLocalized: dir},
tags: 'tags.yml',
};
await expect(getTagsFile(params)).resolves.toMatchInlineSnapshot(`
{
"tag1": {
"description": undefined,
"label": "Tag1 Label",
"permalink": "/tag-1",
},
"tag2": {
"description": "Tag2 Description",
"label": "Tag2",
"permalink": "/tag-2",
},
"tag3": {
"description": "Tag3 Description",
"label": "Tag3 Label",
"permalink": "/tag-3",
},
}
`);
});
it('reads tags file - only keys', async () => {
const {dir} = await createTestTagsFile({
filePath: 'tags.yml',
tagsFileInput: {
tagKey: null,
},
});
const params: Params = {
contentPaths: {contentPath: dir, contentPathLocalized: dir},
tags: 'tags.yml',
};
await expect(getTagsFile(params)).resolves.toMatchInlineSnapshot(`
{
"tagKey": {
"description": undefined,
"label": "Tagkey",
"permalink": "/tag-key",
},
}
`);
});
it('reads tags file - tags option undefined', async () => {
const {dir} = await createTestTagsFile({
filePath: 'tags.yml',
tagsFileInput: {
tag: {label: 'tag label'},
},
});
const params: Params = {
contentPaths: {contentPath: dir, contentPathLocalized: dir},
tags: undefined,
};
await expect(getTagsFile(params)).resolves.toMatchInlineSnapshot(`
{
"tag": {
"description": undefined,
"label": "tag label",
"permalink": "/tag",
},
}
`);
});
it('reads tags file - empty file', async () => {
const {dir} = await createTestTagsFile({
filePath: 'tags.yml',
tagsFileInput: {},
});
const params: Params = {
contentPaths: {contentPath: dir, contentPathLocalized: dir},
tags: undefined,
};
await expect(getTagsFile(params)).resolves.toEqual({});
});
it('reads tags file - prioritizes reading from localized content path', async () => {
const {dir} = await createTestTagsFile({
filePath: 'tags.yml',
tagsFileInput: {
tag: {label: 'tag label'},
},
});
const {dir: dirLocalized} = await createTestTagsFile({
filePath: 'tags.yml',
tagsFileInput: {
tag: {label: 'tag label (localized)'},
},
});
const params: Params = {
contentPaths: {contentPath: dir, contentPathLocalized: dirLocalized},
tags: undefined,
};
await expect(getTagsFile(params)).resolves.toMatchInlineSnapshot(`
{
"tag": {
"description": undefined,
"label": "tag label (localized)",
"permalink": "/tag",
},
}
`);
});
it('reads tags file - custom tags file path', async () => {
const {dir} = await createTestTagsFile({
filePath: 'custom-tags-path.yml',
tagsFileInput: {
tag: {label: 'tag label'},
},
});
const params: Params = {
contentPaths: {contentPath: dir, contentPathLocalized: dir},
tags: 'custom-tags-path.yml',
};
await expect(getTagsFile(params)).resolves.toMatchInlineSnapshot(`
{
"tag": {
"description": undefined,
"label": "tag label",
"permalink": "/tag",
},
}
`);
});
it('throws if duplicate permalink', async () => {
const {dir} = await createTestTagsFile({
filePath: 'tags.yml',
tagsFileInput: {
tag1: {permalink: '/duplicate'},
tag2: {permalink: '/duplicate'},
},
});
const params: Params = {
contentPaths: {contentPath: dir, contentPathLocalized: dir},
tags: undefined,
};
await expect(getTagsFile(params)).rejects.toMatchInlineSnapshot(`
[Error: Duplicate permalinks found in tags file:
- /duplicate]
`);
});
it('throws if custom tags file path does not exist', async () => {
const params: Params = {
contentPaths: {contentPath: 'any', contentPathLocalized: 'localizedAny'},
tags: 'custom-tags-path.yml',
};
await expect(getTagsFile(params)).rejects
.toThrowErrorMatchingInlineSnapshot(`
"No tags file 'custom-tags-path.yml' could be found in any of those directories:
- localizedAny
- any"
`);
});
it('does not read tags file - tags option null/false', async () => {
const {dir} = await createTestTagsFile({
filePath: 'tags.yml',
tagsFileInput: {
tag: {label: 'tag label'},
},
});
await expect(
getTagsFile({
contentPaths: {contentPath: dir, contentPathLocalized: dir},
tags: null,
}),
).resolves.toBeNull();
await expect(
getTagsFile({
contentPaths: {contentPath: dir, contentPathLocalized: dir},
tags: false,
}),
).resolves.toBeNull();
});
it('does not read tags file - tags files has non-default name', async () => {
const {dir} = await createTestTagsFile({
filePath: 'bad-tags-file-name.yml',
tagsFileInput: {
tag: {label: 'tag label'},
},
});
const params: Params = {
contentPaths: {contentPath: dir, contentPathLocalized: dir},
tags: undefined,
};
await expect(getTagsFile(params)).resolves.toBeNull();
});
});

View file

@ -29,3 +29,4 @@ export {
FrontMatterLastUpdateErrorMessage,
FrontMatterLastUpdateSchema,
} from './validationSchemas';
export {getTagsFilePathsToWatch, getTagsFile} from './tagsFile';

View file

@ -0,0 +1,131 @@
/**
* 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 fs from 'fs-extra';
import path from 'node:path';
import _ from 'lodash';
import Joi from 'joi';
import YAML from 'js-yaml';
import {getContentPathList, getDataFilePath} from '@docusaurus/utils';
import type {
ContentPaths,
TagsFile,
TagsFileInput,
TagsPluginOptions,
} from '@docusaurus/utils';
const TagsFileInputSchema = Joi.object<TagsFileInput>().pattern(
Joi.string(),
Joi.object({
label: Joi.string(),
description: Joi.string(),
permalink: Joi.string(),
}).allow(null),
);
export function ensureUniquePermalinks(tags: TagsFile): void {
const permalinks = new Set<string>();
const duplicates = new Set<string>();
for (const [, tag] of Object.entries(tags)) {
const {permalink} = tag;
if (permalinks.has(permalink)) {
duplicates.add(permalink);
} else {
permalinks.add(permalink);
}
}
if (duplicates.size > 0) {
const duplicateList = Array.from(duplicates)
.map((permalink) => ` - ${permalink}`)
.join('\n');
throw new Error(
`Duplicate permalinks found in tags file:\n${duplicateList}`,
);
}
}
export function normalizeTagsFile(data: TagsFileInput): TagsFile {
return _.mapValues(data, (tag, key) => {
return {
label: tag?.label || _.capitalize(key),
description: tag?.description,
permalink: tag?.permalink || `/${_.kebabCase(key)}`,
};
});
}
type GetTagsFileParams = {
tags: TagsPluginOptions['tags'];
contentPaths: ContentPaths;
};
const DefaultTagsFileName = 'tags.yml';
export function getTagsFilePathsToWatch({
tags,
contentPaths,
}: GetTagsFileParams): string[] {
if (tags === false || tags === null) {
return [];
}
const relativeFilePath = tags ?? DefaultTagsFileName;
return getContentPathList(contentPaths).map((contentPath) =>
path.posix.join(contentPath, relativeFilePath),
);
}
export async function getTagsFile({
tags,
contentPaths,
}: GetTagsFileParams): Promise<TagsFile | null> {
if (tags === false || tags === null) {
return null;
}
const relativeFilePath = tags ?? DefaultTagsFileName;
// if returned path is defined, the file exists (localized or not)
const yamlFilePath = await getDataFilePath({
contentPaths,
filePath: relativeFilePath,
});
// If the tags option is undefined, don't throw when the file does not exist
// Retro-compatible behavior: existing sites do not yet have tags.yml
if (tags === undefined && !yamlFilePath) {
return null;
}
if (!yamlFilePath) {
throw new Error(
`No tags file '${relativeFilePath}' could be found in any of those directories:\n- ${getContentPathList(
contentPaths,
).join('\n- ')}`,
);
}
const tagDefinitionContent = await fs.readFile(yamlFilePath, 'utf-8');
if (!tagDefinitionContent.trim()) {
return {};
}
const yamlContent = YAML.load(tagDefinitionContent);
const tagsFileInputResult = TagsFileInputSchema.validate(yamlContent);
if (tagsFileInputResult.error) {
throw new Error(
`There was an error extracting tags from file: ${tagsFileInputResult.error.message}`,
{cause: tagsFileInputResult},
);
}
const tagsFile = normalizeTagsFile(tagsFileInputResult.value);
ensureUniquePermalinks(tagsFile);
return tagsFile;
}

View file

@ -5,7 +5,11 @@
* LICENSE file in the root directory of this source tree.
*/
import {isValidPathname, DEFAULT_PLUGIN_ID, type Tag} from '@docusaurus/utils';
import {
isValidPathname,
DEFAULT_PLUGIN_ID,
type FrontMatterTag,
} from '@docusaurus/utils';
import {addLeadingSlash} from '@docusaurus/utils-common';
import Joi from './Joi';
import {JoiFrontMatter} from './JoiFrontMatter';
@ -113,7 +117,9 @@ export const RouteBasePathSchema = Joi
const FrontMatterTagSchema = JoiFrontMatter.alternatives()
.try(
JoiFrontMatter.string().required(),
JoiFrontMatter.object<Tag>({
// TODO Docusaurus v4 remove this legacy front matter tag object form
// users should use tags.yml instead
JoiFrontMatter.object<FrontMatterTag>({
label: JoiFrontMatter.string().required(),
permalink: JoiFrontMatter.string().required(),
}).required(),