diff --git a/packages/docusaurus-theme-classic/src/__tests__/__snapshots__/translations.test.ts.snap b/packages/docusaurus-theme-classic/src/__tests__/__snapshots__/translations.test.ts.snap index a0e76dbce6..3f3ae40ed4 100644 --- a/packages/docusaurus-theme-classic/src/__tests__/__snapshots__/translations.test.ts.snap +++ b/packages/docusaurus-theme-classic/src/__tests__/__snapshots__/translations.test.ts.snap @@ -55,6 +55,49 @@ Array [ ] `; +exports[`getTranslationFiles should return translation files matching snapshot 2`] = ` +Array [ + Object { + "content": Object { + "item.label.Dropdown": Object { + "description": "Navbar item with label Dropdown", + "message": "Dropdown", + }, + "item.label.Dropdown item 1": Object { + "description": "Navbar item with label Dropdown item 1", + "message": "Dropdown item 1", + }, + "item.label.Dropdown item 2": Object { + "description": "Navbar item with label Dropdown item 2", + "message": "Dropdown item 2", + }, + "title": Object { + "description": "The title in the navbar", + "message": "navbar title", + }, + }, + "path": "navbar", + }, + Object { + "content": Object { + "copyright": Object { + "description": "The footer copyright", + "message": "Copyright FB", + }, + "link.item.label.Link 1": Object { + "description": "The label of footer link with label=Link 1 linking to https://facebook.com", + "message": "Link 1", + }, + "link.item.label.Link 2": Object { + "description": "The label of footer link with label=Link 2 linking to https://facebook.com", + "message": "Link 2", + }, + }, + "path": "footer", + }, +] +`; + exports[`translateThemeConfig should return translated themeConfig matching snapshot 1`] = ` Object { "announcementBar": Object {}, diff --git a/packages/docusaurus-theme-classic/src/__tests__/translations.test.ts b/packages/docusaurus-theme-classic/src/__tests__/translations.test.ts index 64ecdf8a2e..fd0bfd51ec 100644 --- a/packages/docusaurus-theme-classic/src/__tests__/translations.test.ts +++ b/packages/docusaurus-theme-classic/src/__tests__/translations.test.ts @@ -50,14 +50,26 @@ const ThemeConfigSample: ThemeConfig = { }, }; -function getSampleTranslationFiles() { +const ThemeConfigSampleSimpleFooter: ThemeConfig = { + ...ThemeConfigSample, + footer: { + copyright: 'Copyright FB', + style: 'light', + links: [ + {label: 'Link 1', to: 'https://facebook.com'}, + {label: 'Link 2', to: 'https://facebook.com'}, + ], + }, +}; + +function getSampleTranslationFiles(themeConfig: ThemeConfig) { return getTranslationFiles({ - themeConfig: ThemeConfigSample, + themeConfig, }); } -function getSampleTranslationFilesTranslated() { - const translationFiles = getSampleTranslationFiles(); +function getSampleTranslationFilesTranslated(themeConfig: ThemeConfig) { + const translationFiles = getSampleTranslationFiles(themeConfig); return translationFiles.map((translationFile) => updateTranslationFileMessages( translationFile, @@ -68,27 +80,29 @@ function getSampleTranslationFilesTranslated() { describe('getTranslationFiles', () => { test('should return translation files matching snapshot', () => { - expect(getSampleTranslationFiles()).toMatchSnapshot(); + expect(getSampleTranslationFiles(ThemeConfigSample)).toMatchSnapshot(); + expect( + getSampleTranslationFiles(ThemeConfigSampleSimpleFooter), + ).toMatchSnapshot(); }); }); describe('translateThemeConfig', () => { test('should not translate anything if translation files are untranslated', () => { - const translationFiles = getSampleTranslationFiles(); expect( translateThemeConfig({ themeConfig: ThemeConfigSample, - translationFiles, + translationFiles: getSampleTranslationFiles(ThemeConfigSample), }), ).toEqual(ThemeConfigSample); }); test('should return translated themeConfig matching snapshot', () => { - const translationFiles = getSampleTranslationFilesTranslated(); expect( translateThemeConfig({ themeConfig: ThemeConfigSample, - translationFiles, + translationFiles: + getSampleTranslationFilesTranslated(ThemeConfigSample), }), ).toMatchSnapshot(); }); @@ -96,18 +110,21 @@ describe('translateThemeConfig', () => { describe('getTranslationFiles and translateThemeConfig isomorphism', () => { function verifyIsomorphism(themeConfig: ThemeConfig) { - const translationFiles = getTranslationFiles({themeConfig}); const translatedThemeConfig = translateThemeConfig({ themeConfig, - translationFiles, + translationFiles: getTranslationFiles({themeConfig}), }); expect(translatedThemeConfig).toEqual(themeConfig); } - test('should be verified for main sample', () => { + test('should be verified for sample', () => { verifyIsomorphism(ThemeConfigSample); }); + test('should be verified for sample with simple footer', () => { + verifyIsomorphism(ThemeConfigSampleSimpleFooter); + }); + // undefined footer should not make the translation code crash // See https://github.com/facebook/docusaurus/issues/3936 test('should be verified for sample without footer', () => { diff --git a/packages/docusaurus-theme-classic/src/__tests__/validateThemeConfig.test.js b/packages/docusaurus-theme-classic/src/__tests__/validateThemeConfig.test.js index afea10a29f..e987f2ac69 100644 --- a/packages/docusaurus-theme-classic/src/__tests__/validateThemeConfig.test.js +++ b/packages/docusaurus-theme-classic/src/__tests__/validateThemeConfig.test.js @@ -345,6 +345,104 @@ describe('themeConfig', () => { }); }); + test('should allow simple links in footer', () => { + const partialConfig = { + footer: { + links: [ + { + label: 'Privacy', + href: 'https://opensource.facebook.com/legal/privacy/', + }, + { + label: 'Terms', + href: 'https://opensource.facebook.com/legal/terms/', + }, + { + label: 'Data Policy', + href: 'https://opensource.facebook.com/legal/data-policy/', + }, + { + label: 'Cookie Policy', + href: 'https://opensource.facebook.com/legal/cookie-policy/', + }, + ], + }, + }; + const normalizedConfig = testValidateThemeConfig(partialConfig); + + expect(normalizedConfig).toEqual({ + ...normalizedConfig, + footer: { + ...normalizedConfig.footer, + ...partialConfig.footer, + }, + }); + }); + + test('should allow footer column with no title', () => { + const partialConfig = { + footer: { + links: [ + { + items: [ + { + label: 'Data Policy', + href: 'https://opensource.facebook.com/legal/data-policy/', + }, + { + label: 'Cookie Policy', + href: 'https://opensource.facebook.com/legal/cookie-policy/', + }, + ], + }, + ], + }, + }; + const normalizedConfig = testValidateThemeConfig(partialConfig); + + expect(normalizedConfig).toEqual({ + ...normalizedConfig, + footer: { + ...normalizedConfig.footer, + ...partialConfig.footer, + links: [ + { + title: null, // Default value is important to distinguish simple footer from multi-column footer + items: partialConfig.footer.links[0].items, + }, + ], + }, + }); + }); + + test('should reject mix of simple and multi-column links in footer', () => { + const partialConfig = { + footer: { + links: [ + { + title: 'Learn', + items: [ + { + label: 'Introduction', + to: 'docs', + }, + ], + }, + { + label: 'Privacy', + href: 'https://opensource.facebook.com/legal/privacy/', + }, + ], + }, + }; + + expect(() => + testValidateThemeConfig(partialConfig), + ).toThrowErrorMatchingInlineSnapshot( + `"The footer must be either simple or multi-column, and not a mix of the two. See: https://docusaurus.io/docs/api/themes/configuration#footer-links"`, + ); + }); + test('should allow width and height specification for logo ', () => { const altTagConfig = { navbar: { diff --git a/packages/docusaurus-theme-classic/src/theme/Footer/index.tsx b/packages/docusaurus-theme-classic/src/theme/Footer/index.tsx index 026afe6115..0f154f8fe0 100644 --- a/packages/docusaurus-theme-classic/src/theme/Footer/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Footer/index.tsx @@ -9,7 +9,12 @@ import React from 'react'; import clsx from 'clsx'; import Link from '@docusaurus/Link'; -import {FooterLinkItem, useThemeConfig} from '@docusaurus/theme-common'; +import { + FooterLinkItem, + useThemeConfig, + MultiColumnFooter, + SimpleFooter, +} from '@docusaurus/theme-common'; import useBaseUrl from '@docusaurus/useBaseUrl'; import isInternalUrl from '@docusaurus/isInternalUrl'; import styles from './styles.module.css'; @@ -66,6 +71,70 @@ function FooterLogo({ ); } +function MultiColumnLinks({links}: {links: MultiColumnFooter['links']}) { + return ( + <> + {links.map((linkItem, i) => ( +
+
{linkItem.title}
+ +
+ ))} + + ); +} + +function SimpleLinks({links}: {links: SimpleFooter['links']}) { + return ( +
+ {links.map((item, key) => ( + <> + {item.html ? ( + + ) : ( + + )} + {links.length !== key + 1 && ( + ยท + )} + + ))} +
+ ); +} + +function isMultiColumnFooterLinks( + links: MultiColumnFooter['links'] | SimpleFooter['links'], +): links is MultiColumnFooter['links'] { + return 'title' in links[0]; +} + function Footer(): JSX.Element | null { const {footer} = useThemeConfig(); @@ -84,41 +153,18 @@ function Footer(): JSX.Element | null { className={clsx('footer', { 'footer--dark': footer.style === 'dark', })}> -
- {links && links.length > 0 && ( -
- {links.map((linkItem, i) => ( -
- {linkItem.title != null ? ( -
{linkItem.title}
- ) : null} - {linkItem.items != null && - Array.isArray(linkItem.items) && - linkItem.items.length > 0 ? ( -
    - {linkItem.items.map((item, key) => - item.html ? ( -
  • - ) : ( -
  • - -
  • - ), - )} -
- ) : null} -
- ))} -
- )} +
+ {links && + links.length > 0 && + (isMultiColumnFooterLinks(links) ? ( +
+ +
+ ) : ( +
+ +
+ ))} {(logo || copyright) && (
{logo && (logo.src || logo.srcDark) && ( @@ -154,4 +200,4 @@ function Footer(): JSX.Element | null { ); } -export default Footer; +export default React.memo(Footer); diff --git a/packages/docusaurus-theme-classic/src/translations.ts b/packages/docusaurus-theme-classic/src/translations.ts index be96e3b6c1..3593e03004 100644 --- a/packages/docusaurus-theme-classic/src/translations.ts +++ b/packages/docusaurus-theme-classic/src/translations.ts @@ -11,6 +11,8 @@ import { Navbar, NavbarItem, Footer, + MultiColumnFooter, + SimpleFooter, } from '@docusaurus/theme-common'; import {keyBy, chain} from 'lodash'; @@ -69,20 +71,31 @@ function translateNavbar( }; } +function isMultiColumnFooterLinks( + links: MultiColumnFooter['links'] | SimpleFooter['links'], +): links is MultiColumnFooter['links'] { + return 'title' in links[0]; +} + function getFooterTranslationFile(footer: Footer): TranslationFileContent { - // TODO POC code - const footerLinkTitles: TranslationFileContent = chain( - footer.links.filter((link) => !!link.title), + const footerLinkTitles: TranslationFileContent = isMultiColumnFooterLinks( + footer.links, ) - .keyBy((link) => `link.title.${link.title}`) - .mapValues((link) => ({ - message: link.title!, - description: `The title of the footer links column with title=${link.title} in the footer`, - })) - .value(); + ? chain(footer.links.filter((link) => !!link.title)) + .keyBy((link) => `link.title.${link.title}`) + .mapValues((link) => ({ + message: link.title!, + description: `The title of the footer links column with title=${link.title} in the footer`, + })) + .value() + : {}; const footerLinkLabels: TranslationFileContent = chain( - footer.links.flatMap((link) => link.items).filter((link) => !!link.label), + isMultiColumnFooterLinks(footer.links) + ? footer.links + .flatMap((link) => link.items) + .filter((link) => !!link.label) + : footer.links.filter((link) => !!link.label), ) .keyBy((linkItem) => `link.item.label.${linkItem.label}`) .mapValues((linkItem) => ({ @@ -108,17 +121,24 @@ function translateFooter( footer: Footer, footerTranslations: TranslationFileContent, ): Footer { - const links = footer.links.map((link) => ({ - ...link, - title: - footerTranslations[`link.title.${link.title}`]?.message ?? link.title, - items: link.items.map((linkItem) => ({ - ...linkItem, - label: - footerTranslations[`link.item.label.${linkItem.label}`]?.message ?? - linkItem.label, - })), - })); + const links = isMultiColumnFooterLinks(footer.links) + ? footer.links.map((link) => ({ + ...link, + title: + footerTranslations[`link.title.${link.title}`]?.message ?? link.title, + items: link.items.map((linkItem) => ({ + ...linkItem, + label: + footerTranslations[`link.item.label.${linkItem.label}`]?.message ?? + linkItem.label, + })), + })) + : footer.links.map((link) => ({ + ...link, + label: + footerTranslations[`link.item.label.${link.label}`]?.message ?? + link.label, + })); const copyright = footerTranslations.copyright?.message ?? footer.copyright; diff --git a/packages/docusaurus-theme-classic/src/validateThemeConfig.ts b/packages/docusaurus-theme-classic/src/validateThemeConfig.ts index 6acbedc523..aa5b983fcb 100644 --- a/packages/docusaurus-theme-classic/src/validateThemeConfig.ts +++ b/packages/docusaurus-theme-classic/src/validateThemeConfig.ts @@ -311,14 +311,19 @@ const ThemeConfigSchema = Joi.object({ href: Joi.string(), }), copyright: Joi.string(), - links: Joi.array() - .items( - Joi.object({ - title: Joi.string().allow(null), - items: Joi.array().items(FooterLinkItemSchema).default([]), - }), - ) - .default([]), + links: Joi.alternatives( + Joi.array() + .items( + Joi.object({ + title: Joi.string().allow(null).default(null), + items: Joi.array().items(FooterLinkItemSchema).default([]), + }), + ) + .default([]), + Joi.array().items(FooterLinkItemSchema).default([]), + ).messages({ + 'alternatives.match': `The footer must be either simple or multi-column, and not a mix of the two. See: https://docusaurus.io/docs/api/themes/configuration#footer-links`, + }), }).optional(), prism: Joi.object({ theme: Joi.object({ diff --git a/packages/docusaurus-theme-common/src/index.ts b/packages/docusaurus-theme-common/src/index.ts index c53792b012..06d80707c3 100644 --- a/packages/docusaurus-theme-common/src/index.ts +++ b/packages/docusaurus-theme-common/src/index.ts @@ -13,8 +13,9 @@ export type { Navbar, NavbarItem, NavbarLogo, + MultiColumnFooter, + SimpleFooter, Footer, - FooterLinks, FooterLinkItem, ColorModeConfig, } from './utils/useThemeConfig'; diff --git a/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts b/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts index d8050e58ee..7b8def5723 100644 --- a/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts +++ b/packages/docusaurus-theme-common/src/utils/useThemeConfig.ts @@ -73,11 +73,8 @@ export type FooterLinkItem = { html?: string; prependBaseUrlToHref?: string; }; -export type FooterLinks = { - title?: string; - items: FooterLinkItem[]; -}; -export type Footer = { + +export type FooterBase = { style: 'light' | 'dark'; logo?: { alt?: string; @@ -88,9 +85,21 @@ export type Footer = { href?: string; }; copyright?: string; - links: FooterLinks[]; }; +export type MultiColumnFooter = FooterBase & { + links: Array<{ + title: string | null; + items: FooterLinkItem[]; + }>; +}; + +export type SimpleFooter = FooterBase & { + links: FooterLinkItem[]; +}; + +export type Footer = MultiColumnFooter | SimpleFooter; + export type TableOfContents = { minHeadingLevel: number; maxHeadingLevel: number; diff --git a/website/docs/api/themes/theme-configuration.md b/website/docs/api/themes/theme-configuration.md index 4023f10ab1..5c79af4487 100644 --- a/website/docs/api/themes/theme-configuration.md +++ b/website/docs/api/themes/theme-configuration.md @@ -672,7 +672,7 @@ Accepted fields: | `logo` | `Logo` | `undefined` | Customization of the logo object. See [Navbar logo](#navbar-logo) for details. | | `copyright` | `string` | `undefined` | The copyright message to be displayed at the bottom. | | `style` | 'dark' \| 'light' | `'light'` | The color theme of the footer component. | -| `items` | `FooterItem[]` | `[]` | The link groups to be present. | +| `links` | (Column \| FooterLink)[] | `[]` | The link groups to be present. | @@ -699,20 +699,20 @@ module.exports = { ### Footer Links {#footer-links} -You can add links to the footer via `themeConfig.footer.links`. +You can add links to the footer via `themeConfig.footer.links`. There are two types of footer configurations: **multi-column footers** and **simple footers**. -Accepted fields of each link section: +Multi-column footer links have a `title` and a list of `FooterItem`s for each column. | Name | Type | Default | Description | | --- | --- | --- | --- | | `title` | `string` | `undefined` | Label of the section of these links. | -| `items` | `FooterLink[]` | `[]` | Links in this section. | +| `items` | `FooterItem[]` | `[]` | Links in this section. | -Accepted fields of each item in `items`: +Accepted fields of each `FooterItem`: @@ -725,7 +725,7 @@ Accepted fields of each item in `items`: -Example configuration: +Example multi-column configuration: ```js title="docusaurus.config.js" module.exports = { @@ -775,6 +775,40 @@ module.exports = { }; ``` +A simple footer just has a list of `FooterItem`s displayed in a row. + +Example simple configuration: + +```js title="docusaurus.config.js" +module.exports = { + footer: { + // highlight-start + links: [ + { + label: 'Stack Overflow', + href: 'https://stackoverflow.com/questions/tagged/docusaurus', + }, + { + label: 'Discord', + href: 'https://discordapp.com/invite/docusaurus', + }, + { + label: 'Twitter', + href: 'https://twitter.com/docusaurus', + }, + { + html: ` + + Deploys by Netlify + + `, + }, + ], + // highlight-end + }, +}; +``` + ## Table of Contents {#table-of-contents} You can adjust the default table of contents via `themeConfig.tableOfContents`.