feat(core): hash router option - browse site offline (experimental) (#9859)

This commit is contained in:
Sébastien Lorber 2024-05-19 15:44:58 +02:00 committed by GitHub
parent b73ad1ece5
commit 17f3e02a42
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1018 additions and 266 deletions

60
.github/workflows/build-hash-router.yml vendored Normal file
View file

@ -0,0 +1,60 @@
name: Build Hash Router
on:
pull_request:
branches:
- main
- docusaurus-v**
paths:
- packages/**
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
build:
name: Build Hash Router
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- name: Set up Node
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version: '18'
cache: yarn
- name: Installation
run: yarn
- name: Build Hash Router
run: yarn build:website:fast
env:
DOCUSAURUS_ROUTER: 'hash'
BASE_URL: '/docusaurus/' # GH pages deploys under https://facebook.github.io/docusaurus/
- name: Upload Website artifact
uses: actions/upload-artifact@v4
with:
name: website-hash-router-archive
path: website/build
- name: Upload Website Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: website/build
# See https://docusaurus.io/docs/deployment#triggering-deployment-with-github-actions
deploy:
name: Deploy to GitHub Pages
if: ${{ github.event_name != 'pull_request' && github.ref_name == 'main')}}
needs: build
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View file

@ -6,6 +6,7 @@
*/ */
import {addLeadingSlash, removePrefix} from '@docusaurus/utils-common'; import {addLeadingSlash, removePrefix} from '@docusaurus/utils-common';
import logger from '@docusaurus/logger';
import collectRedirects from './collectRedirects'; import collectRedirects from './collectRedirects';
import writeRedirectFiles, { import writeRedirectFiles, {
toRedirectFiles, toRedirectFiles,
@ -15,14 +16,24 @@ import type {LoadContext, Plugin} from '@docusaurus/types';
import type {PluginContext, RedirectItem} from './types'; import type {PluginContext, RedirectItem} from './types';
import type {PluginOptions, Options} from './options'; import type {PluginOptions, Options} from './options';
const PluginName = 'docusaurus-plugin-client-redirects';
export default function pluginClientRedirectsPages( export default function pluginClientRedirectsPages(
context: LoadContext, context: LoadContext,
options: PluginOptions, options: PluginOptions,
): Plugin<void> { ): Plugin<void> {
const {trailingSlash} = context.siteConfig; const {trailingSlash} = context.siteConfig;
const router = context.siteConfig.future.experimental_router;
if (router === 'hash') {
logger.warn(
`${PluginName} does not support the Hash Router and will be disabled.`,
);
return {name: PluginName};
}
return { return {
name: 'docusaurus-plugin-client-redirects', name: PluginName,
async postBuild(props) { async postBuild(props) {
const pluginContext: PluginContext = { const pluginContext: PluginContext = {
relativeRoutesPaths: props.routesPaths.map( relativeRoutesPaths: props.routesPaths.map(

View file

@ -106,6 +106,7 @@ const getPlugin = async (
baseUrl: '/', baseUrl: '/',
url: 'https://docusaurus.io', url: 'https://docusaurus.io',
markdown, markdown,
future: {},
} as DocusaurusConfig; } as DocusaurusConfig;
return pluginContentBlog( return pluginContentBlog(
{ {

View file

@ -16,7 +16,7 @@ import {
applyTrailingSlash, applyTrailingSlash,
} from '@docusaurus/utils-common'; } from '@docusaurus/utils-common';
import {load as cheerioLoad} from 'cheerio'; import {load as cheerioLoad} from 'cheerio';
import type {DocusaurusConfig} from '@docusaurus/types'; import type {DocusaurusConfig, HtmlTags, LoadContext} from '@docusaurus/types';
import type { import type {
FeedType, FeedType,
PluginOptions, PluginOptions,
@ -254,3 +254,59 @@ export async function createBlogFeedFiles({
), ),
); );
} }
export function createFeedHtmlHeadTags({
context,
options,
}: {
context: LoadContext;
options: PluginOptions;
}): HtmlTags {
const feedTypes = options.feedOptions.type;
if (!feedTypes) {
return [];
}
const feedTitle = options.feedOptions.title ?? context.siteConfig.title;
const feedsConfig = {
rss: {
type: 'application/rss+xml',
path: 'rss.xml',
title: `${feedTitle} RSS Feed`,
},
atom: {
type: 'application/atom+xml',
path: 'atom.xml',
title: `${feedTitle} Atom Feed`,
},
json: {
type: 'application/json',
path: 'feed.json',
title: `${feedTitle} JSON Feed`,
},
};
const headTags: HtmlTags = [];
feedTypes.forEach((feedType) => {
const {
type,
path: feedConfigPath,
title: feedConfigTitle,
} = feedsConfig[feedType];
headTags.push({
tagName: 'link',
attributes: {
rel: 'alternate',
type,
href: normalizeUrl([
context.siteConfig.baseUrl,
options.routeBasePath,
feedConfigPath,
]),
title: feedConfigTitle,
},
});
});
return headTags;
}

View file

@ -29,11 +29,11 @@ import {
} from './blogUtils'; } from './blogUtils';
import footnoteIDFixer from './remark/footnoteIDFixer'; import footnoteIDFixer from './remark/footnoteIDFixer';
import {translateContent, getTranslationFiles} from './translations'; import {translateContent, getTranslationFiles} from './translations';
import {createBlogFeedFiles} from './feed'; import {createBlogFeedFiles, createFeedHtmlHeadTags} from './feed';
import {createAllRoutes} from './routes'; import {createAllRoutes} from './routes';
import type {BlogContentPaths, BlogMarkdownLoaderOptions} from './types'; import type {BlogContentPaths, BlogMarkdownLoaderOptions} from './types';
import type {LoadContext, Plugin, HtmlTags} from '@docusaurus/types'; import type {LoadContext, Plugin} from '@docusaurus/types';
import type { import type {
PluginOptions, PluginOptions,
BlogPostFrontMatter, BlogPostFrontMatter,
@ -44,6 +44,8 @@ import type {
BlogPaginated, BlogPaginated,
} from '@docusaurus/plugin-content-blog'; } from '@docusaurus/plugin-content-blog';
const PluginName = 'docusaurus-plugin-content-blog';
export default async function pluginContentBlog( export default async function pluginContentBlog(
context: LoadContext, context: LoadContext,
options: PluginOptions, options: PluginOptions,
@ -55,22 +57,29 @@ export default async function pluginContentBlog(
localizationDir, localizationDir,
i18n: {currentLocale}, i18n: {currentLocale},
} = context; } = context;
const router = siteConfig.future.experimental_router;
const isBlogFeedDisabledBecauseOfHashRouter =
router === 'hash' && !!options.feedOptions.type;
if (isBlogFeedDisabledBecauseOfHashRouter) {
logger.warn(
`${PluginName} feed feature does not support the Hash Router. Feeds won't be generated.`,
);
}
const {onBrokenMarkdownLinks, baseUrl} = siteConfig; const {onBrokenMarkdownLinks, baseUrl} = siteConfig;
const contentPaths: BlogContentPaths = { const contentPaths: BlogContentPaths = {
contentPath: path.resolve(siteDir, options.path), contentPath: path.resolve(siteDir, options.path),
contentPathLocalized: getPluginI18nPath({ contentPathLocalized: getPluginI18nPath({
localizationDir, localizationDir,
pluginName: 'docusaurus-plugin-content-blog', pluginName: PluginName,
pluginId: options.id, pluginId: options.id,
}), }),
}; };
const pluginId = options.id ?? DEFAULT_PLUGIN_ID; const pluginId = options.id ?? DEFAULT_PLUGIN_ID;
const pluginDataDirRoot = path.join( const pluginDataDirRoot = path.join(generatedFilesDir, PluginName);
generatedFilesDir,
'docusaurus-plugin-content-blog',
);
const dataDir = path.join(pluginDataDirRoot, pluginId); const dataDir = path.join(pluginDataDirRoot, pluginId);
// TODO Docusaurus v4 breaking change // TODO Docusaurus v4 breaking change
// module aliasing should be automatic // module aliasing should be automatic
@ -84,7 +93,7 @@ export default async function pluginContentBlog(
}); });
return { return {
name: 'docusaurus-plugin-content-blog', name: PluginName,
getPathsToWatch() { getPathsToWatch() {
const {include} = options; const {include} = options;
@ -295,15 +304,16 @@ export default async function pluginContentBlog(
}, },
async postBuild({outDir, content}) { async postBuild({outDir, content}) {
if (!options.feedOptions.type) { if (
return; !content.blogPosts.length ||
} !options.feedOptions.type ||
const {blogPosts} = content; isBlogFeedDisabledBecauseOfHashRouter
if (!blogPosts.length) { ) {
return; return;
} }
await createBlogFeedFiles({ await createBlogFeedFiles({
blogPosts, blogPosts: content.blogPosts,
options, options,
outDir, outDir,
siteConfig, siteConfig,
@ -312,56 +322,15 @@ export default async function pluginContentBlog(
}, },
injectHtmlTags({content}) { injectHtmlTags({content}) {
if (!content.blogPosts.length || !options.feedOptions.type) { if (
!content.blogPosts.length ||
!options.feedOptions.type ||
isBlogFeedDisabledBecauseOfHashRouter
) {
return {}; return {};
} }
const feedTypes = options.feedOptions.type; return {headTags: createFeedHtmlHeadTags({context, options})};
const feedTitle = options.feedOptions.title ?? context.siteConfig.title;
const feedsConfig = {
rss: {
type: 'application/rss+xml',
path: 'rss.xml',
title: `${feedTitle} RSS Feed`,
},
atom: {
type: 'application/atom+xml',
path: 'atom.xml',
title: `${feedTitle} Atom Feed`,
},
json: {
type: 'application/json',
path: 'feed.json',
title: `${feedTitle} JSON Feed`,
},
};
const headTags: HtmlTags = [];
feedTypes.forEach((feedType) => {
const {
type,
path: feedConfigPath,
title: feedConfigTitle,
} = feedsConfig[feedType];
headTags.push({
tagName: 'link',
attributes: {
rel: 'alternate',
type,
href: normalizeUrl([
baseUrl,
options.routeBasePath,
feedConfigPath,
]),
title: feedConfigTitle,
},
});
});
return {
headTags,
};
}, },
}; };
} }

View file

@ -23,6 +23,7 @@
"@babel/core": "^7.23.3", "@babel/core": "^7.23.3",
"@babel/preset-env": "^7.23.3", "@babel/preset-env": "^7.23.3",
"@docusaurus/core": "3.3.2", "@docusaurus/core": "3.3.2",
"@docusaurus/logger": "3.3.2",
"@docusaurus/theme-common": "3.3.2", "@docusaurus/theme-common": "3.3.2",
"@docusaurus/theme-translations": "3.3.2", "@docusaurus/theme-translations": "3.3.2",
"@docusaurus/types": "3.3.2", "@docusaurus/types": "3.3.2",

View file

@ -11,11 +11,14 @@ import WebpackBar from 'webpackbar';
import Terser from 'terser-webpack-plugin'; import Terser from 'terser-webpack-plugin';
import {injectManifest} from 'workbox-build'; import {injectManifest} from 'workbox-build';
import {normalizeUrl} from '@docusaurus/utils'; import {normalizeUrl} from '@docusaurus/utils';
import logger from '@docusaurus/logger';
import {compile} from '@docusaurus/core/lib/webpack/utils'; import {compile} from '@docusaurus/core/lib/webpack/utils';
import {readDefaultCodeTranslationMessages} from '@docusaurus/theme-translations'; import {readDefaultCodeTranslationMessages} from '@docusaurus/theme-translations';
import type {HtmlTags, LoadContext, Plugin} from '@docusaurus/types'; import type {HtmlTags, LoadContext, Plugin} from '@docusaurus/types';
import type {PluginOptions} from '@docusaurus/plugin-pwa'; import type {PluginOptions} from '@docusaurus/plugin-pwa';
const PluginName = 'docusaurus-plugin-pwa';
const isProd = process.env.NODE_ENV === 'production'; const isProd = process.env.NODE_ENV === 'production';
function getSWBabelLoader() { function getSWBabelLoader() {
@ -47,6 +50,7 @@ export default function pluginPWA(
outDir, outDir,
baseUrl, baseUrl,
i18n: {currentLocale}, i18n: {currentLocale},
siteConfig,
} = context; } = context;
const { const {
debug, debug,
@ -57,8 +61,15 @@ export default function pluginPWA(
swRegister, swRegister,
} = options; } = options;
if (siteConfig.future.experimental_router === 'hash') {
logger.warn(
`${PluginName} does not support the Hash Router and will be disabled.`,
);
return {name: PluginName};
}
return { return {
name: 'docusaurus-plugin-pwa', name: PluginName,
getThemePath() { getThemePath() {
return '../lib/theme'; return '../lib/theme';

View file

@ -12,12 +12,21 @@ import createSitemap from './createSitemap';
import type {PluginOptions, Options} from './options'; import type {PluginOptions, Options} from './options';
import type {LoadContext, Plugin} from '@docusaurus/types'; import type {LoadContext, Plugin} from '@docusaurus/types';
const PluginName = 'docusaurus-plugin-sitemap';
export default function pluginSitemap( export default function pluginSitemap(
context: LoadContext, context: LoadContext,
options: PluginOptions, options: PluginOptions,
): Plugin<void> { ): Plugin<void> {
if (context.siteConfig.future.experimental_router === 'hash') {
logger.warn(
`${PluginName} does not support the Hash Router and will be disabled.`,
);
return {name: PluginName};
}
return { return {
name: 'docusaurus-plugin-sitemap', name: PluginName,
async postBuild({siteConfig, routes, outDir, head}) { async postBuild({siteConfig, routes, outDir, head}) {
if (siteConfig.noIndex) { if (siteConfig.noIndex) {

View file

@ -5,38 +5,21 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import path from 'path';
import fs from 'fs-extra';
import _ from 'lodash';
import logger from '@docusaurus/logger';
import {defaultConfig, compile} from 'eta';
import {normalizeUrl} from '@docusaurus/utils'; import {normalizeUrl} from '@docusaurus/utils';
import {readDefaultCodeTranslationMessages} from '@docusaurus/theme-translations'; import {readDefaultCodeTranslationMessages} from '@docusaurus/theme-translations';
import openSearchTemplate from './templates/opensearch'; import {
createOpenSearchFile,
createOpenSearchHeadTags,
shouldCreateOpenSearchFile,
} from './opensearch';
import type {LoadContext, Plugin} from '@docusaurus/types'; import type {LoadContext, Plugin} from '@docusaurus/types';
import type {ThemeConfig} from '@docusaurus/theme-search-algolia'; import type {ThemeConfig} from '@docusaurus/theme-search-algolia';
const getCompiledOpenSearchTemplate = _.memoize(() =>
compile(openSearchTemplate.trim()),
);
function renderOpenSearchTemplate(data: {
title: string;
siteUrl: string;
searchUrl: string;
faviconUrl: string | null;
}) {
const compiled = getCompiledOpenSearchTemplate();
return compiled(data, defaultConfig);
}
const OPEN_SEARCH_FILENAME = 'opensearch.xml';
export default function themeSearchAlgolia(context: LoadContext): Plugin<void> { export default function themeSearchAlgolia(context: LoadContext): Plugin<void> {
const { const {
baseUrl, baseUrl,
siteConfig: {title, url, favicon, themeConfig}, siteConfig: {themeConfig},
i18n: {currentLocale}, i18n: {currentLocale},
} = context; } = context;
const { const {
@ -70,45 +53,17 @@ export default function themeSearchAlgolia(context: LoadContext): Plugin<void> {
} }
}, },
async postBuild({outDir}) { async postBuild() {
if (searchPagePath) { if (shouldCreateOpenSearchFile({context})) {
const siteUrl = normalizeUrl([url, baseUrl]); await createOpenSearchFile({context});
try {
await fs.writeFile(
path.join(outDir, OPEN_SEARCH_FILENAME),
renderOpenSearchTemplate({
title,
siteUrl,
searchUrl: normalizeUrl([siteUrl, searchPagePath]),
faviconUrl: favicon ? normalizeUrl([siteUrl, favicon]) : null,
}),
);
} catch (err) {
logger.error('Generating OpenSearch file failed.');
throw err;
}
} }
}, },
injectHtmlTags() { injectHtmlTags() {
if (!searchPagePath) { if (shouldCreateOpenSearchFile({context})) {
return {}; return {headTags: createOpenSearchHeadTags({context})};
} }
return {};
return {
headTags: [
{
tagName: 'link',
attributes: {
rel: 'search',
type: 'application/opensearchdescription+xml',
title,
href: normalizeUrl([baseUrl, OPEN_SEARCH_FILENAME]),
},
},
],
};
}, },
}; };
} }

View file

@ -0,0 +1,115 @@
/**
* 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 path from 'path';
import fs from 'fs-extra';
import _ from 'lodash';
import {defaultConfig, compile} from 'eta';
import {normalizeUrl} from '@docusaurus/utils';
import openSearchTemplate from './templates/opensearch';
import type {HtmlTags, LoadContext} from '@docusaurus/types';
import type {ThemeConfig} from '@docusaurus/theme-search-algolia';
const getCompiledOpenSearchTemplate = _.memoize(() =>
compile(openSearchTemplate.trim()),
);
function renderOpenSearchTemplate(data: {
title: string;
siteUrl: string;
searchUrl: string;
faviconUrl: string | null;
}) {
const compiled = getCompiledOpenSearchTemplate();
return compiled(data, defaultConfig);
}
const OPEN_SEARCH_FILENAME = 'opensearch.xml';
export function shouldCreateOpenSearchFile({
context,
}: {
context: LoadContext;
}): boolean {
const {
siteConfig: {
themeConfig,
future: {experimental_router: router},
},
} = context;
const {
algolia: {searchPagePath},
} = themeConfig as ThemeConfig;
return !!searchPagePath && router !== 'hash';
}
function createOpenSearchFileContent({
context,
searchPagePath,
}: {
context: LoadContext;
searchPagePath: string;
}): string {
const {
baseUrl,
siteConfig: {title, url, favicon},
} = context;
const siteUrl = normalizeUrl([url, baseUrl]);
return renderOpenSearchTemplate({
title,
siteUrl,
searchUrl: normalizeUrl([siteUrl, searchPagePath]),
faviconUrl: favicon ? normalizeUrl([siteUrl, favicon]) : null,
});
}
export async function createOpenSearchFile({
context,
}: {
context: LoadContext;
}): Promise<void> {
const {
outDir,
siteConfig: {themeConfig},
} = context;
const {
algolia: {searchPagePath},
} = themeConfig as ThemeConfig;
if (!searchPagePath) {
throw new Error('no searchPagePath provided in themeConfig.algolia');
}
const fileContent = createOpenSearchFileContent({context, searchPagePath});
try {
await fs.writeFile(path.join(outDir, OPEN_SEARCH_FILENAME), fileContent);
} catch (err) {
throw new Error('Generating OpenSearch file failed.', {cause: err});
}
}
export function createOpenSearchHeadTags({
context,
}: {
context: LoadContext;
}): HtmlTags {
const {
baseUrl,
siteConfig: {title},
} = context;
return {
tagName: 'link',
attributes: {
rel: 'search',
type: 'application/opensearchdescription+xml',
title,
href: normalizeUrl([baseUrl, OPEN_SEARCH_FILENAME]),
},
};
}

View file

@ -17,6 +17,8 @@ export type RemarkRehypeOptions = ProcessorOptions['remarkRehypeOptions'];
export type ReportingSeverity = 'ignore' | 'log' | 'warn' | 'throw'; export type ReportingSeverity = 'ignore' | 'log' | 'warn' | 'throw';
export type RouterType = 'browser' | 'hash';
export type ThemeConfig = { export type ThemeConfig = {
[key: string]: unknown; [key: string]: unknown;
}; };
@ -123,6 +125,24 @@ export type StorageConfig = {
export type FutureConfig = { export type FutureConfig = {
experimental_storage: StorageConfig; experimental_storage: StorageConfig;
/**
* Docusaurus can work with 2 router types.
*
* - The "browser" router is the main/default router of Docusaurus.
* It will use the browser history and regular urls to navigate from
* one page to another. A static file will be emitted for each page.
*
* - The "hash" router can be useful in very specific situations (such as
* distributing your app for offline-first usage), but should be avoided
* in most cases. All pages paths will be prefixed with a /#/.
* It will opt out of static site generation, only emit a single index.html
* entry point, and use the browser hash for routing. The Docusaurus site
* content will be rendered client-side, like a regular single page
* application.
* @see https://github.com/facebook/docusaurus/issues/3825
*/
experimental_router: RouterType;
}; };
/** /**

View file

@ -7,6 +7,7 @@
export { export {
ReportingSeverity, ReportingSeverity,
RouterType,
ThemeConfig, ThemeConfig,
MarkdownConfig, MarkdownConfig,
DefaultParseFrontMatter, DefaultParseFrontMatter,

View file

@ -90,6 +90,30 @@ describe('normalizeUrl', () => {
input: ['http://foobar.com', '', 'test', '/'], input: ['http://foobar.com', '', 'test', '/'],
output: 'http://foobar.com/test/', output: 'http://foobar.com/test/',
}, },
{
input: ['http://foobar.com/', '', 'test', '/'],
output: 'http://foobar.com/test/',
},
{
input: ['http://foobar.com', '#', 'test'],
output: 'http://foobar.com/#/test',
},
{
input: ['http://foobar.com/', '#', 'test'],
output: 'http://foobar.com/#/test',
},
{
input: ['http://foobar.com', '/#/', 'test'],
output: 'http://foobar.com/#/test',
},
{
input: ['http://foobar.com', '#/', 'test'],
output: 'http://foobar.com/#/test',
},
{
input: ['http://foobar.com', '/#', 'test'],
output: 'http://foobar.com/#/test',
},
{ {
input: ['/', '', 'hello', '', '/', '/', '', '/', '/world'], input: ['/', '', 'hello', '', '/', '/', '', '/', '/world'],
output: '/hello/world', output: '/hello/world',

View file

@ -90,7 +90,7 @@ export function normalizeUrl(rawUrls: string[]): string {
// first plain protocol part. // first plain protocol part.
// Remove trailing slash before parameters or hash. // Remove trailing slash before parameters or hash.
str = str.replace(/\/(?<search>\?|&|#[^!])/g, '$1'); str = str.replace(/\/(?<search>\?|&|#[^!/])/g, '$1');
// Replace ? in parameters with &. // Replace ? in parameters with &.
const parts = str.split('?'); const parts = str.split('?');

View file

@ -5,16 +5,24 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import React, {startTransition} from 'react'; import React, {startTransition, type ReactNode} from 'react';
import ReactDOM, {type ErrorInfo} from 'react-dom/client'; import ReactDOM, {type ErrorInfo} from 'react-dom/client';
import {BrowserRouter} from 'react-router-dom';
import {HelmetProvider} from 'react-helmet-async'; import {HelmetProvider} from 'react-helmet-async';
import {BrowserRouter, HashRouter} from 'react-router-dom';
import siteConfig from '@generated/docusaurus.config';
import ExecutionEnvironment from './exports/ExecutionEnvironment'; import ExecutionEnvironment from './exports/ExecutionEnvironment';
import App from './App'; import App from './App';
import preload from './preload'; import preload from './preload';
import docusaurus from './docusaurus'; import docusaurus from './docusaurus';
function Router({children}: {children: ReactNode}): ReactNode {
return siteConfig.future.experimental_router === 'hash' ? (
<HashRouter>{children}</HashRouter>
) : (
<BrowserRouter>{children}</BrowserRouter>
);
}
declare global { declare global {
interface NodeModule { interface NodeModule {
hot?: {accept: () => void}; hot?: {accept: () => void};
@ -31,9 +39,9 @@ if (ExecutionEnvironment.canUseDOM) {
const app = ( const app = (
<HelmetProvider> <HelmetProvider>
<BrowserRouter> <Router>
<App /> <App />
</BrowserRouter> </Router>
</HelmetProvider> </HelmetProvider>
); );

View file

@ -40,9 +40,9 @@ function Link(
}: Props, }: Props,
forwardedRef: React.ForwardedRef<HTMLAnchorElement>, forwardedRef: React.ForwardedRef<HTMLAnchorElement>,
): JSX.Element { ): JSX.Element {
const { const {siteConfig} = useDocusaurusContext();
siteConfig: {trailingSlash, baseUrl}, const {trailingSlash, baseUrl} = siteConfig;
} = useDocusaurusContext(); const router = siteConfig.future.experimental_router;
const {withBaseUrl} = useBaseUrlUtils(); const {withBaseUrl} = useBaseUrlUtils();
const brokenLinks = useBrokenLinks(); const brokenLinks = useBrokenLinks();
const innerRef = useRef<HTMLAnchorElement | null>(null); const innerRef = useRef<HTMLAnchorElement | null>(null);
@ -81,6 +81,15 @@ function Link(
? maybeAddBaseUrl(targetLinkWithoutPathnameProtocol) ? maybeAddBaseUrl(targetLinkWithoutPathnameProtocol)
: undefined; : undefined;
// TODO find a way to solve this problem properly
// Fix edge case when useBaseUrl is used on a link
// "./" is useful for images and other resources
// But we don't need it for <Link>
// unfortunately we can't really make the difference :/
if (router === 'hash' && targetLink?.startsWith('./')) {
targetLink = targetLink?.slice(1);
}
if (targetLink && isInternal) { if (targetLink && isInternal) {
targetLink = applyTrailingSlash(targetLink, {trailingSlash, baseUrl}); targetLink = applyTrailingSlash(targetLink, {trailingSlash, baseUrl});
} }
@ -148,8 +157,7 @@ function Link(
const hasInternalTarget = !props.target || props.target === '_self'; const hasInternalTarget = !props.target || props.target === '_self';
// Should we use a regular <a> tag instead of React-Router Link component? // Should we use a regular <a> tag instead of React-Router Link component?
const isRegularHtmlLink = const isRegularHtmlLink = !targetLink || !isInternal || !hasInternalTarget;
!targetLink || !isInternal || !hasInternalTarget || isAnchorLink;
if (!noBrokenLinkCheck && (isAnchorLink || !isRegularHtmlLink)) { if (!noBrokenLinkCheck && (isAnchorLink || !isRegularHtmlLink)) {
brokenLinks.collectLink(targetLink!); brokenLinks.collectLink(targetLink!);

View file

@ -7,13 +7,222 @@
import React from 'react'; import React from 'react';
import {renderHook} from '@testing-library/react-hooks'; import {renderHook} from '@testing-library/react-hooks';
import useBaseUrl, {useBaseUrlUtils} from '../useBaseUrl'; import {fromPartial} from '@total-typescript/shoehorn';
import useBaseUrl, {addBaseUrl, useBaseUrlUtils} from '../useBaseUrl';
import {Context} from '../../docusaurusContext'; import {Context} from '../../docusaurusContext';
import type {DocusaurusContext} from '@docusaurus/types'; import type {DocusaurusContext, FutureConfig} from '@docusaurus/types';
import type {BaseUrlOptions} from '@docusaurus/useBaseUrl'; import type {BaseUrlOptions} from '@docusaurus/useBaseUrl';
type AddBaseUrlParams = Parameters<typeof addBaseUrl>[0];
const future: FutureConfig = fromPartial({
experimental_router: 'browser',
});
const forcePrepend = {forcePrependBaseUrl: true}; const forcePrepend = {forcePrependBaseUrl: true};
// TODO migrate more tests here, it's easier to test a pure function
describe('addBaseUrl', () => {
function baseTest(params: Partial<AddBaseUrlParams>) {
return addBaseUrl({
siteUrl: 'https://docusaurus.io',
baseUrl: '/baseUrl/',
url: 'hello',
router: 'browser',
...params,
});
}
describe('with browser router', () => {
function test(params: {
url: AddBaseUrlParams['url'];
baseUrl: AddBaseUrlParams['baseUrl'];
options?: AddBaseUrlParams['options'];
}) {
return baseTest({
...params,
router: 'browser',
});
}
it('/baseUrl/ + hello', () => {
expect(
test({
baseUrl: '/baseUrl/',
url: 'hello',
}),
).toBe('/baseUrl/hello');
});
it('/baseUrl/ + hello - absolute option', () => {
expect(
test({
baseUrl: '/baseUrl/',
url: 'hello',
options: {absolute: true},
}),
).toBe('https://docusaurus.io/baseUrl/hello');
});
it('/baseUrl/ + /hello', () => {
expect(
test({
baseUrl: '/baseUrl/',
url: '/hello',
}),
).toBe('/baseUrl/hello');
});
it('/baseUrl/ + /hello - absolute option', () => {
expect(
test({
baseUrl: '/baseUrl/',
url: '/hello',
options: {absolute: true},
}),
).toBe('https://docusaurus.io/baseUrl/hello');
});
it('/ + hello', () => {
expect(
test({
baseUrl: '/',
url: 'hello',
}),
).toBe('/hello');
});
it('/ + hello - absolute', () => {
expect(
test({
baseUrl: '/',
url: 'hello',
options: {absolute: true},
}),
).toBe('https://docusaurus.io/hello');
});
it('/ + /hello', () => {
expect(
test({
baseUrl: '/',
url: '/hello',
}),
).toBe('/hello');
});
it('/ + /hello - absolute', () => {
expect(
test({
baseUrl: '/',
url: '/hello',
options: {absolute: true},
}),
).toBe('https://docusaurus.io/hello');
});
});
describe('with hash router', () => {
function test(params: {
url: AddBaseUrlParams['url'];
baseUrl: AddBaseUrlParams['baseUrl'];
options?: AddBaseUrlParams['options'];
}) {
return baseTest({
...params,
router: 'hash',
});
}
it('/baseUrl/ + hello', () => {
expect(
test({
baseUrl: '/baseUrl/',
url: 'hello',
}),
).toBe('./hello');
});
it('/baseUrl/ + hello - absolute option', () => {
expect(
test({
baseUrl: '/baseUrl/',
url: 'hello',
options: {absolute: true},
}),
).toBe('./hello');
});
it('/baseUrl/ + /hello', () => {
expect(
test({
baseUrl: '/baseUrl/',
url: '/hello',
}),
).toBe('./hello');
});
it('/baseUrl/ + /hello - absolute option', () => {
expect(
test({
baseUrl: '/baseUrl/',
url: '/hello',
options: {absolute: true},
}),
).toBe('./hello');
});
it('/ + hello', () => {
expect(
test({
baseUrl: '/',
url: 'hello',
}),
).toBe('./hello');
});
it('/ + hello - absolute', () => {
expect(
test({
baseUrl: '/',
url: 'hello',
options: {absolute: true},
}),
).toBe('./hello');
});
it('/ + /hello', () => {
expect(
test({
baseUrl: '/',
url: 'hello',
options: {absolute: true},
}),
).toBe('./hello');
});
it('/ + /hello - absolute', () => {
expect(
test({
baseUrl: '/',
url: 'hello',
options: {absolute: true},
}),
).toBe('./hello');
});
});
/*
src
:
"img/docusaurus.svg"
srcDark
:
"img/docusaurus_keytar.svg"
*/
});
describe('useBaseUrl', () => { describe('useBaseUrl', () => {
const createUseBaseUrlMock = const createUseBaseUrlMock =
(context: DocusaurusContext) => (url: string, options?: BaseUrlOptions) => (context: DocusaurusContext) => (url: string, options?: BaseUrlOptions) =>
@ -27,6 +236,7 @@ describe('useBaseUrl', () => {
siteConfig: { siteConfig: {
baseUrl: '/', baseUrl: '/',
url: 'https://docusaurus.io', url: 'https://docusaurus.io',
future,
}, },
} as DocusaurusContext); } as DocusaurusContext);
@ -55,6 +265,7 @@ describe('useBaseUrl', () => {
siteConfig: { siteConfig: {
baseUrl: '/docusaurus/', baseUrl: '/docusaurus/',
url: 'https://docusaurus.io', url: 'https://docusaurus.io',
future,
}, },
} as DocusaurusContext); } as DocusaurusContext);
@ -96,6 +307,7 @@ describe('useBaseUrlUtils().withBaseUrl()', () => {
siteConfig: { siteConfig: {
baseUrl: '/', baseUrl: '/',
url: 'https://docusaurus.io', url: 'https://docusaurus.io',
future,
}, },
} as DocusaurusContext); } as DocusaurusContext);
@ -124,6 +336,7 @@ describe('useBaseUrlUtils().withBaseUrl()', () => {
siteConfig: { siteConfig: {
baseUrl: '/docusaurus/', baseUrl: '/docusaurus/',
url: 'https://docusaurus.io', url: 'https://docusaurus.io',
future,
}, },
} as DocusaurusContext); } as DocusaurusContext);

View file

@ -9,19 +9,34 @@ import {useCallback} from 'react';
import useDocusaurusContext from './useDocusaurusContext'; import useDocusaurusContext from './useDocusaurusContext';
import {hasProtocol} from './isInternalUrl'; import {hasProtocol} from './isInternalUrl';
import type {BaseUrlOptions, BaseUrlUtils} from '@docusaurus/useBaseUrl'; import type {BaseUrlOptions, BaseUrlUtils} from '@docusaurus/useBaseUrl';
import type {RouterType} from '@docusaurus/types';
function addBaseUrl( export function addBaseUrl({
siteUrl: string, siteUrl,
baseUrl: string, baseUrl,
url: string, url,
{forcePrependBaseUrl = false, absolute = false}: BaseUrlOptions = {}, options: {forcePrependBaseUrl = false, absolute = false} = {},
): string { router,
}: {
siteUrl: string;
baseUrl: string;
url: string;
router: RouterType;
options?: BaseUrlOptions;
}): string {
// It never makes sense to add base url to a local anchor url, or one with a // It never makes sense to add base url to a local anchor url, or one with a
// protocol // protocol
if (!url || url.startsWith('#') || hasProtocol(url)) { if (!url || url.startsWith('#') || hasProtocol(url)) {
return url; return url;
} }
// TODO hash router + /baseUrl/ is unlikely to work well in all situations
// This will support most cases, but not all
// See https://github.com/facebook/docusaurus/pull/9859
if (router === 'hash') {
return url.startsWith('/') ? `.${url}` : `./${url}`;
}
if (forcePrependBaseUrl) { if (forcePrependBaseUrl) {
return baseUrl + url.replace(/^\//, ''); return baseUrl + url.replace(/^\//, '');
} }
@ -41,14 +56,14 @@ function addBaseUrl(
} }
export function useBaseUrlUtils(): BaseUrlUtils { export function useBaseUrlUtils(): BaseUrlUtils {
const { const {siteConfig} = useDocusaurusContext();
siteConfig: {baseUrl, url: siteUrl}, const {baseUrl, url: siteUrl} = siteConfig;
} = useDocusaurusContext(); const router = siteConfig.future.experimental_router;
const withBaseUrl = useCallback( const withBaseUrl = useCallback(
(url: string, options?: BaseUrlOptions) => (url: string, options?: BaseUrlOptions) =>
addBaseUrl(siteUrl, baseUrl, url, options), addBaseUrl({siteUrl, baseUrl, url, options, router}),
[siteUrl, baseUrl], [siteUrl, baseUrl, router],
); );
return { return {

View file

@ -20,12 +20,20 @@ import {compile} from '../webpack/utils';
import {PerfLogger} from '../utils'; import {PerfLogger} from '../utils';
import {loadI18n} from '../server/i18n'; import {loadI18n} from '../server/i18n';
import {generateStaticFiles, loadAppRenderer} from '../ssg'; import {
import {compileSSRTemplate} from '../templates/templates'; generateHashRouterEntrypoint,
generateStaticFiles,
loadAppRenderer,
} from '../ssg';
import {
compileSSRTemplate,
renderHashRouterTemplate,
} from '../templates/templates';
import defaultSSRTemplate from '../templates/ssr.html.template'; import defaultSSRTemplate from '../templates/ssr.html.template';
import type {SSGParams} from '../ssg';
import type {Manifest} from 'react-loadable-ssr-addon-v5-slorber'; import type {Manifest} from 'react-loadable-ssr-addon-v5-slorber';
import type {LoadedPlugin, Props} from '@docusaurus/types'; import type {LoadedPlugin, Props, RouterType} from '@docusaurus/types';
import type {SiteCollectedData} from '../common'; import type {SiteCollectedData} from '../common';
export type BuildCLIOptions = Pick< export type BuildCLIOptions = Pick<
@ -164,7 +172,9 @@ async function buildLocale({
); );
const {props} = site; const {props} = site;
const {outDir, plugins} = props; const {outDir, plugins, siteConfig} = props;
const router = siteConfig.future.experimental_router;
// We can build the 2 configs in parallel // We can build the 2 configs in parallel
const [{clientConfig, clientManifestPath}, {serverConfig, serverBundlePath}] = const [{clientConfig, clientManifestPath}, {serverConfig, serverBundlePath}] =
@ -181,15 +191,20 @@ async function buildLocale({
); );
// Run webpack to build JS bundle (client) and static html files (server). // Run webpack to build JS bundle (client) and static html files (server).
await PerfLogger.async('Bundling with Webpack', () => await PerfLogger.async('Bundling with Webpack', () => {
compile([clientConfig, serverConfig]), if (router === 'hash') {
); return compile([clientConfig]);
} else {
return compile([clientConfig, serverConfig]);
}
});
const {collectedData} = await PerfLogger.async('SSG', () => const {collectedData} = await PerfLogger.async('SSG', () =>
executeSSG({ executeSSG({
props, props,
serverBundlePath, serverBundlePath,
clientManifestPath, clientManifestPath,
router,
}), }),
); );
@ -220,11 +235,13 @@ async function executeSSG({
props, props,
serverBundlePath, serverBundlePath,
clientManifestPath, clientManifestPath,
router,
}: { }: {
props: Props; props: Props;
serverBundlePath: string; serverBundlePath: string;
clientManifestPath: string; clientManifestPath: string;
}) { router: RouterType;
}): Promise<{collectedData: SiteCollectedData}> {
const manifest: Manifest = await PerfLogger.async( const manifest: Manifest = await PerfLogger.async(
'Read client manifest', 'Read client manifest',
() => fs.readJSON(clientManifestPath, 'utf-8'), () => fs.readJSON(clientManifestPath, 'utf-8'),
@ -234,6 +251,27 @@ async function executeSSG({
compileSSRTemplate(props.siteConfig.ssrTemplate ?? defaultSSRTemplate), compileSSRTemplate(props.siteConfig.ssrTemplate ?? defaultSSRTemplate),
); );
const params: SSGParams = {
trailingSlash: props.siteConfig.trailingSlash,
outDir: props.outDir,
baseUrl: props.baseUrl,
manifest,
headTags: props.headTags,
preBodyTags: props.preBodyTags,
postBodyTags: props.postBodyTags,
ssrTemplate,
noIndex: props.siteConfig.noIndex,
DOCUSAURUS_VERSION,
};
if (router === 'hash') {
PerfLogger.start('Generate Hash Router entry point');
const content = renderHashRouterTemplate({params});
await generateHashRouterEntrypoint({content, params});
PerfLogger.end('Generate Hash Router entry point');
return {collectedData: {}};
}
const renderer = await PerfLogger.async('Load App renderer', () => const renderer = await PerfLogger.async('Load App renderer', () =>
loadAppRenderer({ loadAppRenderer({
serverBundlePath, serverBundlePath,
@ -244,18 +282,7 @@ async function executeSSG({
generateStaticFiles({ generateStaticFiles({
pathnames: props.routesPaths, pathnames: props.routesPaths,
renderer, renderer,
params: { params,
trailingSlash: props.siteConfig.trailingSlash,
outDir: props.outDir,
baseUrl: props.baseUrl,
manifest,
headTags: props.headTags,
preBodyTags: props.preBodyTags,
postBodyTags: props.postBodyTags,
ssrTemplate,
noIndex: props.siteConfig.noIndex,
DOCUSAURUS_VERSION,
},
}), }),
); );

View file

@ -20,12 +20,18 @@ import {
} from '../../server/site'; } from '../../server/site';
import {formatPluginName} from '../../server/plugins/pluginsUtils'; import {formatPluginName} from '../../server/plugins/pluginsUtils';
import type {StartCLIOptions} from './start'; import type {StartCLIOptions} from './start';
import type {LoadedPlugin} from '@docusaurus/types'; import type {LoadedPlugin, RouterType} from '@docusaurus/types';
export type OpenUrlContext = { export type OpenUrlContext = {
host: string; host: string;
port: number; port: number;
getOpenUrl: ({baseUrl}: {baseUrl: string}) => string; getOpenUrl: ({
baseUrl,
router,
}: {
baseUrl: string;
router: RouterType;
}) => string;
}; };
export async function createOpenUrlContext({ export async function createOpenUrlContext({
@ -40,9 +46,13 @@ export async function createOpenUrlContext({
return process.exit(); return process.exit();
} }
const getOpenUrl: OpenUrlContext['getOpenUrl'] = ({baseUrl}) => { const getOpenUrl: OpenUrlContext['getOpenUrl'] = ({baseUrl, router}) => {
const urls = prepareUrls(protocol, host, port); const urls = prepareUrls(protocol, host, port);
return normalizeUrl([urls.localUrlForBrowser, baseUrl]); return normalizeUrl([
urls.localUrlForBrowser,
router === 'hash' ? '/#/' : '',
baseUrl,
]);
}; };
return {host, port, getOpenUrl}; return {host, port, getOpenUrl};
@ -83,6 +93,7 @@ export async function createReloadableSite(startParams: StartParams) {
const getOpenUrl = () => const getOpenUrl = () =>
openUrlContext.getOpenUrl({ openUrlContext.getOpenUrl({
baseUrl: site.props.baseUrl, baseUrl: site.props.baseUrl,
router: site.props.siteConfig.future.experimental_router,
}); });
const printOpenUrlMessage = () => { const printOpenUrlMessage = () => {

View file

@ -81,7 +81,8 @@ async function createDevServerConfig({
'access-control-allow-origin': '*', 'access-control-allow-origin': '*',
}, },
devMiddleware: { devMiddleware: {
publicPath: baseUrl, publicPath:
siteConfig.future.experimental_router === 'hash' ? 'auto' : baseUrl,
// Reduce log verbosity, see https://github.com/facebook/docusaurus/pull/5420#issuecomment-906613105 // Reduce log verbosity, see https://github.com/facebook/docusaurus/pull/5420#issuecomment-906613105
stats: 'summary', stats: 'summary',
}, },

View file

@ -8,6 +8,7 @@ exports[`loadSiteConfig website with .cjs siteConfig 1`] = `
"clientModules": [], "clientModules": [],
"customFields": {}, "customFields": {},
"future": { "future": {
"experimental_router": "browser",
"experimental_storage": { "experimental_storage": {
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",
@ -68,6 +69,7 @@ exports[`loadSiteConfig website with ts + js config 1`] = `
"clientModules": [], "clientModules": [],
"customFields": {}, "customFields": {},
"future": { "future": {
"experimental_router": "browser",
"experimental_storage": { "experimental_storage": {
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",
@ -128,6 +130,7 @@ exports[`loadSiteConfig website with valid JS CJS config 1`] = `
"clientModules": [], "clientModules": [],
"customFields": {}, "customFields": {},
"future": { "future": {
"experimental_router": "browser",
"experimental_storage": { "experimental_storage": {
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",
@ -188,6 +191,7 @@ exports[`loadSiteConfig website with valid JS ESM config 1`] = `
"clientModules": [], "clientModules": [],
"customFields": {}, "customFields": {},
"future": { "future": {
"experimental_router": "browser",
"experimental_storage": { "experimental_storage": {
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",
@ -248,6 +252,7 @@ exports[`loadSiteConfig website with valid TypeScript CJS config 1`] = `
"clientModules": [], "clientModules": [],
"customFields": {}, "customFields": {},
"future": { "future": {
"experimental_router": "browser",
"experimental_storage": { "experimental_storage": {
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",
@ -308,6 +313,7 @@ exports[`loadSiteConfig website with valid TypeScript ESM config 1`] = `
"clientModules": [], "clientModules": [],
"customFields": {}, "customFields": {},
"future": { "future": {
"experimental_router": "browser",
"experimental_storage": { "experimental_storage": {
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",
@ -368,6 +374,7 @@ exports[`loadSiteConfig website with valid async config 1`] = `
"clientModules": [], "clientModules": [],
"customFields": {}, "customFields": {},
"future": { "future": {
"experimental_router": "browser",
"experimental_storage": { "experimental_storage": {
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",
@ -430,6 +437,7 @@ exports[`loadSiteConfig website with valid async config creator function 1`] = `
"clientModules": [], "clientModules": [],
"customFields": {}, "customFields": {},
"future": { "future": {
"experimental_router": "browser",
"experimental_storage": { "experimental_storage": {
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",
@ -492,6 +500,7 @@ exports[`loadSiteConfig website with valid config creator function 1`] = `
"clientModules": [], "clientModules": [],
"customFields": {}, "customFields": {},
"future": { "future": {
"experimental_router": "browser",
"experimental_storage": { "experimental_storage": {
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",
@ -557,6 +566,7 @@ exports[`loadSiteConfig website with valid siteConfig 1`] = `
"customFields": {}, "customFields": {},
"favicon": "img/docusaurus.ico", "favicon": "img/docusaurus.ico",
"future": { "future": {
"experimental_router": "browser",
"experimental_storage": { "experimental_storage": {
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",

View file

@ -78,6 +78,7 @@ exports[`load loads props for site with custom i18n path 1`] = `
"clientModules": [], "clientModules": [],
"customFields": {}, "customFields": {},
"future": { "future": {
"experimental_router": "browser",
"experimental_storage": { "experimental_storage": {
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",

View file

@ -42,6 +42,7 @@ describe('normalizeConfig', () => {
type: 'sessionStorage', type: 'sessionStorage',
namespace: true, namespace: true,
}, },
experimental_router: 'hash',
}, },
tagline: 'my awesome site', tagline: 'my awesome site',
organizationName: 'facebook', organizationName: 'facebook',
@ -648,6 +649,7 @@ describe('future', () => {
type: 'sessionStorage', type: 'sessionStorage',
namespace: 'myNamespace', namespace: 'myNamespace',
}, },
experimental_router: 'hash',
}; };
expect( expect(
normalizeConfig({ normalizeConfig({
@ -675,6 +677,97 @@ describe('future', () => {
`); `);
}); });
describe('router', () => {
it('accepts router - undefined', () => {
expect(
normalizeConfig({
future: {
experimental_router: undefined,
},
}),
).toEqual(
expect.objectContaining({
future: expect.objectContaining({experimental_router: 'browser'}),
}),
);
});
it('accepts router - hash', () => {
expect(
normalizeConfig({
future: {
experimental_router: 'hash',
},
}),
).toEqual(
expect.objectContaining({
future: expect.objectContaining({experimental_router: 'hash'}),
}),
);
});
it('accepts router - browser', () => {
expect(
normalizeConfig({
future: {
experimental_router: 'browser',
},
}),
).toEqual(
expect.objectContaining({
future: expect.objectContaining({experimental_router: 'browser'}),
}),
);
});
it('rejects router - invalid enum value', () => {
// @ts-expect-error: invalid
const router: DocusaurusConfig['future']['experimental_router'] =
'badRouter';
expect(() =>
normalizeConfig({
future: {
experimental_router: router,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_router" must be one of [browser, hash]
"
`);
});
it('rejects router - null', () => {
const router: DocusaurusConfig['future']['experimental_router'] = null;
expect(() =>
normalizeConfig({
future: {
experimental_router: router,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_router" must be one of [browser, hash]
"future.experimental_router" must be a string
"
`);
});
it('rejects router - number', () => {
// @ts-expect-error: invalid
const router: DocusaurusConfig['future']['experimental_router'] = 42;
expect(() =>
normalizeConfig({
future: {
experimental_router: router,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_router" must be one of [browser, hash]
"future.experimental_router" must be a string
"
`);
});
});
describe('storage', () => { describe('storage', () => {
it('accepts storage - undefined', () => { it('accepts storage - undefined', () => {
expect( expect(
@ -707,9 +800,9 @@ describe('future', () => {
}), }),
).toEqual( ).toEqual(
expect.objectContaining({ expect.objectContaining({
future: { future: expect.objectContaining({
experimental_storage: storage, experimental_storage: storage,
}, }),
}), }),
); );
}); });
@ -757,12 +850,12 @@ describe('future', () => {
}), }),
).toEqual( ).toEqual(
expect.objectContaining({ expect.objectContaining({
future: { future: expect.objectContaining({
experimental_storage: { experimental_storage: {
...DEFAULT_STORAGE_CONFIG, ...DEFAULT_STORAGE_CONFIG,
...storage, ...storage,
}, },
}, }),
}), }),
); );
}); });
@ -779,12 +872,12 @@ describe('future', () => {
}), }),
).toEqual( ).toEqual(
expect.objectContaining({ expect.objectContaining({
future: { future: expect.objectContaining({
experimental_storage: { experimental_storage: {
...DEFAULT_STORAGE_CONFIG, ...DEFAULT_STORAGE_CONFIG,
type: 'localStorage', type: 'localStorage',
}, },
}, }),
}), }),
); );
}); });
@ -850,12 +943,12 @@ describe('future', () => {
}), }),
).toEqual( ).toEqual(
expect.objectContaining({ expect.objectContaining({
future: { future: expect.objectContaining({
experimental_storage: { experimental_storage: {
...DEFAULT_STORAGE_CONFIG, ...DEFAULT_STORAGE_CONFIG,
...storage, ...storage,
}, },
}, }),
}), }),
); );
}); });
@ -872,12 +965,12 @@ describe('future', () => {
}), }),
).toEqual( ).toEqual(
expect.objectContaining({ expect.objectContaining({
future: { future: expect.objectContaining({
experimental_storage: { experimental_storage: {
...DEFAULT_STORAGE_CONFIG, ...DEFAULT_STORAGE_CONFIG,
...storage, ...storage,
}, },
}, }),
}), }),
); );
}); });

View file

@ -8,6 +8,10 @@
import {loadHtmlTags} from '../htmlTags'; import {loadHtmlTags} from '../htmlTags';
import type {LoadedPlugin} from '@docusaurus/types'; import type {LoadedPlugin} from '@docusaurus/types';
function testHtmlTags(plugins: LoadedPlugin[]) {
return loadHtmlTags({plugins, router: 'browser'});
}
const pluginEmpty = { const pluginEmpty = {
name: 'plugin-empty', name: 'plugin-empty',
} as LoadedPlugin; } as LoadedPlugin;
@ -85,7 +89,7 @@ const pluginMaybeInjectHeadTags = {
describe('loadHtmlTags', () => { describe('loadHtmlTags', () => {
it('works for an empty plugin', () => { it('works for an empty plugin', () => {
const htmlTags = loadHtmlTags([pluginEmpty]); const htmlTags = testHtmlTags([pluginEmpty]);
expect(htmlTags).toMatchInlineSnapshot(` expect(htmlTags).toMatchInlineSnapshot(`
{ {
"headTags": "", "headTags": "",
@ -96,7 +100,7 @@ describe('loadHtmlTags', () => {
}); });
it('only injects headTags', () => { it('only injects headTags', () => {
const htmlTags = loadHtmlTags([pluginHeadTags]); const htmlTags = testHtmlTags([pluginHeadTags]);
expect(htmlTags).toMatchInlineSnapshot(` expect(htmlTags).toMatchInlineSnapshot(`
{ {
"headTags": "<link rel="preconnect" href="www.google-analytics.com"> "headTags": "<link rel="preconnect" href="www.google-analytics.com">
@ -109,7 +113,7 @@ describe('loadHtmlTags', () => {
}); });
it('only injects preBodyTags', () => { it('only injects preBodyTags', () => {
const htmlTags = loadHtmlTags([pluginPreBodyTags]); const htmlTags = testHtmlTags([pluginPreBodyTags]);
expect(htmlTags).toMatchInlineSnapshot(` expect(htmlTags).toMatchInlineSnapshot(`
{ {
"headTags": "", "headTags": "",
@ -120,7 +124,7 @@ describe('loadHtmlTags', () => {
}); });
it('only injects postBodyTags', () => { it('only injects postBodyTags', () => {
const htmlTags = loadHtmlTags([pluginPostBodyTags]); const htmlTags = testHtmlTags([pluginPostBodyTags]);
expect(htmlTags).toMatchInlineSnapshot(` expect(htmlTags).toMatchInlineSnapshot(`
{ {
"headTags": "", "headTags": "",
@ -132,7 +136,7 @@ describe('loadHtmlTags', () => {
}); });
it('allows multiple plugins that inject different part of html tags', () => { it('allows multiple plugins that inject different part of html tags', () => {
const htmlTags = loadHtmlTags([ const htmlTags = testHtmlTags([
pluginHeadTags, pluginHeadTags,
pluginPostBodyTags, pluginPostBodyTags,
pluginPreBodyTags, pluginPreBodyTags,
@ -150,7 +154,7 @@ describe('loadHtmlTags', () => {
}); });
it('allows multiple plugins that might/might not inject html tags', () => { it('allows multiple plugins that might/might not inject html tags', () => {
const htmlTags = loadHtmlTags([ const htmlTags = testHtmlTags([
pluginEmpty, pluginEmpty,
pluginHeadTags, pluginHeadTags,
pluginPostBodyTags, pluginPostBodyTags,
@ -169,7 +173,7 @@ describe('loadHtmlTags', () => {
}); });
it('throws for invalid tag', () => { it('throws for invalid tag', () => {
expect(() => expect(() =>
loadHtmlTags([ testHtmlTags([
// @ts-expect-error: test // @ts-expect-error: test
{ {
injectHtmlTags() { injectHtmlTags() {
@ -191,7 +195,7 @@ describe('loadHtmlTags', () => {
it('throws for invalid tagName', () => { it('throws for invalid tagName', () => {
expect(() => expect(() =>
loadHtmlTags([ testHtmlTags([
{ {
// @ts-expect-error: test // @ts-expect-error: test
injectHtmlTags() { injectHtmlTags() {
@ -210,7 +214,7 @@ describe('loadHtmlTags', () => {
it('throws for invalid tag object', () => { it('throws for invalid tag object', () => {
expect(() => expect(() =>
loadHtmlTags([ testHtmlTags([
{ {
// @ts-expect-error: test // @ts-expect-error: test
injectHtmlTags() { injectHtmlTags() {

View file

@ -39,6 +39,7 @@ export const DEFAULT_STORAGE_CONFIG: StorageConfig = {
export const DEFAULT_FUTURE_CONFIG: FutureConfig = { export const DEFAULT_FUTURE_CONFIG: FutureConfig = {
experimental_storage: DEFAULT_STORAGE_CONFIG, experimental_storage: DEFAULT_STORAGE_CONFIG,
experimental_router: 'browser',
}; };
export const DEFAULT_MARKDOWN_CONFIG: MarkdownConfig = { export const DEFAULT_MARKDOWN_CONFIG: MarkdownConfig = {
@ -206,6 +207,9 @@ const STORAGE_CONFIG_SCHEMA = Joi.object({
const FUTURE_CONFIG_SCHEMA = Joi.object<FutureConfig>({ const FUTURE_CONFIG_SCHEMA = Joi.object<FutureConfig>({
experimental_storage: STORAGE_CONFIG_SCHEMA, experimental_storage: STORAGE_CONFIG_SCHEMA,
experimental_router: Joi.string()
.equal('browser', 'hash')
.default(DEFAULT_FUTURE_CONFIG.experimental_router),
}) })
.optional() .optional()
.default(DEFAULT_FUTURE_CONFIG); .default(DEFAULT_FUTURE_CONFIG);

View file

@ -14,6 +14,7 @@ import type {
HtmlTagObject, HtmlTagObject,
HtmlTags, HtmlTags,
LoadedPlugin, LoadedPlugin,
RouterType,
} from '@docusaurus/types'; } from '@docusaurus/types';
function assertIsHtmlTagObject(val: unknown): asserts val is HtmlTagObject { function assertIsHtmlTagObject(val: unknown): asserts val is HtmlTagObject {
@ -36,16 +37,35 @@ function assertIsHtmlTagObject(val: unknown): asserts val is HtmlTagObject {
} }
} }
function htmlTagObjectToString(tag: unknown): string { function hashRouterAbsoluteToRelativeTagAttribute(
name: string,
value: string,
): string {
if ((name === 'src' || name === 'href') && value.startsWith('/')) {
return `.${value}`;
}
return value;
}
function htmlTagObjectToString({
tag,
router,
}: {
tag: unknown;
router: RouterType;
}): string {
assertIsHtmlTagObject(tag); assertIsHtmlTagObject(tag);
const isVoidTag = (voidHtmlTags as string[]).includes(tag.tagName); const isVoidTag = (voidHtmlTags as string[]).includes(tag.tagName);
const tagAttributes = tag.attributes ?? {}; const tagAttributes = tag.attributes ?? {};
const attributes = Object.keys(tagAttributes) const attributes = Object.keys(tagAttributes)
.map((attr) => { .map((attr) => {
const value = tagAttributes[attr]!; let value = tagAttributes[attr]!;
if (typeof value === 'boolean') { if (typeof value === 'boolean') {
return value ? attr : undefined; return value ? attr : undefined;
} }
if (router === 'hash') {
value = hashRouterAbsoluteToRelativeTagAttribute(attr, value);
}
return `${attr}="${escapeHTML(value)}"`; return `${attr}="${escapeHTML(value)}"`;
}) })
.filter((str): str is string => Boolean(str)); .filter((str): str is string => Boolean(str));
@ -55,10 +75,18 @@ function htmlTagObjectToString(tag: unknown): string {
return openingTag + innerHTML + closingTag; return openingTag + innerHTML + closingTag;
} }
function createHtmlTagsString(tags: HtmlTags | undefined): string { function createHtmlTagsString({
tags,
router,
}: {
tags: HtmlTags | undefined;
router: RouterType;
}): string {
return (Array.isArray(tags) ? tags : [tags]) return (Array.isArray(tags) ? tags : [tags])
.filter(Boolean) .filter(Boolean)
.map((val) => (typeof val === 'string' ? val : htmlTagObjectToString(val))) .map((val) =>
typeof val === 'string' ? val : htmlTagObjectToString({tag: val, router}),
)
.join('\n'); .join('\n');
} }
@ -66,9 +94,13 @@ function createHtmlTagsString(tags: HtmlTags | undefined): string {
* Runs the `injectHtmlTags` lifecycle, and aggregates all plugins' tags into * Runs the `injectHtmlTags` lifecycle, and aggregates all plugins' tags into
* directly render-able HTML markup. * directly render-able HTML markup.
*/ */
export function loadHtmlTags( export function loadHtmlTags({
plugins: LoadedPlugin[], plugins,
): Pick<Props, 'headTags' | 'preBodyTags' | 'postBodyTags'> { router,
}: {
plugins: LoadedPlugin[];
router: RouterType;
}): Pick<Props, 'headTags' | 'preBodyTags' | 'postBodyTags'> {
const pluginHtmlTags = plugins.map( const pluginHtmlTags = plugins.map(
(plugin) => plugin.injectHtmlTags?.({content: plugin.content}) ?? {}, (plugin) => plugin.injectHtmlTags?.({content: plugin.content}) ?? {},
); );
@ -78,7 +110,7 @@ export function loadHtmlTags(
tagTypes, tagTypes,
tagTypes.map((type) => tagTypes.map((type) =>
pluginHtmlTags pluginHtmlTags
.map((tags) => createHtmlTagsString(tags[type])) .map((tags) => createHtmlTagsString({tags: tags[type], router}))
.join('\n') .join('\n')
.trim(), .trim(),
), ),

View file

@ -147,7 +147,10 @@ function createSiteProps(
codeTranslations: siteCodeTranslations, codeTranslations: siteCodeTranslations,
} = context; } = context;
const {headTags, preBodyTags, postBodyTags} = loadHtmlTags(plugins); const {headTags, preBodyTags, postBodyTags} = loadHtmlTags({
plugins,
router: siteConfig.future.experimental_router,
});
const siteMetadata = createSiteMetadata({plugins, siteVersion}); const siteMetadata = createSiteMetadata({plugins, siteVersion});

View file

@ -227,6 +227,20 @@ It might also require to wrap your client code in ${logger.code(
return parts.join('\n'); return parts.join('\n');
} }
export async function generateHashRouterEntrypoint({
content,
params,
}: {
content: string;
params: SSGParams;
}): Promise<void> {
await writeStaticFile({
pathname: '/',
content,
params,
});
}
async function writeStaticFile({ async function writeStaticFile({
content, content,
pathname, pathname,

View file

@ -113,3 +113,41 @@ export function renderSSRTemplate({
return ssrTemplate(data); return ssrTemplate(data);
} }
export function renderHashRouterTemplate({
params,
}: {
params: SSGParams;
}): string {
const {
// baseUrl,
headTags,
preBodyTags,
postBodyTags,
manifest,
DOCUSAURUS_VERSION,
ssrTemplate,
} = params;
const {scripts, stylesheets} = getScriptsAndStylesheets({
manifest,
modules: [],
});
const data: SSRTemplateData = {
appHtml: '',
baseUrl: './',
htmlAttributes: '',
bodyAttributes: '',
headTags,
preBodyTags,
postBodyTags,
metaAttributes: [],
scripts,
stylesheets,
noIndex: false,
version: DOCUSAURUS_VERSION,
};
return ssrTemplate(data);
}

View file

@ -66,7 +66,7 @@ describe('base webpack config', () => {
const props = { const props = {
outDir: '', outDir: '',
siteDir: path.resolve(__dirname, '__fixtures__', 'base_test_site'), siteDir: path.resolve(__dirname, '__fixtures__', 'base_test_site'),
siteConfig: {staticDirectories: ['static']}, siteConfig: {staticDirectories: ['static'], future: {}},
baseUrl: '', baseUrl: '',
generatedFilesDir: '', generatedFilesDir: '',
routesPaths: [''], routesPaths: [''],

View file

@ -112,7 +112,8 @@ export async function createBaseConfig({
chunkFilename: isProd chunkFilename: isProd
? 'assets/js/[name].[contenthash:8].js' ? 'assets/js/[name].[contenthash:8].js'
: '[name].js', : '[name].js',
publicPath: baseUrl, publicPath:
siteConfig.future.experimental_router === 'hash' ? 'auto' : baseUrl,
hashFunction: 'xxhash64', hashFunction: 'xxhash64',
}, },
// Don't throw warning when asset created is over 250kb // Don't throw warning when asset created is over 250kb

View file

@ -6,7 +6,6 @@
*/ */
import path from 'path'; import path from 'path';
import logger from '@docusaurus/logger';
import merge from 'webpack-merge'; import merge from 'webpack-merge';
import WebpackBar from 'webpackbar'; import WebpackBar from 'webpackbar';
import webpack from 'webpack'; import webpack from 'webpack';
@ -15,29 +14,12 @@ import ReactLoadableSSRAddon from 'react-loadable-ssr-addon-v5-slorber';
import HtmlWebpackPlugin from 'html-webpack-plugin'; import HtmlWebpackPlugin from 'html-webpack-plugin';
import {createBaseConfig} from './base'; import {createBaseConfig} from './base';
import ChunkAssetPlugin from './plugins/ChunkAssetPlugin'; import ChunkAssetPlugin from './plugins/ChunkAssetPlugin';
import {formatStatsErrorMessage} from './utils';
import CleanWebpackPlugin from './plugins/CleanWebpackPlugin'; import CleanWebpackPlugin from './plugins/CleanWebpackPlugin';
import ForceTerminatePlugin from './plugins/ForceTerminatePlugin';
import {createStaticDirectoriesCopyPlugin} from './plugins/StaticDirectoriesCopyPlugin';
import type {Props} from '@docusaurus/types'; import type {Props} from '@docusaurus/types';
import type {Configuration} from 'webpack'; import type {Configuration} from 'webpack';
// When building, include the plugin to force terminate building if errors
// happened in the client bundle.
class ForceTerminatePlugin implements webpack.WebpackPluginInstance {
apply(compiler: webpack.Compiler) {
compiler.hooks.done.tap('client:done', (stats) => {
if (stats.hasErrors()) {
const errorsWarnings = stats.toJson('errors-warnings');
logger.error(
`Client bundle compiled with errors therefore further build is impossible.\n${formatStatsErrorMessage(
errorsWarnings,
)}`,
);
process.exit(1);
}
});
}
}
async function createBaseClientConfig({ async function createBaseClientConfig({
props, props,
hydrate, hydrate,
@ -68,6 +50,7 @@ async function createBaseClientConfig({
new WebpackBar({ new WebpackBar({
name: 'Client', name: 'Client',
}), }),
await createStaticDirectoriesCopyPlugin({props}),
], ],
}); });
} }
@ -129,7 +112,12 @@ export async function createBuildClientConfig({
bundleAnalyzer: boolean; bundleAnalyzer: boolean;
}): Promise<{config: Configuration; clientManifestPath: string}> { }): Promise<{config: Configuration; clientManifestPath: string}> {
// Apply user webpack config. // Apply user webpack config.
const {generatedFilesDir} = props; const {generatedFilesDir, siteConfig} = props;
const router = siteConfig.future.experimental_router;
// With the hash router, we don't hydrate the React app, even in build mode!
// This is because it will always be a client-rendered React app
const hydrate = router !== 'hash';
const clientManifestPath = path.join( const clientManifestPath = path.join(
generatedFilesDir, generatedFilesDir,
@ -137,7 +125,7 @@ export async function createBuildClientConfig({
); );
const config: Configuration = merge( const config: Configuration = merge(
await createBaseClientConfig({props, minify, hydrate: true}), await createBaseClientConfig({props, minify, hydrate}),
{ {
plugins: [ plugins: [
new ForceTerminatePlugin(), new ForceTerminatePlugin(),

View file

@ -0,0 +1,30 @@
/**
* 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 logger from '@docusaurus/logger';
import {formatStatsErrorMessage} from '../utils';
import type webpack from 'webpack';
// When building, include the plugin to force terminate building if errors
// happened in the client bundle.
export default class ForceTerminatePlugin
implements webpack.WebpackPluginInstance
{
apply(compiler: webpack.Compiler) {
compiler.hooks.done.tap('client:done', (stats) => {
if (stats.hasErrors()) {
const errorsWarnings = stats.toJson('errors-warnings');
logger.error(
`Client bundle compiled with errors therefore further build is impossible.\n${formatStatsErrorMessage(
errorsWarnings,
)}`,
);
process.exit(1);
}
});
}
}

View file

@ -0,0 +1,54 @@
/**
* 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 path from 'path';
import fs from 'fs-extra';
import CopyWebpackPlugin from 'copy-webpack-plugin';
import type {Props} from '@docusaurus/types';
export async function createStaticDirectoriesCopyPlugin({
props,
}: {
props: Props;
}): Promise<CopyWebpackPlugin | undefined> {
const {
outDir,
siteDir,
siteConfig: {staticDirectories: staticDirectoriesOption},
} = props;
// The staticDirectories option can contain empty directories, or non-existent
// directories (e.g. user deleted `static`). Instead of issuing an error, we
// just silently filter them out, because user could have never configured it
// in the first place (the default option should always "work").
const staticDirectories: string[] = (
await Promise.all(
staticDirectoriesOption.map(async (dir) => {
const staticDir = path.resolve(siteDir, dir);
if (
(await fs.pathExists(staticDir)) &&
(await fs.readdir(staticDir)).length > 0
) {
return staticDir;
}
return '';
}),
)
).filter(Boolean);
if (staticDirectories.length === 0) {
return undefined;
}
return new CopyWebpackPlugin({
patterns: staticDirectories.map((dir) => ({
from: dir,
to: outDir,
toType: 'dir',
})),
});
}

View file

@ -6,11 +6,9 @@
*/ */
import path from 'path'; import path from 'path';
import fs from 'fs-extra';
import merge from 'webpack-merge'; import merge from 'webpack-merge';
import {NODE_MAJOR_VERSION, NODE_MINOR_VERSION} from '@docusaurus/utils'; import {NODE_MAJOR_VERSION, NODE_MINOR_VERSION} from '@docusaurus/utils';
import WebpackBar from 'webpackbar'; import WebpackBar from 'webpackbar';
import CopyWebpackPlugin from 'copy-webpack-plugin';
import {createBaseConfig} from './base'; import {createBaseConfig} from './base';
import type {Props} from '@docusaurus/types'; import type {Props} from '@docusaurus/types';
import type {Configuration} from 'webpack'; import type {Configuration} from 'webpack';
@ -48,48 +46,8 @@ export default async function createServerConfig(params: {
name: 'Server', name: 'Server',
color: 'yellow', color: 'yellow',
}), }),
await createStaticDirectoriesCopyPlugin(params),
].filter(Boolean), ].filter(Boolean),
}); });
return {config, serverBundlePath}; return {config, serverBundlePath};
} }
async function createStaticDirectoriesCopyPlugin({props}: {props: Props}) {
const {
outDir,
siteDir,
siteConfig: {staticDirectories: staticDirectoriesOption},
} = props;
// The staticDirectories option can contain empty directories, or non-existent
// directories (e.g. user deleted `static`). Instead of issuing an error, we
// just silently filter them out, because user could have never configured it
// in the first place (the default option should always "work").
const staticDirectories: string[] = (
await Promise.all(
staticDirectoriesOption.map(async (dir) => {
const staticDir = path.resolve(siteDir, dir);
if (
(await fs.pathExists(staticDir)) &&
(await fs.readdir(staticDir)).length > 0
) {
return staticDir;
}
return '';
}),
)
).filter(Boolean);
if (staticDirectories.length === 0) {
return undefined;
}
return new CopyWebpackPlugin({
patterns: staticDirectories.map((dir) => ({
from: dir,
to: outDir,
toType: 'dir',
})),
});
}

View file

@ -201,6 +201,7 @@ export default {
type: 'localStorage', type: 'localStorage',
namespace: true, namespace: true,
}, },
experimental_router: 'hash',
}, },
}; };
``` ```
@ -208,6 +209,7 @@ export default {
- `experimental_storage`: Site-wide browser storage options that theme authors should strive to respect. - `experimental_storage`: Site-wide browser storage options that theme authors should strive to respect.
- `type`: The browser storage theme authors should use. Possible values are `localStorage` and `sessionStorage`. Defaults to `localStorage`. - `type`: The browser storage theme authors should use. Possible values are `localStorage` and `sessionStorage`. Defaults to `localStorage`.
- `namespace`: Whether to namespace the browser storage keys to avoid storage key conflicts when Docusaurus sites are hosted under the same domain, or on localhost. Possible values are `string | boolean`. The namespace is appended at the end of the storage keys `key-namespace`. Use `true` to automatically generate a random namespace from your site `url + baseUrl`. Defaults to `false` (no namespace, historical behavior). - `namespace`: Whether to namespace the browser storage keys to avoid storage key conflicts when Docusaurus sites are hosted under the same domain, or on localhost. Possible values are `string | boolean`. The namespace is appended at the end of the storage keys `key-namespace`. Use `true` to automatically generate a random namespace from your site `url + baseUrl`. Defaults to `false` (no namespace, historical behavior).
- `experimental_router`: The router type to use. Possible values are `browser` and `hash`. Defaults to `browser`. The `hash` router is only useful for rare cases where you want to opt-out of static site generation, have a fully client-side app with a single `index.html` entrypoint file. This can be useful to distribute a Docusaurus site as a `.zip` archive that you can [browse locally without running a web server](https://github.com/facebook/docusaurus/issues/3825).
### `noIndex` {#noIndex} ### `noIndex` {#noIndex}

View file

@ -23,8 +23,8 @@ import ConfigLocalized from './docusaurus.config.localized.json';
import PrismLight from './src/utils/prismLight'; import PrismLight from './src/utils/prismLight';
import PrismDark from './src/utils/prismDark'; import PrismDark from './src/utils/prismDark';
import type {Config, DocusaurusConfig} from '@docusaurus/types';
import type {Config} from '@docusaurus/types';
import type * as Preset from '@docusaurus/preset-classic'; import type * as Preset from '@docusaurus/preset-classic';
import type {Options as DocsOptions} from '@docusaurus/plugin-content-docs'; import type {Options as DocsOptions} from '@docusaurus/plugin-content-docs';
import type {Options as BlogOptions} from '@docusaurus/plugin-content-blog'; import type {Options as BlogOptions} from '@docusaurus/plugin-content-blog';
@ -95,6 +95,9 @@ function getNextVersionName() {
// Test with: DOCUSAURUS_CRASH_TEST=true yarn build:website:fast // Test with: DOCUSAURUS_CRASH_TEST=true yarn build:website:fast
const crashTest = process.env.DOCUSAURUS_CRASH_TEST === 'true'; const crashTest = process.env.DOCUSAURUS_CRASH_TEST === 'true';
const router = process.env
.DOCUSAURUS_ROUTER as DocusaurusConfig['future']['experimental_router'];
const isDev = process.env.NODE_ENV === 'development'; const isDev = process.env.NODE_ENV === 'development';
const isDeployPreview = const isDeployPreview =
@ -151,6 +154,7 @@ export default async function createConfigAsync() {
experimental_storage: { experimental_storage: {
namespace: true, namespace: true,
}, },
experimental_router: router,
}, },
// Dogfood both settings: // Dogfood both settings:
// - force trailing slashes for deploy previews // - force trailing slashes for deploy previews