feat(theme-classic): new configuration syntax for a simple footer (#6132)

Co-authored-by: Joshua Chen <sidachen2003@gmail.com>
Co-authored-by: sebastienlorber <lorber.sebastien@gmail.com>
This commit is contained in:
Christopher Klint 2021-12-20 19:45:27 +01:00 committed by GitHub
parent cb4265253a
commit d987c22996
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 364 additions and 91 deletions

View file

@ -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 {},

View file

@ -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', () => {

View file

@ -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: {

View file

@ -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,35 +71,12 @@ function FooterLogo({
);
}
function Footer(): JSX.Element | null {
const {footer} = useThemeConfig();
const {copyright, links = [], logo = {}} = footer || {};
const sources = {
light: useBaseUrl(logo.src),
dark: useBaseUrl(logo.srcDark || logo.src),
};
if (!footer) {
return null;
}
function MultiColumnLinks({links}: {links: MultiColumnFooter['links']}) {
return (
<footer
className={clsx('footer', {
'footer--dark': footer.style === 'dark',
})}>
<div className="container">
{links && links.length > 0 && (
<div className="row footer__links">
<>
{links.map((linkItem, i) => (
<div key={i} className="col footer__col">
{linkItem.title != null ? (
<div className="footer__title">{linkItem.title}</div>
) : null}
{linkItem.items != null &&
Array.isArray(linkItem.items) &&
linkItem.items.length > 0 ? (
<ul className="footer__items">
{linkItem.items.map((item, key) =>
item.html ? (
@ -114,11 +96,75 @@ function Footer(): JSX.Element | null {
),
)}
</ul>
) : null}
</div>
))}
</div>
</>
);
}
function SimpleLinks({links}: {links: SimpleFooter['links']}) {
return (
<div className="footer__links">
{links.map((item, key) => (
<>
{item.html ? (
<span
key={key}
className="footer__link-item"
// Developer provided the HTML, so assume it's safe.
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: item.html,
}}
/>
) : (
<FooterLink {...item} />
)}
{links.length !== key + 1 && (
<span className="footer__link-separator">·</span>
)}
</>
))}
</div>
);
}
function isMultiColumnFooterLinks(
links: MultiColumnFooter['links'] | SimpleFooter['links'],
): links is MultiColumnFooter['links'] {
return 'title' in links[0];
}
function Footer(): JSX.Element | null {
const {footer} = useThemeConfig();
const {copyright, links = [], logo = {}} = footer || {};
const sources = {
light: useBaseUrl(logo.src),
dark: useBaseUrl(logo.srcDark || logo.src),
};
if (!footer) {
return null;
}
return (
<footer
className={clsx('footer', {
'footer--dark': footer.style === 'dark',
})}>
<div className="container container-fluid">
{links &&
links.length > 0 &&
(isMultiColumnFooterLinks(links) ? (
<div className="row footer__links">
<MultiColumnLinks links={links} />
</div>
) : (
<div className="footer__links text--center">
<SimpleLinks links={links} />
</div>
))}
{(logo || copyright) && (
<div className="footer__bottom text--center">
{logo && (logo.src || logo.srcDark) && (
@ -154,4 +200,4 @@ function Footer(): JSX.Element | null {
);
}
export default Footer;
export default React.memo(Footer);

View file

@ -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,
)
? 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();
.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,7 +121,8 @@ function translateFooter(
footer: Footer,
footerTranslations: TranslationFileContent,
): Footer {
const links = footer.links.map((link) => ({
const links = isMultiColumnFooterLinks(footer.links)
? footer.links.map((link) => ({
...link,
title:
footerTranslations[`link.title.${link.title}`]?.message ?? link.title,
@ -118,6 +132,12 @@ function translateFooter(
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;

View file

@ -311,14 +311,19 @@ const ThemeConfigSchema = Joi.object({
href: Joi.string(),
}),
copyright: Joi.string(),
links: Joi.array()
links: Joi.alternatives(
Joi.array()
.items(
Joi.object({
title: Joi.string().allow(null),
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({

View file

@ -13,8 +13,9 @@ export type {
Navbar,
NavbarItem,
NavbarLogo,
MultiColumnFooter,
SimpleFooter,
Footer,
FooterLinks,
FooterLinkItem,
ColorModeConfig,
} from './utils/useThemeConfig';

View file

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

View file

@ -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` | <code>'dark' \| 'light'</code> | `'light'` | The color theme of the footer component. |
| `items` | `FooterItem[]` | `[]` | The link groups to be present. |
| `links` | <code>(Column \| FooterLink)[]</code> | `[]` | The link groups to be present. |
</APITable>
@ -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.
<APITable name="footer-links">
| 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. |
</APITable>
Accepted fields of each item in `items`:
Accepted fields of each `FooterItem`:
<APITable name="footer-items">
@ -725,7 +725,7 @@ Accepted fields of each item in `items`:
</APITable>
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: `
<a href="https://www.netlify.com" target="_blank" rel="noreferrer noopener" aria-label="Deploys by Netlify">
<img src="https://www.netlify.com/img/global/badges/netlify-color-accent.svg" alt="Deploys by Netlify" />
</a>
`,
},
],
// highlight-end
},
};
```
## Table of Contents {#table-of-contents}
You can adjust the default table of contents via `themeConfig.tableOfContents`.