Compare commits

...

24 Commits

Author SHA1 Message Date
ozakione 46c57d6cd5 wip tags file validation 2024-03-31 23:30:40 +02:00
ozakione cdb7c07bdc consistent file naming 2024-03-29 20:18:03 +01:00
ozakione e147034d2e wip tests 2024-03-29 20:14:59 +01:00
OzakIOne c42c15eb32 refactor: apply lint autofix 2024-03-29 13:55:15 +00:00
ozakione bed9bb3063 wip tests 2024-03-29 14:49:55 +01:00
ozaki 30599c362e
Update packages/docusaurus-plugin-showcase/package.json
Co-authored-by: Sébastien Lorber <slorber@users.noreply.github.com>
2024-03-29 12:07:36 +01:00
OzakIOne 022efa3653 refactor: apply lint autofix 2024-03-27 19:06:39 +00:00
ozakione 8bee535ffa wip images 2024-03-27 20:01:22 +01:00
ozakione 993efd235c wip process markdown 2024-03-27 00:03:46 +01:00
ozakione 984518efaf fix: warning 2024-03-26 19:20:09 +01:00
ozakione 6e40a79b6f wip use plugin page code logic 2024-03-26 19:13:35 +01:00
OzakIOne 49b67463bd refactor: apply lint autofix 2024-03-25 11:34:27 +00:00
ozakione 9790934ec4 wip markdown process: crash 2024-03-25 12:30:00 +01:00
OzakIOne 1c825e02d2 refactor: apply lint autofix 2024-03-24 22:12:39 +00:00
ozakione dddb8cfc78 cspell autolint fix 2024-03-24 23:07:36 +01:00
ozakione c542c81cb8 ShowcaseDetails component 2024-03-24 23:06:28 +01:00
OzakIOne 7daa9a1d04 refactor: apply lint autofix 2024-03-24 21:07:25 +00:00
ozakione 8d1b174917 wip 2024-03-24 22:02:40 +01:00
OzakIOne ec35998bde refactor: apply lint autofix 2024-03-21 17:47:52 +00:00
ozakione 7cf981c262 wip routes per yaml 2024-03-21 18:42:54 +01:00
OzakIOne a2516dc31a refactor: apply lint autofix 2024-03-21 13:08:45 +00:00
ozakione cd3908b36f wip 2024-03-21 14:03:25 +01:00
OzakIOne f6b0d4622c refactor: apply lint autofix 2024-03-20 15:47:26 +00:00
ozakione 2b37b51b36 wip init 2024-03-20 16:39:55 +01:00
38 changed files with 2458 additions and 0 deletions

View File

@ -0,0 +1,3 @@
.tsbuildinfo*
tsconfig*
__tests__

View File

@ -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).

View File

@ -0,0 +1,37 @@
{
"name": "@docusaurus/plugin-content-showcase",
"version": "3.0.0",
"description": "Showcase plugin for Docusaurus.",
"main": "lib/index.js",
"types": "src/plugin-content-showcase.d.ts",
"scripts": {
"build": "tsc",
"watch": "tsc --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.0.0",
"@docusaurus/types": "3.0.0",
"@docusaurus/utils": "3.0.0",
"@docusaurus/utils-validation": "3.0.0",
"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"
}
}

View File

@ -0,0 +1,21 @@
/**
* 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',
markdown: {
parseFrontMatter: async (params) => {
const result = await params.defaultParseFrontMatter(params);
result.frontMatter.custom_frontMatter = 'added by parseFrontMatter';
return result;
},
},
};

View File

@ -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"]

View File

@ -0,0 +1,19 @@
// 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/",
},
],
}
`;

View File

@ -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 {escapeRegexp} from '@docusaurus/utils';
import {validateShowcaseFrontMatter} from '../frontMatter';
import type {ShowcaseFrontMatter} from '@docusaurus/plugin-content-showcase';
function testField(params: {
prefix: string;
validFrontMatters: ShowcaseFrontMatter[];
convertibleFrontMatter?: [
ConvertibleFrontMatter: {[key: string]: unknown},
ConvertedFrontMatter: ShowcaseFrontMatter,
][];
invalidFrontMatters?: [
InvalidFrontMatter: {[key: string]: unknown},
ErrorMessage: string,
][];
}) {
// eslint-disable-next-line jest/require-top-level-describe
test(`[${params.prefix}] accept valid values`, () => {
params.validFrontMatters.forEach((frontMatter) => {
expect(validateShowcaseFrontMatter(frontMatter)).toEqual(frontMatter);
});
});
// eslint-disable-next-line jest/require-top-level-describe
test(`[${params.prefix}] convert valid values`, () => {
params.convertibleFrontMatter?.forEach(
([convertibleFrontMatter, convertedFrontMatter]) => {
expect(validateShowcaseFrontMatter(convertibleFrontMatter)).toEqual(
convertedFrontMatter,
);
},
);
});
// eslint-disable-next-line jest/require-top-level-describe
test(`[${params.prefix}] throw error for values`, () => {
params.invalidFrontMatters?.forEach(([frontMatter, message]) => {
try {
validateShowcaseFrontMatter(frontMatter);
throw new Error(
`Doc front matter is expected to be rejected, but was accepted successfully:\n ${JSON.stringify(
frontMatter,
null,
2,
)}`,
);
} catch (err) {
// eslint-disable-next-line jest/no-conditional-expect
expect((err as Error).message).toMatch(
new RegExp(escapeRegexp(message)),
);
}
});
});
}
describe('doc front matter schema', () => {
it('accepts valid frontmatter', () => {
const frontMatter: ShowcaseFrontMatter = {
title: 'title',
description: 'description',
preview: 'preview',
source: 'source',
tags: [],
website: 'website',
};
expect(validateShowcaseFrontMatter(frontMatter)).toEqual(frontMatter);
});
it('reject invalid frontmatter', () => {
const frontMatter = {};
expect(() =>
validateShowcaseFrontMatter(frontMatter),
).toThrowErrorMatchingInlineSnapshot(
`""title" is required. "description" is required. "preview" is required. "website" is required. "source" is required. "tags" is required"`,
);
});
});
describe('validateShowcaseFrontMatter full', () => {
testField({
prefix: 'valid full frontmatter',
validFrontMatters: [
{
title: 'title',
description: 'description',
preview: 'preview',
source: 'source',
tags: [],
website: 'website',
},
],
});
});

View File

@ -0,0 +1,32 @@
/**
* 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 pluginContentPages from '../index';
import {validateOptions} from '../options';
describe('docusaurus-plugin-content-showcase', () => {
it('loads simple showcase', async () => {
const siteDir = path.join(__dirname, '__fixtures__', 'website');
const context = await loadContext({siteDir});
const plugin = pluginContentPages(
context,
validateOptions({
validate: normalizePluginOptions,
options: {
path: 'src/showcase',
},
}),
);
const showcaseMetadata = await plugin.loadContent!();
expect(showcaseMetadata).toMatchSnapshot();
});
});

View File

@ -0,0 +1,127 @@
/**
* 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 {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',
};
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, 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"`);
});
});

View File

@ -0,0 +1,24 @@
/**
* 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 type {ShowcaseFrontMatter} from '@docusaurus/plugin-content-showcase';
const showcaseFrontMatterSchema = Joi.object({
title: Joi.string().required(),
description: Joi.string().required(),
preview: Joi.string().required(),
website: Joi.string().required(),
source: Joi.string().required(),
tags: Joi.array().items(Joi.string()).required(),
});
export function validateShowcaseFrontMatter(frontMatter: {
[key: string]: unknown;
}): ShowcaseFrontMatter {
return validateFrontMatter(frontMatter, showcaseFrontMatterSchema);
}

View File

@ -0,0 +1,200 @@
/**
* 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 {
getFolderContainingFile,
getPluginI18nPath,
Globby,
} 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,
ShowcaseItem,
TagOption,
} from '@docusaurus/plugin-content-showcase';
import type {ShowcaseContentPaths} from './types';
export function getContentPathList(
contentPaths: ShowcaseContentPaths,
): string[] {
return [contentPaths.contentPathLocalized, contentPaths.contentPath];
}
async function getTagsDefinition(
filePath: string | TagOption[],
): Promise<string[]> {
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<ShowcaseItem | null> {
const {siteDir, localizationDir} = context;
const contentPaths: ShowcaseContentPaths = {
contentPath: path.resolve(siteDir, options.path),
contentPathLocalized: getPluginI18nPath({
localizationDir,
pluginName: 'docusaurus-plugin-content-pages',
pluginId: options.id,
}),
};
return {
name: 'docusaurus-plugin-content-showcase',
// todo doesn't work
// getPathsToWatch() {
// const {include} = options;
// return getContentPathList(contentPaths).flatMap((contentPath) =>
// include.map((pattern) => `${contentPath}/${pattern}`),
// );
// },
async loadContent(): Promise<ShowcaseItem | null> {
const {include} = options;
if (!(await fs.pathExists(contentPaths.contentPath))) {
return null;
}
// const {baseUrl} = siteConfig;
const showcaseFiles = await Globby(include, {
cwd: contentPaths.contentPath,
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(
getContentPathList(contentPaths),
relativeSource,
);
const sourcePath = path.join(contentPath, relativeSource);
const rawYaml = await fs.readFile(sourcePath, 'utf-8');
const unsafeYaml = Yaml.load(rawYaml) as {[key: string]: unknown};
const yaml = validateShowcaseFrontMatter(unsafeYaml);
validateFrontMatterTags(yaml.tags, createdTagSchema);
return yaml;
}
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 as Error},
);
}
}
return {
items: await Promise.all(
filteredShowcaseFiles.map(doProcessShowcaseSourceFile),
),
};
},
async contentLoaded({content, actions}) {
if (!content) {
return;
}
const {addRoute, createData} = actions;
const showcaseAllData = await createData(
'showcaseAll.json',
JSON.stringify(content.items),
);
addRoute({
path: '/showcaseAll',
component: '@theme/Showcase',
modules: {
content: showcaseAllData,
// img: '@site/src/showcase/website/ozaki/aot.jpg',
},
exact: true,
});
},
};
}
export {validateOptions} from './options';

View File

@ -0,0 +1,22 @@
/**
* 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 {LoaderContext} from 'webpack';
export default function markdownLoader(
this: LoaderContext<undefined>,
fileString: string,
): void {
const callback = this.async();
// const options = this.getOptions();
// TODO provide additional md processing here? like interlinking pages?
// fileString = linkify(fileString)
return callback(null, fileString);
}

View File

@ -0,0 +1,50 @@
/**
* 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 type {OptionValidationContext} from '@docusaurus/types';
import type {PluginOptions, Options} from '@docusaurus/plugin-content-showcase';
export const DEFAULT_OPTIONS: PluginOptions = {
id: 'showcase',
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<PluginOptions>({
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({
validate,
options,
}: OptionValidationContext<Options, PluginOptions>): PluginOptions {
const validatedOptions = validate(PluginOptionSchema, options);
return validatedOptions;
}

View File

@ -0,0 +1,60 @@
/**
* 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 TagOption = {
label: string;
description: {
message: string;
id: string;
};
color: string;
};
export type PluginOptions = {
id?: string;
path: string;
routeBasePath: string;
include: string[];
exclude: string[];
tags: string | TagOption[];
};
type TagType =
| 'favorite'
| 'opensource'
| 'product'
| 'design'
| 'i18n'
| 'versioning'
| 'large'
| 'meta'
| 'personal'
| 'rtl';
export type ShowcaseFrontMatter = {
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 ShowcaseItem = {
items: ShowcaseFrontMatter[];
};
export type Options = Partial<PluginOptions>;
export default function pluginContentShowcase(
context: LoadContext,
options: PluginOptions,
): Promise<Plugin<ShowcaseItem | null>>;
}

View File

@ -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;
};

View File

@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": false,
"incremental": true,
"tsBuildInfoFile": "./lib/.tsbuildinfo",
"rootDir": "src",
"outDir": "lib"
},
"include": ["src"],
"exclude": ["**/__tests__/**"]
}

View File

@ -26,6 +26,7 @@
"@docusaurus/plugin-content-blog": "3.0.0",
"@docusaurus/plugin-content-docs": "3.0.0",
"@docusaurus/plugin-content-pages": "3.0.0",
"@docusaurus/plugin-content-showcase": "3.0.0",
"@docusaurus/theme-common": "3.0.0",
"@docusaurus/theme-translations": "3.0.0",
"@docusaurus/types": "3.0.0",
@ -41,6 +42,7 @@
"postcss": "^8.4.26",
"prism-react-renderer": "^2.3.0",
"prismjs": "^1.29.0",
"react-popper": "^2.3.0",
"react-router-dom": "^5.3.4",
"rtlcss": "^4.1.0",
"tslib": "^2.6.0",

View File

@ -247,6 +247,96 @@ declare module '@theme/BlogPostItems' {
export default function BlogPostItem(props: Props): JSX.Element;
}
declare module '@theme/ShowcaseDetails' {
import type {ShowcaseItem} from '@docusaurus/plugin-content-showcase';
export type User = ShowcaseItem['website'][number];
export type Props = {
content: User;
};
export default function Showcase(props: Props): JSX.Element;
}
declare module '@theme/Showcase' {
import type {ShowcaseItem} from '@docusaurus/plugin-content-showcase';
export type User = ShowcaseItem['website'][number];
export type Props = {
content: User[];
};
export default function Showcase(props: Props): JSX.Element;
}
declare module '@theme/Showcase/ShowcaseCard' {
export type User = {
title: string;
description: string;
preview: string | null; // null = use our serverless screenshot service
website: string;
source: string | null;
tags: TagType[];
};
export interface Props {
readonly user: User;
}
export default function ShowcaseCard(props: Props): JSX.Element;
}
declare module '@theme/Showcase/ShowcaseTooltip' {
export interface Props {
anchorEl?: HTMLElement | string;
id: string;
text: string;
children: React.ReactElement;
}
export default function ShowcaseTooltip(props: Props): JSX.Element;
}
declare module '@theme/Showcase/ShowcaseTagSelect' {
import {type ComponentProps, type ReactNode, type ReactElement} from 'react';
export interface Props extends ComponentProps<'input'> {
icon: ReactElement<ComponentProps<'svg'>>;
label: ReactNode;
tag: TagType;
}
export default function ShowcaseTagSelect(props: Props): JSX.Element;
}
declare module '@theme/Showcase/ShowcaseFilterToggle' {
export type Operator = 'OR' | 'AND';
export default function ShowcaseFilterToggle(): JSX.Element;
}
declare module '@theme/Showcase/FavoriteIcon' {
import {type ReactNode, type ComponentProps} from 'react';
export type SvgIconProps = ComponentProps<'svg'> & {
viewBox?: string;
size?: 'inherit' | 'small' | 'medium' | 'large';
color?:
| 'inherit'
| 'primary'
| 'secondary'
| 'success'
| 'error'
| 'warning';
svgClass?: string; // Class attribute on the child
colorAttr?: string; // Applies a color attribute to the SVG element.
children: ReactNode; // Node passed into the SVG element.
};
export type Props = Omit<SvgIconProps, 'children'>;
export default function FavoriteIcon(props: Props): JSX.Element;
}
declare module '@theme/BlogPostItem/Container' {
import type {ReactNode} from 'react';

View File

@ -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.
*/
import React from 'react';
import clsx from 'clsx';
import type {Props, SvgIconProps} from '@theme/Showcase/FavoriteIcon';
import styles from './styles.module.css';
function Svg(props: SvgIconProps): JSX.Element {
const {
svgClass,
colorAttr,
children,
color = 'inherit',
size = 'medium',
viewBox = '0 0 24 24',
...rest
} = props;
return (
<svg
viewBox={viewBox}
color={colorAttr}
aria-hidden
className={clsx(styles.svgIcon, styles[color], styles[size], svgClass)}
{...rest}>
{children}
</svg>
);
}
export default function FavoriteIcon(props: Props): JSX.Element {
return (
<Svg {...props}>
<path d="M12,21.35L10.55,20.03C5.4,15.36 2,12.27 2,8.5C2,5.41 4.42,3 7.5,3C9.24,3 10.91,3.81 12,5.08C13.09,3.81 14.76,3 16.5,3C19.58,3 22,5.41 22,8.5C22,12.27 18.6,15.36 13.45,20.03L12,21.35Z" />
</Svg>
);
}

View File

@ -0,0 +1,54 @@
/**
* 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.
*/
.svgIcon {
user-select: none;
width: 1em;
height: 1em;
display: inline-block;
fill: currentColor;
flex-shrink: 0;
color: inherit;
}
/* font-size */
.small {
font-size: 1.25rem;
}
.medium {
font-size: 1.5rem;
}
.large {
font-size: 2.185rem;
}
/* colors */
.primary {
color: var(--ifm-color-primary);
}
.secondary {
color: var(--ifm-color-secondary);
}
.success {
color: var(--ifm-color-success);
}
.error {
color: var(--ifm-color-error);
}
.warning {
color: var(--ifm-color-warning);
}
.inherit {
color: inherit;
}

View File

@ -0,0 +1,239 @@
/**
* 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, {translate} from '@docusaurus/Translate';
import FavoriteIcon from '@theme/Showcase/FavoriteIcon';
import Heading from '@theme/Heading';
import Tooltip from '@theme/Showcase/ShowcaseTooltip';
import styles from './styles.module.css';
const Tags: {[type in TagType]: Tag} = {
favorite: {
label: translate({message: 'Favorite'}),
description: translate({
message:
'Our favorite Docusaurus sites that you must absolutely check out!',
id: 'showcase.tag.favorite.description',
}),
color: '#e9669e',
},
opensource: {
label: translate({message: 'Open-Source'}),
description: translate({
message: 'Open-Source Docusaurus sites can be useful for inspiration!',
id: 'showcase.tag.opensource.description',
}),
color: '#39ca30',
},
product: {
label: translate({message: 'Product'}),
description: translate({
message: 'Docusaurus sites associated to a commercial product!',
id: 'showcase.tag.product.description',
}),
color: '#dfd545',
},
design: {
label: translate({message: 'Design'}),
description: translate({
message:
'Beautiful Docusaurus sites, polished and standing out from the initial template!',
id: 'showcase.tag.design.description',
}),
color: '#a44fb7',
},
i18n: {
label: translate({message: 'I18n'}),
description: translate({
message:
'Translated Docusaurus sites using the internationalization support with more than 1 locale.',
id: 'showcase.tag.i18n.description',
}),
color: '#127f82',
},
versioning: {
label: translate({message: 'Versioning'}),
description: translate({
message:
'Docusaurus sites using the versioning feature of the docs plugin to manage multiple versions.',
id: 'showcase.tag.versioning.description',
}),
color: '#fe6829',
},
large: {
label: translate({message: 'Large'}),
description: translate({
message:
'Very large Docusaurus sites, including many more pages than the average!',
id: 'showcase.tag.large.description',
}),
color: '#8c2f00',
},
meta: {
label: translate({message: 'Meta'}),
description: translate({
message: 'Docusaurus sites of Meta (formerly Facebook) projects',
id: 'showcase.tag.meta.description',
}),
color: '#4267b2', // Facebook blue
},
personal: {
label: translate({message: 'Personal'}),
description: translate({
message:
'Personal websites, blogs and digital gardens built with Docusaurus',
id: 'showcase.tag.personal.description',
}),
color: '#14cfc3',
},
rtl: {
label: translate({message: 'RTL Direction'}),
description: translate({
message:
'Docusaurus sites using the right-to-left reading direction support.',
id: 'showcase.tag.rtl.description',
}),
color: '#ffcfc3',
},
};
const TagList = Object.keys(Tags) as TagType[];
type TagType =
| 'favorite'
| 'opensource'
| 'product'
| 'design'
| 'i18n'
| 'versioning'
| 'large'
| 'meta'
| 'personal'
| 'rtl';
type User = {
title: string;
description: string;
preview: string | null; // null = use our serverless screenshot service
website: string;
source: string | null;
tags: TagType[];
};
type Tag = {
label: string;
description: string;
color: string;
};
function sortBy<T>(
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;
}
const TagComp = React.forwardRef<HTMLLIElement, Tag>(
({label, color, description}, ref) => (
<li ref={ref} className={styles.tag} title={description}>
<span className={styles.textLabel}>{label.toLowerCase()}</span>
<span className={styles.colorLabel} style={{backgroundColor: color}} />
</li>
),
);
function ShowcaseCardTag({tags}: {tags: 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) => {
const id = `showcase_card_tag_${tagObject.tag}`;
return (
<Tooltip
key={index}
text={tagObject.description}
anchorEl="#__docusaurus"
id={id}>
<TagComp key={index} {...tagObject} />
</Tooltip>
);
})}
</>
);
}
function getCardImage(user: User): string {
return (
user.preview ??
`https://slorber-api-screenshot.netlify.app/${encodeURIComponent(
user.website,
)}/showcase`
);
}
function ShowcaseCard({user}: {user: User}) {
const image = getCardImage(user);
return (
<li key={user.title} className="card shadow--md">
<div className={clsx('card__image', styles.showcaseCardImage)}>
<img src={image} alt={user.title} />
</div>
<div className="card__body">
<div className={clsx(styles.showcaseCardHeader)}>
<Heading as="h4" className={styles.showcaseCardTitle}>
<Link href={user.website} className={styles.showcaseCardLink}>
{user.title}
</Link>
</Heading>
{user.tags.includes('favorite') && (
<FavoriteIcon svgClass={styles.svgIconFavorite} size="small" />
)}
{user.source && (
<Link
href={user.source}
className={clsx(
'button button--secondary button--sm',
styles.showcaseCardSrcBtn,
)}>
<Translate id="showcase.card.sourceLink">source</Translate>
</Link>
)}
</div>
<p className={styles.showcaseCardBody}>{user.description}</p>
</div>
<ul className={clsx('card__footer', styles.cardFooter)}>
<ShowcaseCardTag tags={user.tags} />
</ul>
</li>
);
}
export default React.memo(ShowcaseCard);

View File

@ -0,0 +1,99 @@
/**
* 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 .svgIconFavorite {
margin-right: 0.25rem;
}
.showcaseCardHeader .svgIconFavorite {
color: var(--site-color-svg-icon-favorite);
}
.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;
}

View File

@ -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 React, {useState, useEffect, useCallback} from 'react';
import clsx from 'clsx';
import {useHistory, useLocation} from '@docusaurus/router';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import type {Operator} from '@theme/Showcase/ShowcaseFilterToggle';
import styles from './styles.module.css';
const OperatorQueryKey = 'operator';
type UserState = {
scrollTopPosition: number;
focusedElementId: string | undefined;
};
function prepareUserState(): UserState | undefined {
if (ExecutionEnvironment.canUseDOM) {
return {
scrollTopPosition: window.scrollY,
focusedElementId: document.activeElement?.id,
};
}
return undefined;
}
function readOperator(search: string): Operator {
return (new URLSearchParams(search).get(OperatorQueryKey) ??
'OR') as Operator;
}
export default function ShowcaseFilterToggle(): JSX.Element {
const id = 'showcase_filter_toggle';
const location = useLocation();
const history = useHistory();
const [operator, setOperator] = useState(false);
useEffect(() => {
setOperator(readOperator(location.search) === 'AND');
}, [location]);
const toggleOperator = useCallback(() => {
setOperator((o) => !o);
const searchParams = new URLSearchParams(location.search);
searchParams.delete(OperatorQueryKey);
if (!operator) {
searchParams.append(OperatorQueryKey, 'AND');
}
history.push({
...location,
search: searchParams.toString(),
state: prepareUserState(),
});
}, [operator, location, history]);
const ClearTag = () => {
history.push({
...location,
search: '',
state: prepareUserState(),
});
};
return (
<div className="row" style={{alignItems: 'center'}}>
<input
type="checkbox"
id={id}
className="screen-reader-only"
aria-label="Toggle between or and and for the tags you selected"
onChange={toggleOperator}
onKeyDown={(e) => {
if (e.key === 'Enter') {
toggleOperator();
}
}}
checked={operator}
/>
<label htmlFor={id} className={clsx(styles.checkboxLabel, 'shadow--md')}>
{/* eslint-disable @docusaurus/no-untranslated-text */}
<span className={styles.checkboxLabelOr}>OR</span>
<span className={styles.checkboxLabelAnd}>AND</span>
{/* eslint-enable @docusaurus/no-untranslated-text */}
</label>
{/* eslint-disable-next-line @docusaurus/no-untranslated-text */}
<button
className="button button--outline button--primary"
type="button"
onClick={() => ClearTag()}>
Clear All
</button>
</div>
);
}

View File

@ -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)));
}

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 React, {
useCallback,
useState,
useEffect,
type ComponentProps,
type ReactNode,
type ReactElement,
} from 'react';
import {useHistory, useLocation} from '@docusaurus/router';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import styles from './styles.module.css';
type UserState = {
scrollTopPosition: number;
focusedElementId: string | undefined;
};
function prepareUserState(): UserState | undefined {
if (ExecutionEnvironment.canUseDOM) {
return {
scrollTopPosition: window.scrollY,
focusedElementId: document.activeElement?.id,
};
}
return undefined;
}
function toggleListItem<T>(list: T[], item: T): T[] {
const itemIndex = list.indexOf(item);
if (itemIndex === -1) {
return list.concat(item);
}
const newList = [...list];
newList.splice(itemIndex, 1);
return newList;
}
type TagType =
| 'favorite'
| 'opensource'
| 'product'
| 'design'
| 'i18n'
| 'versioning'
| 'large'
| 'meta'
| 'personal'
| 'rtl';
interface Props extends ComponentProps<'input'> {
icon: ReactElement<ComponentProps<'svg'>>;
label: ReactNode;
tag: TagType;
}
const TagQueryStringKey = 'tags';
function readSearchTags(search: string): TagType[] {
return new URLSearchParams(search).getAll(TagQueryStringKey) as TagType[];
}
function replaceSearchTags(search: string, newTags: TagType[]) {
const searchParams = new URLSearchParams(search);
searchParams.delete(TagQueryStringKey);
newTags.forEach((tag) => searchParams.append(TagQueryStringKey, tag));
return searchParams.toString();
}
function ShowcaseTagSelect(
{id, icon, label, tag, ...rest}: Props,
ref: React.ForwardedRef<HTMLLabelElement>,
) {
const location = useLocation();
const history = useHistory();
const [selected, setSelected] = useState(false);
useEffect(() => {
const tags = readSearchTags(location.search);
setSelected(tags.includes(tag));
}, [tag, location]);
const toggleTag = useCallback(() => {
const tags = readSearchTags(location.search);
const newTags = toggleListItem(tags, tag);
const newSearch = replaceSearchTags(location.search, newTags);
history.push({
...location,
search: newSearch,
state: prepareUserState(),
});
}, [tag, location, history]);
return (
<>
<input
type="checkbox"
id={id}
className="screen-reader-only"
onKeyDown={(e) => {
if (e.key === 'Enter') {
toggleTag();
}
}}
onFocus={(e) => {
if (e.relatedTarget) {
e.target.nextElementSibling?.dispatchEvent(
new KeyboardEvent('focus'),
);
}
}}
onBlur={(e) => {
e.target.nextElementSibling?.dispatchEvent(new KeyboardEvent('blur'));
}}
onChange={toggleTag}
checked={selected}
{...rest}
/>
<label ref={ref} htmlFor={id} className={styles.checkboxLabel}>
{label}
{icon}
</label>
</>
);
}
export default React.forwardRef(ShowcaseTagSelect);

View File

@ -0,0 +1,38 @@
/**
* 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: var(--site-color-checkbox-checked-bg);
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);
}

View File

@ -0,0 +1,145 @@
/**
* 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, {useEffect, useState, useRef} from 'react';
import ReactDOM from 'react-dom';
import {usePopper} from 'react-popper';
import styles from './styles.module.css';
interface Props {
anchorEl?: HTMLElement | string;
id: string;
text: string;
children: React.ReactElement;
}
export default function Tooltip({
children,
id,
anchorEl,
text,
}: Props): JSX.Element {
const [open, setOpen] = useState(false);
const [referenceElement, setReferenceElement] = useState<HTMLElement | null>(
null,
);
const [popperElement, setPopperElement] = useState<HTMLElement | null>(null);
const [arrowElement, setArrowElement] = useState<HTMLElement | null>(null);
const [container, setContainer] = useState<Element | null>(null);
const {styles: popperStyles, attributes} = usePopper(
referenceElement,
popperElement,
{
modifiers: [
{
name: 'arrow',
options: {
element: arrowElement,
},
},
{
name: 'offset',
options: {
offset: [0, 8],
},
},
],
},
);
const timeout = useRef<number | null>(null);
const tooltipId = `${id}_tooltip`;
useEffect(() => {
if (anchorEl) {
if (typeof anchorEl === 'string') {
setContainer(document.querySelector(anchorEl));
} else {
setContainer(anchorEl);
}
} else {
setContainer(document.body);
}
}, [container, anchorEl]);
useEffect(() => {
const showEvents = ['mouseenter', 'focus'];
const hideEvents = ['mouseleave', 'blur'];
const handleOpen = () => {
// There is no point in displaying an empty tooltip.
if (text === '') {
return;
}
// Remove the title ahead of time to avoid displaying
// two tooltips at the same time (native + this one).
referenceElement?.removeAttribute('title');
timeout.current = window.setTimeout(() => {
setOpen(true);
}, 400);
};
const handleClose = () => {
clearInterval(timeout.current!);
setOpen(false);
};
if (referenceElement) {
showEvents.forEach((event) => {
referenceElement.addEventListener(event, handleOpen);
});
hideEvents.forEach((event) => {
referenceElement.addEventListener(event, handleClose);
});
}
return () => {
if (referenceElement) {
showEvents.forEach((event) => {
referenceElement.removeEventListener(event, handleOpen);
});
hideEvents.forEach((event) => {
referenceElement.removeEventListener(event, handleClose);
});
}
};
}, [referenceElement, text]);
return (
<>
{React.cloneElement(children, {
ref: setReferenceElement,
'aria-describedby': open ? tooltipId : undefined,
})}
{container
? ReactDOM.createPortal(
open && (
<div
id={tooltipId}
role="tooltip"
ref={setPopperElement}
className={styles.tooltip}
style={popperStyles.popper}
{...attributes.popper}>
{text}
<span
ref={setArrowElement}
className={styles.tooltipArrow}
style={popperStyles.arrow}
/>
</div>
),
container,
)
: container}
</>
);
}

View File

@ -0,0 +1,45 @@
/**
* 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.
*/
.tooltip {
border-radius: 4px;
padding: 4px 8px;
color: var(--site-color-tooltip);
background: var(--site-color-tooltip-background);
font-size: 0.8rem;
z-index: 500;
line-height: 1.4;
font-weight: 500;
max-width: 300px;
opacity: 0.92;
}
.tooltipArrow {
visibility: hidden;
}
.tooltipArrow,
.tooltipArrow::before {
position: absolute;
width: 8px;
height: 8px;
background: inherit;
}
.tooltipArrow::before {
visibility: visible;
content: '';
transform: rotate(45deg);
}
.tooltip[data-popper-placement^='top'] > .tooltipArrow {
bottom: -4px;
}
.tooltip[data-popper-placement^='bottom'] > .tooltipArrow {
top: -4px;
}

View File

@ -0,0 +1,468 @@
/**
* 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, {useEffect, useMemo, useState} from 'react';
import clsx from 'clsx';
import Link from '@docusaurus/Link';
import {useHistory, useLocation} from 'react-router-dom';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import Translate, {translate} from '@docusaurus/Translate';
import {usePluralForm} from '@docusaurus/theme-common';
import type {User, Props} from '@theme/Showcase';
import Layout from '@theme/Layout';
import Heading from '@theme/Heading';
import FavoriteIcon from '@theme/Showcase/FavoriteIcon';
import ShowcaseCard from '@theme/Showcase/ShowcaseCard';
import ShowcaseTooltip from '@theme/Showcase/ShowcaseTooltip';
import ShowcaseTagSelect from '@theme/Showcase/ShowcaseTagSelect';
import ShowcaseFilterToggle from '@theme/Showcase/ShowcaseFilterToggle';
import type {Operator} from '@theme/Showcase/ShowcaseFilterToggle';
import type {TagType} from '@docusaurus/plugin-content-showcase';
import styles from './styles.module.css';
type Users = User[];
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';
const OperatorQueryKey = 'operator';
function readOperator(search: string): Operator {
return (new URLSearchParams(search).get(OperatorQueryKey) ??
'OR') as Operator;
}
type UserState = {
scrollTopPosition: number;
focusedElementId: string | undefined;
};
type Tag = {
label: string;
description: string;
color: string;
};
const Tags: {[type in TagType]: Tag} = {
favorite: {
label: translate({message: 'Favorite'}),
description: translate({
message:
'Our favorite Docusaurus sites that you must absolutely check out!',
id: 'showcase.tag.favorite.description',
}),
color: '#e9669e',
},
opensource: {
label: translate({message: 'Open-Source'}),
description: translate({
message: 'Open-Source Docusaurus sites can be useful for inspiration!',
id: 'showcase.tag.opensource.description',
}),
color: '#39ca30',
},
product: {
label: translate({message: 'Product'}),
description: translate({
message: 'Docusaurus sites associated to a commercial product!',
id: 'showcase.tag.product.description',
}),
color: '#dfd545',
},
design: {
label: translate({message: 'Design'}),
description: translate({
message:
'Beautiful Docusaurus sites, polished and standing out from the initial template!',
id: 'showcase.tag.design.description',
}),
color: '#a44fb7',
},
i18n: {
label: translate({message: 'I18n'}),
description: translate({
message:
'Translated Docusaurus sites using the internationalization support with more than 1 locale.',
id: 'showcase.tag.i18n.description',
}),
color: '#127f82',
},
versioning: {
label: translate({message: 'Versioning'}),
description: translate({
message:
'Docusaurus sites using the versioning feature of the docs plugin to manage multiple versions.',
id: 'showcase.tag.versioning.description',
}),
color: '#fe6829',
},
large: {
label: translate({message: 'Large'}),
description: translate({
message:
'Very large Docusaurus sites, including many more pages than the average!',
id: 'showcase.tag.large.description',
}),
color: '#8c2f00',
},
meta: {
label: translate({message: 'Meta'}),
description: translate({
message: 'Docusaurus sites of Meta (formerly Facebook) projects',
id: 'showcase.tag.meta.description',
}),
color: '#4267b2', // Facebook blue
},
personal: {
label: translate({message: 'Personal'}),
description: translate({
message:
'Personal websites, blogs and digital gardens built with Docusaurus',
id: 'showcase.tag.personal.description',
}),
color: '#14cfc3',
},
rtl: {
label: translate({message: 'RTL Direction'}),
description: translate({
message:
'Docusaurus sites using the right-to-left reading direction support.',
id: 'showcase.tag.rtl.description',
}),
color: '#ffcfc3',
},
};
const TagList = Object.keys(Tags) as TagType[];
function sortBy<T>(
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;
}
function sortUsers(users: Users): Users {
// Sort by site name
let result = sortBy(users, (user) => user.title.toLowerCase());
// Sort by favorite tag, favorites first
result = sortBy(result, (user) => (user.tags.includes('favorite') ? -1 : 1));
return result;
}
function ShowcaseHeader() {
return (
<section className="margin-top--lg margin-bottom--lg text--center">
<Heading as="h1">{TITLE}</Heading>
<p>{DESCRIPTION}</p>
<Link className="button button--primary" to={SUBMIT_URL}>
<Translate id="showcase.header.button">
🙏 Please add your site
</Translate>
</Link>
</section>
);
}
function prepareUserState(): UserState | undefined {
if (ExecutionEnvironment.canUseDOM) {
return {
scrollTopPosition: window.scrollY,
focusedElementId: document.activeElement?.id,
};
}
return undefined;
}
function restoreUserState(userState: UserState | null) {
const {scrollTopPosition, focusedElementId} = userState ?? {
scrollTopPosition: 0,
focusedElementId: undefined,
};
// @ts-expect-error: if focusedElementId is undefined it returns null
document.getElementById(focusedElementId)?.focus();
window.scrollTo({top: scrollTopPosition});
}
function filterUsers(
users: Users,
selectedTags: TagType[],
operator: Operator,
searchName: string | null,
) {
if (searchName) {
// eslint-disable-next-line no-param-reassign
users = users.filter((user) =>
user.title.toLowerCase().includes(searchName.toLowerCase()),
);
}
if (selectedTags.length === 0) {
return users;
}
return users.filter((user) => {
if (user.tags.length === 0) {
return false;
}
if (operator === 'AND') {
return selectedTags.every((tag) => user.tags.includes(tag));
}
return selectedTags.some((tag) => user.tags.includes(tag));
});
}
const SearchNameQueryKey = 'name';
function readSearchName(search: string) {
return new URLSearchParams(search).get(SearchNameQueryKey);
}
const TagQueryStringKey = 'tags';
function readSearchTags(search: string): TagType[] {
return new URLSearchParams(search).getAll(TagQueryStringKey) as TagType[];
}
function useFilteredUsers(users: Users) {
const location = useLocation<UserState>();
const [operator, setOperator] = useState<Operator>('OR');
// On SSR / first mount (hydration) no tag is selected
const [selectedTags, setSelectedTags] = useState<TagType[]>([]);
const [searchName, setSearchName] = useState<string | null>(null);
// Sync tags from QS to state (delayed on purpose to avoid SSR/Client
// hydration mismatch)
useEffect(() => {
setSelectedTags(readSearchTags(location.search));
setOperator(readOperator(location.search));
setSearchName(readSearchName(location.search));
restoreUserState(location.state);
}, [location]);
return useMemo(
() => filterUsers(sortUsers(users), selectedTags, operator, searchName),
[selectedTags, operator, searchName, users],
);
}
function useSiteCountPlural() {
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},
),
);
}
function ShowcaseFilters({users}: {users: Users}) {
const filteredUsers = useFilteredUsers(users);
const siteCountPlural = useSiteCountPlural();
return (
<section className="container margin-top--l margin-bottom--lg">
<div className={clsx('margin-bottom--sm', styles.filterCheckbox)}>
<div>
<Heading as="h2">
<Translate id="showcase.filters.title">Filters</Translate>
</Heading>
<span>{siteCountPlural(filteredUsers.length)}</span>
</div>
<ShowcaseFilterToggle />
</div>
<ul className={clsx('clean-list', styles.checkboxList)}>
{TagList.map((tag, i) => {
const {label, description, color} = Tags[tag];
const id = `showcase_checkbox_id_${tag}`;
return (
<li key={i} className={styles.checkboxListItem}>
<ShowcaseTooltip
id={id}
text={description}
anchorEl="#__docusaurus">
<ShowcaseTagSelect
tag={tag}
id={id}
label={label}
icon={
tag === 'favorite' ? (
<FavoriteIcon svgClass={styles.svgIconFavoriteXs} />
) : (
<span
style={{
backgroundColor: color,
width: 10,
height: 10,
borderRadius: '50%',
marginLeft: 8,
}}
/>
)
}
/>
</ShowcaseTooltip>
</li>
);
})}
</ul>
</section>
);
}
function SearchBar() {
const history = useHistory();
const location = useLocation();
const [value, setValue] = useState<string | null>(null);
useEffect(() => {
setValue(readSearchName(location.search));
}, [location]);
return (
<div className={styles.searchContainer}>
<input
id="searchbar"
placeholder={translate({
message: 'Search for site name...',
id: 'showcase.searchBar.placeholder',
})}
value={value ?? undefined}
onInput={(e) => {
setValue(e.currentTarget.value);
const newSearch = new URLSearchParams(location.search);
newSearch.delete(SearchNameQueryKey);
if (e.currentTarget.value) {
newSearch.set(SearchNameQueryKey, e.currentTarget.value);
}
history.push({
...location,
search: newSearch.toString(),
state: prepareUserState(),
});
setTimeout(() => {
document.getElementById('searchbar')?.focus();
}, 0);
}}
/>
</div>
);
}
function ShowcaseCards({users}: {users: Users}) {
const filteredUsers = useFilteredUsers(users);
if (filteredUsers.length === 0) {
return (
<section className="margin-top--lg margin-bottom--xl">
<div className="container padding-vert--md text--center">
<Heading as="h2">
<Translate id="showcase.usersList.noResult">No result</Translate>
</Heading>
</div>
</section>
);
}
const favoriteUsers = users.filter((user) => user.tags.includes('favorite'));
const otherUsers = users.filter((user) => !user.tags.includes('favorite'));
return (
<section className="margin-top--lg margin-bottom--xl">
{filteredUsers.length === sortUsers(users).length ? (
<>
<div className={styles.showcaseFavorite}>
<div className="container">
<div
className={clsx(
'margin-bottom--md',
styles.showcaseFavoriteHeader,
)}>
<Heading as="h2">
<Translate id="showcase.favoritesList.title">
Our favorites
</Translate>
</Heading>
<FavoriteIcon svgClass={styles.svgIconFavorite} />
</div>
<ul
className={clsx(
'container',
'clean-list',
styles.showcaseList,
)}>
{favoriteUsers.map((user) => (
<ShowcaseCard key={user.title} user={user} />
))}
</ul>
</div>
</div>
<div className="container margin-top--lg">
<Heading as="h2" className={styles.showcaseHeader}>
<Translate id="showcase.usersList.allUsers">All sites</Translate>
</Heading>
<ul className={clsx('clean-list', styles.showcaseList)}>
{otherUsers.map((user) => (
<ShowcaseCard key={user.title} user={user} />
))}
</ul>
</div>
</>
) : (
<div className="container">
<div
className={clsx('margin-bottom--md', styles.showcaseFavoriteHeader)}
/>
<ul className={clsx('clean-list', styles.showcaseList)}>
{filteredUsers.map((user) => (
<ShowcaseCard key={user.title} user={user} />
))}
</ul>
</div>
)}
</section>
);
}
export default function Showcase(props: Props): JSX.Element {
const users = props.content;
return (
<Layout title="Showcase">
<div>{JSON.stringify(props)}</div>
<main className="margin-vert--lg">
<ShowcaseHeader />
<ShowcaseFilters users={users} />
<div
style={{display: 'flex', marginLeft: 'auto'}}
className="container">
<SearchBar />
</div>
<ShowcaseCards users={users} />
</main>
</Layout>
);
}

View File

@ -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.
*/
.filterCheckbox {
justify-content: space-between;
}
.filterCheckbox,
.checkboxList {
display: flex;
align-items: center;
}
.filterCheckbox > div:first-child {
display: flex;
flex: 1 1 auto;
align-items: center;
}
.filterCheckbox > div > * {
margin-bottom: 0;
margin-right: 8px;
}
.checkboxList {
flex-wrap: wrap;
}
.checkboxListItem {
user-select: none;
white-space: nowrap;
height: 32px;
font-size: 0.8rem;
margin-top: 0.5rem;
margin-right: 0.5rem;
}
.checkboxListItem:last-child {
margin-right: 0;
}
.searchContainer {
margin-left: auto;
}
.searchContainer input {
height: 30px;
border-radius: 15px;
padding: 10px;
border: 1px solid gray;
}
.showcaseList {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
}
.showcaseFavorite {
padding-top: 2rem;
padding-bottom: 2rem;
background-color: var(--site-color-favorite-background);
}
.showcaseFavoriteHeader {
display: flex;
align-items: center;
}
.showcaseFavoriteHeader > h2 {
margin-bottom: 0;
}
.showcaseFavoriteHeader > svg {
width: 30px;
height: 30px;
}
.svgIconFavoriteXs,
.svgIconFavorite {
color: var(--site-color-svg-icon-favorite);
}
.svgIconFavoriteXs {
margin-left: 0.625rem;
font-size: 1rem;
}
.svgIconFavorite {
margin-left: 1rem;
}

View File

@ -0,0 +1,21 @@
/**
* 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 {Props} from '@theme/ShowcaseDetails';
import Layout from '@theme/Layout';
export default function Showcase(props: Props): JSX.Element {
const {content: MDXPageContent} = props;
const {title, description} = MDXPageContent;
return (
<Layout title="Showcase Details">
<div>Title {JSON.stringify(title)}</div>
<div>Description {JSON.stringify(description)}</div>
</Layout>
);
}

View File

@ -68,6 +68,7 @@ Datagit's
dedup
devto
dingers
dinooooooo
Dmitry
docsearch
Docsify
@ -105,6 +106,7 @@ Flightcontrol's
Formik
FOUC
froms
frontmatter
funboxteam
gabrielcsapo
Gifs
@ -228,7 +230,9 @@ orta
Outerbounds
outerbounds
overrideable
Ozaki
ozaki
ozakione
OShannessy
pageview
Palenight

View File

@ -239,6 +239,7 @@ export default async function createConfigAsync() {
],
themes: ['live-codeblock', ...dogfoodingThemeInstances],
plugins: [
'content-showcase',
[
'./src/plugins/changelog/index.js',
{

View File

@ -0,0 +1,16 @@
---
title: Clement;
description: Description from frontmatter
preview: https://github.com/ozakione.png
website: https://github.com/ozakione
source: source
tags:
- favorite
- opensource
---
# Hello
- some test
text

View File

@ -0,0 +1,6 @@
title: 'Dinosaur'
description: 'Docusaurus dinooooooo'
preview: https://upload.wikimedia.org/wikipedia/commons/thumb/0/0f/Grosser_Panda.JPG/2560px-Grosser_Panda.JPG
website: 'https://agile-ts.org/'
source: 'https://github.com/agile-ts/documentation'
tags: ['opensource', 'design']

View File

@ -0,0 +1,6 @@
title: 'Ozaki'
description: 'Ozaki website'
preview: /img/aot.jpg
website: 'https://agile-ts.org/'
source: 'https://github.com/agile-ts/documentation'
tags: ['opensource', 'design']

View File

@ -0,0 +1,6 @@
title: 'Seb'
description: "Docusaurus maintainer's personal website"
preview: require('./showcase/test.png')
website: 'https://agile-ts.org/'
source: 'https://github.com/agile-ts/documentation'
tags: ['opensource', 'design']

View File

@ -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'