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 logger from '@docusaurus/logger';
import collectRedirects from './collectRedirects';
import writeRedirectFiles, {
toRedirectFiles,
@ -15,14 +16,24 @@ import type {LoadContext, Plugin} from '@docusaurus/types';
import type {PluginContext, RedirectItem} from './types';
import type {PluginOptions, Options} from './options';
const PluginName = 'docusaurus-plugin-client-redirects';
export default function pluginClientRedirectsPages(
context: LoadContext,
options: PluginOptions,
): Plugin<void> {
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 {
name: 'docusaurus-plugin-client-redirects',
name: PluginName,
async postBuild(props) {
const pluginContext: PluginContext = {
relativeRoutesPaths: props.routesPaths.map(

View file

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

View file

@ -16,7 +16,7 @@ import {
applyTrailingSlash,
} from '@docusaurus/utils-common';
import {load as cheerioLoad} from 'cheerio';
import type {DocusaurusConfig} from '@docusaurus/types';
import type {DocusaurusConfig, HtmlTags, LoadContext} from '@docusaurus/types';
import type {
FeedType,
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';
import footnoteIDFixer from './remark/footnoteIDFixer';
import {translateContent, getTranslationFiles} from './translations';
import {createBlogFeedFiles} from './feed';
import {createBlogFeedFiles, createFeedHtmlHeadTags} from './feed';
import {createAllRoutes} from './routes';
import type {BlogContentPaths, BlogMarkdownLoaderOptions} from './types';
import type {LoadContext, Plugin, HtmlTags} from '@docusaurus/types';
import type {LoadContext, Plugin} from '@docusaurus/types';
import type {
PluginOptions,
BlogPostFrontMatter,
@ -44,6 +44,8 @@ import type {
BlogPaginated,
} from '@docusaurus/plugin-content-blog';
const PluginName = 'docusaurus-plugin-content-blog';
export default async function pluginContentBlog(
context: LoadContext,
options: PluginOptions,
@ -55,22 +57,29 @@ export default async function pluginContentBlog(
localizationDir,
i18n: {currentLocale},
} = 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 contentPaths: BlogContentPaths = {
contentPath: path.resolve(siteDir, options.path),
contentPathLocalized: getPluginI18nPath({
localizationDir,
pluginName: 'docusaurus-plugin-content-blog',
pluginName: PluginName,
pluginId: options.id,
}),
};
const pluginId = options.id ?? DEFAULT_PLUGIN_ID;
const pluginDataDirRoot = path.join(
generatedFilesDir,
'docusaurus-plugin-content-blog',
);
const pluginDataDirRoot = path.join(generatedFilesDir, PluginName);
const dataDir = path.join(pluginDataDirRoot, pluginId);
// TODO Docusaurus v4 breaking change
// module aliasing should be automatic
@ -84,7 +93,7 @@ export default async function pluginContentBlog(
});
return {
name: 'docusaurus-plugin-content-blog',
name: PluginName,
getPathsToWatch() {
const {include} = options;
@ -295,15 +304,16 @@ export default async function pluginContentBlog(
},
async postBuild({outDir, content}) {
if (!options.feedOptions.type) {
return;
}
const {blogPosts} = content;
if (!blogPosts.length) {
if (
!content.blogPosts.length ||
!options.feedOptions.type ||
isBlogFeedDisabledBecauseOfHashRouter
) {
return;
}
await createBlogFeedFiles({
blogPosts,
blogPosts: content.blogPosts,
options,
outDir,
siteConfig,
@ -312,56 +322,15 @@ export default async function pluginContentBlog(
},
injectHtmlTags({content}) {
if (!content.blogPosts.length || !options.feedOptions.type) {
if (
!content.blogPosts.length ||
!options.feedOptions.type ||
isBlogFeedDisabledBecauseOfHashRouter
) {
return {};
}
const feedTypes = options.feedOptions.type;
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,
};
return {headTags: createFeedHtmlHeadTags({context, options})};
},
};
}

View file

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

View file

@ -12,12 +12,21 @@ import createSitemap from './createSitemap';
import type {PluginOptions, Options} from './options';
import type {LoadContext, Plugin} from '@docusaurus/types';
const PluginName = 'docusaurus-plugin-sitemap';
export default function pluginSitemap(
context: LoadContext,
options: PluginOptions,
): 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 {
name: 'docusaurus-plugin-sitemap',
name: PluginName,
async postBuild({siteConfig, routes, outDir, head}) {
if (siteConfig.noIndex) {

View file

@ -5,38 +5,21 @@
* 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 {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 {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> {
const {
baseUrl,
siteConfig: {title, url, favicon, themeConfig},
siteConfig: {themeConfig},
i18n: {currentLocale},
} = context;
const {
@ -70,45 +53,17 @@ export default function themeSearchAlgolia(context: LoadContext): Plugin<void> {
}
},
async postBuild({outDir}) {
if (searchPagePath) {
const siteUrl = normalizeUrl([url, baseUrl]);
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;
}
async postBuild() {
if (shouldCreateOpenSearchFile({context})) {
await createOpenSearchFile({context});
}
},
injectHtmlTags() {
if (!searchPagePath) {
return {};
if (shouldCreateOpenSearchFile({context})) {
return {headTags: createOpenSearchHeadTags({context})};
}
return {
headTags: [
{
tagName: 'link',
attributes: {
rel: 'search',
type: 'application/opensearchdescription+xml',
title,
href: normalizeUrl([baseUrl, OPEN_SEARCH_FILENAME]),
},
},
],
};
return {};
},
};
}

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 RouterType = 'browser' | 'hash';
export type ThemeConfig = {
[key: string]: unknown;
};
@ -123,6 +125,24 @@ export type StorageConfig = {
export type FutureConfig = {
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 {
ReportingSeverity,
RouterType,
ThemeConfig,
MarkdownConfig,
DefaultParseFrontMatter,

View file

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

View file

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

View file

@ -5,16 +5,24 @@
* 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 {BrowserRouter} from 'react-router-dom';
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 App from './App';
import preload from './preload';
import docusaurus from './docusaurus';
function Router({children}: {children: ReactNode}): ReactNode {
return siteConfig.future.experimental_router === 'hash' ? (
<HashRouter>{children}</HashRouter>
) : (
<BrowserRouter>{children}</BrowserRouter>
);
}
declare global {
interface NodeModule {
hot?: {accept: () => void};
@ -31,9 +39,9 @@ if (ExecutionEnvironment.canUseDOM) {
const app = (
<HelmetProvider>
<BrowserRouter>
<Router>
<App />
</BrowserRouter>
</Router>
</HelmetProvider>
);

View file

@ -40,9 +40,9 @@ function Link(
}: Props,
forwardedRef: React.ForwardedRef<HTMLAnchorElement>,
): JSX.Element {
const {
siteConfig: {trailingSlash, baseUrl},
} = useDocusaurusContext();
const {siteConfig} = useDocusaurusContext();
const {trailingSlash, baseUrl} = siteConfig;
const router = siteConfig.future.experimental_router;
const {withBaseUrl} = useBaseUrlUtils();
const brokenLinks = useBrokenLinks();
const innerRef = useRef<HTMLAnchorElement | null>(null);
@ -81,6 +81,15 @@ function Link(
? maybeAddBaseUrl(targetLinkWithoutPathnameProtocol)
: 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) {
targetLink = applyTrailingSlash(targetLink, {trailingSlash, baseUrl});
}
@ -148,8 +157,7 @@ function Link(
const hasInternalTarget = !props.target || props.target === '_self';
// Should we use a regular <a> tag instead of React-Router Link component?
const isRegularHtmlLink =
!targetLink || !isInternal || !hasInternalTarget || isAnchorLink;
const isRegularHtmlLink = !targetLink || !isInternal || !hasInternalTarget;
if (!noBrokenLinkCheck && (isAnchorLink || !isRegularHtmlLink)) {
brokenLinks.collectLink(targetLink!);

View file

@ -7,13 +7,222 @@
import React from 'react';
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 type {DocusaurusContext} from '@docusaurus/types';
import type {DocusaurusContext, FutureConfig} from '@docusaurus/types';
import type {BaseUrlOptions} from '@docusaurus/useBaseUrl';
type AddBaseUrlParams = Parameters<typeof addBaseUrl>[0];
const future: FutureConfig = fromPartial({
experimental_router: 'browser',
});
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', () => {
const createUseBaseUrlMock =
(context: DocusaurusContext) => (url: string, options?: BaseUrlOptions) =>
@ -27,6 +236,7 @@ describe('useBaseUrl', () => {
siteConfig: {
baseUrl: '/',
url: 'https://docusaurus.io',
future,
},
} as DocusaurusContext);
@ -55,6 +265,7 @@ describe('useBaseUrl', () => {
siteConfig: {
baseUrl: '/docusaurus/',
url: 'https://docusaurus.io',
future,
},
} as DocusaurusContext);
@ -96,6 +307,7 @@ describe('useBaseUrlUtils().withBaseUrl()', () => {
siteConfig: {
baseUrl: '/',
url: 'https://docusaurus.io',
future,
},
} as DocusaurusContext);
@ -124,6 +336,7 @@ describe('useBaseUrlUtils().withBaseUrl()', () => {
siteConfig: {
baseUrl: '/docusaurus/',
url: 'https://docusaurus.io',
future,
},
} as DocusaurusContext);

View file

@ -9,19 +9,34 @@ import {useCallback} from 'react';
import useDocusaurusContext from './useDocusaurusContext';
import {hasProtocol} from './isInternalUrl';
import type {BaseUrlOptions, BaseUrlUtils} from '@docusaurus/useBaseUrl';
import type {RouterType} from '@docusaurus/types';
function addBaseUrl(
siteUrl: string,
baseUrl: string,
url: string,
{forcePrependBaseUrl = false, absolute = false}: BaseUrlOptions = {},
): string {
export function addBaseUrl({
siteUrl,
baseUrl,
url,
options: {forcePrependBaseUrl = false, absolute = false} = {},
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
// protocol
if (!url || url.startsWith('#') || hasProtocol(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) {
return baseUrl + url.replace(/^\//, '');
}
@ -41,14 +56,14 @@ function addBaseUrl(
}
export function useBaseUrlUtils(): BaseUrlUtils {
const {
siteConfig: {baseUrl, url: siteUrl},
} = useDocusaurusContext();
const {siteConfig} = useDocusaurusContext();
const {baseUrl, url: siteUrl} = siteConfig;
const router = siteConfig.future.experimental_router;
const withBaseUrl = useCallback(
(url: string, options?: BaseUrlOptions) =>
addBaseUrl(siteUrl, baseUrl, url, options),
[siteUrl, baseUrl],
addBaseUrl({siteUrl, baseUrl, url, options, router}),
[siteUrl, baseUrl, router],
);
return {

View file

@ -20,12 +20,20 @@ import {compile} from '../webpack/utils';
import {PerfLogger} from '../utils';
import {loadI18n} from '../server/i18n';
import {generateStaticFiles, loadAppRenderer} from '../ssg';
import {compileSSRTemplate} from '../templates/templates';
import {
generateHashRouterEntrypoint,
generateStaticFiles,
loadAppRenderer,
} from '../ssg';
import {
compileSSRTemplate,
renderHashRouterTemplate,
} from '../templates/templates';
import defaultSSRTemplate from '../templates/ssr.html.template';
import type {SSGParams} from '../ssg';
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';
export type BuildCLIOptions = Pick<
@ -164,7 +172,9 @@ async function buildLocale({
);
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
const [{clientConfig, clientManifestPath}, {serverConfig, serverBundlePath}] =
@ -181,15 +191,20 @@ async function buildLocale({
);
// Run webpack to build JS bundle (client) and static html files (server).
await PerfLogger.async('Bundling with Webpack', () =>
compile([clientConfig, serverConfig]),
);
await PerfLogger.async('Bundling with Webpack', () => {
if (router === 'hash') {
return compile([clientConfig]);
} else {
return compile([clientConfig, serverConfig]);
}
});
const {collectedData} = await PerfLogger.async('SSG', () =>
executeSSG({
props,
serverBundlePath,
clientManifestPath,
router,
}),
);
@ -220,11 +235,13 @@ async function executeSSG({
props,
serverBundlePath,
clientManifestPath,
router,
}: {
props: Props;
serverBundlePath: string;
clientManifestPath: string;
}) {
router: RouterType;
}): Promise<{collectedData: SiteCollectedData}> {
const manifest: Manifest = await PerfLogger.async(
'Read client manifest',
() => fs.readJSON(clientManifestPath, 'utf-8'),
@ -234,6 +251,27 @@ async function executeSSG({
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', () =>
loadAppRenderer({
serverBundlePath,
@ -244,18 +282,7 @@ async function executeSSG({
generateStaticFiles({
pathnames: props.routesPaths,
renderer,
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,
},
params,
}),
);

View file

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

View file

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

View file

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

View file

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

View file

@ -42,6 +42,7 @@ describe('normalizeConfig', () => {
type: 'sessionStorage',
namespace: true,
},
experimental_router: 'hash',
},
tagline: 'my awesome site',
organizationName: 'facebook',
@ -648,6 +649,7 @@ describe('future', () => {
type: 'sessionStorage',
namespace: 'myNamespace',
},
experimental_router: 'hash',
};
expect(
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', () => {
it('accepts storage - undefined', () => {
expect(
@ -707,9 +800,9 @@ describe('future', () => {
}),
).toEqual(
expect.objectContaining({
future: {
future: expect.objectContaining({
experimental_storage: storage,
},
}),
}),
);
});
@ -757,12 +850,12 @@ describe('future', () => {
}),
).toEqual(
expect.objectContaining({
future: {
future: expect.objectContaining({
experimental_storage: {
...DEFAULT_STORAGE_CONFIG,
...storage,
},
},
}),
}),
);
});
@ -779,12 +872,12 @@ describe('future', () => {
}),
).toEqual(
expect.objectContaining({
future: {
future: expect.objectContaining({
experimental_storage: {
...DEFAULT_STORAGE_CONFIG,
type: 'localStorage',
},
},
}),
}),
);
});
@ -850,12 +943,12 @@ describe('future', () => {
}),
).toEqual(
expect.objectContaining({
future: {
future: expect.objectContaining({
experimental_storage: {
...DEFAULT_STORAGE_CONFIG,
...storage,
},
},
}),
}),
);
});
@ -872,12 +965,12 @@ describe('future', () => {
}),
).toEqual(
expect.objectContaining({
future: {
future: expect.objectContaining({
experimental_storage: {
...DEFAULT_STORAGE_CONFIG,
...storage,
},
},
}),
}),
);
});

View file

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

View file

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

View file

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

View file

@ -147,7 +147,10 @@ function createSiteProps(
codeTranslations: siteCodeTranslations,
} = context;
const {headTags, preBodyTags, postBodyTags} = loadHtmlTags(plugins);
const {headTags, preBodyTags, postBodyTags} = loadHtmlTags({
plugins,
router: siteConfig.future.experimental_router,
});
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');
}
export async function generateHashRouterEntrypoint({
content,
params,
}: {
content: string;
params: SSGParams;
}): Promise<void> {
await writeStaticFile({
pathname: '/',
content,
params,
});
}
async function writeStaticFile({
content,
pathname,

View file

@ -113,3 +113,41 @@ export function renderSSRTemplate({
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 = {
outDir: '',
siteDir: path.resolve(__dirname, '__fixtures__', 'base_test_site'),
siteConfig: {staticDirectories: ['static']},
siteConfig: {staticDirectories: ['static'], future: {}},
baseUrl: '',
generatedFilesDir: '',
routesPaths: [''],

View file

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

View file

@ -6,7 +6,6 @@
*/
import path from 'path';
import logger from '@docusaurus/logger';
import merge from 'webpack-merge';
import WebpackBar from 'webpackbar';
import webpack from 'webpack';
@ -15,29 +14,12 @@ import ReactLoadableSSRAddon from 'react-loadable-ssr-addon-v5-slorber';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import {createBaseConfig} from './base';
import ChunkAssetPlugin from './plugins/ChunkAssetPlugin';
import {formatStatsErrorMessage} from './utils';
import CleanWebpackPlugin from './plugins/CleanWebpackPlugin';
import ForceTerminatePlugin from './plugins/ForceTerminatePlugin';
import {createStaticDirectoriesCopyPlugin} from './plugins/StaticDirectoriesCopyPlugin';
import type {Props} from '@docusaurus/types';
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({
props,
hydrate,
@ -68,6 +50,7 @@ async function createBaseClientConfig({
new WebpackBar({
name: 'Client',
}),
await createStaticDirectoriesCopyPlugin({props}),
],
});
}
@ -129,7 +112,12 @@ export async function createBuildClientConfig({
bundleAnalyzer: boolean;
}): Promise<{config: Configuration; clientManifestPath: string}> {
// 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(
generatedFilesDir,
@ -137,7 +125,7 @@ export async function createBuildClientConfig({
);
const config: Configuration = merge(
await createBaseClientConfig({props, minify, hydrate: true}),
await createBaseClientConfig({props, minify, hydrate}),
{
plugins: [
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 fs from 'fs-extra';
import merge from 'webpack-merge';
import {NODE_MAJOR_VERSION, NODE_MINOR_VERSION} from '@docusaurus/utils';
import WebpackBar from 'webpackbar';
import CopyWebpackPlugin from 'copy-webpack-plugin';
import {createBaseConfig} from './base';
import type {Props} from '@docusaurus/types';
import type {Configuration} from 'webpack';
@ -48,48 +46,8 @@ export default async function createServerConfig(params: {
name: 'Server',
color: 'yellow',
}),
await createStaticDirectoriesCopyPlugin(params),
].filter(Boolean),
});
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',
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.
- `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).
- `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}

View file

@ -23,8 +23,8 @@ import ConfigLocalized from './docusaurus.config.localized.json';
import PrismLight from './src/utils/prismLight';
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 {Options as DocsOptions} from '@docusaurus/plugin-content-docs';
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
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 isDeployPreview =
@ -151,6 +154,7 @@ export default async function createConfigAsync() {
experimental_storage: {
namespace: true,
},
experimental_router: router,
},
// Dogfood both settings:
// - force trailing slashes for deploy previews