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
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
Expand Up @@ -15,14 +15,24 @@
props: Props,
): JSX.Element | null {
const {announcementBar} = useThemeConfig();
const {content} = announcementBar!;
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}}
/>
);
const {content: Content} = announcementBar!;

// TODO Docusaurus v4: remove legacy annoncement bar html string form?

Check failure on line 20 in packages/docusaurus-theme-classic/src/theme/AnnouncementBar/Content/index.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unknown word (annoncement) -- Docusaurus v4: remove legacy annoncement 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)}>
<Content />
</div>
);

}
13 changes: 11 additions & 2 deletions packages/docusaurus-theme-common/src/utils/useThemeConfig.ts
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,14 @@ 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 emit errors on duplicate keys
return {...oldThemeConfig, ...newThemeConfig};
}
1 change: 1 addition & 0 deletions packages/docusaurus-types/src/config.d.ts
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
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
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
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
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
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
Expand Up @@ -125,5 +125,6 @@ exports[`load loads props for site with custom i18n path 1`] = `
"pluginVersions": {},
"siteVersion": undefined,
},
"themeConfigPath": undefined,
}
`;
63 changes: 57 additions & 6 deletions packages/docusaurus/src/server/config.ts
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,53 @@ 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 prevent users to not provide any theme config at all
if (
!conventionalThemeConfigPath &&
Object.keys(siteConfig.themeConfig ?? {}).length
) {
throw new Error(
`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
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
33 changes: 33 additions & 0 deletions website/docusaurus.theme.tsx
@@ -0,0 +1,33 @@
/**
* 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 type {ReactNode} from 'react';
import Link from '@docusaurus/Link';
import Translate from '@docusaurus/Translate';

export default {
announcementBar: {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

process is not defined for agolia however no error reported for navbar Debug label with isDev

id: 'announcementBar-3', // Increment on change
// content: `🎉️ <b><a target="_blank" href="https://docusaurus.io/blog/releases/3.0">Docusaurus v3.0</a> is now out!</b> 🥳️`,
content: function AnnoncementBarContent(): ReactNode {

Check failure on line 15 in website/docusaurus.theme.tsx

View workflow job for this annotation

GitHub Actions / Lint

Unknown word (Annoncement) -- content: function AnnoncementBarContent(): ReactNode
return (
<b>
<Translate
values={{
link: (
// eslint-disable-next-line @docusaurus/no-untranslated-text
<Link to="/blog/releases/3.0">
TEST IT WORKS Docusaurus v3.0
</Link>
),
}}>
{'🎉 {link}, is now out! 🥳'}
</Translate>
</b>
);
},
},
};