Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add support for browser-based theme config file (docusaurus.theme.js) #9619

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
7 changes: 7 additions & 0 deletions packages/docusaurus-module-type-aliases/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ declare module '@generated/docusaurus.config' {
export default config;
}

declare module '@generated/docusaurus.theme' {
import type {ThemeConfig} from '@docusaurus/types';

const themeConfig: ThemeConfig;
export default themeConfig;
}

declare module '@generated/site-metadata' {
import type {SiteMetadata} from '@docusaurus/types';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,23 @@ export default function AnnouncementBarContent(
props: Props,
): JSX.Element | null {
const {announcementBar} = useThemeConfig();
const {content} = announcementBar!;
const {content: Content} = announcementBar!;

// TODO Docusaurus v4: remove legacy announcement bar html string form?
if (typeof Content === 'string') {
return (
<div
{...props}
className={clsx(styles.content, props.className)}
// Developer provided the HTML, so assume it's safe.
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{__html: Content}}
/>
);
}
return (
<div
{...props}
className={clsx(styles.content, props.className)}
// Developer provided the HTML, so assume it's safe.
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{__html: content}}
/>
<div {...props} className={clsx(styles.content, props.className)}>
<Content />
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@ import IconExternalLink from '@theme/Icon/ExternalLink';
import type {Props} from '@theme/Footer/LinkItem';

export default function FooterLinkItem({item}: Props): JSX.Element {
const {to, href, label, prependBaseUrlToHref, ...props} = item;
const {to, href, label, prependBaseUrlToHref, node, ...props} = item;
const toUrl = useBaseUrl(to);
const normalizedHref = useBaseUrl(href, {forcePrependBaseUrl: true});

if (node) {
const NodeElement = node as React.ElementType;
return <NodeElement />;
}

return (
<Link
className="footer__link-item"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import React from 'react';
import {type FooterLinkItem} from '@docusaurus/theme-common';
import LinkItem from '@theme/Footer/LinkItem';
import type {Props} from '@theme/Footer/Links/Simple';

Expand All @@ -30,7 +31,7 @@ export default function FooterLinksSimple({links}: Props): JSX.Element {
return (
<div className="footer__links text--center">
<div className="footer__links">
{links.map((item, i) => (
{links.map((item: FooterLinkItem, i: number) => (
<React.Fragment key={i}>
<SimpleLinkItem item={item} />
{links.length !== i + 1 && <Separator />}
Expand Down
24 changes: 22 additions & 2 deletions packages/docusaurus-theme-common/src/utils/useThemeConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/

import type {ComponentType} from 'react';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import type {PrismTheme} from 'prism-react-renderer';
import type {DeepPartial} from 'utility-types';
Expand Down Expand Up @@ -51,7 +52,7 @@ export type ColorModeConfig = {

export type AnnouncementBarConfig = {
id: string;
content: string;
content: string | ComponentType;
backgroundColor: string;
textColor: string;
isCloseable: boolean;
Expand Down Expand Up @@ -99,6 +100,7 @@ export type TableOfContents = {
maxHeadingLevel: number;
};

// TODO use TS interface declaration merging?
// Theme config after validation/normalization
export type ThemeConfig = {
docs: {
Expand Down Expand Up @@ -129,7 +131,25 @@ export type UserThemeConfig = DeepPartial<ThemeConfig>;

/**
* A convenient/more semantic way to get theme config from context.
* TODO remove old themeConfig in Docusaurus v4?
* TODO remove this hook in v4 in favor of a core hook?
*/
export function useThemeConfig(): ThemeConfig {
return useDocusaurusContext().siteConfig.themeConfig as ThemeConfig;
const oldThemeConfig = useDocusaurusContext().siteConfig
.themeConfig as ThemeConfig;
const newThemeConfig = useDocusaurusContext().themeConfig as ThemeConfig;

// TODO docusaurus-theme-classic/src/options.ts
// assigns default values in oldThemeConfig (navbar, footer...)
const duplicateKeys = Object.keys(oldThemeConfig).filter((key) =>
Object.prototype.hasOwnProperty.call(newThemeConfig, key),
);

if (duplicateKeys.length > 0) {
console.warn(
`Duplicate keys found in siteConfig.themeConfig and themeConfig: ${duplicateKeys}`,
);
}

return {...oldThemeConfig, ...newThemeConfig};
}
1 change: 1 addition & 0 deletions packages/docusaurus-types/src/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {PluginConfig, PresetConfig, HtmlTagObject} from './plugin';

export type ReportingSeverity = 'ignore' | 'log' | 'warn' | 'throw';

// TODO use TypeScript interface declaration merging
export type ThemeConfig = {
[key: string]: unknown;
};
Expand Down
4 changes: 3 additions & 1 deletion packages/docusaurus-types/src/context.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import type {DocusaurusConfig} from './config';
import type {DocusaurusConfig, ThemeConfig} from './config';
import type {CodeTranslations, I18n} from './i18n';
import type {LoadedPlugin, PluginVersionInformation} from './plugin';
import type {RouteConfig} from './routing';

export type DocusaurusContext = {
siteConfig: DocusaurusConfig;
siteMetadata: SiteMetadata;
themeConfig: ThemeConfig;
globalData: GlobalData;
i18n: I18n;
codeTranslations: CodeTranslations;
Expand All @@ -34,6 +35,7 @@ export type LoadContext = {
generatedFilesDir: string;
siteConfig: DocusaurusConfig;
siteConfigPath: string;
themeConfigPath: string | undefined; // TODO Docusaurus v4: make it required
outDir: string;
/**
* Directory where all source translations for the current locale can be found
Expand Down
2 changes: 2 additions & 0 deletions packages/docusaurus-utils/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export const DEFAULT_BUILD_DIR_NAME = 'build';
*/
export const DEFAULT_CONFIG_FILE_NAME = 'docusaurus.config';

export const DEFAULT_THEME_FILE_NAME = 'docusaurus.theme';

/** Can be absolute or relative to site directory. */
export const BABEL_CONFIG_FILE_NAME =
process.env.DOCUSAURUS_BABEL_CONFIG_FILE_NAME ?? 'babel.config.js';
Expand Down
1 change: 1 addition & 0 deletions packages/docusaurus-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export {
DOCUSAURUS_VERSION,
DEFAULT_BUILD_DIR_NAME,
DEFAULT_CONFIG_FILE_NAME,
DEFAULT_THEME_FILE_NAME,
BABEL_CONFIG_FILE_NAME,
GENERATED_FILES_DIR_NAME,
SRC_DIR_NAME,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ describe('DocusaurusContextProvider', () => {
"i18n": {},
"siteConfig": {},
"siteMetadata": {},
"themeConfig": {},
}
`);
});
Expand Down
2 changes: 2 additions & 0 deletions packages/docusaurus/src/client/docusaurusContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import React, {type ReactNode} from 'react';
import siteConfig from '@generated/docusaurus.config';
import themeConfig from '@generated/docusaurus.theme';
import globalData from '@generated/globalData';
import i18n from '@generated/i18n';
import codeTranslations from '@generated/codeTranslations';
Expand All @@ -18,6 +19,7 @@ import type {DocusaurusContext} from '@docusaurus/types';
const contextValue: DocusaurusContext = {
siteConfig,
siteMetadata,
themeConfig,
globalData,
i18n,
codeTranslations,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,5 +125,6 @@ exports[`load loads props for site with custom i18n path 1`] = `
"pluginVersions": {},
"siteVersion": undefined,
},
"themeConfigPath": undefined,
}
`;
60 changes: 54 additions & 6 deletions packages/docusaurus/src/server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ import fs from 'fs-extra';
import logger from '@docusaurus/logger';
import {
DEFAULT_CONFIG_FILE_NAME,
DEFAULT_THEME_FILE_NAME,
findAsyncSequential,
loadFreshModule,
} from '@docusaurus/utils';
import {validateConfig} from './configValidation';
import type {LoadContext} from '@docusaurus/types';
import type {DocusaurusConfig, LoadContext} from '@docusaurus/types';

async function findConfig(siteDir: string) {
async function getConventionalSiteConfigPath(siteDir: string) {
// We could support .mjs, .ts, etc. in the future
const candidates = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs'].map(
(ext) => DEFAULT_CONFIG_FILE_NAME + ext,
Expand All @@ -26,10 +27,10 @@ async function findConfig(siteDir: string) {
fs.pathExists,
);
if (!configPath) {
logger.error('No config file found.');
logger.info`Expected one of:${candidates}
const errorMessage = logger.interpolate`No config file found.
Expected one of:${candidates}
You can provide a custom config path with the code=${'--config'} option.`;
throw new Error();
throw new Error(errorMessage);
}
return configPath;
}
Expand All @@ -43,7 +44,7 @@ export async function loadSiteConfig({
}): Promise<Pick<LoadContext, 'siteConfig' | 'siteConfigPath'>> {
const siteConfigPath = customConfigFilePath
? path.resolve(siteDir, customConfigFilePath)
: await findConfig(siteDir);
: await getConventionalSiteConfigPath(siteDir);

if (!(await fs.pathExists(siteConfigPath))) {
throw new Error(`Config file at "${siteConfigPath}" not found.`);
Expand All @@ -62,3 +63,50 @@ export async function loadSiteConfig({
);
return {siteConfig, siteConfigPath};
}

async function findConventionalThemeConfigPath(
siteDir: string,
): Promise<string | undefined> {
// We could support .mjs, .ts, etc. in the future
const candidates = ['.tsx', '.ts', '.jsx', '.js'].map(
(ext) => DEFAULT_THEME_FILE_NAME + ext,
);
const themeConfigPath = await findAsyncSequential(
candidates.map((file) => path.join(siteDir, file)),
fs.pathExists,
);

return themeConfigPath;
}

// TODO add tests for this
export async function findThemeConfigPath(
siteDir: string,
siteConfig: DocusaurusConfig,
): Promise<string | undefined> {
// TODO add support for custom themeConfig file path
// EX: siteConfig.themeConfig: './theme.tsx'
// Note: maybe it would be simpler to provide this path through the CLI?
if (typeof siteConfig.themeConfig === 'string') {
const customThemeConfigPath = siteConfig.themeConfig;
if (!(await fs.pathExists(customThemeConfigPath))) {
throw new Error(
`Theme config file at "${customThemeConfigPath}" not found.`,
);
}
return customThemeConfigPath;
}
const conventionalThemeConfigPath = await findConventionalThemeConfigPath(
siteDir,
);
// In Docusaurus v3 we require users to provide either the theme config
// through the conventional path, or through the legacy siteConfig attribute
// To avoid issues we warn users when no theme config is provided at all
if (
!conventionalThemeConfigPath &&
Object.keys(siteConfig.themeConfig ?? {}).length === 0
) {
logger.warn`Theme config file couldn't be found at ${DEFAULT_THEME_FILE_NAME}.js or ${DEFAULT_THEME_FILE_NAME}.tsx`;
}
return conventionalThemeConfigPath;
}
28 changes: 27 additions & 1 deletion packages/docusaurus/src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ import {
localizePath,
DEFAULT_BUILD_DIR_NAME,
DEFAULT_CONFIG_FILE_NAME,
DEFAULT_THEME_FILE_NAME,
GENERATED_FILES_DIR_NAME,
} from '@docusaurus/utils';
import {loadSiteConfig} from './config';
import {findThemeConfigPath, loadSiteConfig} from './config';
import {loadClientModules} from './clientModules';
import {loadPlugins} from './plugins';
import {loadRoutes} from './routes';
Expand Down Expand Up @@ -68,6 +69,8 @@ export async function loadContext(
customConfigFilePath,
});

const themeConfigPath = await findThemeConfigPath(siteDir, initialSiteConfig);

const i18n = await loadI18n(initialSiteConfig, {locale});

const baseUrl = localizePath({
Expand Down Expand Up @@ -106,6 +109,7 @@ export async function loadContext(
localizationDir,
siteConfig,
siteConfigPath,
themeConfigPath,
outDir,
baseUrl,
i18n,
Expand All @@ -126,6 +130,7 @@ export async function load(options: LoadContextOptions): Promise<Props> {
generatedFilesDir,
siteConfig,
siteConfigPath,
themeConfigPath,
outDir,
baseUrl,
i18n,
Expand Down Expand Up @@ -172,6 +177,25 @@ export default ${JSON.stringify(siteConfig, null, 2)};
`,
);

const themeConfigContent = themeConfigPath
? `export {default} from '@site/${path.relative(
siteDir,
themeConfigPath,
)}';`
: // TODO Docusaurus v4: require theme config file, remove this fallback
`export default {};`;
const genThemeConfig = generate(
generatedFilesDir,
`${DEFAULT_THEME_FILE_NAME}.js`,
`/*
* AUTOGENERATED - DON'T EDIT
* Your edits in this file will be overwritten in the next build!
* Modify the docusaurus.config.js file at your site's root instead.
*/
${themeConfigContent}
`,
);

const genClientModules = generate(
generatedFilesDir,
'client-modules.js',
Expand Down Expand Up @@ -236,6 +260,7 @@ ${Object.entries(registry)
genWarning,
genClientModules,
genSiteConfig,
genThemeConfig,
genRegistry,
genRoutesChunkNames,
genRoutes,
Expand All @@ -248,6 +273,7 @@ ${Object.entries(registry)
return {
siteConfig,
siteConfigPath,
themeConfigPath,
siteMetadata,
siteDir,
outDir,
Expand Down
Loading