diff --git a/packages/docusaurus-theme-classic/src/theme-classic.d.ts b/packages/docusaurus-theme-classic/src/theme-classic.d.ts index 03a00f833a76..8dd8f87f8a19 100644 --- a/packages/docusaurus-theme-classic/src/theme-classic.d.ts +++ b/packages/docusaurus-theme-classic/src/theme-classic.d.ts @@ -582,6 +582,71 @@ declare module '@theme/DocCard' { export default function DocCard(props: Props): ReactNode; } +declare module '@theme/DocCard/Heading' { + import type {ReactNode} from 'react'; + import type {PropSidebarItem} from '@docusaurus/plugin-content-docs'; + + export interface Props { + readonly item: PropSidebarItem; + readonly icon: ReactNode; + readonly title: string; + } + + export default function DocCardHeading(props: Props): ReactNode; +} + +declare module '@theme/DocCard/Heading/Icon' { + import type {ReactNode} from 'react'; + import type {PropSidebarItem} from '@docusaurus/plugin-content-docs'; + + export interface Props { + readonly item: PropSidebarItem; + readonly icon: ReactNode; + } + + export default function DocCardHeadingIcon(props: Props): ReactNode; +} + +declare module '@theme/DocCard/Heading/Text' { + import type {ReactNode} from 'react'; + import type {PropSidebarItem} from '@docusaurus/plugin-content-docs'; + + export interface Props { + readonly item: PropSidebarItem; + readonly title: string; + } + + export default function DocCardHeadingText(props: Props): ReactNode; +} + +declare module '@theme/DocCard/Description' { + import type {ReactNode} from 'react'; + import type {PropSidebarItem} from '@docusaurus/plugin-content-docs'; + + export interface Props { + readonly item: PropSidebarItem; + readonly description: string; + } + + export default function DocCardDescription(props: Props): ReactNode; +} + +declare module '@theme/DocCard/Layout' { + import type {ReactNode} from 'react'; + import type {PropSidebarItem} from '@docusaurus/plugin-content-docs'; + + export interface Props { + readonly item: PropSidebarItem; + readonly className?: string; + readonly href: string; + readonly icon: ReactNode; + readonly title: string; + readonly description?: string; + } + + export default function DocCardLayout(props: Props): ReactNode; +} + declare module '@theme/DocCardList' { import type {ReactNode} from 'react'; import type {PropSidebarItem} from '@docusaurus/plugin-content-docs'; diff --git a/packages/docusaurus-theme-classic/src/theme/DocCard/Description/index.tsx b/packages/docusaurus-theme-classic/src/theme/DocCard/Description/index.tsx new file mode 100644 index 000000000000..55d2ce312b8e --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/DocCard/Description/index.tsx @@ -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. + */ + +import React, {type ReactNode} from 'react'; +import clsx from 'clsx'; +import {ThemeClassNames} from '@docusaurus/theme-common'; +import type {Props} from '@theme/DocCard/Description'; + +import styles from './styles.module.css'; + +export default function DocCardDescription({description}: Props): ReactNode { + return ( +

+ {description} +

+ ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/DocCard/Description/styles.module.css b/packages/docusaurus-theme-classic/src/theme/DocCard/Description/styles.module.css new file mode 100644 index 000000000000..c28ebe6cd137 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/DocCard/Description/styles.module.css @@ -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. + */ + +.cardDescription { + font-size: 0.8rem; +} diff --git a/packages/docusaurus-theme-classic/src/theme/DocCard/Heading/Icon/index.tsx b/packages/docusaurus-theme-classic/src/theme/DocCard/Heading/Icon/index.tsx new file mode 100644 index 000000000000..d646aa277082 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/DocCard/Heading/Icon/index.tsx @@ -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 React, {type ReactNode} from 'react'; +import clsx from 'clsx'; +import {ThemeClassNames} from '@docusaurus/theme-common'; +import type {Props} from '@theme/DocCard/Heading/Icon'; + +import styles from './styles.module.css'; + +export default function DocCardHeadingIcon({icon}: Props): ReactNode { + return ( + + {icon} + + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/DocCard/Heading/Icon/styles.module.css b/packages/docusaurus-theme-classic/src/theme/DocCard/Heading/Icon/styles.module.css new file mode 100644 index 000000000000..0362537d321d --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/DocCard/Heading/Icon/styles.module.css @@ -0,0 +1,11 @@ +/** + * 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. + */ + +.cardTitleIcon { + font-size: 1.6rem; + margin-right: 0.6rem; +} diff --git a/packages/docusaurus-theme-classic/src/theme/DocCard/Heading/Text/index.tsx b/packages/docusaurus-theme-classic/src/theme/DocCard/Heading/Text/index.tsx new file mode 100644 index 000000000000..9b091534331f --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/DocCard/Heading/Text/index.tsx @@ -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. + */ + +import React, {type ReactNode} from 'react'; +import clsx from 'clsx'; +import {ThemeClassNames} from '@docusaurus/theme-common'; +import type {Props} from '@theme/DocCard/Heading/Text'; + +import styles from './styles.module.css'; + +export default function DocCardHeadingText({title}: Props): ReactNode { + return ( + + {title} + + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/DocCard/Heading/Text/styles.module.css b/packages/docusaurus-theme-classic/src/theme/DocCard/Heading/Text/styles.module.css new file mode 100644 index 000000000000..a240e033473d --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/DocCard/Heading/Text/styles.module.css @@ -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. + */ + +.cardTitleText { + font-size: 1.2rem; +} diff --git a/packages/docusaurus-theme-classic/src/theme/DocCard/Heading/index.tsx b/packages/docusaurus-theme-classic/src/theme/DocCard/Heading/index.tsx new file mode 100644 index 000000000000..7e5487e3ff9c --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/DocCard/Heading/index.tsx @@ -0,0 +1,28 @@ +/** + * 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 clsx from 'clsx'; +import {ThemeClassNames} from '@docusaurus/theme-common'; +import Heading from '@theme/Heading'; +import Icon from '@theme/DocCard/Heading/Icon'; +import Text from '@theme/DocCard/Heading/Text'; +import type {Props} from '@theme/DocCard/Heading'; + +import styles from './styles.module.css'; + +export default function DocCardHeading({item, title, icon}: Props): ReactNode { + return ( + + {icon && } + + + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/DocCard/Heading/styles.module.css b/packages/docusaurus-theme-classic/src/theme/DocCard/Heading/styles.module.css new file mode 100644 index 000000000000..9054ee359fa6 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/DocCard/Heading/styles.module.css @@ -0,0 +1,11 @@ +/** + * 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. + */ + +.cardTitle { + display: inline-flex; + align-items: center; +} diff --git a/packages/docusaurus-theme-classic/src/theme/DocCard/Layout/index.tsx b/packages/docusaurus-theme-classic/src/theme/DocCard/Layout/index.tsx new file mode 100644 index 000000000000..448ec09921c8 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/DocCard/Layout/index.tsx @@ -0,0 +1,55 @@ +/** + * 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 clsx from 'clsx'; +import Link from '@docusaurus/Link'; +import {ThemeClassNames} from '@docusaurus/theme-common'; +import Heading from '@theme/DocCard/Heading'; +import Description from '@theme/DocCard/Description'; +import type {Props} from '@theme/DocCard/Layout'; + +import styles from './styles.module.css'; + +function Container({ + className, + href, + children, +}: { + className?: string; + href: string; + children: ReactNode; +}): ReactNode { + return ( + + {children} + + ); +} + +export default function DocCardLayout({ + item, + className, + href, + icon, + title, + description, +}: Props): ReactNode { + return ( + + + {description && } + + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/DocCard/styles.module.css b/packages/docusaurus-theme-classic/src/theme/DocCard/Layout/styles.module.css similarity index 89% rename from packages/docusaurus-theme-classic/src/theme/DocCard/styles.module.css rename to packages/docusaurus-theme-classic/src/theme/DocCard/Layout/styles.module.css index 63c3d9856b70..41ab7bb7d12a 100644 --- a/packages/docusaurus-theme-classic/src/theme/DocCard/styles.module.css +++ b/packages/docusaurus-theme-classic/src/theme/DocCard/Layout/styles.module.css @@ -24,11 +24,3 @@ .cardContainer *:last-child { margin-bottom: 0; } - -.cardTitle { - font-size: 1.2rem; -} - -.cardDescription { - font-size: 0.8rem; -} diff --git a/packages/docusaurus-theme-classic/src/theme/DocCard/index.tsx b/packages/docusaurus-theme-classic/src/theme/DocCard/index.tsx index 076235d68702..643e0063dff6 100644 --- a/packages/docusaurus-theme-classic/src/theme/DocCard/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/DocCard/index.tsx @@ -6,122 +6,71 @@ */ import React, {type ReactNode} from 'react'; -import clsx from 'clsx'; -import Link from '@docusaurus/Link'; import { useDocById, findFirstSidebarItemLink, } from '@docusaurus/plugin-content-docs/client'; -import {usePluralForm} from '@docusaurus/theme-common'; +import { + extractLeadingEmoji, + useDocCardDescriptionCategoryItemsPlural, +} from '@docusaurus/theme-common/internal'; import isInternalUrl from '@docusaurus/isInternalUrl'; -import {translate} from '@docusaurus/Translate'; +import Layout from '@theme/DocCard/Layout'; import type {Props} from '@theme/DocCard'; -import Heading from '@theme/Heading'; import type { PropSidebarItemCategory, PropSidebarItemLink, } from '@docusaurus/plugin-content-docs'; -import styles from './styles.module.css'; - -function useCategoryItemsPlural() { - const {selectMessage} = usePluralForm(); - return (count: number) => - selectMessage( - count, - translate( - { - message: '1 item|{count} items', - id: 'theme.docs.DocCard.categoryDescription.plurals', - description: - 'The default description for a category card in the generated index about how many items this category includes', - }, - {count}, - ), - ); -} - -function CardContainer({ - className, - href, - children, -}: { - className?: string; - href: string; - children: ReactNode; -}): ReactNode { - return ( - - {children} - - ); +function getFallbackEmojiIcon( + item: PropSidebarItemLink | PropSidebarItemCategory, +): string { + if (item.type === 'category') { + return '๐Ÿ—ƒ'; + } + return isInternalUrl(item.href) ? '๐Ÿ“„๏ธ' : '๐Ÿ”—'; } -function CardLayout({ - className, - href, - icon, - title, - description, -}: { - className?: string; - href: string; - icon: ReactNode; - title: string; - description?: string; -}): ReactNode { - return ( - - - {icon} {title} - - {description && ( -

- {description} -

- )} -
- ); +function getIconTitleProps( + item: PropSidebarItemLink | PropSidebarItemCategory, +): {icon: ReactNode; title: string} { + const extracted = extractLeadingEmoji(item.label); + const emoji = extracted.emoji ?? getFallbackEmojiIcon(item); + return { + icon: emoji, + title: extracted.rest.trim(), + }; } function CardCategory({item}: {item: PropSidebarItemCategory}): ReactNode { const href = findFirstSidebarItemLink(item); - const categoryItemsPlural = useCategoryItemsPlural(); + const categoryItemsPlural = useDocCardDescriptionCategoryItemsPlural(); // Unexpected: categories that don't have a link have been filtered upfront if (!href) { return null; } - return ( - ); } function CardLink({item}: {item: PropSidebarItemLink}): ReactNode { - const icon = isInternalUrl(item.href) ? '๐Ÿ“„๏ธ' : '๐Ÿ”—'; const doc = useDocById(item.docId ?? undefined); return ( - ); } diff --git a/packages/docusaurus-theme-common/src/internal.ts b/packages/docusaurus-theme-common/src/internal.ts index 9d8b904cd8bd..02c5776173d1 100644 --- a/packages/docusaurus-theme-common/src/internal.ts +++ b/packages/docusaurus-theme-common/src/internal.ts @@ -91,6 +91,8 @@ export {PluginHtmlClassNameProvider} from './utils/metadataUtils'; export {splitNavbarItems, NavbarProvider} from './utils/navbarUtils'; +export {extractLeadingEmoji} from './utils/emojiUtils'; + export { useTOCHighlight, type TOCHighlightConfig, @@ -103,6 +105,7 @@ export {useLockBodyScroll} from './hooks/useLockBodyScroll'; export {useCodeWordWrap} from './hooks/useCodeWordWrap'; export {useBackToTopButton} from './hooks/useBackToTopButton'; +export {useDocCardDescriptionCategoryItemsPlural} from './translations/docsTranslations'; export { useBlogTagsPostsPageTitle, useBlogAuthorPageTitle, diff --git a/packages/docusaurus-theme-common/src/translations/docsTranslations.tsx b/packages/docusaurus-theme-common/src/translations/docsTranslations.tsx new file mode 100644 index 000000000000..5d6d76f5f69f --- /dev/null +++ b/packages/docusaurus-theme-common/src/translations/docsTranslations.tsx @@ -0,0 +1,28 @@ +/** + * 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} from '@docusaurus/Translate'; +import {usePluralForm} from '../utils/usePluralForm'; + +export function useDocCardDescriptionCategoryItemsPlural(): ( + count: number, +) => string { + const {selectMessage} = usePluralForm(); + return (count: number) => + selectMessage( + count, + translate( + { + message: '1 item|{count} items', + id: 'theme.docs.DocCard.categoryDescription.plurals', + description: + 'The default description for a category card in the generated index about how many items this category includes', + }, + {count}, + ), + ); +} diff --git a/packages/docusaurus-theme-common/src/utils/ThemeClassNames.ts b/packages/docusaurus-theme-common/src/utils/ThemeClassNames.ts index a414908d8870..4b039f652dca 100644 --- a/packages/docusaurus-theme-common/src/utils/ThemeClassNames.ts +++ b/packages/docusaurus-theme-common/src/utils/ThemeClassNames.ts @@ -100,6 +100,13 @@ export const ThemeClassNames = { docSidebarItemLinkLevel: (level: number) => `theme-doc-sidebar-item-link-level-${level}` as const, // TODO add other stable classNames here + docCard: { + container: 'theme-doc-card-container', + heading: 'theme-doc-card-heading', + icon: 'theme-doc-card-icon', + title: 'theme-doc-card-title', + description: 'theme-doc-card-description', + }, }, blog: { // TODO add other stable classNames here diff --git a/packages/docusaurus-theme-common/src/utils/__tests__/emojiUtils.test.ts b/packages/docusaurus-theme-common/src/utils/__tests__/emojiUtils.test.ts new file mode 100644 index 000000000000..267702ff8504 --- /dev/null +++ b/packages/docusaurus-theme-common/src/utils/__tests__/emojiUtils.test.ts @@ -0,0 +1,66 @@ +/** + * 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 {extractLeadingEmoji} from '../emojiUtils'; + +describe('extractLeadingEmoji', () => { + it('extracts simple leading emoji', () => { + expect(extractLeadingEmoji('๐Ÿ˜€ Hello World')).toEqual({ + emoji: '๐Ÿ˜€', + rest: ' Hello World', + }); + }); + + it('extracts only the first emoji', () => { + expect(extractLeadingEmoji('๐Ÿ˜€๐Ÿ˜€ Hello World')).toEqual({ + emoji: '๐Ÿ˜€', + rest: '๐Ÿ˜€ Hello World', + }); + }); + + it('extracts emoji with multiple code points - ๐Ÿ‡ซ๐Ÿ‡ท', () => { + expect(extractLeadingEmoji('๐Ÿ‡ซ๐Ÿ‡ท Hello World')).toEqual({ + emoji: '๐Ÿ‡ซ๐Ÿ‡ท', + rest: ' Hello World', + }); + }); + + it('extracts emoji with multiple code points - ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ', () => { + expect(extractLeadingEmoji('๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ Hello World')).toEqual({ + emoji: '๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ', + rest: ' Hello World', + }); + }); + + it('preserves original string', () => { + expect(extractLeadingEmoji('Hello World')).toEqual({ + emoji: null, + rest: 'Hello World', + }); + }); + + it('preserves original string - leading emoji after space', () => { + expect(extractLeadingEmoji(' ๐Ÿ˜€ Hello World')).toEqual({ + emoji: null, + rest: ' ๐Ÿ˜€ Hello World', + }); + }); + + it('preserves original string - middle emoji', () => { + expect(extractLeadingEmoji('Hello ๐Ÿ˜€ World')).toEqual({ + emoji: null, + rest: 'Hello ๐Ÿ˜€ World', + }); + }); + + it('preserves original string - trailing emoji', () => { + expect(extractLeadingEmoji('Hello World ๐Ÿ˜€')).toEqual({ + emoji: null, + rest: 'Hello World ๐Ÿ˜€', + }); + }); +}); diff --git a/packages/docusaurus-theme-common/src/utils/emojiUtils.ts b/packages/docusaurus-theme-common/src/utils/emojiUtils.ts new file mode 100644 index 000000000000..c66cbfb3fa01 --- /dev/null +++ b/packages/docusaurus-theme-common/src/utils/emojiUtils.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. + */ + +const segmenter = new Intl.Segmenter(undefined, {granularity: 'grapheme'}); + +/** + * This method splits "โš ๏ธ Hello World" into "โš ๏ธ" + " Hello World". + * It is quite strict and dumb, only useful to handle best-effort heuristics. + * It only extracts a leading emoji if it is the first grapheme of the string. + * It only extracts one emoji, even if multiples are present. + * It doesn't trim the remaining string. + * If you need something more clever, it should be built on top. + * @param input + */ +export function extractLeadingEmoji(input: string): { + emoji: string | null; + rest: string; +} { + const it = segmenter.segment(input)[Symbol.iterator](); + + // const first = segmenter.segment(input).containing(0)?.segment; + const grapheme = it.next().value?.segment; + + if (!grapheme) { + return {emoji: null, rest: input}; + } + + // Leading grapheme contains an emoji (covers flags/ZWJ/skin tones) + if ( + !/\p{Extended_Pictographic}/u.test(grapheme) && + !/\p{Emoji}/u.test(grapheme) + ) { + return {emoji: null, rest: input}; + } + + return {emoji: grapheme, rest: input.slice(grapheme.length)}; +} diff --git a/website/docs/api/misc/_category_.yml b/website/docs/api/misc/_category_.yml index 2fb307376467..738a412be53a 100644 --- a/website/docs/api/misc/_category_.yml +++ b/website/docs/api/misc/_category_.yml @@ -1,2 +1,4 @@ label: Miscellaneous position: 4 +link: + type: generated-index