refactor(core): refactor routes generation logic (#7054)

* refactor(core): refactor routes generation logic

* fixes
This commit is contained in:
Joshua Chen 2022-03-29 16:37:29 +08:00 committed by GitHub
parent e31e91ef47
commit 77662260f8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 551 additions and 506 deletions

View file

@ -27,24 +27,26 @@ declare module '@generated/site-metadata' {
}
declare module '@generated/registry' {
const registry: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly [key: string]: [() => Promise<any>, string, string];
};
import type {Registry} from '@docusaurus/types';
const registry: Registry;
export default registry;
}
declare module '@generated/routes' {
import type {Route} from '@docusaurus/types';
import type {RouteConfig as RRRouteConfig} from 'react-router-config';
const routes: Route[];
type RouteConfig = RRRouteConfig & {
path: string;
};
const routes: RouteConfig[];
export default routes;
}
declare module '@generated/routesChunkNames' {
import type {RouteChunksTree} from '@docusaurus/types';
import type {RouteChunkNames} from '@docusaurus/types';
const routesChunkNames: {[route: string]: RouteChunksTree};
const routesChunkNames: RouteChunkNames;
export = routesChunkNames;
}

View file

@ -292,19 +292,15 @@ export default async function pluginContentBlog(
exact: true,
modules: {
sidebar: aliasedSource(sidebarProp),
items: items.map((postID) =>
// To tell routes.js this is an import and not a nested object
// to recurse.
({
content: {
__import: true,
path: blogItemsToMetadata[postID]!.source,
query: {
truncated: true,
},
items: items.map((postID) => ({
content: {
__import: true,
path: blogItemsToMetadata[postID]!.source,
query: {
truncated: true,
},
}),
),
},
})),
metadata: aliasedSource(pageMetadataPath),
},
});

View file

@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import type {Route} from '@docusaurus/types';
import type {RouteConfig} from 'react-router-config';
import {findHomePageRoute, isSamePath} from '../routesUtils';
describe('isSamePath', () => {
@ -41,7 +41,7 @@ describe('isSamePath', () => {
});
describe('findHomePageRoute', () => {
const homePage: Route = {
const homePage: RouteConfig = {
path: '/',
exact: true,
};

View file

@ -8,7 +8,7 @@
import {useMemo} from 'react';
import generatedRoutes from '@generated/routes';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import type {Route} from '@docusaurus/types';
import type {RouteConfig} from 'react-router-config';
/**
* Compare the 2 paths, case insensitive and ignoring trailing slash
@ -34,18 +34,18 @@ export function findHomePageRoute({
baseUrl,
routes: initialRoutes,
}: {
routes: Route[];
routes: RouteConfig[];
baseUrl: string;
}): Route | undefined {
function isHomePageRoute(route: Route): boolean {
}): RouteConfig | undefined {
function isHomePageRoute(route: RouteConfig): boolean {
return route.path === baseUrl && route.exact === true;
}
function isHomeParentRoute(route: Route): boolean {
function isHomeParentRoute(route: RouteConfig): boolean {
return route.path === baseUrl && !route.exact;
}
function doFindHomePageRoute(routes: Route[]): Route | undefined {
function doFindHomePageRoute(routes: RouteConfig[]): RouteConfig | undefined {
if (routes.length === 0) {
return undefined;
}
@ -66,7 +66,7 @@ export function findHomePageRoute({
* Fetches the route that points to "/". Use this instead of the naive "/",
* because the homepage may not exist.
*/
export function useHomePageRoute(): Route | undefined {
export function useHomePageRoute(): RouteConfig | undefined {
const {baseUrl} = useDocusaurusContext().siteConfig;
return useMemo(
() => findHomePageRoute({routes: generatedRoutes, baseUrl}),

View file

@ -11,19 +11,42 @@ import type {CommanderStatic} from 'commander';
import type {ParsedUrlQueryInput} from 'querystring';
import type Joi from 'joi';
import type {
DeepRequired,
Required as RequireKeys,
DeepPartial,
DeepRequired,
} from 'utility-types';
import type {Location} from 'history';
import type Loadable from 'react-loadable';
// === Configuration ===
export type ReportingSeverity = 'ignore' | 'log' | 'warn' | 'error' | 'throw';
export type PluginOptions = {id?: string} & {[key: string]: unknown};
export type PluginConfig =
| string
| [string, PluginOptions]
| [PluginModule, PluginOptions]
| PluginModule;
export type PresetConfig = string | [string, {[key: string]: unknown}];
export type ThemeConfig = {
[key: string]: unknown;
};
export type I18nLocaleConfig = {
label: string;
htmlLang: string;
direction: string;
};
export type I18nConfig = {
defaultLocale: string;
locales: [string, ...string[]];
localeConfigs: {[locale: string]: Partial<I18nLocaleConfig>};
};
/**
* Docusaurus config, after validation/normalization.
*/
@ -92,6 +115,8 @@ export type Config = RequireKeys<
'title' | 'url' | 'baseUrl'
>;
// === Data loading ===
/**
* - `type: 'package'`, plugin is in a different package.
* - `type: 'project'`, plugin is in the same docusaurus project.
@ -115,26 +140,29 @@ export type SiteMetadata = {
readonly pluginVersions: {[pluginName: string]: PluginVersionInformation};
};
// Inspired by Chrome JSON, because it's a widely supported i18n format
// https://developer.chrome.com/apps/i18n-messages
// https://support.crowdin.com/file-formats/chrome-json/
// https://www.applanga.com/docs/formats/chrome_i18n_json
// https://docs.transifex.com/formats/chrome-json
// https://help.phrase.com/help/chrome-json-messages
/**
* Inspired by Chrome JSON, because it's a widely supported i18n format
* @see https://developer.chrome.com/apps/i18n-messages
* @see https://support.crowdin.com/file-formats/chrome-json/
* @see https://www.applanga.com/docs/formats/chrome_i18n_json
* @see https://docs.transifex.com/formats/chrome-json
* @see https://help.phrase.com/help/chrome-json-messages
*/
export type TranslationMessage = {message: string; description?: string};
export type TranslationFileContent = {[key: string]: TranslationMessage};
export type TranslationFile = {path: string; content: TranslationFileContent};
export type I18nLocaleConfig = {
label: string;
htmlLang: string;
direction: string;
};
export type I18nConfig = {
defaultLocale: string;
locales: [string, ...string[]];
localeConfigs: {[locale: string]: Partial<I18nLocaleConfig>};
/**
* An abstract representation of how a translation file exists on disk. The core
* would handle the file reading/writing; plugins just need to deal with
* translations in-memory.
*/
export type TranslationFile = {
/**
* Relative to the directory where it's expected to be found. For plugin
* files, it's relative to `i18n/<locale>/<pluginName>/<pluginId>`. Should NOT
* have any extension.
*/
path: string;
content: TranslationFileContent;
};
export type I18n = DeepRequired<I18nConfig> & {currentLocale: string};
@ -153,21 +181,6 @@ export type DocusaurusContext = {
// isBrowser: boolean; // Not here on purpose!
};
export type Preset = {
plugins?: PluginConfig[];
themes?: PluginConfig[];
};
export type PresetModule = {
<T>(context: LoadContext, presetOptions: T): Preset;
};
export type ImportedPresetModule = PresetModule & {
default?: PresetModule;
};
export type PresetConfig = string | [string, {[key: string]: unknown}];
export type HostPortCLIOptions = {
host?: string;
port?: string;
@ -191,14 +204,11 @@ export type ServeCLIOptions = HostPortCLIOptions &
build: boolean;
};
export type BuildOptions = ConfigOptions & {
export type BuildCLIOptions = ConfigOptions & {
bundleAnalyzer: boolean;
outDir: string;
minify: boolean;
skipBuild: boolean;
};
export type BuildCLIOptions = BuildOptions & {
locale?: string;
};
@ -219,8 +229,6 @@ export type LoadContext = {
codeTranslations: {[msgId: string]: string};
};
export type HtmlTags = string | HtmlTagObject | (string | HtmlTagObject)[];
export type Props = LoadContext & {
readonly headTags: string;
readonly preBodyTags: string;
@ -231,12 +239,27 @@ export type Props = LoadContext & {
readonly plugins: LoadedPlugin[];
};
// === Plugin ===
export type PluginContentLoadedActions = {
addRoute: (config: RouteConfig) => void;
createData: (name: string, data: string) => Promise<string>;
setGlobalData: (data: unknown) => void;
};
export type ConfigureWebpackUtils = {
getStyleLoaders: (
isServer: boolean,
cssOptions: {
[key: string]: unknown;
},
) => RuleSetRule[];
getJSLoader: (options: {
isServer: boolean;
babelOptions?: {[key: string]: unknown};
}) => RuleSetRule;
};
export type AllContent = {
[pluginName: string]: {
[pluginID: string]: unknown;
@ -246,6 +269,37 @@ export type AllContent = {
// TODO improve type (not exposed by postcss-loader)
export type PostCssOptions = {[key: string]: unknown} & {plugins: unknown[]};
type HtmlTagObject = {
/**
* Attributes of the html tag.
* E.g. `{ disabled: true, value: "demo", rel: "preconnect" }`
*/
attributes?: Partial<{[key: string]: string | boolean}>;
/** The tag name, e.g. `div`, `script`, `link`, `meta` */
tagName: string;
/** The inner HTML */
innerHTML?: string;
};
export type HtmlTags = string | HtmlTagObject | (string | HtmlTagObject)[];
export type ValidationSchema<T> = Joi.ObjectSchema<T>;
export type Validate<T, U> = (
validationSchema: ValidationSchema<U>,
options: T,
) => U;
export type OptionValidationContext<T, U> = {
validate: Validate<T, U>;
options: T;
};
export type ThemeConfigValidationContext<T> = {
validate: Validate<T, T>;
themeConfig: Partial<T>;
};
export type Plugin<Content = unknown> = {
name: string;
loadContent?: () => Promise<Content>;
@ -266,7 +320,9 @@ export type Plugin<Content = unknown> = {
utils: ConfigureWebpackUtils,
content: Content,
) => WebpackConfiguration & {
mergeStrategy?: ConfigureWebpackFnMergeStrategy;
mergeStrategy?: {
[key: string]: CustomizeRuleString;
};
};
configurePostCss?: (options: PostCssOptions) => PostCssOptions;
getThemePath?: () => string;
@ -334,9 +390,7 @@ export type NormalizedPluginConfig = {
export type InitializedPlugin = Plugin & {
readonly options: Required<PluginOptions>;
readonly version: PluginVersionInformation;
/**
* The absolute path to the folder containing the entry point file.
*/
/** The absolute path to the folder containing the entry point file. */
readonly path: string;
};
@ -372,48 +426,71 @@ export type ImportedPluginModule = PluginModule & {
default?: PluginModule;
};
export type ConfigureWebpackFn = Plugin['configureWebpack'];
export type ConfigureWebpackFnMergeStrategy = {
[key: string]: CustomizeRuleString;
};
export type ConfigurePostCssFn = Plugin['configurePostCss'];
export type PluginOptions = {id?: string} & {[key: string]: unknown};
export type PluginConfig =
| string
| [string, PluginOptions]
| [PluginModule, PluginOptions]
| PluginModule;
export type ChunkRegistry = {
loader: string;
modulePath: string;
export type Preset = {
plugins?: PluginConfig[];
themes?: PluginConfig[];
};
export type PresetModule = {
<T>(context: LoadContext, presetOptions: T): Preset;
};
export type ImportedPresetModule = PresetModule & {
default?: PresetModule;
};
// === Route registry ===
/**
* A "module" represents a unit of serialized data emitted from the plugin. It
* will be imported on client-side and passed as props, context, etc.
*
* If it's a string, it's a file path that Webpack can `require`; if it's
* an object, it can also contain `query` or other metadata.
*/
export type Module =
| {
path: string;
/**
* A marker that tells the route generator this is an import and not a
* nested object to recurse.
*/
__import?: boolean;
path: string;
query?: ParsedUrlQueryInput;
}
| string;
export type RouteModule = {
[module: string]: Module | RouteModule | RouteModule[];
};
export type ChunkNames = {
[name: string]: string | null | ChunkNames | ChunkNames[];
/**
* Represents the data attached to each route. Since the routes.js is a
* monolithic data file, any data (like props) should be serialized separately
* and registered here as file paths (a {@link Module}), so that we could
* code-split.
*/
export type RouteModules = {
[propName: string]: Module | RouteModules | RouteModules[];
};
/**
* Represents a "slice" of the final route structure returned from the plugin
* `addRoute` action.
*/
export type RouteConfig = {
/** With leading slash. Trailing slash will be normalized by config. */
path: string;
/** Component used to render this route, a path that Webpack can `require`. */
component: string;
modules?: RouteModule;
/**
* Props. Each entry should be `[propName]: pathToPropModule` (created with
* `createData`)
*/
modules?: RouteModules;
/** Nested routes config. */
routes?: RouteConfig[];
/** React router config option: `exact` routes would not match subroutes. */
exact?: boolean;
/** Used to sort routes. Higher-priority routes will be placed first. */
priority?: number;
/** Extra props; will be copied to routes.js. */
[propName: string]: unknown;
};
@ -435,11 +512,57 @@ export type PluginRouteContext = RouteContext & {
};
};
export type Route = {
readonly path: string;
readonly component: ReturnType<typeof Loadable>;
readonly exact?: boolean;
readonly routes?: Route[];
/**
* The shape would be isomorphic to {@link RouteModules}:
* {@link Module} -> `string`, `RouteModules[]` -> `ChunkNames[]`.
*
* Each `string` chunk name will correlate with one key in the {@link Registry}.
*/
export type ChunkNames = {
[propName: string]: string | ChunkNames | ChunkNames[];
};
/**
* A map from route paths (with a hash) to the chunk names of each module, which
* the bundler will collect.
*
* Chunk keys are routes with a hash, because 2 routes can conflict with each
* other if they have the same path, e.g.: parent=/docs, child=/docs
*
* @see https://github.com/facebook/docusaurus/issues/2917
*/
export type RouteChunkNames = {
[routePathHashed: string]: ChunkNames;
};
/**
* Each key is the chunk name, which you can get from `routeChunkNames` (see
* {@link RouteChunkNames}). The values are the opts data that react-loadable
* needs. For example:
*
* ```js
* const options = {
* optsLoader: {
* component: () => import('./Pages.js'),
* content.foo: () => import('./doc1.md'),
* },
* optsModules: ['./Pages.js', './doc1.md'],
* optsWebpack: [
* require.resolveWeak('./Pages.js'),
* require.resolveWeak('./doc1.md'),
* ],
* }
* ```
*
* @see https://github.com/jamiebuilds/react-loadable#declaring-which-modules-are-being-loaded
*/
export type Registry = {
readonly [chunkName: string]: [
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Loader: () => Promise<any>,
ModuleName: string,
ResolvedModuleName: string,
];
};
/**
@ -449,56 +572,12 @@ export type ThemeAliases = {
[alias: string]: string;
};
export type ConfigureWebpackUtils = {
getStyleLoaders: (
isServer: boolean,
cssOptions: {
[key: string]: unknown;
},
) => RuleSetRule[];
getJSLoader: (options: {
isServer: boolean;
babelOptions?: {[key: string]: unknown};
}) => RuleSetRule;
};
type HtmlTagObject = {
/**
* Attributes of the html tag.
* E.g. `{ disabled: true, value: "demo", rel: "preconnect" }`
*/
attributes?: Partial<{[key: string]: string | boolean}>;
/** The tag name, e.g. `div`, `script`, `link`, `meta` */
tagName: string;
/** The inner HTML */
innerHTML?: string;
};
export type ValidationSchema<T> = Joi.ObjectSchema<T>;
export type Validate<T, U> = (
validationSchema: ValidationSchema<U>,
options: T,
) => U;
export type OptionValidationContext<T, U> = {
validate: Validate<T, U>;
options: T;
};
export type ThemeConfigValidationContext<T> = {
validate: Validate<T, T>;
themeConfig: Partial<T>;
};
export type TOCItem = {
readonly value: string;
readonly id: string;
readonly level: number;
};
export type RouteChunksTree = {[x: string | number]: string | RouteChunksTree};
export type ClientModule = {
onRouteUpdate?: (args: {
previousLocation: Location | null;

View file

@ -6,59 +6,10 @@
*/
import {jest} from '@jest/globals';
import {genChunkName, readOutputHTMLFile, generate} from '../emitUtils';
import {readOutputHTMLFile, generate} from '../emitUtils';
import path from 'path';
import fs from 'fs-extra';
describe('genChunkName', () => {
it('works', () => {
const firstAssert: {[key: string]: string} = {
'/docs/adding-blog': 'docs-adding-blog-062',
'/docs/versioning': 'docs-versioning-8a8',
'/': 'index',
'/blog/2018/04/30/How-I-Converted-Profilo-To-Docusaurus':
'blog-2018-04-30-how-i-converted-profilo-to-docusaurus-4f2',
'/youtube': 'youtube-429',
'/users/en/': 'users-en-f7a',
'/blog': 'blog-c06',
};
Object.keys(firstAssert).forEach((str) => {
expect(genChunkName(str)).toBe(firstAssert[str]);
});
});
it("doesn't allow different chunk name for same path", () => {
expect(genChunkName('path/is/similar', 'oldPrefix')).toEqual(
genChunkName('path/is/similar', 'newPrefix'),
);
});
it('emits different chunk names for different paths even with same preferred name', () => {
const secondAssert: {[key: string]: string} = {
'/blog/1': 'blog-85-f-089',
'/blog/2': 'blog-353-489',
};
Object.keys(secondAssert).forEach((str) => {
expect(genChunkName(str, undefined, 'blog')).toBe(secondAssert[str]);
});
});
it('only generates short unique IDs', () => {
const thirdAssert: {[key: string]: string} = {
a: '0cc175b9',
b: '92eb5ffe',
c: '4a8a08f0',
d: '8277e091',
};
Object.keys(thirdAssert).forEach((str) => {
expect(genChunkName(str, undefined, undefined, true)).toBe(
thirdAssert[str],
);
});
expect(genChunkName('d', undefined, undefined, true)).toBe('8277e091');
});
});
describe('readOutputHTMLFile', () => {
it('trailing slash undefined', async () => {
await expect(

View file

@ -8,7 +8,6 @@
import path from 'path';
import fs from 'fs-extra';
import {createHash} from 'crypto';
import {simpleHash, docuHash} from './hashUtils';
import {findAsyncSequential} from './jsUtils';
const fileHash = new Map<string, string>();
@ -18,7 +17,8 @@ const fileHash = new Map<string, string>();
* differs from cache (for hot reload performance).
*
* @param generatedFilesDir Absolute path.
* @param file Path relative to `generatedFilesDir`.
* @param file Path relative to `generatedFilesDir`. File will always be
* outputted; no need to ensure directory exists.
* @param content String content to write.
* @param skipCache If `true` (defaults as `true` for production), file is
* force-rewritten, skipping cache.
@ -29,7 +29,7 @@ export async function generate(
content: string,
skipCache: boolean = process.env.NODE_ENV === 'production',
): Promise<void> {
const filepath = path.join(generatedFilesDir, file);
const filepath = path.resolve(generatedFilesDir, file);
if (skipCache) {
await fs.outputFile(filepath, content);
@ -62,35 +62,6 @@ export async function generate(
}
}
const chunkNameCache = new Map<string, string>();
/**
* Generate unique chunk name given a module path.
*/
export function genChunkName(
modulePath: string,
prefix?: string,
preferredName?: string,
shortId: boolean = process.env.NODE_ENV === 'production',
): string {
let chunkName = chunkNameCache.get(modulePath);
if (!chunkName) {
if (shortId) {
chunkName = simpleHash(modulePath, 8);
} else {
let str = modulePath;
if (preferredName) {
const shortHash = simpleHash(modulePath, 3);
str = `${preferredName}${shortHash}`;
}
const name = str === '/' ? 'index' : docuHash(str);
chunkName = prefix ? `${prefix}---${name}` : name;
}
chunkNameCache.set(modulePath, chunkName);
}
return chunkName;
}
/**
* @param permalink The URL that the HTML file corresponds to, without base URL
* @param outDir Full path to the output directory

View file

@ -22,7 +22,7 @@ export {
DEFAULT_PLUGIN_ID,
WEBPACK_URL_LOADER_LIMIT,
} from './constants';
export {generate, genChunkName, readOutputHTMLFile} from './emitUtils';
export {generate, readOutputHTMLFile} from './emitUtils';
export {
getFileCommitDate,
FileNotTrackedError,

View file

@ -34,20 +34,16 @@ const canPrefetch = (routePath: string) =>
const canPreload = (routePath: string) =>
!isSlowConnection() && !loaded[routePath];
// Remove the last part containing the route hash
// input: /blog/2018/12/14/Happy-First-Birthday-Slash-fe9
// output: /blog/2018/12/14/Happy-First-Birthday-Slash
const removeRouteNameHash = (str: string) => str.replace(/-[^-]+$/, '');
const getChunkNamesToLoad = (path: string): string[] =>
Object.entries(routesChunkNames)
.filter(
([routeNameWithHash]) => removeRouteNameHash(routeNameWithHash) === path,
// Remove the last part containing the route hash
// input: /blog/2018/12/14/Happy-First-Birthday-Slash-fe9
// output: /blog/2018/12/14/Happy-First-Birthday-Slash
([routeNameWithHash]) =>
routeNameWithHash.replace(/-[^-]+$/, '') === path,
)
.flatMap(([, routeChunks]) =>
// flat() is useful for nested chunk names, it's not like array.flat()
Object.values(flat(routeChunks)),
);
.flatMap(([, routeChunks]) => Object.values(flat(routeChunks)));
const docusaurus = {
prefetch: (routePath: string): boolean => {

View file

@ -34,27 +34,12 @@ export default function ComponentCreator(
});
}
const chunkNamesKey = `${path}-${hash}`;
const chunkNames = routesChunkNames[chunkNamesKey]!;
const optsModules: string[] = [];
const optsWebpack: string[] = [];
const chunkNames = routesChunkNames[`${path}-${hash}`]!;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const optsLoader: {[key: string]: () => Promise<any>} = {};
const optsModules: string[] = [];
const optsWebpack: string[] = [];
/* Prepare opts data that react-loadable needs
https://github.com/jamiebuilds/react-loadable#declaring-which-modules-are-being-loaded
Example:
- optsLoader:
{
component: () => import('./Pages.js'),
content.foo: () => import('./doc1.md'),
}
- optsModules: ['./Pages.js', './doc1.md']
- optsWebpack: [
require.resolveWeak('./Pages.js'),
require.resolveWeak('./doc1.md'),
]
*/
const flatChunkNames = flat(chunkNames);
Object.entries(flatChunkNames).forEach(([key, chunkName]) => {
const chunkRegistry = registry[chunkName];

View file

@ -5,18 +5,27 @@
* LICENSE file in the root directory of this source tree.
*/
import type {RouteChunksTree} from '@docusaurus/types';
import type {ChunkNames} from '@docusaurus/types';
const isTree = (x: string | RouteChunksTree): x is RouteChunksTree =>
type Chunk = ChunkNames[string];
type Tree = Exclude<Chunk, string>;
const isTree = (x: Chunk): x is Tree =>
typeof x === 'object' && !!x && Object.keys(x).length > 0;
export default function flat(target: RouteChunksTree): {
[keyPath: string]: string;
} {
/**
* Takes a tree, and flattens it into a map of keyPath -> value.
*
* ```js
* flat({ a: { b: 1 } }) === { "a.b": 1 };
* flat({ a: [1, 2] }) === { "a.0": 1, "a.1": 2 };
* ```
*/
export default function flat(target: ChunkNames): {[keyPath: string]: string} {
const delimiter = '.';
const output: {[keyPath: string]: string} = {};
function step(object: RouteChunksTree, prefix?: string | number) {
function step(object: Tree, prefix?: string | number) {
Object.entries(object).forEach(([key, value]) => {
const newKey = prefix ? `${prefix}${delimiter}${key}` : key;

View file

@ -30,7 +30,7 @@ exports[`loadRoutes loads flat route config 1`] = `
},
},
"routesChunkNames": {
"/blog-1e7": {
"/blog-599": {
"component": "component---theme-blog-list-pagea-6-a-7ba",
"items": [
{
@ -39,29 +39,26 @@ exports[`loadRoutes loads flat route config 1`] = `
},
{
"content": "content---blog-7-b-8-fd9",
"metadata": null,
},
{
"content": "content---blog-7-b-8-fd9",
"metadata": null,
},
],
},
},
"routesConfig": "
import React from 'react';
"routesConfig": "import React from 'react';
import ComponentCreator from '@docusaurus/ComponentCreator';
export default [
{
path: '/blog',
component: ComponentCreator('/blog','1e7'),
component: ComponentCreator('/blog', '599'),
exact: true
},
{
path: '*',
component: ComponentCreator('*')
}
component: ComponentCreator('*'),
},
];
",
"routesPaths": [
@ -119,24 +116,23 @@ exports[`loadRoutes loads nested route config 1`] = `
"metadata": "metadata---docs-foo-baz-2-cf-fa7",
},
},
"routesConfig": "
import React from 'react';
"routesConfig": "import React from 'react';
import ComponentCreator from '@docusaurus/ComponentCreator';
export default [
{
path: '/docs:route',
component: ComponentCreator('/docs:route','52d'),
component: ComponentCreator('/docs:route', '52d'),
routes: [
{
path: '/docs/hello',
component: ComponentCreator('/docs/hello','44b'),
component: ComponentCreator('/docs/hello', '44b'),
exact: true,
sidebar: \\"main\\"
},
{
path: 'docs/foo/baz',
component: ComponentCreator('docs/foo/baz','070'),
component: ComponentCreator('docs/foo/baz', '070'),
sidebar: \\"secondary\\",
\\"key:a\\": \\"containing colon\\",
\\"key'b\\": \\"containing quote\\",
@ -148,8 +144,8 @@ export default [
},
{
path: '*',
component: ComponentCreator('*')
}
component: ComponentCreator('*'),
},
];
",
"routesPaths": [
@ -173,19 +169,18 @@ exports[`loadRoutes loads route config with empty (but valid) path string 1`] =
"component": "component---hello-world-jse-0-f-b6c",
},
},
"routesConfig": "
import React from 'react';
"routesConfig": "import React from 'react';
import ComponentCreator from '@docusaurus/ComponentCreator';
export default [
{
path: '',
component: ComponentCreator('','b2a')
component: ComponentCreator('', 'b2a')
},
{
path: '*',
component: ComponentCreator('*')
}
component: ComponentCreator('*'),
},
];
",
"routesPaths": [

View file

@ -6,9 +6,58 @@
*/
import {jest} from '@jest/globals';
import {loadRoutes, handleDuplicateRoutes} from '../routes';
import {loadRoutes, handleDuplicateRoutes, genChunkName} from '../routes';
import type {RouteConfig} from '@docusaurus/types';
describe('genChunkName', () => {
it('works', () => {
const firstAssert: {[key: string]: string} = {
'/docs/adding-blog': 'docs-adding-blog-062',
'/docs/versioning': 'docs-versioning-8a8',
'/': 'index',
'/blog/2018/04/30/How-I-Converted-Profilo-To-Docusaurus':
'blog-2018-04-30-how-i-converted-profilo-to-docusaurus-4f2',
'/youtube': 'youtube-429',
'/users/en/': 'users-en-f7a',
'/blog': 'blog-c06',
};
Object.keys(firstAssert).forEach((str) => {
expect(genChunkName(str)).toBe(firstAssert[str]);
});
});
it("doesn't allow different chunk name for same path", () => {
expect(genChunkName('path/is/similar', 'oldPrefix')).toEqual(
genChunkName('path/is/similar', 'newPrefix'),
);
});
it('emits different chunk names for different paths even with same preferred name', () => {
const secondAssert: {[key: string]: string} = {
'/blog/1': 'blog-85-f-089',
'/blog/2': 'blog-353-489',
};
Object.keys(secondAssert).forEach((str) => {
expect(genChunkName(str, undefined, 'blog')).toBe(secondAssert[str]);
});
});
it('only generates short unique IDs', () => {
const thirdAssert: {[key: string]: string} = {
a: '0cc175b9',
b: '92eb5ffe',
c: '4a8a08f0',
d: '8277e091',
};
Object.keys(thirdAssert).forEach((str) => {
expect(genChunkName(str, undefined, undefined, true)).toBe(
thirdAssert[str],
);
});
expect(genChunkName('d', undefined, undefined, true)).toBe('8277e091');
});
});
describe('handleDuplicateRoutes', () => {
const routes: RouteConfig[] = [
{
@ -110,14 +159,12 @@ describe('loadRoutes', () => {
},
{
content: 'blog/2018-12-14-Happy-First-Birthday-Slash.md',
metadata: null,
},
{
content: {
__import: true,
path: 'blog/2018-12-14-Happy-First-Birthday-Slash.md',
},
metadata: null,
},
],
},

View file

@ -6,7 +6,6 @@
*/
import {docuHash, generate} from '@docusaurus/utils';
import fs from 'fs-extra';
import path from 'path';
import type {
LoadContext,
@ -28,7 +27,8 @@ import {applyRouteTrailingSlash, sortConfig} from './routeConfig';
/**
* Initializes the plugins, runs `loadContent`, `translateContent`,
* `contentLoaded`, and `translateThemeConfig`.
* `contentLoaded`, and `translateThemeConfig`. Because `contentLoaded` is
* side-effect-ful (it generates temp files), so is this function.
*/
export async function loadPlugins(context: LoadContext): Promise<{
plugins: LoadedPlugin[];
@ -99,65 +99,53 @@ export async function loadPlugins(context: LoadContext): Promise<{
if (!plugin.contentLoaded) {
return;
}
const pluginId = plugin.options.id;
// plugins data files are namespaced by pluginName/pluginId
const dataDirRoot = path.join(context.generatedFilesDir, plugin.name);
const dataDir = path.join(dataDirRoot, pluginId);
const createData: PluginContentLoadedActions['createData'] = async (
name,
data,
) => {
const modulePath = path.join(dataDir, name);
await fs.ensureDir(path.dirname(modulePath));
await generate(dataDir, name, data);
return modulePath;
};
const dataDir = path.join(
context.generatedFilesDir,
plugin.name,
pluginId,
);
// TODO this would be better to do all that in the codegen phase
// TODO handle context for nested routes
const pluginRouteContext: PluginRouteContext = {
plugin: {name: plugin.name, id: pluginId},
data: undefined, // TODO allow plugins to provide context data
};
const pluginRouteContextModulePath = await createData(
const pluginRouteContextModulePath = path.join(
dataDir,
`${docuHash('pluginRouteContextModule')}.json`,
);
await generate(
'/',
pluginRouteContextModulePath,
JSON.stringify(pluginRouteContext, null, 2),
);
const addRoute: PluginContentLoadedActions['addRoute'] = (
initialRouteConfig,
) => {
// Trailing slash behavior is handled in a generic way for all plugins
const finalRouteConfig = applyRouteTrailingSlash(initialRouteConfig, {
trailingSlash: context.siteConfig.trailingSlash,
baseUrl: context.siteConfig.baseUrl,
});
pluginsRouteConfigs.push({
...finalRouteConfig,
modules: {
...finalRouteConfig.modules,
__routeContextModule: pluginRouteContextModulePath,
},
});
};
// the plugins global data are namespaced to avoid data conflicts:
// - by plugin name
// - by plugin id (allow using multiple instances of the same plugin)
const setGlobalData: PluginContentLoadedActions['setGlobalData'] = (
data,
) => {
globalData[plugin.name] = globalData[plugin.name] ?? {};
globalData[plugin.name]![pluginId] = data;
};
const actions: PluginContentLoadedActions = {
addRoute,
createData,
setGlobalData,
addRoute(initialRouteConfig) {
// Trailing slash behavior is handled generically for all plugins
const finalRouteConfig = applyRouteTrailingSlash(
initialRouteConfig,
context.siteConfig,
);
pluginsRouteConfigs.push({
...finalRouteConfig,
modules: {
...finalRouteConfig.modules,
__routeContextModule: pluginRouteContextModulePath,
},
});
},
async createData(name, data) {
const modulePath = path.join(dataDir, name);
await generate(dataDir, name, data);
return modulePath;
},
setGlobalData(data) {
globalData[plugin.name] ??= {};
globalData[plugin.name]![pluginId] = data;
},
};
const translatedContent =

View file

@ -11,6 +11,7 @@ import {
type ApplyTrailingSlashParams,
} from '@docusaurus/utils-common';
/** Recursively applies trailing slash config to all nested routes. */
export function applyRouteTrailingSlash(
route: RouteConfig,
params: ApplyTrailingSlashParams,

View file

@ -6,34 +6,90 @@
*/
import {
genChunkName,
docuHash,
normalizeUrl,
removeSuffix,
simpleHash,
escapePath,
reportMessage,
} from '@docusaurus/utils';
import {stringify} from 'querystring';
import _ from 'lodash';
import query from 'querystring';
import {getAllFinalRoutes} from './utils';
import type {
ChunkRegistry,
Module,
RouteConfig,
RouteModule,
RouteModules,
ChunkNames,
RouteChunkNames,
ReportingSeverity,
} from '@docusaurus/types';
type RegistryMap = {
[chunkName: string]: ChunkRegistry;
type LoadedRoutes = {
/** Serialized routes config that can be directly emitted into temp file. */
routesConfig: string;
/** @see {ChunkNames} */
routesChunkNames: RouteChunkNames;
/** A map from chunk name to module loaders. */
registry: {
[chunkName: string]: {loader: string; modulePath: string};
};
/**
* Collect all page paths for injecting it later in the plugin lifecycle.
* This is useful for plugins like sitemaps, redirects etc... Only collects
* "actual" pages, i.e. those without subroutes, because if a route has
* subroutes, it is probably a wrapper.
*/
routesPaths: string[];
};
/** Indents every line of `str` by one level. */
function indent(str: string) {
const spaces = ' ';
return `${spaces}${str.replace(/\n/g, `\n${spaces}`)}`;
return ` ${str.replace(/\n/g, `\n `)}`;
}
function createRouteCodeString({
const chunkNameCache = new Map<string, string>();
/**
* Generates a unique chunk name that can be used in the chunk registry.
*
* @param modulePath A path to generate chunk name from. The actual value has no
* semantic significance.
* @param prefix A prefix to append to the chunk name, to avoid name clash.
* @param preferredName Chunk names default to `modulePath`, and this can supply
* a more human-readable name.
* @param shortId When `true`, the chunk name would only be a hash without any
* other characters. Useful for bundle size. Defaults to `true` in production.
*/
export function genChunkName(
modulePath: string,
prefix?: string,
preferredName?: string,
shortId: boolean = process.env.NODE_ENV === 'production',
): string {
let chunkName = chunkNameCache.get(modulePath);
if (!chunkName) {
if (shortId) {
chunkName = simpleHash(modulePath, 8);
} else {
let str = modulePath;
if (preferredName) {
const shortHash = simpleHash(modulePath, 3);
str = `${preferredName}${shortHash}`;
}
const name = str === '/' ? 'index' : docuHash(str);
chunkName = prefix ? `${prefix}---${name}` : name;
}
chunkNameCache.set(modulePath, chunkName);
}
return chunkName;
}
/**
* Takes a piece of route config, and serializes it into raw JS code. The shape
* is the same as react-router's `RouteConfig`. Formatting is similar to
* `JSON.stringify` but without all the quotes.
*/
function serializeRouteConfig({
routePath,
routeHash,
exact,
@ -48,7 +104,7 @@ function createRouteCodeString({
}) {
const parts = [
`path: '${routePath}'`,
`component: ComponentCreator('${routePath}','${routeHash}')`,
`component: ComponentCreator('${routePath}', '${routeHash}')`,
];
if (exact) {
@ -58,7 +114,7 @@ function createRouteCodeString({
if (subroutesCodeStrings) {
parts.push(
`routes: [
${indent(removeSuffix(subroutesCodeStrings.join(',\n'), ',\n'))}
${indent(subroutesCodeStrings.join(',\n'))}
]`,
);
}
@ -89,96 +145,67 @@ ${indent(parts.join(',\n'))}
}`;
}
const NotFoundRouteCode = `{
path: '*',
component: ComponentCreator('*')
}`;
const RoutesImportsCode = [
`import React from 'react';`,
`import ComponentCreator from '@docusaurus/ComponentCreator';`,
].join('\n');
function isModule(value: unknown): value is Module {
if (typeof value === 'string') {
return true;
}
if (
typeof value === 'object' &&
const isModule = (value: unknown): value is Module =>
typeof value === 'string' ||
(typeof value === 'object' &&
// eslint-disable-next-line no-underscore-dangle
(value as {[key: string]: unknown})?.__import &&
(value as {[key: string]: unknown})?.path
) {
return true;
}
return false;
}
!!(value as {[key: string]: unknown})?.__import);
/** Takes a {@link Module} and returns the string path it represents. */
function getModulePath(target: Module): string {
if (typeof target === 'string') {
return target;
}
const queryStr = target.query ? `?${stringify(target.query)}` : '';
const queryStr = target.query ? `?${query.stringify(target.query)}` : '';
return `${target.path}${queryStr}`;
}
function genRouteChunkNames(
registry: RegistryMap,
value: Module,
prefix?: string,
name?: string,
): string;
function genRouteChunkNames(
registry: RegistryMap,
value: RouteModule,
prefix?: string,
name?: string,
/**
* Takes a route module (which is a tree of modules), and transforms each module
* into a chunk name. It also mutates `res.registry` and registers the loaders
* for each chunk.
*
* @param routeModule One route module to be transformed.
* @param prefix Prefix passed to {@link genChunkName}.
* @param name Preferred name passed to {@link genChunkName}.
* @param res The route structures being loaded.
*/
function genChunkNames(
routeModule: RouteModules,
prefix: string,
name: string,
res: LoadedRoutes,
): ChunkNames;
function genRouteChunkNames(
registry: RegistryMap,
value: RouteModule[],
prefix?: string,
name?: string,
): ChunkNames[];
function genRouteChunkNames(
registry: RegistryMap,
value: RouteModule | RouteModule[] | Module,
prefix?: string,
name?: string,
function genChunkNames(
routeModule: RouteModules | RouteModules[] | Module,
prefix: string,
name: string,
res: LoadedRoutes,
): ChunkNames | ChunkNames[] | string;
function genRouteChunkNames(
// TODO instead of passing a mutating the registry, return a registry slice?
registry: RegistryMap,
value: RouteModule | RouteModule[] | Module | null | undefined,
prefix?: string,
name?: string,
): null | string | ChunkNames | ChunkNames[] {
if (!value) {
return null;
}
if (Array.isArray(value)) {
return value.map((val, index) =>
genRouteChunkNames(registry, val, `${index}`, name),
);
}
if (isModule(value)) {
const modulePath = getModulePath(value);
function genChunkNames(
routeModule: RouteModules | RouteModules[] | Module,
prefix: string,
name: string,
res: LoadedRoutes,
): string | ChunkNames | ChunkNames[] {
if (isModule(routeModule)) {
// This is a leaf node, no need to recurse
const modulePath = getModulePath(routeModule);
const chunkName = genChunkName(modulePath, prefix, name);
const loader = `() => import(/* webpackChunkName: '${chunkName}' */ '${escapePath(
res.registry[chunkName] = {
loader: `() => import(/* webpackChunkName: '${chunkName}' */ '${escapePath(
modulePath,
)}')`,
modulePath,
)}')`;
registry[chunkName] = {loader, modulePath};
};
return chunkName;
}
const newValue: ChunkNames = {};
Object.entries(value).forEach(([key, v]) => {
newValue[key] = genRouteChunkNames(registry, v, key, name);
});
return newValue;
if (Array.isArray(routeModule)) {
return routeModule.map((val, index) =>
genChunkNames(val, `${index}`, name, res),
);
}
return _.mapValues(routeModule, (v, key) => genChunkNames(v, key, name, res));
}
export function handleDuplicateRoutes(
@ -212,80 +239,82 @@ This could lead to non-deterministic routing behavior.`;
}
}
export async function loadRoutes(
pluginsRouteConfigs: RouteConfig[],
baseUrl: string,
onDuplicateRoutes: ReportingSeverity,
): Promise<{
registry: {[chunkName: string]: ChunkRegistry};
routesConfig: string;
routesChunkNames: {[routePath: string]: ChunkNames};
routesPaths: string[];
}> {
handleDuplicateRoutes(pluginsRouteConfigs, onDuplicateRoutes);
const registry: {[chunkName: string]: ChunkRegistry} = {};
const routesPaths: string[] = [normalizeUrl([baseUrl, '404.html'])];
const routesChunkNames: {[routePath: string]: ChunkNames} = {};
/**
* This is the higher level overview of route code generation. For each route
* config node, it return the node's serialized form, and mutate `registry`,
* `routesPaths`, and `routesChunkNames` accordingly.
*/
function genRouteCode(routeConfig: RouteConfig, res: LoadedRoutes): string {
const {
path: routePath,
component,
modules = {},
routes: subroutes,
priority,
exact,
...props
} = routeConfig;
// This is the higher level overview of route code generation.
function generateRouteCode(routeConfig: RouteConfig): string {
const {
path: routePath,
component,
modules = {},
routes: subroutes,
exact,
priority,
...props
} = routeConfig;
if (typeof routePath !== 'string' || !component) {
throw new Error(
`Invalid route config: path must be a string and component is required.
if (typeof routePath !== 'string' || !component) {
throw new Error(
`Invalid route config: path must be a string and component is required.
${JSON.stringify(routeConfig)}`,
);
}
// Collect all page paths for injecting it later in the plugin lifecycle
// This is useful for plugins like sitemaps, redirects etc...
// If a route has subroutes, it is not necessarily a valid page path (more
// likely to be a wrapper)
if (!subroutes) {
routesPaths.push(routePath);
}
// We hash the route to generate the key, because 2 routes can conflict with
// each others if they have the same path, ex: parent=/docs, child=/docs
// see https://github.com/facebook/docusaurus/issues/2917
const routeHash = simpleHash(JSON.stringify(routeConfig), 3);
const chunkNamesKey = `${routePath}-${routeHash}`;
routesChunkNames[chunkNamesKey] = {
...genRouteChunkNames(registry, {component}, 'component', component),
...genRouteChunkNames(registry, modules, 'module', routePath),
};
return createRouteCodeString({
routePath: routeConfig.path.replace(/'/g, "\\'"),
routeHash,
exact,
subroutesCodeStrings: subroutes?.map(generateRouteCode),
props,
});
);
}
const routesConfig = `
${RoutesImportsCode}
if (!subroutes) {
res.routesPaths.push(routePath);
}
const routeHash = simpleHash(JSON.stringify(routeConfig), 3);
res.routesChunkNames[`${routePath}-${routeHash}`] = {
...genChunkNames({component}, 'component', component, res),
...genChunkNames(modules, 'module', routePath, res),
};
return serializeRouteConfig({
routePath: routePath.replace(/'/g, "\\'"),
routeHash,
subroutesCodeStrings: subroutes?.map((r) => genRouteCode(r, res)),
exact,
props,
});
}
/**
* Routes are prepared into three temp files:
*
* - `routesConfig`, the route config passed to react-router. This file is kept
* minimal, because it can't be code-splitted.
* - `routesChunkNames`, a mapping from route paths (hashed) to code-splitted
* chunk names.
* - `registry`, a mapping from chunk names to options for react-loadable.
*/
export async function loadRoutes(
routeConfigs: RouteConfig[],
baseUrl: string,
onDuplicateRoutes: ReportingSeverity,
): Promise<LoadedRoutes> {
handleDuplicateRoutes(routeConfigs, onDuplicateRoutes);
const res: LoadedRoutes = {
// To be written
routesConfig: '',
routesChunkNames: {},
registry: {},
routesPaths: [normalizeUrl([baseUrl, '404.html'])],
};
res.routesConfig = `import React from 'react';
import ComponentCreator from '@docusaurus/ComponentCreator';
export default [
${indent(`${pluginsRouteConfigs.map(generateRouteCode).join(',\n')},`)}
${indent(NotFoundRouteCode)}
${indent(`${routeConfigs.map((r) => genRouteCode(r, res)).join(',\n')},`)}
{
path: '*',
component: ComponentCreator('*'),
},
];
`;
return {
registry,
routesConfig,
routesChunkNames,
routesPaths,
};
return res;
}

View file

@ -14,10 +14,7 @@ import {
applyConfigurePostCss,
getHttpsConfig,
} from '../utils';
import type {
ConfigureWebpackFn,
ConfigureWebpackFnMergeStrategy,
} from '@docusaurus/types';
import type {Plugin} from '@docusaurus/types';
describe('customize JS loader', () => {
it('getCustomizableJSLoader defaults to babel loader', () => {
@ -63,7 +60,7 @@ describe('extending generated webpack config', () => {
},
};
const configureWebpack: ConfigureWebpackFn = (
const configureWebpack: Plugin['configureWebpack'] = (
generatedConfig,
isServer,
) => {
@ -99,7 +96,7 @@ describe('extending generated webpack config', () => {
},
};
const configureWebpack: ConfigureWebpackFn = () => ({
const configureWebpack: Plugin['configureWebpack'] = () => ({
entry: 'entry.js',
output: {
path: path.join(__dirname, 'dist'),
@ -128,9 +125,9 @@ describe('extending generated webpack config', () => {
},
};
const createConfigureWebpack: (
mergeStrategy?: ConfigureWebpackFnMergeStrategy,
) => ConfigureWebpackFn = (mergeStrategy) => () => ({
const createConfigureWebpack: (mergeStrategy?: {
[key: string]: 'prepend' | 'append';
}) => Plugin['configureWebpack'] = (mergeStrategy) => () => ({
module: {
rules: [{use: 'zzz'}],
},

View file

@ -25,8 +25,7 @@ import crypto from 'crypto';
import logger from '@docusaurus/logger';
import type {TransformOptions} from '@babel/core';
import type {
ConfigureWebpackFn,
ConfigurePostCssFn,
Plugin,
PostCssOptions,
ConfigureWebpackUtils,
} from '@docusaurus/types';
@ -172,7 +171,7 @@ export const getCustomizableJSLoader =
* @returns final/ modified webpack config
*/
export function applyConfigureWebpack(
configureWebpack: ConfigureWebpackFn,
configureWebpack: NonNullable<Plugin['configureWebpack']>,
config: Configuration,
isServer: boolean,
jsLoader: 'babel' | ((isServer: boolean) => RuleSetRule) | undefined,
@ -198,7 +197,7 @@ export function applyConfigureWebpack(
}
export function applyConfigurePostCss(
configurePostCss: NonNullable<ConfigurePostCssFn>,
configurePostCss: NonNullable<Plugin['configurePostCss']>,
config: Configuration,
): Configuration {
type LocalPostCSSLoader = unknown & {

View file

@ -46,13 +46,13 @@ Create a route to add to the website.
interface RouteConfig {
path: string;
component: string;
modules?: RouteModule;
modules?: RouteModules;
routes?: RouteConfig[];
exact?: boolean;
priority?: number;
}
interface RouteModule {
[module: string]: Module | RouteModule | RouteModule[];
interface RouteModules {
[module: string]: Module | RouteModules | RouteModules[];
}
type Module =
| {