From 17f3e02a4244b1893b7624ec3c948bfa926feacd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Lorber?= Date: Sun, 19 May 2024 15:44:58 +0200 Subject: [PATCH] feat(core): hash router option - browse site offline (experimental) (#9859) --- .github/workflows/build-hash-router.yml | 60 +++++ .../src/index.ts | 13 +- .../src/__tests__/index.test.ts | 1 + .../src/feed.ts | 58 ++++- .../src/index.ts | 91 +++----- packages/docusaurus-plugin-pwa/package.json | 1 + packages/docusaurus-plugin-pwa/src/index.ts | 13 +- .../docusaurus-plugin-sitemap/src/index.ts | 11 +- .../src/index.ts | 69 +----- .../src/opensearch.ts | 115 ++++++++++ packages/docusaurus-types/src/config.d.ts | 20 ++ packages/docusaurus-types/src/index.d.ts | 1 + .../src/__tests__/urlUtils.test.ts | 24 ++ packages/docusaurus-utils/src/urlUtils.ts | 2 +- .../docusaurus/src/client/clientEntry.tsx | 18 +- .../docusaurus/src/client/exports/Link.tsx | 18 +- .../exports/__tests__/useBaseUrl.test.tsx | 217 +++++++++++++++++- .../src/client/exports/useBaseUrl.ts | 37 ++- packages/docusaurus/src/commands/build.ts | 67 ++++-- .../docusaurus/src/commands/start/utils.ts | 19 +- .../docusaurus/src/commands/start/webpack.ts | 3 +- .../__snapshots__/config.test.ts.snap | 10 + .../__tests__/__snapshots__/site.test.ts.snap | 1 + .../server/__tests__/configValidation.test.ts | 113 ++++++++- .../src/server/__tests__/htmlTags.test.ts | 22 +- .../docusaurus/src/server/configValidation.ts | 4 + packages/docusaurus/src/server/htmlTags.ts | 48 +++- packages/docusaurus/src/server/site.ts | 5 +- packages/docusaurus/src/ssg.ts | 14 ++ .../docusaurus/src/templates/templates.ts | 38 +++ .../src/webpack/__tests__/base.test.ts | 2 +- packages/docusaurus/src/webpack/base.ts | 3 +- packages/docusaurus/src/webpack/client.ts | 32 +-- .../webpack/plugins/ForceTerminatePlugin.ts | 30 +++ .../plugins/StaticDirectoriesCopyPlugin.ts | 54 +++++ packages/docusaurus/src/webpack/server.ts | 42 ---- website/docs/api/docusaurus.config.js.mdx | 2 + website/docusaurus.config.ts | 6 +- 38 files changed, 1018 insertions(+), 266 deletions(-) create mode 100644 .github/workflows/build-hash-router.yml create mode 100644 packages/docusaurus-theme-search-algolia/src/opensearch.ts create mode 100644 packages/docusaurus/src/webpack/plugins/ForceTerminatePlugin.ts create mode 100644 packages/docusaurus/src/webpack/plugins/StaticDirectoriesCopyPlugin.ts diff --git a/.github/workflows/build-hash-router.yml b/.github/workflows/build-hash-router.yml new file mode 100644 index 000000000000..9653bab207f7 --- /dev/null +++ b/.github/workflows/build-hash-router.yml @@ -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 diff --git a/packages/docusaurus-plugin-client-redirects/src/index.ts b/packages/docusaurus-plugin-client-redirects/src/index.ts index e7aa97961f37..91cfa3a8a153 100644 --- a/packages/docusaurus-plugin-client-redirects/src/index.ts +++ b/packages/docusaurus-plugin-client-redirects/src/index.ts @@ -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 { 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( diff --git a/packages/docusaurus-plugin-content-blog/src/__tests__/index.test.ts b/packages/docusaurus-plugin-content-blog/src/__tests__/index.test.ts index 3244150dc217..16802634bcdb 100644 --- a/packages/docusaurus-plugin-content-blog/src/__tests__/index.test.ts +++ b/packages/docusaurus-plugin-content-blog/src/__tests__/index.test.ts @@ -106,6 +106,7 @@ const getPlugin = async ( baseUrl: '/', url: 'https://docusaurus.io', markdown, + future: {}, } as DocusaurusConfig; return pluginContentBlog( { diff --git a/packages/docusaurus-plugin-content-blog/src/feed.ts b/packages/docusaurus-plugin-content-blog/src/feed.ts index 95e63ac5af69..b8bc8c1481c9 100644 --- a/packages/docusaurus-plugin-content-blog/src/feed.ts +++ b/packages/docusaurus-plugin-content-blog/src/feed.ts @@ -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; +} diff --git a/packages/docusaurus-plugin-content-blog/src/index.ts b/packages/docusaurus-plugin-content-blog/src/index.ts index 673bb373a191..8906296a8f63 100644 --- a/packages/docusaurus-plugin-content-blog/src/index.ts +++ b/packages/docusaurus-plugin-content-blog/src/index.ts @@ -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})}; }, }; } diff --git a/packages/docusaurus-plugin-pwa/package.json b/packages/docusaurus-plugin-pwa/package.json index 3403749d5d2d..b9c3df6a07f9 100644 --- a/packages/docusaurus-plugin-pwa/package.json +++ b/packages/docusaurus-plugin-pwa/package.json @@ -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", diff --git a/packages/docusaurus-plugin-pwa/src/index.ts b/packages/docusaurus-plugin-pwa/src/index.ts index b3c8a4e7e670..e42783ee5056 100644 --- a/packages/docusaurus-plugin-pwa/src/index.ts +++ b/packages/docusaurus-plugin-pwa/src/index.ts @@ -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'; diff --git a/packages/docusaurus-plugin-sitemap/src/index.ts b/packages/docusaurus-plugin-sitemap/src/index.ts index e228d1d2d7cf..986d05aaca50 100644 --- a/packages/docusaurus-plugin-sitemap/src/index.ts +++ b/packages/docusaurus-plugin-sitemap/src/index.ts @@ -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 { + 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) { diff --git a/packages/docusaurus-theme-search-algolia/src/index.ts b/packages/docusaurus-theme-search-algolia/src/index.ts index af54aafba33f..f7fb8bdc328b 100644 --- a/packages/docusaurus-theme-search-algolia/src/index.ts +++ b/packages/docusaurus-theme-search-algolia/src/index.ts @@ -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 { const { baseUrl, - siteConfig: {title, url, favicon, themeConfig}, + siteConfig: {themeConfig}, i18n: {currentLocale}, } = context; const { @@ -70,45 +53,17 @@ export default function themeSearchAlgolia(context: LoadContext): Plugin { } }, - 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 {}; }, }; } diff --git a/packages/docusaurus-theme-search-algolia/src/opensearch.ts b/packages/docusaurus-theme-search-algolia/src/opensearch.ts new file mode 100644 index 000000000000..f802e4e09448 --- /dev/null +++ b/packages/docusaurus-theme-search-algolia/src/opensearch.ts @@ -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 { + 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]), + }, + }; +} diff --git a/packages/docusaurus-types/src/config.d.ts b/packages/docusaurus-types/src/config.d.ts index 422ce7a574ee..62fc082c9ba9 100644 --- a/packages/docusaurus-types/src/config.d.ts +++ b/packages/docusaurus-types/src/config.d.ts @@ -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; }; /** diff --git a/packages/docusaurus-types/src/index.d.ts b/packages/docusaurus-types/src/index.d.ts index 1504e6d03450..8217758df97f 100644 --- a/packages/docusaurus-types/src/index.d.ts +++ b/packages/docusaurus-types/src/index.d.ts @@ -7,6 +7,7 @@ export { ReportingSeverity, + RouterType, ThemeConfig, MarkdownConfig, DefaultParseFrontMatter, diff --git a/packages/docusaurus-utils/src/__tests__/urlUtils.test.ts b/packages/docusaurus-utils/src/__tests__/urlUtils.test.ts index 1f63138d9780..35b5ac79307c 100644 --- a/packages/docusaurus-utils/src/__tests__/urlUtils.test.ts +++ b/packages/docusaurus-utils/src/__tests__/urlUtils.test.ts @@ -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', diff --git a/packages/docusaurus-utils/src/urlUtils.ts b/packages/docusaurus-utils/src/urlUtils.ts index ddfc04272c36..f6b2de027cbd 100644 --- a/packages/docusaurus-utils/src/urlUtils.ts +++ b/packages/docusaurus-utils/src/urlUtils.ts @@ -90,7 +90,7 @@ export function normalizeUrl(rawUrls: string[]): string { // first plain protocol part. // Remove trailing slash before parameters or hash. - str = str.replace(/\/(?\?|&|#[^!])/g, '$1'); + str = str.replace(/\/(?\?|&|#[^!/])/g, '$1'); // Replace ? in parameters with &. const parts = str.split('?'); diff --git a/packages/docusaurus/src/client/clientEntry.tsx b/packages/docusaurus/src/client/clientEntry.tsx index c5fcf5848af6..eb2f67368a20 100644 --- a/packages/docusaurus/src/client/clientEntry.tsx +++ b/packages/docusaurus/src/client/clientEntry.tsx @@ -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' ? ( + {children} + ) : ( + {children} + ); +} + declare global { interface NodeModule { hot?: {accept: () => void}; @@ -31,9 +39,9 @@ if (ExecutionEnvironment.canUseDOM) { const app = ( - + - + ); diff --git a/packages/docusaurus/src/client/exports/Link.tsx b/packages/docusaurus/src/client/exports/Link.tsx index 0e67536f885e..08901b9bd4c4 100644 --- a/packages/docusaurus/src/client/exports/Link.tsx +++ b/packages/docusaurus/src/client/exports/Link.tsx @@ -40,9 +40,9 @@ function Link( }: Props, forwardedRef: React.ForwardedRef, ): 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(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 + // 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 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!); diff --git a/packages/docusaurus/src/client/exports/__tests__/useBaseUrl.test.tsx b/packages/docusaurus/src/client/exports/__tests__/useBaseUrl.test.tsx index 1c6724da3069..babbadbe05d8 100644 --- a/packages/docusaurus/src/client/exports/__tests__/useBaseUrl.test.tsx +++ b/packages/docusaurus/src/client/exports/__tests__/useBaseUrl.test.tsx @@ -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[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) { + 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); diff --git a/packages/docusaurus/src/client/exports/useBaseUrl.ts b/packages/docusaurus/src/client/exports/useBaseUrl.ts index 0ba33b8c24ea..609e9c844770 100644 --- a/packages/docusaurus/src/client/exports/useBaseUrl.ts +++ b/packages/docusaurus/src/client/exports/useBaseUrl.ts @@ -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 { diff --git a/packages/docusaurus/src/commands/build.ts b/packages/docusaurus/src/commands/build.ts index b823dbd1b627..dee3fc8869ea 100644 --- a/packages/docusaurus/src/commands/build.ts +++ b/packages/docusaurus/src/commands/build.ts @@ -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, }), ); diff --git a/packages/docusaurus/src/commands/start/utils.ts b/packages/docusaurus/src/commands/start/utils.ts index 1d1c70766132..223733286d1a 100644 --- a/packages/docusaurus/src/commands/start/utils.ts +++ b/packages/docusaurus/src/commands/start/utils.ts @@ -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 = () => { diff --git a/packages/docusaurus/src/commands/start/webpack.ts b/packages/docusaurus/src/commands/start/webpack.ts index c3b0ceec8296..ddcd7abb2c35 100644 --- a/packages/docusaurus/src/commands/start/webpack.ts +++ b/packages/docusaurus/src/commands/start/webpack.ts @@ -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', }, diff --git a/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap b/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap index 47a2827d2d29..4876428de152 100644 --- a/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap +++ b/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap @@ -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", diff --git a/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap b/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap index a7837a67e18c..dd5d0e45cb02 100644 --- a/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap +++ b/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap @@ -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", diff --git a/packages/docusaurus/src/server/__tests__/configValidation.test.ts b/packages/docusaurus/src/server/__tests__/configValidation.test.ts index cf7fee326970..3fb607deba47 100644 --- a/packages/docusaurus/src/server/__tests__/configValidation.test.ts +++ b/packages/docusaurus/src/server/__tests__/configValidation.test.ts @@ -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, }, - }, + }), }), ); }); diff --git a/packages/docusaurus/src/server/__tests__/htmlTags.test.ts b/packages/docusaurus/src/server/__tests__/htmlTags.test.ts index ba2f82fdc39a..717e5ca79e03 100644 --- a/packages/docusaurus/src/server/__tests__/htmlTags.test.ts +++ b/packages/docusaurus/src/server/__tests__/htmlTags.test.ts @@ -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": " @@ -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() { diff --git a/packages/docusaurus/src/server/configValidation.ts b/packages/docusaurus/src/server/configValidation.ts index bf581897a4d4..450b0cc88804 100644 --- a/packages/docusaurus/src/server/configValidation.ts +++ b/packages/docusaurus/src/server/configValidation.ts @@ -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({ experimental_storage: STORAGE_CONFIG_SCHEMA, + experimental_router: Joi.string() + .equal('browser', 'hash') + .default(DEFAULT_FUTURE_CONFIG.experimental_router), }) .optional() .default(DEFAULT_FUTURE_CONFIG); diff --git a/packages/docusaurus/src/server/htmlTags.ts b/packages/docusaurus/src/server/htmlTags.ts index e61f94680404..46bd39ab75cc 100644 --- a/packages/docusaurus/src/server/htmlTags.ts +++ b/packages/docusaurus/src/server/htmlTags.ts @@ -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 { +export function loadHtmlTags({ + plugins, + router, +}: { + plugins: LoadedPlugin[]; + router: RouterType; +}): Pick { 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(), ), diff --git a/packages/docusaurus/src/server/site.ts b/packages/docusaurus/src/server/site.ts index 19e18b3f176c..2b9f51512046 100644 --- a/packages/docusaurus/src/server/site.ts +++ b/packages/docusaurus/src/server/site.ts @@ -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}); diff --git a/packages/docusaurus/src/ssg.ts b/packages/docusaurus/src/ssg.ts index 9fc0cb7d66c0..c1253ca23851 100644 --- a/packages/docusaurus/src/ssg.ts +++ b/packages/docusaurus/src/ssg.ts @@ -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 { + await writeStaticFile({ + pathname: '/', + content, + params, + }); +} + async function writeStaticFile({ content, pathname, diff --git a/packages/docusaurus/src/templates/templates.ts b/packages/docusaurus/src/templates/templates.ts index 24c5fb100593..3484b3d8e5e8 100644 --- a/packages/docusaurus/src/templates/templates.ts +++ b/packages/docusaurus/src/templates/templates.ts @@ -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); +} diff --git a/packages/docusaurus/src/webpack/__tests__/base.test.ts b/packages/docusaurus/src/webpack/__tests__/base.test.ts index 95c3ae6285ca..83beeb158473 100644 --- a/packages/docusaurus/src/webpack/__tests__/base.test.ts +++ b/packages/docusaurus/src/webpack/__tests__/base.test.ts @@ -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: [''], diff --git a/packages/docusaurus/src/webpack/base.ts b/packages/docusaurus/src/webpack/base.ts index f7fcb238de8f..5d3ab12bedf9 100644 --- a/packages/docusaurus/src/webpack/base.ts +++ b/packages/docusaurus/src/webpack/base.ts @@ -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 diff --git a/packages/docusaurus/src/webpack/client.ts b/packages/docusaurus/src/webpack/client.ts index ac17617f237e..0b9987f46024 100644 --- a/packages/docusaurus/src/webpack/client.ts +++ b/packages/docusaurus/src/webpack/client.ts @@ -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(), diff --git a/packages/docusaurus/src/webpack/plugins/ForceTerminatePlugin.ts b/packages/docusaurus/src/webpack/plugins/ForceTerminatePlugin.ts new file mode 100644 index 000000000000..15a41a2127f1 --- /dev/null +++ b/packages/docusaurus/src/webpack/plugins/ForceTerminatePlugin.ts @@ -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); + } + }); + } +} diff --git a/packages/docusaurus/src/webpack/plugins/StaticDirectoriesCopyPlugin.ts b/packages/docusaurus/src/webpack/plugins/StaticDirectoriesCopyPlugin.ts new file mode 100644 index 000000000000..8efaa2fd456c --- /dev/null +++ b/packages/docusaurus/src/webpack/plugins/StaticDirectoriesCopyPlugin.ts @@ -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 { + 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', + })), + }); +} diff --git a/packages/docusaurus/src/webpack/server.ts b/packages/docusaurus/src/webpack/server.ts index 10d274aa6663..4e7e4dabeb7f 100644 --- a/packages/docusaurus/src/webpack/server.ts +++ b/packages/docusaurus/src/webpack/server.ts @@ -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', - })), - }); -} diff --git a/website/docs/api/docusaurus.config.js.mdx b/website/docs/api/docusaurus.config.js.mdx index 295774c412b5..9fc51186bd6d 100644 --- a/website/docs/api/docusaurus.config.js.mdx +++ b/website/docs/api/docusaurus.config.js.mdx @@ -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} diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index aae27b1a4d8e..f42da17542bd 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -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