diff --git a/packages/docusaurus-plugin-content-showcase/src/__tests__/__snapshots__/index.test.ts.snap b/packages/docusaurus-plugin-content-showcase/src/__tests__/__snapshots__/index.test.ts.snap index f23e1c240f..097bb675fe 100644 --- a/packages/docusaurus-plugin-content-showcase/src/__tests__/__snapshots__/index.test.ts.snap +++ b/packages/docusaurus-plugin-content-showcase/src/__tests__/__snapshots__/index.test.ts.snap @@ -2,7 +2,7 @@ exports[`docusaurus-plugin-content-showcase loads simple showcase 1`] = ` { - "website": [ + "items": [ { "description": "World", "preview": "github.com/ozakione.png", diff --git a/packages/docusaurus-plugin-content-showcase/src/__tests__/options.test.ts b/packages/docusaurus-plugin-content-showcase/src/__tests__/options.test.ts index 52d1dd7b71..591eff07e7 100644 --- a/packages/docusaurus-plugin-content-showcase/src/__tests__/options.test.ts +++ b/packages/docusaurus-plugin-content-showcase/src/__tests__/options.test.ts @@ -6,7 +6,7 @@ */ import {normalizePluginOptions} from '@docusaurus/utils-validation'; import {validateOptions, DEFAULT_OPTIONS} from '../options'; -import type {Options} from '@docusaurus/plugin-content-pages'; +import type {Options} from '@docusaurus/plugin-content-showcase'; function testValidate(options: Options) { return validateOptions({validate: normalizePluginOptions, options}); @@ -60,4 +60,68 @@ describe('normalizeShowcasePluginOptions', () => { routeBasePath: '/', }); }); + + it('accepts correctly defined tags file options', () => { + const userOptions = { + tags: '@site/showcase/tags.yaml', + }; + expect(testValidate(userOptions)).toEqual({ + ...defaultOptions, + ...userOptions, + }); + }); + + it('reject badly defined tags file options', () => { + const userOptions = { + tags: 42, + }; + expect(() => + testValidate( + // @ts-expect-error: bad attributes + userOptions, + ), + ).toThrowErrorMatchingInlineSnapshot( + `""tags" must be one of [string, array]"`, + ); + }); + + it('accepts correctly defined tags object options', () => { + const userOptions = { + tags: [ + { + label: 'foo', + description: { + message: 'bar', + id: 'baz', + }, + color: 'red', + }, + ], + }; + expect(testValidate(userOptions)).toEqual({ + ...defaultOptions, + ...userOptions, + }); + }); + + it('reject bedly defined tags object options', () => { + const userOptions = { + tags: [ + { + label: 'foo', + description: { + message: 'bar', + id: 'baz', + }, + color: 42, + }, + ], + }; + expect(() => + testValidate( + // @ts-expect-error: bad attributes + userOptions, + ), + ).toThrowErrorMatchingInlineSnapshot(`""tags[0].color" must be a string"`); + }); }); diff --git a/packages/docusaurus-plugin-content-showcase/src/index.ts b/packages/docusaurus-plugin-content-showcase/src/index.ts index 22a014cc9e..0559cd2744 100644 --- a/packages/docusaurus-plugin-content-showcase/src/index.ts +++ b/packages/docusaurus-plugin-content-showcase/src/index.ts @@ -14,9 +14,15 @@ import { } from '@docusaurus/utils'; import Yaml from 'js-yaml'; +import {Joi} from '@docusaurus/utils-validation'; import {validateShowcaseFrontMatter} from './frontMatter'; +import {tagSchema} from './options'; import type {LoadContext, Plugin} from '@docusaurus/types'; -import type {PluginOptions, Content} from '@docusaurus/plugin-content-showcase'; +import type { + PluginOptions, + ShowcaseItem, + TagOption, +} from '@docusaurus/plugin-content-showcase'; import type {ShowcaseContentPaths} from './types'; export function getContentPathList( @@ -25,10 +31,59 @@ export function getContentPathList( return [contentPaths.contentPathLocalized, contentPaths.contentPath]; } +async function getTagsDefinition( + filePath: string | TagOption[], +): Promise { + if (Array.isArray(filePath)) { + return filePath.map((tag) => tag.label); + } + + const rawYaml = await fs.readFile(filePath, 'utf-8'); + const unsafeYaml: any = Yaml.load(rawYaml); + console.log('unsafeYaml:', unsafeYaml); + + const transformedData = unsafeYaml.tags.map((item: any) => { + const [label] = Object.keys(item); // Extract label from object key + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const {description, color} = item[label]; // Extract description and color + return {label, description, color}; // Create new object with transformed structure + }); + console.log('transformedData:', transformedData); + + const safeYaml = tagSchema.validate(transformedData); + + if (safeYaml.error) { + throw new Error(`Invalid tags.yaml file: ${safeYaml.error.message}`); + } + + const tagLabels = safeYaml.value.map((tag: any) => Object.keys(tag)[0]); + return tagLabels; +} + +function createTagSchema(tags: string[]): Joi.Schema { + return Joi.alternatives().try( + Joi.string().valid(...tags), // Schema for single string + Joi.array().items(Joi.string().valid(...tags)), // Schema for array of strings + ); +} + +function validateFrontMatterTags( + frontMatterTags: string[], + tagListSchema: Joi.Schema, +): void { + const result = tagListSchema.validate(frontMatterTags); + if (result.error) { + throw new Error( + `Front matter contains invalid tags: ${result.error.message}`, + ); + } +} + export default function pluginContentShowcase( context: LoadContext, options: PluginOptions, -): Plugin { +): Plugin { const {siteDir, localizationDir} = context; const contentPaths: ShowcaseContentPaths = { @@ -51,7 +106,7 @@ export default function pluginContentShowcase( // ); // }, - async loadContent(): Promise { + async loadContent(): Promise { const {include} = options; if (!(await fs.pathExists(contentPaths.contentPath))) { @@ -64,6 +119,23 @@ export default function pluginContentShowcase( ignore: options.exclude, }); + const filteredShowcaseFiles = showcaseFiles.filter( + (source) => source !== 'tags.yaml', + ); + + // todo refactor ugly + const tagFilePath = path.join( + await getFolderContainingFile( + getContentPathList(contentPaths), + 'tags.yaml', + ), + 'tags.yaml', + ); + + const tagList = await getTagsDefinition(tagFilePath); + const createdTagSchema = createTagSchema(tagList); + console.log('createdTagSchema:', createdTagSchema.describe()); + async function processShowcaseSourceFile(relativeSource: string) { // Lookup in localized folder in priority const contentPath = await getFolderContainingFile( @@ -72,9 +144,14 @@ export default function pluginContentShowcase( ); const sourcePath = path.join(contentPath, relativeSource); + const rawYaml = await fs.readFile(sourcePath, 'utf-8'); const unsafeYaml = Yaml.load(rawYaml) as {[key: string]: unknown}; - return validateShowcaseFrontMatter(unsafeYaml); + const yaml = validateShowcaseFrontMatter(unsafeYaml); + + validateFrontMatterTags(yaml.tags, createdTagSchema); + + return yaml; } async function doProcessShowcaseSourceFile(relativeSource: string) { @@ -89,8 +166,8 @@ export default function pluginContentShowcase( } return { - website: await Promise.all( - showcaseFiles.map(doProcessShowcaseSourceFile), + items: await Promise.all( + filteredShowcaseFiles.map(doProcessShowcaseSourceFile), ), }; }, @@ -104,7 +181,7 @@ export default function pluginContentShowcase( const showcaseAllData = await createData( 'showcaseAll.json', - JSON.stringify(content.website), + JSON.stringify(content.items), ); addRoute({ diff --git a/packages/docusaurus-plugin-content-showcase/src/options.ts b/packages/docusaurus-plugin-content-showcase/src/options.ts index 3eff51e430..f718d01fd9 100644 --- a/packages/docusaurus-plugin-content-showcase/src/options.ts +++ b/packages/docusaurus-plugin-content-showcase/src/options.ts @@ -12,18 +12,33 @@ import type {PluginOptions, Options} from '@docusaurus/plugin-content-showcase'; export const DEFAULT_OPTIONS: PluginOptions = { id: 'showcase', - path: 'src/showcase/website', // Path to data on filesystem, relative to site dir. + path: 'showcase', // Path to data on filesystem, relative to site dir. routeBasePath: '/', // URL Route. include: ['**/*.{yml,yaml}'], // Extensions to include. exclude: GlobExcludeDefault, + tags: '@site/showcase/tags.yaml', }; +export const tagSchema = Joi.array().items( + Joi.object({ + label: Joi.string().required(), + description: Joi.object({ + message: Joi.string().required(), + id: Joi.string().required(), + }).required(), + color: Joi.string().required(), + }), +); + const PluginOptionSchema = Joi.object({ path: Joi.string().default(DEFAULT_OPTIONS.path), routeBasePath: RouteBasePathSchema.default(DEFAULT_OPTIONS.routeBasePath), include: Joi.array().items(Joi.string()).default(DEFAULT_OPTIONS.include), exclude: Joi.array().items(Joi.string()).default(DEFAULT_OPTIONS.exclude), id: Joi.string().default(DEFAULT_OPTIONS.id), + tags: Joi.alternatives() + .try(Joi.string().default(DEFAULT_OPTIONS.tags), tagSchema) + .default(DEFAULT_OPTIONS.tags), }); export function validateOptions({ diff --git a/packages/docusaurus-plugin-content-showcase/src/plugin-content-showcase.d.ts b/packages/docusaurus-plugin-content-showcase/src/plugin-content-showcase.d.ts index 5c0475f5ff..b17055bc80 100644 --- a/packages/docusaurus-plugin-content-showcase/src/plugin-content-showcase.d.ts +++ b/packages/docusaurus-plugin-content-showcase/src/plugin-content-showcase.d.ts @@ -8,8 +8,13 @@ declare module '@docusaurus/plugin-content-showcase' { import type {LoadContext, Plugin} from '@docusaurus/types'; - export type Assets = { - image?: string; + export type TagOption = { + label: string; + description: { + message: string; + id: string; + }; + color: string; }; export type PluginOptions = { @@ -18,9 +23,10 @@ declare module '@docusaurus/plugin-content-showcase' { routeBasePath: string; include: string[]; exclude: string[]; + tags: string | TagOption[]; }; - export type TagType = + type TagType = | 'favorite' | 'opensource' | 'product' @@ -41,41 +47,14 @@ declare module '@docusaurus/plugin-content-showcase' { readonly tags: TagType[]; }; - export type Content = { - website: { - title: string; - description: string; - preview: string | null; // null = use our serverless screenshot service - website: string; - source: string | null; - sourcePath?: string; - tags: TagType[]; - }[]; + export type ShowcaseItem = { + items: ShowcaseFrontMatter[]; }; export type Options = Partial; - export default function pluginShowcase( + export default function pluginContentShowcase( context: LoadContext, options: PluginOptions, - ): Promise>; - - export type ShowcaseMetadata = { - /** Path to the Markdown source, with `@site` alias. */ - readonly source: string; - /** - * Used to generate the page h1 heading, tab title, and pagination title. - */ - readonly title: string; - /** Full link including base URL. */ - readonly permalink: string; - /** - * Description used in the meta. Could be an empty string (empty content) - */ - readonly description: string; - /** Front matter, as-is. */ - readonly frontMatter: Content['website'][number] & {[key: string]: unknown}; - /** Tags, normalized. */ - readonly tags: TagType[]; - }; + ): Promise>; } diff --git a/packages/docusaurus-theme-classic/src/theme-classic.d.ts b/packages/docusaurus-theme-classic/src/theme-classic.d.ts index d35e90336d..3c3e7cb6f0 100644 --- a/packages/docusaurus-theme-classic/src/theme-classic.d.ts +++ b/packages/docusaurus-theme-classic/src/theme-classic.d.ts @@ -248,9 +248,9 @@ declare module '@theme/BlogPostItems' { } declare module '@theme/ShowcaseDetails' { - import type {Content} from '@docusaurus/plugin-content-showcase'; + import type {ShowcaseItem} from '@docusaurus/plugin-content-showcase'; - export type User = Content['website'][number]; + export type User = ShowcaseItem['website'][number]; export type Props = { content: User; @@ -260,9 +260,9 @@ declare module '@theme/ShowcaseDetails' { } declare module '@theme/Showcase' { - import type {Content} from '@docusaurus/plugin-content-showcase'; + import type {ShowcaseItem} from '@docusaurus/plugin-content-showcase'; - export type User = Content['website'][number]; + export type User = ShowcaseItem['website'][number]; export type Props = { content: User[]; diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index ac82efa218..74e97dd10b 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -239,7 +239,7 @@ export default async function createConfigAsync() { ], themes: ['live-codeblock', ...dogfoodingThemeInstances], plugins: [ - 'showcase', + 'content-showcase', [ './src/plugins/changelog/index.js', { diff --git a/website/src/showcase/website/clem/clem.mdx b/website/showcase/clem/clem.mdx similarity index 100% rename from website/src/showcase/website/clem/clem.mdx rename to website/showcase/clem/clem.mdx diff --git a/website/src/showcase/website/dino.yaml b/website/showcase/dino.yaml similarity index 100% rename from website/src/showcase/website/dino.yaml rename to website/showcase/dino.yaml diff --git a/website/src/showcase/website/ozaki/ozaki.yaml b/website/showcase/ozaki/ozaki.yaml similarity index 100% rename from website/src/showcase/website/ozaki/ozaki.yaml rename to website/showcase/ozaki/ozaki.yaml diff --git a/website/src/showcase/website/seb.yaml b/website/showcase/seb.yaml similarity index 100% rename from website/src/showcase/website/seb.yaml rename to website/showcase/seb.yaml diff --git a/website/showcase/tags.yaml b/website/showcase/tags.yaml new file mode 100644 index 0000000000..ef7d2b839b --- /dev/null +++ b/website/showcase/tags.yaml @@ -0,0 +1,61 @@ +tags: + - favorite: + label: 'Favorite' + description: + message: 'Our favorite Docusaurus sites that you must absolutely check out!' + id: 'showcase.tag.favorite.description' + color: '#e9669e' + - opensource: + label: 'Open-Source' + description: + message: 'Open-Source Docusaurus sites can be useful for inspiration!' + id: 'showcase.tag.opensource.description' + color: '#39ca30' + - product: + label: 'Product' + description: + message: 'Docusaurus sites associated to a commercial product!' + id: 'showcase.tag.product.description' + color: '#dfd545' + - design: + label: 'Design' + description: + message: 'Beautiful Docusaurus sites, polished and standing out from the initial template!' + id: 'showcase.tag.design.description' + color: '#a44fb7' + - i18n: + label: 'I18n' + description: + message: 'Translated Docusaurus sites using the internationalization support with more than 1 locale.' + id: 'showcase.tag.i18n.description' + color: '#127f82' + - versioning: + label: 'Versioning' + description: + message: 'Docusaurus sites using the versioning feature of the docs plugin to manage multiple versions.' + id: 'showcase.tag.versioning.description' + color: '#fe6829' + - large: + label: 'Large' + description: + message: 'Very large Docusaurus sites, including many more pages than the average!' + id: 'showcase.tag.large.description' + color: '#8c2f00' + - meta: + label: 'Meta' + description: + message: 'Docusaurus sites of Meta (formerly Facebook) projects' + id: 'showcase.tag.meta.description' + color: '#4267b2' + - personal: + label: 'Personal' + description: + message: 'Personal websites, blogs and digital gardens built with Docusaurus' + id: 'showcase.tag.personal.description' + color: '#14cfc3' + - rtl: + label: 'RTL Direction' + description: + message: 'Docusaurus sites using the right-to-left reading direction support.' + id: 'showcase.tag.rtl.description' + color: '#ffcfc3'