mirror of
https://github.com/facebook/docusaurus.git
synced 2025-05-22 21:47:01 +02:00
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:
parent
cb4265253a
commit
d987c22996
9 changed files with 364 additions and 91 deletions
|
@ -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 {},
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -13,8 +13,9 @@ export type {
|
|||
Navbar,
|
||||
NavbarItem,
|
||||
NavbarLogo,
|
||||
MultiColumnFooter,
|
||||
SimpleFooter,
|
||||
Footer,
|
||||
FooterLinks,
|
||||
FooterLinkItem,
|
||||
ColorModeConfig,
|
||||
} from './utils/useThemeConfig';
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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`.
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue