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