diff --git a/admin/scripts/showcaseCheck.js b/admin/scripts/showcaseCheck.js new file mode 100644 index 000000000000..6c967d9be42a --- /dev/null +++ b/admin/scripts/showcaseCheck.js @@ -0,0 +1,62 @@ +/** + * 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 'path'; +import yaml from 'js-yaml'; +import {load} from 'cheerio'; + +async function parseFiles(directoryPath) { + try { + const files = await fs.promises.readdir(directoryPath); + + for (const file of files) { + const filePath = path.join(directoryPath, file); + const stat = await fs.promises.stat(filePath); + + if (stat.isDirectory()) { + await parseFiles(filePath); // Recursively process subdirectories + } else { + await processYamlFile(filePath); // Process individual YAML file + } + } + } catch (err) { + console.error('Error reading directory:', err); + } +} +async function processYamlFile(filePath) { + const data = await fs.promises.readFile(filePath, 'utf8'); + const {website} = yaml.load(data); + + try { + const html = await fetchWebsiteHtml(website); + const $ = load(html); + const generatorMeta = $('meta[name="generator"]'); + + if (generatorMeta.length === 0) { + console.log(`Website ${website} is not a Docusaurus site.`); + } + } catch (error) { + console.error(`Error fetching website ${website}:`, error.message); + } +} + +async function fetchWebsiteHtml(url) { + const response = await fetch(url, { + headers: { + 'Accept-Encoding': 'gzip, deflate', + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch ${url}: ${response.statusText}`); + } + + return response.text(); +} + +// processYamlFiles('../../website/showcase'); +parseFiles('./admin/scripts/showcase'); diff --git a/packages/docusaurus-plugin-content-showcase/.npmignore b/packages/docusaurus-plugin-content-showcase/.npmignore new file mode 100644 index 000000000000..03c9ae1e1b54 --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/.npmignore @@ -0,0 +1,3 @@ +.tsbuildinfo* +tsconfig* +__tests__ diff --git a/packages/docusaurus-plugin-content-showcase/README.md b/packages/docusaurus-plugin-content-showcase/README.md new file mode 100644 index 000000000000..33623bba6cbe --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/README.md @@ -0,0 +1,7 @@ +# `@docusaurus/plugin-content-showcase` + +Showcase plugin for Docusaurus. + +## Usage + +See [plugin-content-showcase documentation](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-showcase). diff --git a/packages/docusaurus-plugin-content-showcase/package.json b/packages/docusaurus-plugin-content-showcase/package.json new file mode 100644 index 000000000000..b95974a2f14e --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/package.json @@ -0,0 +1,51 @@ +{ + "name": "@docusaurus/plugin-content-showcase", + "version": "3.3.2", + "description": "Showcase plugin for Docusaurus.", + "main": "lib/index.js", + "sideEffects": false, + "exports": { + "./lib/*": "./lib/*", + "./src/*": "./src/*", + "./client": { + "type": "./lib/client/index.d.ts", + "default": "./lib/client/index.js" + }, + ".": { + "types": "./src/plugin-content-showcase.d.ts", + "default": "./lib/index.js" + } + }, + "types": "src/plugin-content-showcase.d.ts", + "scripts": { + "build": "tsc --build", + "watch": "tsc --build --watch" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/facebook/docusaurus.git", + "directory": "packages/docusaurus-plugin-content-showcase" + }, + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.3.2", + "@docusaurus/types": "3.3.2", + "@docusaurus/utils": "3.3.2", + "@docusaurus/utils-validation": "3.3.2", + "@docusaurus/theme-common": "3.3.2", + "fs-extra": "^11.1.1", + "js-yaml": "^4.1.0", + "tslib": "^2.6.0", + "webpack": "^5.88.1" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + }, + "engines": { + "node": ">=18.0" + } +} diff --git a/packages/docusaurus-plugin-content-showcase/src/__tests__/__fixtures__/tags.yml b/packages/docusaurus-plugin-content-showcase/src/__tests__/__fixtures__/tags.yml new file mode 100644 index 000000000000..6bc8d21dd7fd --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/src/__tests__/__fixtures__/tags.yml @@ -0,0 +1,6 @@ +favorite: + label: "Favorite" + description: + message: "Our favorite Docusaurus sites that you must absolutely check out!" + id: "showcase.tag.favorite.description" + color: "#e9669e" diff --git a/packages/docusaurus-plugin-content-showcase/src/__tests__/__fixtures__/website/docusaurus.config.js b/packages/docusaurus-plugin-content-showcase/src/__tests__/__fixtures__/website/docusaurus.config.js new file mode 100644 index 000000000000..ae48be19a450 --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/src/__tests__/__fixtures__/website/docusaurus.config.js @@ -0,0 +1,14 @@ +/** + * 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. + */ + +module.exports = { + title: 'My Site', + tagline: 'The tagline of my site', + url: 'https://your-docusaurus-site.example.com', + baseUrl: '/', + favicon: 'img/favicon.ico', +}; diff --git a/packages/docusaurus-plugin-content-showcase/src/__tests__/__fixtures__/website/src/showcase/site.yaml b/packages/docusaurus-plugin-content-showcase/src/__tests__/__fixtures__/website/src/showcase/site.yaml new file mode 100644 index 000000000000..fcd8f830b83b --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/src/__tests__/__fixtures__/website/src/showcase/site.yaml @@ -0,0 +1,6 @@ +title: "Hello" +description: "World" +preview: github.com/ozakione.png +website: "https://docusaurus.io/" +source: "https://github.com/facebook/docusaurus" +tags: ["opensource", "meta"] diff --git a/packages/docusaurus-plugin-content-showcase/src/__tests__/__fixtures__/website/src/showcase/tags.yaml b/packages/docusaurus-plugin-content-showcase/src/__tests__/__fixtures__/website/src/showcase/tags.yaml new file mode 100644 index 000000000000..dc6e89b9877a --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/src/__tests__/__fixtures__/website/src/showcase/tags.yaml @@ -0,0 +1,12 @@ +opensource: + label: "Open-Source" + description: + message: "Open-Source Docusaurus sites can be useful for inspiration!" + id: "showcase.tag.opensource.description" + color: "#39ca30" +meta: + label: "Meta" + description: + message: "Docusaurus sites of Meta (formerly Facebook) projects" + id: "showcase.tag.meta.description" + color: "#4267b2" 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 new file mode 100644 index 000000000000..512898564a27 --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/src/__tests__/__snapshots__/index.test.ts.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`docusaurus-plugin-content-showcase loads simple showcase 1`] = ` +{ + "items": [ + { + "description": "World", + "preview": "github.com/ozakione.png", + "source": "https://github.com/facebook/docusaurus", + "tags": [ + "opensource", + "meta", + ], + "title": "Hello", + "website": "https://docusaurus.io/", + }, + ], +} +`; + +exports[`docusaurus-plugin-content-showcase loads simple showcase with tags in options 1`] = ` +{ + "items": [ + { + "description": "World", + "preview": "github.com/ozakione.png", + "source": "https://github.com/facebook/docusaurus", + "tags": [ + "opensource", + "meta", + ], + "title": "Hello", + "website": "https://docusaurus.io/", + }, + ], +} +`; diff --git a/packages/docusaurus-plugin-content-showcase/src/__tests__/index.test.ts b/packages/docusaurus-plugin-content-showcase/src/__tests__/index.test.ts new file mode 100644 index 000000000000..0522f31f9b5e --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/src/__tests__/index.test.ts @@ -0,0 +1,88 @@ +/** + * 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 path from 'path'; +import {loadContext} from '@docusaurus/core/src/server/site'; +import {normalizePluginOptions} from '@docusaurus/utils-validation'; +import {fromPartial} from '@total-typescript/shoehorn'; +import pluginContentShowcase from '../index'; +import {validateOptions} from '../options'; +import type {PluginOptions} from '@docusaurus/plugin-content-showcase'; + +const loadPluginContent = async (siteDir: string, options: PluginOptions) => { + const context = await loadContext({siteDir}); + const plugin = await pluginContentShowcase( + context, + validateOptions({ + validate: normalizePluginOptions, + options, + }), + ); + return plugin.loadContent!(); +}; + +describe('docusaurus-plugin-content-showcase', () => { + it('loads simple showcase', async () => { + const siteDir = path.join(__dirname, '__fixtures__', 'website'); + const showcaseMetadata = await loadPluginContent( + siteDir, + fromPartial({ + path: 'src/showcase', + tags: 'tags.yaml', + }), + ); + + expect(showcaseMetadata).toMatchSnapshot(); + }); + + it('loads simple showcase with tags in options', async () => { + const tags = { + opensource: { + label: 'Open-Source', + description: { + message: + 'Open-Source Docusaurus sites can be useful for inspiration!', + id: 'showcase.tag.opensource.description', + }, + color: '#39ca30', + }, + meta: { + label: 'Meta', + description: { + message: 'Docusaurus sites of Meta (formerly Facebook) projects', + id: 'showcase.tag.meta.description', + }, + color: '#4267b2', + }, + }; + const siteDir = path.join(__dirname, '__fixtures__', 'website'); + const showcaseMetadata = await loadPluginContent( + siteDir, + fromPartial({ + path: 'src/showcase', + tags, + }), + ); + + expect(showcaseMetadata).toMatchSnapshot(); + }); + + it('throw loading inexistant tags file', async () => { + const siteDir = path.join(__dirname, '__fixtures__', 'website'); + await expect(async () => { + await loadPluginContent( + siteDir, + fromPartial({ + path: 'src/showcase', + tags: 'wrong.yaml', + }), + ); + }).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to read tags file for showcase"`, + ); + }); +}); diff --git a/packages/docusaurus-plugin-content-showcase/src/__tests__/options.test.ts b/packages/docusaurus-plugin-content-showcase/src/__tests__/options.test.ts new file mode 100644 index 000000000000..006660b62b27 --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/src/__tests__/options.test.ts @@ -0,0 +1,134 @@ +/** + * 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 {fromPartial} from '@total-typescript/shoehorn'; +import {normalizePluginOptions} from '@docusaurus/utils-validation'; +import {validateOptions, DEFAULT_OPTIONS} from '../options'; +import type {Options} from '@docusaurus/plugin-content-showcase'; + +function testValidate(options: Options) { + return validateOptions({validate: normalizePluginOptions, options}); +} +const defaultOptions = { + ...DEFAULT_OPTIONS, + id: 'default', +}; + +// todo add test that validate and reject tags.yaml file + +describe('normalizeShowcasePluginOptions', () => { + it('returns default options for undefined user options', () => { + expect(testValidate({})).toEqual(defaultOptions); + }); + + it('fills in default options for partially defined user options', () => { + expect(testValidate({path: 'src/foo'})).toEqual({ + ...defaultOptions, + path: 'src/foo', + }); + }); + + it('accepts correctly defined user options', () => { + const userOptions = { + path: 'src/showcase', + routeBasePath: '/showcase', + include: ['**/*.{yaml,yml}'], + exclude: ['**/$*/'], + }; + expect(testValidate(userOptions)).toEqual({ + ...defaultOptions, + ...userOptions, + }); + }); + + it('rejects bad path inputs', () => { + expect(() => { + testValidate({ + // @ts-expect-error: bad attribute + path: 42, + }); + }).toThrowErrorMatchingInlineSnapshot(`""path" must be a string"`); + }); + + it('empty routeBasePath replace default path("/")', () => { + expect( + testValidate({ + routeBasePath: '', + }), + ).toEqual({ + ...defaultOptions, + 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, object]"`, + ); + }); + + it('accepts correctly defined tags object options', () => { + const userOptions = { + tags: { + favorite: { + label: 'Favorite', + description: { + message: + 'Our favorite Docusaurus sites that you must absolutely check out!', + id: 'showcase.tag.favorite.description', + }, + color: '#e9669e', + }, + }, + }; + expect(testValidate(fromPartial(userOptions))).toEqual({ + ...defaultOptions, + ...userOptions, + }); + }); + + it('reject badly defined tags object options', () => { + const userOptions = { + tags: { + favorite: { + label: 32, + description: { + message: + 'Our favorite Docusaurus sites that you must absolutely check out!', + id: 'showcase.tag.favorite.description', + }, + color: '#e9669e', + }, + }, + }; + expect(() => + testValidate( + // @ts-expect-error: bad attributes + userOptions, + ), + ).toThrowErrorMatchingInlineSnapshot( + `""tags.favorite.label" must be a string"`, + ); + }); +}); diff --git a/packages/docusaurus-plugin-content-showcase/src/__tests__/tags.test.ts b/packages/docusaurus-plugin-content-showcase/src/__tests__/tags.test.ts new file mode 100644 index 000000000000..ac4946ec56d1 --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/src/__tests__/tags.test.ts @@ -0,0 +1,61 @@ +/** + * 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 {fromPartial} from '@total-typescript/shoehorn'; +import {getTagsList} from '../tags'; + +const tags = { + favorite: { + label: 'Favorite', + description: { + message: + 'Our favorite Docusaurus sites that you must absolutely check out!', + id: 'showcase.tag.favorite.description', + }, + color: '#e9669e', + }, +}; + +const invalidTags = { + opensource: { + label: 'Open-Source', + description: { + message: 'Open-Source Docusaurus sites can be useful for inspiration!', + id: 'showcase.tag.opensource.description', + }, + color: '#39c', + }, +}; + +describe('showcase tags', () => { + it('get tag list', async () => { + const {tagKeys} = await getTagsList({ + configTags: fromPartial(tags), + configPath: '', + }); + expect(tagKeys).toEqual(Object.keys(tags)); + }); + + it('get tag list from file', async () => { + const {tagKeys} = await getTagsList({ + configTags: './__fixtures__/tags.yml', + configPath: __dirname, + }); + expect(tagKeys).toEqual(Object.keys(tags)); + }); + + it('error get tag list', async () => { + const tagList = getTagsList({ + configTags: fromPartial(invalidTags), + configPath: '', + }); + + await expect(() => tagList).rejects.toThrowErrorMatchingInlineSnapshot( + `"There was an error extracting tags: Color must be a hexadecimal color string (e.g., #14cfc3 #E9669E)"`, + ); + }); +}); diff --git a/packages/docusaurus-plugin-content-showcase/src/__tests__/validation.test.ts b/packages/docusaurus-plugin-content-showcase/src/__tests__/validation.test.ts new file mode 100644 index 000000000000..8145d7dbd0e4 --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/src/__tests__/validation.test.ts @@ -0,0 +1,91 @@ +/** + * 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 {fromPartial} from '@total-typescript/shoehorn'; +import {createShowcaseItemSchema, validateShowcaseItem} from '../validation'; +import {getTagsList} from '../tags'; +import type {ShowcaseItem} from '@docusaurus/plugin-content-showcase'; + +const 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', + }, +}; + +async function prepareSchema() { + const {tagKeys} = await getTagsList({ + configTags: fromPartial(tags), + configPath: '', + }); + return createShowcaseItemSchema(tagKeys); +} + +describe('showcase item schema', () => { + it('accepts valid item', async () => { + const item: ShowcaseItem = { + title: 'title', + description: 'description', + preview: 'preview', + source: 'source', + tags: [], + website: 'website', + }; + const showcaseItemSchema = await prepareSchema(); + expect(validateShowcaseItem({item, showcaseItemSchema})).toEqual(item); + }); + it('reject invalid tags', async () => { + const item: ShowcaseItem = { + title: 'title', + description: 'description', + preview: 'preview', + source: 'source', + // @ts-expect-error: invalid tag + tags: ['invalid'], + website: 'website', + }; + const showcaseItemSchema = await prepareSchema(); + expect(() => + validateShowcaseItem({item, showcaseItemSchema}), + ).toThrowErrorMatchingInlineSnapshot( + `""tags[0]" must be one of [favorite, opensource]"`, + ); + }); + it('reject invalid item', async () => { + const item: ShowcaseItem = fromPartial({}); + const showcaseItemSchema = await prepareSchema(); + expect(() => + validateShowcaseItem({item, showcaseItemSchema}), + ).toThrowErrorMatchingInlineSnapshot( + `""title" is required. "description" is required. "preview" is required. "website" is required. "source" is required"`, + ); + }); + it('reject invalid item value', async () => { + // @ts-expect-error: title should be a string + const item: ShowcaseItem = fromPartial({title: 42}); + const showcaseItemSchema = await prepareSchema(); + + expect(() => + validateShowcaseItem({item, showcaseItemSchema}), + ).toThrowErrorMatchingInlineSnapshot( + `""title" must be a string. "description" is required. "preview" is required. "website" is required. "source" is required"`, + ); + }); +}); diff --git a/packages/docusaurus-plugin-content-showcase/src/client/index.ts b/packages/docusaurus-plugin-content-showcase/src/client/index.ts new file mode 100644 index 000000000000..0fd4866208e3 --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/src/client/index.ts @@ -0,0 +1,158 @@ +/** + * 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 {useCallback, useMemo} from 'react'; +import {translate} from '@docusaurus/Translate'; +import { + usePluralForm, + useQueryString, + useQueryStringList, + type ListUpdateFunction, +} from '@docusaurus/theme-common'; +import useRouteContext from '@docusaurus/useRouteContext'; +import type { + TagType, + Operator, + ShowcaseItem, + TagsOption, + ShowcaseContextType, +} from '@docusaurus/plugin-content-showcase'; + +export function filterItems({ + items, + tags, + operator, + searchName, +}: { + items: ShowcaseItem[]; + tags: TagType[]; + operator: Operator; + searchName: string | undefined | null; +}): ShowcaseItem[] { + if (searchName) { + // eslint-disable-next-line no-param-reassign + items = items.filter((user) => + user.title.toLowerCase().includes(searchName.toLowerCase()), + ); + } + if (tags.length === 0) { + return items; + } + return items.filter((user) => { + if (user.tags.length === 0) { + return false; + } + if (operator === 'AND') { + return tags.every((tag) => user.tags.includes(tag)); + } + return tags.some((tag) => user.tags.includes(tag)); + }); +} + +export function useSearchName(): [ + string, + (newValue: string | null, options?: {push: boolean}) => void, +] { + return useQueryString('name'); +} + +export function useTags(): [string[], ListUpdateFunction] { + return useQueryStringList('tags'); +} + +export function useOperator(): [Operator, () => void] { + const [searchOperator, setSearchOperator] = useQueryString('operator'); + const operator: Operator = searchOperator === 'AND' ? 'AND' : 'OR'; + const toggleOperator = useCallback(() => { + const newOperator = operator === 'OR' ? 'AND' : null; + setSearchOperator(newOperator); + }, [operator, setSearchOperator]); + return [operator, toggleOperator]; +} + +export function useFilteredItems(items: ShowcaseItem[]): ShowcaseItem[] { + const [tags] = useTags(); + const [searchName] = useSearchName() ?? ['']; + const [operator] = useOperator(); + return useMemo( + () => + filterItems({ + items, + tags: tags as TagType[], + operator, + searchName, + }), + [items, tags, operator, searchName], + ); +} + +export function useSiteCountPlural(): (sitesCount: number) => string { + const {selectMessage} = usePluralForm(); + return (sitesCount: number) => + selectMessage( + sitesCount, + translate( + { + id: 'showcase.filters.resultCount', + description: + 'Pluralized label for the number of sites found on the showcase. Use as much plural forms (separated by "|") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)', + message: '1 site|{sitesCount} sites', + }, + {sitesCount}, + ), + ); +} + +export function sortBy( + array: T[], + getter: (item: T) => string | number | boolean, +): T[] { + const sortedArray = [...array]; + sortedArray.sort((a, b) => + // eslint-disable-next-line no-nested-ternary + getter(a) > getter(b) ? 1 : getter(b) > getter(a) ? -1 : 0, + ); + return sortedArray; +} + +export type Tag = { + label: string; + description: string; + color: string; +}; + +export function sortItems(params: ShowcaseItem[]): ShowcaseItem[] { + let result = params; + // Sort by site name + result = sortBy(result, (user) => user.title.toLowerCase()); + // Sort by favorite tag, favorites first + result = sortBy(result, (user) => !user.tags.includes('favorite')); + return result; +} + +function useShowcase() { + const routeContext = useRouteContext(); + const showcase = routeContext?.data?.showcase; + if (!showcase) { + throw new Error( + 'showcase-related hooks can only be called on the showcase page', + ); + } + return showcase as ShowcaseContextType; +} + +export function useShowcaseItems(): ShowcaseItem[] { + return useShowcase().items; +} + +export function useShowcaseApiScreenshot(): string { + return useShowcase().screenshotApi; +} + +export function useShowcaseTags(): TagsOption { + return useShowcase().tags; +} diff --git a/packages/docusaurus-plugin-content-showcase/src/index.ts b/packages/docusaurus-plugin-content-showcase/src/index.ts new file mode 100644 index 000000000000..09f1720900d7 --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/src/index.ts @@ -0,0 +1,101 @@ +/** + * 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 'path'; +import {getPluginI18nPath} from '@docusaurus/utils'; +import {createShowcaseItemSchema} from './validation'; +import {getTagsList} from './tags'; +import {processContentLoaded} from './lifecycle/contentLoaded'; +import {processLoadContent} from './lifecycle/loadContent'; +import type {LoadContext, Plugin} from '@docusaurus/types'; +import type { + PluginOptions, + ShowcaseItems, +} from '@docusaurus/plugin-content-showcase'; +import type {ShowcaseContentPaths} from './types'; + +export function getContentPathList( + contentPaths: ShowcaseContentPaths, +): string[] { + return [contentPaths.contentPathLocalized, contentPaths.contentPath]; +} + +export default async function pluginContentShowcase( + context: LoadContext, + options: PluginOptions, +): Promise> { + const {siteDir, localizationDir} = context; + // todo check for better naming of path: sitePath + const { + include, + exclude, + tags, + routeBasePath, + path: sitePath, + id, + screenshotApi, + } = options; + + const contentPaths: ShowcaseContentPaths = { + contentPath: path.resolve(siteDir, sitePath), + contentPathLocalized: getPluginI18nPath({ + localizationDir, + pluginName: 'docusaurus-plugin-content-showcase', + pluginId: id, + }), + }; + + const {tags: validatedTags, tagKeys} = await getTagsList({ + configTags: tags, + configPath: contentPaths.contentPath, + }); + + const showcaseItemSchema = createShowcaseItemSchema(tagKeys); + + return { + name: 'docusaurus-plugin-content-showcase', + + getPathsToWatch() { + return getContentPathList(contentPaths).flatMap((contentPath) => + include.map((pattern) => `${contentPath}/${pattern}`), + ); + }, + + async loadContent(): Promise { + if (!(await fs.pathExists(contentPaths.contentPath))) { + throw new Error( + `The showcase content path does not exist: ${contentPaths.contentPath}`, + ); + } + + return processLoadContent({ + include, + exclude, + contentPaths, + showcaseItemSchema, + }); + }, + + async contentLoaded({content, actions: {addRoute, createData}}) { + if (!content) { + return; + } + + await processContentLoaded({ + content, + tags: validatedTags, + screenshotApi, + routeBasePath, + addRoute, + createData, + }); + }, + }; +} + +export {validateOptions} from './options'; diff --git a/packages/docusaurus-plugin-content-showcase/src/lifecycle/contentLoaded.ts b/packages/docusaurus-plugin-content-showcase/src/lifecycle/contentLoaded.ts new file mode 100644 index 000000000000..27a508909937 --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/src/lifecycle/contentLoaded.ts @@ -0,0 +1,41 @@ +/** + * 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 type { + ShowcaseItems, + TagsOption, +} from '@docusaurus/plugin-content-showcase'; +import type {PluginContentLoadedActions} from '@docusaurus/types'; + +export async function processContentLoaded({ + content, + tags, + routeBasePath, + screenshotApi, + addRoute, + createData, +}: { + content: ShowcaseItems; + routeBasePath: string; + tags: TagsOption; + screenshotApi: string; + addRoute: PluginContentLoadedActions['addRoute']; + createData: PluginContentLoadedActions['createData']; +}): Promise { + addRoute({ + path: routeBasePath, + component: '@theme/Showcase', + context: { + showcase: await createData('showcase.json', { + items: content.items, + tags, + screenshotApi, + }), + }, + exact: true, + }); +} diff --git a/packages/docusaurus-plugin-content-showcase/src/lifecycle/loadContent.ts b/packages/docusaurus-plugin-content-showcase/src/lifecycle/loadContent.ts new file mode 100644 index 000000000000..aaeade6ea926 --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/src/lifecycle/loadContent.ts @@ -0,0 +1,72 @@ +/** + * 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 'path'; +import Yaml from 'js-yaml'; +import { + Globby, + getContentPathList, + getFolderContainingFile, +} from '@docusaurus/utils'; +import {validateShowcaseItem} from '../validation'; +import type {Joi} from '@docusaurus/utils-validation'; +import type {ShowcaseContentPaths} from '../types'; +import type { + PluginOptions, + ShowcaseItems, +} from '@docusaurus/plugin-content-showcase'; + +export async function processLoadContent({ + include, + exclude, + contentPaths, + showcaseItemSchema, +}: { + include: PluginOptions['include']; + exclude: PluginOptions['exclude']; + contentPaths: ShowcaseContentPaths; + showcaseItemSchema: Joi.ObjectSchema; +}): Promise { + const showcaseFiles = await Globby(include, { + cwd: contentPaths.contentPath, + ignore: [...exclude], + }); + + async function processShowcaseSourceFile(relativeSource: string) { + // Lookup in localized folder in priority + const contentPath = await getFolderContainingFile( + getContentPathList(contentPaths), + relativeSource, + ); + + const sourcePath = path.join(contentPath, relativeSource); + const data = await fs.readFile(sourcePath, 'utf-8'); + const item = Yaml.load(data); + const showcaseItem = validateShowcaseItem({ + item, + showcaseItemSchema, + }); + + return showcaseItem; + } + + async function doProcessShowcaseSourceFile(relativeSource: string) { + try { + return await processShowcaseSourceFile(relativeSource); + } catch (err) { + throw new Error( + `Processing of page source file path=${relativeSource} failed.`, + {cause: err}, + ); + } + } + + return { + items: await Promise.all(showcaseFiles.map(doProcessShowcaseSourceFile)), + }; +} diff --git a/packages/docusaurus-plugin-content-showcase/src/options.ts b/packages/docusaurus-plugin-content-showcase/src/options.ts new file mode 100644 index 000000000000..a9181cef823a --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/src/options.ts @@ -0,0 +1,41 @@ +/** + * 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 {Joi, RouteBasePathSchema} from '@docusaurus/utils-validation'; +import {GlobExcludeDefault} from '@docusaurus/utils'; +import {tagSchema} from './tags'; +import type {OptionValidationContext} from '@docusaurus/types'; +import type {PluginOptions, Options} from '@docusaurus/plugin-content-showcase'; + +export const DEFAULT_OPTIONS: PluginOptions = { + path: 'showcase', // Path to data on filesystem, relative to site dir. + routeBasePath: '/showcase', // URL Route. + include: ['**/*.{yml,yaml}'], + // TODO exclude won't work if user pass a custom file name + exclude: [...GlobExcludeDefault, 'tags.*'], + screenshotApi: 'https://slorber-api-screenshot.netlify.app', + tags: 'tags.yml', +}; + +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), + tags: Joi.alternatives() + .try(Joi.string().default(DEFAULT_OPTIONS.tags), tagSchema) + .default(DEFAULT_OPTIONS.tags), + screenshotApi: Joi.string().default(DEFAULT_OPTIONS.screenshotApi), +}); + +export function validateOptions({ + validate, + options, +}: OptionValidationContext): PluginOptions { + const validatedOptions = validate(PluginOptionSchema, options); + return validatedOptions; +} 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 new file mode 100644 index 000000000000..3cc4779094a6 --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/src/plugin-content-showcase.d.ts @@ -0,0 +1,73 @@ +/** + * 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. + */ + +declare module '@docusaurus/plugin-content-showcase' { + import type {LoadContext, Plugin} from '@docusaurus/types'; + + export type ShowcaseContextType = { + items: ShowcaseItem[]; + tags: TagsOption; + screenshotApi: string; + }; + + type Tag = { + label: string; + description: { + message: string; + id: string; + }; + color: string; + }; + + export type Operator = 'AND' | 'OR'; + + export type TagType = + | 'favorite' + | 'opensource' + | 'product' + | 'design' + | 'i18n' + | 'versioning' + | 'large' + | 'meta' + | 'personal' + | 'rtl'; + + export type TagsOption = { + [type in TagType]: Tag; + }; + + export type PluginOptions = { + id?: string; + path: string; + routeBasePath: string; + include: string[]; + exclude: string[]; + tags: string | TagsOption; + screenshotApi: string; + }; + + export type ShowcaseItem = { + readonly title: string; + readonly description: string; + readonly preview: string | null; // null = use our serverless screenshot service + readonly website: string; + readonly source: string | null; + readonly tags: TagType[]; + }; + + export type ShowcaseItems = { + items: ShowcaseItem[]; + }; + + export type Options = Partial; + + export default function pluginContentShowcase( + context: LoadContext, + options: PluginOptions, + ): Promise>; +} diff --git a/packages/docusaurus-plugin-content-showcase/src/tags.ts b/packages/docusaurus-plugin-content-showcase/src/tags.ts new file mode 100644 index 000000000000..babd01b6a3d8 --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/src/tags.ts @@ -0,0 +1,81 @@ +/** + * 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 'path'; +import Yaml from 'js-yaml'; +import {Joi} from '@docusaurus/utils-validation'; +import type { + PluginOptions, + TagsOption, +} from '@docusaurus/plugin-content-showcase'; + +export const tagSchema = Joi.object().pattern( + Joi.string(), + Joi.object({ + label: Joi.string().required(), + description: Joi.object({ + message: Joi.string().required(), + id: Joi.string().required(), + }).required(), + color: Joi.string() + .pattern(/^#[\dA-Fa-f]{6}$/) + .required() + .messages({ + 'string.pattern.base': + 'Color must be a hexadecimal color string (e.g., #14cfc3 #E9669E)', + }), + }), +); + +export async function getTagsList({ + configTags, + configPath, +}: { + configTags: PluginOptions['tags']; + configPath: PluginOptions['path']; +}): Promise<{tagKeys: string[]; tags: TagsOption}> { + if (typeof configTags === 'object') { + const tags = tagSchema.validate(configTags); + if (tags.error) { + throw new Error( + `There was an error extracting tags: ${tags.error.message}`, + {cause: tags}, + ); + } + return { + tagKeys: Object.keys(tags.value), + tags: tags.value, + }; + } + + const tagsPath = path.resolve(configPath, configTags); + + try { + const data = await fs.readFile(tagsPath, 'utf-8'); + const unsafeData = Yaml.load(data); + const tags = tagSchema.validate(unsafeData); + + if (tags.error) { + throw new Error( + `There was an error extracting tags: ${tags.error.message}`, + {cause: tags}, + ); + } + + return { + tagKeys: Object.keys(tags.value), + tags: tags.value, + }; + } catch (error) { + throw new Error(`Failed to read tags file for showcase`, {cause: error}); + } +} + +export function createTagSchema(tags: string[]): Joi.Schema { + return Joi.array().items(Joi.string().valid(...tags)); +} diff --git a/packages/docusaurus-plugin-content-showcase/src/types.ts b/packages/docusaurus-plugin-content-showcase/src/types.ts new file mode 100644 index 000000000000..6ec0b452815f --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/src/types.ts @@ -0,0 +1,10 @@ +/** + * 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 type ShowcaseContentPaths = { + contentPath: string; + contentPathLocalized: string; +}; diff --git a/packages/docusaurus-plugin-content-showcase/src/validation.ts b/packages/docusaurus-plugin-content-showcase/src/validation.ts new file mode 100644 index 000000000000..b25cf946835c --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/src/validation.ts @@ -0,0 +1,33 @@ +/** + * 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 {Joi, validateFrontMatter} from '@docusaurus/utils-validation'; +import {createTagSchema} from './tags'; +import type {ShowcaseItem} from '@docusaurus/plugin-content-showcase'; + +export const createShowcaseItemSchema = (tags: string[]): Joi.ObjectSchema => { + const tagsSchema = createTagSchema(tags); + + return Joi.object({ + title: Joi.string().required(), + description: Joi.string().required(), + preview: Joi.string().required().allow(null), + website: Joi.string().required(), + source: Joi.string().required().allow(null), + tags: tagsSchema, + }); +}; + +export function validateShowcaseItem({ + item, + showcaseItemSchema, +}: { + item: unknown; + showcaseItemSchema: Joi.ObjectSchema; +}): ShowcaseItem { + return validateFrontMatter(item, showcaseItemSchema); +} diff --git a/packages/docusaurus-plugin-content-showcase/tsconfig.client.json b/packages/docusaurus-plugin-content-showcase/tsconfig.client.json new file mode 100644 index 000000000000..5e2b6e245224 --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/tsconfig.client.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + "tsBuildInfoFile": "lib/.tsbuildinfo-client" + }, + "include": ["src/client", "src/*.d.ts"], + "exclude": ["**/__tests__/**"] +} diff --git a/packages/docusaurus-plugin-content-showcase/tsconfig.json b/packages/docusaurus-plugin-content-showcase/tsconfig.json new file mode 100644 index 000000000000..fd1428b0b32c --- /dev/null +++ b/packages/docusaurus-plugin-content-showcase/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "references": [{"path": "./tsconfig.client.json"}], + "compilerOptions": { + "noEmit": false, + "incremental": true, + "tsBuildInfoFile": "lib/.tsbuildinfo", + "rootDir": "src", + "outDir": "lib" + }, + "include": ["src"], + "exclude": ["src/client", "**/__tests__/**"] +} diff --git a/packages/docusaurus-theme-classic/package.json b/packages/docusaurus-theme-classic/package.json index 25bdfd72d03c..0ab6433b9bef 100644 --- a/packages/docusaurus-theme-classic/package.json +++ b/packages/docusaurus-theme-classic/package.json @@ -26,6 +26,7 @@ "@docusaurus/plugin-content-blog": "3.3.2", "@docusaurus/plugin-content-docs": "3.3.2", "@docusaurus/plugin-content-pages": "3.3.2", + "@docusaurus/plugin-content-showcase": "3.3.2", "@docusaurus/theme-common": "3.3.2", "@docusaurus/theme-translations": "3.3.2", "@docusaurus/types": "3.3.2", diff --git a/packages/docusaurus-theme-classic/src/theme-classic.d.ts b/packages/docusaurus-theme-classic/src/theme-classic.d.ts index 39e7195893b4..c8dc4605f1a4 100644 --- a/packages/docusaurus-theme-classic/src/theme-classic.d.ts +++ b/packages/docusaurus-theme-classic/src/theme-classic.d.ts @@ -247,6 +247,76 @@ declare module '@theme/BlogPostItems' { export default function BlogPostItem(props: Props): JSX.Element; } +declare module '@theme/Showcase' { + import type { + ShowcaseItem, + TagsOption, + } from '@docusaurus/plugin-content-showcase'; + + export type Props = { + items: ShowcaseItem[]; + tags: TagsOption; + screenshotApi: string; + }; + + export default function Showcase(props: Props): JSX.Element; +} + +declare module '@theme/Showcase/FavoriteIcon' { + export interface Props { + className?: string; + style?: React.ComponentProps<'svg'>['style']; + size: 'small' | 'medium' | 'large'; + } + + export default function FavoriteIcon(props: Props): JSX.Element; +} + +declare module '@theme/Showcase/ShowcaseCard' { + import type {ShowcaseItem} from '@docusaurus/plugin-content-showcase'; + + export interface Props { + readonly item: ShowcaseItem; + } + + export default function ShowcaseCard(props: Props): JSX.Element; +} + +declare module '@theme/Showcase/ShowcaseCards' { + export default function ShowcaseCards(): JSX.Element; +} + +declare module '@theme/Showcase/ShowcaseTagSelect' { + import {type ComponentProps, type ReactElement} from 'react'; + import type {TagType} from '@docusaurus/plugin-content-showcase'; + + interface Props extends ComponentProps<'input'> { + tag: TagType; + label: string; + description: string; + icon: ReactElement>; + // TODO: update this type + rest?: any; + } + + export default function ShowcaseTagSelect(props: Props): JSX.Element; +} + +declare module '@theme/Showcase/ShowcaseFilters' { + export default function ShowcaseFilters(): JSX.Element; +} + +declare module '@theme/Showcase/OperatorButton' { + export default function OperatorButton(): JSX.Element; +} +declare module '@theme/Showcase/ClearAllButton' { + export default function ClearAllButton(): JSX.Element; +} + +declare module '@theme/Showcase/ShowcaseSearchBar' { + export default function ShowcaseSearchBar(): JSX.Element; +} + declare module '@theme/BlogPostItem/Container' { import type {ReactNode} from 'react'; diff --git a/packages/docusaurus-theme-classic/src/theme/Showcase/ClearAllButton/index.tsx b/packages/docusaurus-theme-classic/src/theme/Showcase/ClearAllButton/index.tsx new file mode 100644 index 000000000000..48208c02241f --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Showcase/ClearAllButton/index.tsx @@ -0,0 +1,26 @@ +/** + * 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 React, {type ReactNode} from 'react'; +import {useClearQueryString} from '@docusaurus/theme-common'; +import Translate from '@docusaurus/Translate'; + +export default function ClearAllButton(): ReactNode { + const clearQueryString = useClearQueryString(); + return ( + + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Showcase/FavoriteIcon/index.tsx b/packages/docusaurus-theme-classic/src/theme/Showcase/FavoriteIcon/index.tsx new file mode 100644 index 000000000000..9b880ca3eea6 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Showcase/FavoriteIcon/index.tsx @@ -0,0 +1,26 @@ +/** + * 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 React from 'react'; +import clsx from 'clsx'; +import type {Props} from '@theme/Showcase/FavoriteIcon'; +import styles from './styles.module.css'; + +export default function FavoriteIcon({ + size, + className, + style, +}: Props): React.ReactNode { + return ( + + + + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Showcase/FavoriteIcon/styles.module.css b/packages/docusaurus-theme-classic/src/theme/Showcase/FavoriteIcon/styles.module.css new file mode 100644 index 000000000000..4eda3471db71 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Showcase/FavoriteIcon/styles.module.css @@ -0,0 +1,27 @@ +/** + * 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. + */ + +.svg { + user-select: none; + color: #e9669e; + width: 1em; + height: 1em; + display: inline-block; + fill: currentColor; +} + +.small { + font-size: 1rem; +} + +.medium { + font-size: 1.25rem; +} + +.large { + font-size: 1.8rem; +} diff --git a/packages/docusaurus-theme-classic/src/theme/Showcase/OperatorButton/index.tsx b/packages/docusaurus-theme-classic/src/theme/Showcase/OperatorButton/index.tsx new file mode 100644 index 000000000000..d58b194a58a3 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Showcase/OperatorButton/index.tsx @@ -0,0 +1,51 @@ +/** + * 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 React, {useId} from 'react'; +import clsx from 'clsx'; +import {useOperator} from '@docusaurus/plugin-content-showcase/client'; +import Translate from '@docusaurus/Translate'; +import styles from './styles.module.css'; + +export default function OperatorButton(): JSX.Element { + const id = useId(); + const [operator, toggleOperator] = useOperator(); + return ( + <> + { + if (e.key === 'Enter') { + toggleOperator(); + } + }} + /> + {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + + + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Showcase/OperatorButton/styles.module.css b/packages/docusaurus-theme-classic/src/theme/Showcase/OperatorButton/styles.module.css new file mode 100644 index 000000000000..4fde44d184fc --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Showcase/OperatorButton/styles.module.css @@ -0,0 +1,57 @@ +/** + * 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. + */ + +.checkboxLabel { + --height: 25px; + --width: 80px; + --border: 2px; + display: flex; + width: var(--width); + height: var(--height); + position: relative; + border-radius: var(--height); + border: var(--border) solid var(--ifm-color-primary-darkest); + cursor: pointer; + justify-content: space-around; + opacity: 0.75; + transition: opacity var(--ifm-transition-fast) + var(--ifm-transition-timing-default); + box-shadow: var(--ifm-global-shadow-md); +} + +.checkboxLabel:hover { + opacity: 1; + box-shadow: var(--ifm-global-shadow-md), + 0 0 2px 1px var(--ifm-color-primary-dark); +} + +.checkboxLabel::after { + position: absolute; + content: ''; + inset: 0; + width: calc(var(--width) / 2); + height: 100%; + border-radius: var(--height); + background-color: var(--ifm-color-primary-darkest); + transition: transform var(--ifm-transition-fast) + var(--ifm-transition-timing-default); + transform: translateX(calc(var(--width) / 2 - var(--border))); +} + +input:focus-visible ~ .checkboxLabel::after { + outline: 2px solid currentColor; +} + +.checkboxLabel > * { + font-size: 0.8rem; + color: inherit; + transition: opacity 150ms ease-in 50ms; +} + +input:checked ~ .checkboxLabel::after { + transform: translateX(calc(-1 * var(--border))); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseCard/index.tsx b/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseCard/index.tsx new file mode 100644 index 000000000000..7355682d8bb2 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseCard/index.tsx @@ -0,0 +1,105 @@ +/** + * 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 React from 'react'; +import clsx from 'clsx'; +import Link from '@docusaurus/Link'; +import Translate from '@docusaurus/Translate'; +import { + sortBy, + useShowcaseTags, + useShowcaseApiScreenshot, +} from '@docusaurus/plugin-content-showcase/client'; +import Heading from '@theme/Heading'; +import FavoriteIcon from '@theme/Showcase/FavoriteIcon'; +import type {ShowcaseItem, TagType} from '@docusaurus/plugin-content-showcase'; +import styles from './styles.module.css'; + +function TagItem({ + label, + description, + color, +}: { + label: string; + description: { + message: string; + id: string; + }; + color: string; +}) { + return ( +
  • + {label.toLowerCase()} + +
  • + ); +} + +// TODO move tag reorder logic into hook +function ShowcaseCardTag({tags}: {tags: TagType[]}) { + const Tags = useShowcaseTags(); + const TagList = Object.keys(Tags) as TagType[]; + + const tagObjects = tags.map((tag) => ({tag, ...Tags[tag]})); + + // Keep same order for all tags + const tagObjectsSorted = sortBy(tagObjects, (tagObject) => + TagList.indexOf(tagObject.tag), + ); + + return ( + <> + {tagObjectsSorted.map((tagObject, index) => { + return ; + })} + + ); +} + +function getCardImage(item: ShowcaseItem, api: string): string { + return item.preview ?? `${api}/${encodeURIComponent(item.website)}/showcase`; +} + +function ShowcaseCard({item}: {item: ShowcaseItem}) { + const api = useShowcaseApiScreenshot(); + const image = getCardImage(item, api); + return ( +
  • +
    + {item.title} +
    +
    +
    + + + {item.title} + + + {item.tags.includes('favorite') && ( + + )} + {item.source && ( + + source + + )} +
    +

    {item.description}

    +
    +
      + +
    +
  • + ); +} + +export default React.memo(ShowcaseCard); diff --git a/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseCard/styles.module.css b/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseCard/styles.module.css new file mode 100644 index 000000000000..c8bda2ee5f0a --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseCard/styles.module.css @@ -0,0 +1,95 @@ +/** + * 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. + */ + +.showcaseCardImage { + overflow: hidden; + height: 150px; + border-bottom: 2px solid var(--ifm-color-emphasis-200); +} + +.showcaseCardHeader { + display: flex; + align-items: center; + margin-bottom: 12px; +} + +.showcaseCardTitle { + margin-bottom: 0; + flex: 1 1 auto; +} + +.showcaseCardTitle a { + text-decoration: none; + background: linear-gradient( + var(--ifm-color-primary), + var(--ifm-color-primary) + ) + 0% 100% / 0% 1px no-repeat; + transition: background-size ease-out 200ms; +} + +.showcaseCardTitle a:not(:focus):hover { + background-size: 100% 1px; +} + +.showcaseCardTitle, +.showcaseCardHeader { + margin-right: 0.25rem; +} + +.showcaseCardSrcBtn { + margin-left: 6px; + padding-left: 12px; + padding-right: 12px; + border: none; +} + +.showcaseCardSrcBtn:focus-visible { + background-color: var(--ifm-color-secondary-dark); +} + +[data-theme='dark'] .showcaseCardSrcBtn { + background-color: var(--ifm-color-emphasis-200) !important; + color: inherit; +} + +[data-theme='dark'] .showcaseCardSrcBtn:hover { + background-color: var(--ifm-color-emphasis-300) !important; +} + +.showcaseCardBody { + font-size: smaller; + line-height: 1.66; +} + +.cardFooter { + display: flex; + flex-wrap: wrap; +} + +.tag { + font-size: 0.675rem; + border: 1px solid var(--ifm-color-secondary-darkest); + cursor: default; + margin-right: 6px; + margin-bottom: 6px !important; + border-radius: 12px; + display: inline-flex; + align-items: center; +} + +.tag .textLabel { + margin-left: 8px; +} + +.tag .colorLabel { + width: 7px; + height: 7px; + border-radius: 50%; + margin-left: 6px; + margin-right: 6px; +} diff --git a/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseCards/index.tsx b/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseCards/index.tsx new file mode 100644 index 000000000000..dcd785598a28 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseCards/index.tsx @@ -0,0 +1,111 @@ +/** + * 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 type {ReactNode} from 'react'; +import clsx from 'clsx'; +import Translate from '@docusaurus/Translate'; +import { + useFilteredItems, + sortItems, + useShowcaseItems, +} from '@docusaurus/plugin-content-showcase/client'; +import Heading from '@theme/Heading'; +import FavoriteIcon from '@theme/Showcase/FavoriteIcon'; +import ShowcaseCard from '@theme/Showcase/ShowcaseCard'; +import type {ShowcaseItem} from '@docusaurus/plugin-content-showcase'; +import styles from './styles.module.css'; + +function HeadingNoResult() { + return ( + + No result + + ); +} + +function HeadingFavorites() { + return ( + + Our favorites + + + ); +} + +function HeadingAllSites() { + return ( + + All sites + + ); +} + +function CardList({ + heading, + items, +}: { + heading?: ReactNode; + items: ShowcaseItem[]; +}) { + return ( +
    + {heading} +
      + {items.map((item) => ( + + ))} +
    +
    + ); +} + +function NoResultSection() { + return ( +
    +
    + +
    +
    + ); +} + +export default function ShowcaseCards(): JSX.Element { + const items = useShowcaseItems(); + + const filteredItems = useFilteredItems(items); + + if (filteredItems.length === 0) { + return ; + } + + const sortedItems = sortItems(items); + + const favoriteItems = sortedItems.filter((item: ShowcaseItem) => + item.tags.includes('favorite'), + ); + + const otherItems = sortedItems.filter( + (item: ShowcaseItem) => !item.tags.includes('favorite'), + ); + + return ( +
    + {filteredItems.length === sortedItems.length ? ( + <> +
    + } items={favoriteItems} /> +
    +
    + } items={otherItems} /> +
    + + ) : ( + + )} +
    + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseCards/styles.module.css b/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseCards/styles.module.css new file mode 100644 index 000000000000..b1512e62d7ca --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseCards/styles.module.css @@ -0,0 +1,27 @@ +/** + * 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. + */ + +.cardList { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 24px; +} + +.showcaseFavorite { + padding-top: 2rem; + padding-bottom: 2rem; + background-color: #f6fdfd; +} + +html[data-theme='dark'] .showcaseFavorite { + background-color: #232525; +} + +.headingFavorites { + display: flex; + align-items: center; +} diff --git a/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseFilters/index.tsx b/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseFilters/index.tsx new file mode 100644 index 000000000000..f0dc3c9e4dfd --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseFilters/index.tsx @@ -0,0 +1,117 @@ +/** + * 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 type {ReactNode, CSSProperties} from 'react'; +import clsx from 'clsx'; +import Translate from '@docusaurus/Translate'; +import { + useFilteredItems, + useSiteCountPlural, + useShowcaseItems, + useShowcaseTags, +} from '@docusaurus/plugin-content-showcase/client'; +import FavoriteIcon from '@theme/Showcase/FavoriteIcon'; +import Heading from '@theme/Heading'; +import ShowcaseTagSelect from '@theme/Showcase/ShowcaseTagSelect'; +import OperatorButton from '@theme/Showcase/OperatorButton'; +import ClearAllButton from '@theme/Showcase/ClearAllButton'; +import type {TagType} from '@docusaurus/plugin-content-showcase'; +import styles from './styles.module.css'; + +function TagCircleIcon({color, style}: {color: string; style?: CSSProperties}) { + return ( + + ); +} + +function ShowcaseTagListItem({tag}: {tag: TagType}) { + const tags = useShowcaseTags(); + const {label, description, color} = tags[tag]; + return ( +
  • + + ) : ( + + ) + } + /> +
  • + ); +} + +function ShowcaseTagList() { + const tags = useShowcaseTags(); + const TagList = Object.keys(tags) as TagType[]; + return ( +
      + {TagList.map((tag) => { + return ; + })} +
    + ); +} + +function HeadingText() { + const items = useShowcaseItems(); + const filteredItems = useFilteredItems(items); + const siteCountPlural = useSiteCountPlural(); + return ( +
    + + Filters + + {siteCountPlural(filteredItems.length)} +
    + ); +} + +function HeadingButtons() { + return ( +
    + + +
    + ); +} + +function HeadingRow() { + return ( +
    + + +
    + ); +} + +export default function ShowcaseFilters(): ReactNode { + return ( +
    + + +
    + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseFilters/styles.module.css b/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseFilters/styles.module.css new file mode 100644 index 000000000000..627b3322a12c --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseFilters/styles.module.css @@ -0,0 +1,53 @@ +/** + * 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. + */ + +.headingRow { + display: flex; + align-items: center; + justify-content: space-between; +} + +.headingText { + display: flex; + align-items: baseline; +} + +.headingText > h2 { + margin-bottom: 0; +} + +.headingText > span { + margin-left: 8px; +} + +.headingButtons { + display: flex; + align-items: center; +} + +.headingButtons > * { + margin-left: 8px; +} + +.tagList { + display: flex; + align-items: center; + flex-wrap: wrap; +} + +.tagListItem { + user-select: none; + white-space: nowrap; + height: 32px; + font-size: 0.8rem; + margin-top: 0.5rem; + margin-right: 0.5rem; +} + +.tagListItem:last-child { + margin-right: 0; +} diff --git a/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseSearchBar/index.tsx b/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseSearchBar/index.tsx new file mode 100644 index 000000000000..db55fc63ec46 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseSearchBar/index.tsx @@ -0,0 +1,29 @@ +/** + * 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 {type ReactNode} from 'react'; +import {translate} from '@docusaurus/Translate'; +import {useSearchName} from '@docusaurus/plugin-content-showcase/client'; +import styles from './styles.module.css'; + +export default function ShowcaseSearchBar(): ReactNode { + const [searchName, setSearchName] = useSearchName(); + return ( +
    + { + setSearchName(e.currentTarget.value); + }} + /> +
    + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseSearchBar/styles.module.css b/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseSearchBar/styles.module.css new file mode 100644 index 000000000000..22e60d479b14 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseSearchBar/styles.module.css @@ -0,0 +1,17 @@ +/** + * 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. + */ + +.searchBar { + margin-left: auto; +} + +.searchBar input { + height: 30px; + border-radius: 15px; + padding: 10px; + border: 1px solid gray; +} diff --git a/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseTagSelect/index.tsx b/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseTagSelect/index.tsx new file mode 100644 index 000000000000..c47c88451b69 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseTagSelect/index.tsx @@ -0,0 +1,57 @@ +/** + * 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 React, {useCallback, type ReactNode, useId} from 'react'; +import {useTags} from '@docusaurus/plugin-content-showcase/client'; +import type {Props} from '@theme/Showcase/ShowcaseTagSelect'; +import styles from './styles.module.css'; + +function useTagState(tag: string) { + const [tags, setTags] = useTags(); + const isSelected = tags.includes(tag); + const toggle = useCallback(() => { + setTags((list) => { + return list.includes(tag) + ? list.filter((t) => t !== tag) + : [...list, tag]; + }); + }, [tag, setTags]); + + return [isSelected, toggle] as const; +} + +export default function ShowcaseTagSelect({ + icon, + label, + description, + tag, + ...rest +}: Props): ReactNode { + const id = useId(); + const [isSelected, toggle] = useTagState(tag); + return ( + <> + { + if (e.key === 'Enter') { + toggle(); + } + }} + {...rest} + /> + + + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseTagSelect/styles.module.css b/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseTagSelect/styles.module.css new file mode 100644 index 000000000000..ea6cb1939424 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Showcase/ShowcaseTagSelect/styles.module.css @@ -0,0 +1,42 @@ +/** + * 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. + */ + +.checkboxLabel:hover { + opacity: 1; + box-shadow: 0 0 2px 1px var(--ifm-color-secondary-darkest); +} + +input[type='checkbox'] + .checkboxLabel { + display: flex; + align-items: center; + cursor: pointer; + line-height: 1.5; + border-radius: 4px; + padding: 0.275rem 0.8rem; + opacity: 0.85; + transition: opacity 200ms ease-out; + border: 2px solid var(--ifm-color-secondary-darkest); +} + +input:focus-visible + .checkboxLabel { + outline: 2px solid currentColor; +} + +input:checked + .checkboxLabel { + opacity: 0.9; + background-color: hsl(167deg 56% 73% / 25%); + border: 2px solid var(--ifm-color-primary-darkest); +} + +input:checked + .checkboxLabel:hover { + opacity: 0.75; + box-shadow: 0 0 2px 1px var(--ifm-color-primary-dark); +} + +html[data-theme='dark'] input:checked + .checkboxLabel { + background-color: hsl(167deg 56% 73% / 10%); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Showcase/index.tsx b/packages/docusaurus-theme-classic/src/theme/Showcase/index.tsx new file mode 100644 index 000000000000..b7597da88800 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Showcase/index.tsx @@ -0,0 +1,51 @@ +/** + * 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 Translate, {translate} from '@docusaurus/Translate'; +import Link from '@docusaurus/Link'; +import Layout from '@theme/Layout'; +import Heading from '@theme/Heading'; +import ShowcaseSearchBar from '@theme/Showcase/ShowcaseSearchBar'; +import ShowcaseCards from '@theme/Showcase/ShowcaseCards'; +import ShowcaseFilters from '@theme/Showcase/ShowcaseFilters'; + +const TITLE = translate({message: 'Docusaurus Site Showcase'}); +const DESCRIPTION = translate({ + message: 'List of websites people are building with Docusaurus', +}); +const SUBMIT_URL = 'https://github.com/facebook/docusaurus/discussions/7826'; + +function ShowcaseHeader() { + return ( +
    + {TITLE} +

    {DESCRIPTION}

    + + + 🙏 Please add your site + + +
    + ); +} + +export default function Showcase(): JSX.Element { + return ( + +
    + + +
    + +
    + +
    +
    + ); +} diff --git a/packages/docusaurus-theme-common/src/contexts/docsPreferredVersion.tsx b/packages/docusaurus-theme-common/src/contexts/docsPreferredVersion.tsx index 908b4854c926..1b0dde3c30bb 100644 --- a/packages/docusaurus-theme-common/src/contexts/docsPreferredVersion.tsx +++ b/packages/docusaurus-theme-common/src/contexts/docsPreferredVersion.tsx @@ -13,6 +13,8 @@ import React, { useCallback, type ReactNode, } from 'react'; +// TODO Docusaurus v4: remove theme-common > content-plugin/client dependency +// Move every plugin-specific code to content-plugin/client directly import { useAllDocsData, useDocsData, diff --git a/packages/docusaurus-theme-common/src/index.ts b/packages/docusaurus-theme-common/src/index.ts index 6859089b5a38..bc08100ce21d 100644 --- a/packages/docusaurus-theme-common/src/index.ts +++ b/packages/docusaurus-theme-common/src/index.ts @@ -103,6 +103,7 @@ export { useQueryString, useQueryStringList, useClearQueryString, + type ListUpdateFunction, } from './utils/historyUtils'; export { diff --git a/packages/docusaurus-theme-common/src/utils/docsUtils.tsx b/packages/docusaurus-theme-common/src/utils/docsUtils.tsx index 597a5dea3e50..68f147322aa3 100644 --- a/packages/docusaurus-theme-common/src/utils/docsUtils.tsx +++ b/packages/docusaurus-theme-common/src/utils/docsUtils.tsx @@ -8,6 +8,8 @@ import {useMemo} from 'react'; import {matchPath, useLocation} from '@docusaurus/router'; import renderRoutes from '@docusaurus/renderRoutes'; +// TODO Docusaurus v4: remove theme-common > content-plugin/client dependency +// Move every plugin-specific code to content-plugin/client directly import { useAllDocsData, useActivePlugin, diff --git a/packages/docusaurus-theme-common/src/utils/historyUtils.ts b/packages/docusaurus-theme-common/src/utils/historyUtils.ts index 4695896a1ecd..108d79cb0d23 100644 --- a/packages/docusaurus-theme-common/src/utils/historyUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/historyUtils.ts @@ -115,7 +115,7 @@ function useQueryStringListValues(key: string): string[] { } type ListUpdate = string[] | ((oldValues: string[]) => string[]); -type ListUpdateFunction = ( +export type ListUpdateFunction = ( update: ListUpdate, options?: {push: boolean}, ) => void; diff --git a/packages/docusaurus-theme-common/src/utils/searchUtils.ts b/packages/docusaurus-theme-common/src/utils/searchUtils.ts index 082f0fb7085c..6e0781853ccb 100644 --- a/packages/docusaurus-theme-common/src/utils/searchUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/searchUtils.ts @@ -5,6 +5,8 @@ * LICENSE file in the root directory of this source tree. */ +// TODO Docusaurus v4: remove theme-common > content-plugin/client dependency +// Move every plugin-specific code to content-plugin/client directly import { useAllDocsData, useActivePluginAndVersion, diff --git a/packages/docusaurus-theme-common/src/utils/structuredDataUtils.ts b/packages/docusaurus-theme-common/src/utils/structuredDataUtils.ts index 3a8a985e5cd8..019d780963b6 100644 --- a/packages/docusaurus-theme-common/src/utils/structuredDataUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/structuredDataUtils.ts @@ -7,6 +7,9 @@ import {useBaseUrlUtils, type BaseUrlUtils} from '@docusaurus/useBaseUrl'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; + +// TODO Docusaurus v4: remove theme-common > content-plugin/client dependency +// Move every plugin-specific code to content-plugin/client directly import {useBlogMetadata} from '@docusaurus/plugin-content-blog/client'; import type {Props as BlogListPageStructuredDataProps} from '@theme/BlogListPage/StructuredData'; import {useBlogPost} from '../contexts/blogPost'; diff --git a/project-words.txt b/project-words.txt index cc4e06e18cc5..e7e8a3ad62ee 100644 --- a/project-words.txt +++ b/project-words.txt @@ -130,6 +130,7 @@ IANAD idempotency Iframes Immer +inexistant infima Infima Infima's @@ -234,6 +235,8 @@ outerbounds Outerbounds overrideable ozaki +Ozaki +ozakione pageview palenight Palenight diff --git a/website/docs/api/plugins/plugin-content-showcase.mdx b/website/docs/api/plugins/plugin-content-showcase.mdx new file mode 100644 index 000000000000..6d013ac08546 --- /dev/null +++ b/website/docs/api/plugins/plugin-content-showcase.mdx @@ -0,0 +1,182 @@ +--- +# TODO change sidebar +sidebar_position: 2 +slug: /api/plugins/@docusaurus/plugin-content-showcase +--- + +# 📦 plugin-content-showcase + +import APITable from '@site/src/components/APITable'; + +Provides the Showcase feature and is the default blog plugin for Docusaurus. + +## Installation {#installation} + +```bash npm2yarn +npm install --save @docusaurus/plugin-content-showcase +``` + +:::tip + +If you use the preset `@docusaurus/preset-classic`, you don't need to install this plugin as a dependency. + +You can configure this plugin through the [preset options](../../using-plugins.mdx#docusauruspreset-classic). + +::: + +## Configuration {#configuration} + +Accepted fields: + +```mdx-code-block + +``` + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `path` | `string` | `'showcase'` | Path to the blog content directory on the file system, relative to site dir. | +| `routeBasePath` | `string` | `'/showcase'` | URL route for the blog section of your site. **DO NOT** include a trailing slash. Use `/` to put the blog at root path. | +| `include` | `string[]` | `['**/*.{yml,yaml}']` | Array of glob patterns matching Markdown files to be built, relative to the content path. | +| `exclude` | `string[]` | _See example configuration_ | Array of glob patterns matching Markdown files to be excluded. Serves as refinement based on the `include` option. | +| `tags` | `string \| [TagOption](#TagsOption)` | `string` | | + +```mdx-code-block + +``` + +### Types {#types} + +#### `TagsOption` {#TagsOption} + +```ts +type Tag = { + label: string; + description: { + message: string; + id: string; + }; + color: string; +}; + +type TagsOption = { + [key: string]: Tag; +}; +``` + +### Example configuration {#ex-config} + +You can configure this plugin through preset options or plugin options. + +:::tip + +Most Docusaurus users configure this plugin through the preset options. + +::: + +```js config-tabs +// Preset Options: blog +// Plugin Options: @docusaurus/plugin-content-showcase + +const config = { + path: 'showcase', + routeBasePath: 'showcase', + include: ['**/*.{yml,yaml}'], + exclude: [ + '**/_*.{js,jsx,ts,tsx,md,mdx}', + '**/_*/**', + '**/*.test.{js,jsx,ts,tsx}', + '**/__tests__/**', + ], + // tags: 'tags.yml' + // or + tags: { + hello: { + label: 'Hello', + description: { + message: 'Hello', + id: 'Hello', + }, + color: '#FF0000', + }, + docusaurus: { + label: 'Docusaurus', + description: { + message: 'Docusaurus', + id: 'Docusaurus', + }, + color: '#00FF00', + }, + }, +}; +``` + +## Markdown front matter {#markdown-front-matter} + +Markdown documents can use the following Markdown [front matter](../../guides/markdown-features/markdown-features-intro.mdx#front-matter) metadata fields, enclosed by a line `---` on either side. + +Accepted fields: + +```mdx-code-block + + + +``` + +| Name | Type | Default | Description | +| --- | --- | --- | --- | +| `title` | `string` | `undefined` | Title of the showcase item. | +| `description` | `string` | `undefined` | Description on the showcase item. | +| `preview` | `string \| null` | `undefined` | Image preview of the showcase item, either an url or link to a file | +| `website` | `string` | `undefined` | | +| `source` | `string \| null` | `undefined` | Link of the showcase item's source code | +| `tags` | `string[]` | `undefined` | ⚠️ Prefer using `authors`. A description of the author. | + +```mdx-code-block + +``` + +Example: + +```md +--- +title: Dyte +description: The most developer friendly live video SDK +preview: ./showcase/dyte.png +website: https://docs.dyte.io +source: https://github.com/dyte-in/docs +tags: + - favorite + - product + - design + - versioning + - large + - opensource +--- + +A Markdown blog post +``` + +## i18n {#i18n} + +Read the [i18n introduction](../../i18n/i18n-introduction.mdx) first. + +### Translation files location {#translation-files-location} + +- **Base path**: `website/i18n/[locale]/docusaurus-plugin-content-showcase` +- **Multi-instance path**: `website/i18n/[locale]/docusaurus-plugin-content-showcase-[pluginId]` +- **JSON files**: extracted with [`docusaurus write-translations`](../../cli.mdx#docusaurus-write-translations-sitedir) +- **Markdown files**: `website/i18n/[locale]/docusaurus-plugin-content-showcase` + +### Example file-system structure {#example-file-system-structure} + +```bash +website/i18n/[locale]/docusaurus-plugin-content-showcase +│ +│ # translations for website/blog +├── authors.yml +├── first-blog-post.md +├── second-blog-post.md +│ +│ # translations for the plugin options that will be rendered +└── options.json +``` diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index aae27b1a4d8e..54abcbd681a8 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -266,6 +266,12 @@ export default async function createConfigAsync() { ], themes: ['live-codeblock', ...dogfoodingThemeInstances], plugins: [ + [ + 'content-showcase', + { + routeBasePath: '/showcaseAll', + }, + ], [ './src/plugins/changelog/index.js', { diff --git a/website/showcase/ozaki.yaml b/website/showcase/ozaki.yaml new file mode 100644 index 000000000000..ccc7bc4dffb1 --- /dev/null +++ b/website/showcase/ozaki.yaml @@ -0,0 +1,8 @@ +title: Ozaki +description: Ozaki is a young developer +preview: null +website: https://github.com/ozakione/ +source: null +tags: + - favorite + - opensource diff --git a/website/showcase/tags.yml b/website/showcase/tags.yml new file mode 100644 index 000000000000..8c94a0e1d09e --- /dev/null +++ b/website/showcase/tags.yml @@ -0,0 +1,12 @@ +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: 'These sites are open source, so you can learn from them!' + id: 'showcase.tag.opensource.description' + color: '#f6993f'