@docusaurus/core 3.0.1 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -218,6 +218,9 @@ cli.arguments('<command>').action((cmd) => {
218
218
  logger.error` Unknown command name=${cmd}.`;
219
219
  });
220
220
 
221
+ // === The above is the commander configuration ===
222
+ // They don't start any code execution yet until cli.parse() is called below
223
+
221
224
  /**
222
225
  * @param {string | undefined} command
223
226
  */
@@ -237,12 +240,29 @@ function isInternalCommand(command) {
237
240
  );
238
241
  }
239
242
 
240
- if (!isInternalCommand(process.argv.slice(2)[0])) {
241
- await externalCommand(cli);
242
- }
243
+ // process.argv always looks like this:
244
+ // [
245
+ // '/path/to/node',
246
+ // '/path/to/docusaurus.mjs',
247
+ // '<subcommand>',
248
+ // ...subcommandArgs
249
+ // ]
243
250
 
244
- if (!process.argv.slice(2).length) {
251
+ // There is no subcommand
252
+ // TODO: can we use commander to handle this case?
253
+ if (process.argv.length < 3 || process.argv[2]?.startsWith('--')) {
245
254
  cli.outputHelp();
255
+ process.exit(1);
256
+ }
257
+
258
+ // There is an unrecognized subcommand
259
+ // Let plugins extend the CLI before parsing
260
+ if (!isInternalCommand(process.argv[2])) {
261
+ // TODO: in this step, we must assume default site structure because there's
262
+ // no way to know the siteDir/config yet. Maybe the root cli should be
263
+ // responsible for parsing these arguments?
264
+ // https://github.com/facebook/docusaurus/issues/8903
265
+ await externalCommand(cli);
246
266
  }
247
267
 
248
268
  cli.parse(process.argv);
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+ /// <reference types="@docusaurus/module-type-aliases" />
8
+ import { type ReactNode } from 'react';
9
+ import type { BrokenLinks } from '@docusaurus/useBrokenLinks';
10
+ export type StatefulBrokenLinks = BrokenLinks & {
11
+ getCollectedLinks: () => string[];
12
+ getCollectedAnchors: () => string[];
13
+ };
14
+ export declare const createStatefulBrokenLinks: () => StatefulBrokenLinks;
15
+ export declare const useBrokenLinksContext: () => BrokenLinks;
16
+ export declare function BrokenLinksProvider({ children, brokenLinks, }: {
17
+ children: ReactNode;
18
+ brokenLinks: BrokenLinks;
19
+ }): JSX.Element;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+ import React, { useContext } from 'react';
8
+ export const createStatefulBrokenLinks = () => {
9
+ // Set to dedup, as it's not useful to collect multiple times the same value
10
+ const allAnchors = new Set();
11
+ const allLinks = new Set();
12
+ return {
13
+ collectAnchor: (anchor) => {
14
+ typeof anchor !== 'undefined' && allAnchors.add(anchor);
15
+ },
16
+ collectLink: (link) => {
17
+ typeof link !== 'undefined' && allLinks.add(link);
18
+ },
19
+ getCollectedAnchors: () => [...allAnchors],
20
+ getCollectedLinks: () => [...allLinks],
21
+ };
22
+ };
23
+ const Context = React.createContext({
24
+ collectAnchor: () => {
25
+ // No-op for client
26
+ },
27
+ collectLink: () => {
28
+ // No-op for client
29
+ },
30
+ });
31
+ export const useBrokenLinksContext = () => useContext(Context);
32
+ export function BrokenLinksProvider({ children, brokenLinks, }) {
33
+ return <Context.Provider value={brokenLinks}>{children}</Context.Provider>;
34
+ }
@@ -10,7 +10,7 @@ import { applyTrailingSlash } from '@docusaurus/utils-common';
10
10
  import useDocusaurusContext from './useDocusaurusContext';
11
11
  import isInternalUrl from './isInternalUrl';
12
12
  import ExecutionEnvironment from './ExecutionEnvironment';
13
- import { useLinksCollector } from '../LinksCollector';
13
+ import useBrokenLinks from './useBrokenLinks';
14
14
  import { useBaseUrlUtils } from './useBaseUrl';
15
15
  // TODO all this wouldn't be necessary if we used ReactRouter basename feature
16
16
  // We don't automatically add base urls to all links,
@@ -21,7 +21,7 @@ const shouldAddBaseUrlAutomatically = (to) => to.startsWith('/');
21
21
  function Link({ isNavLink, to, href, activeClassName, isActive, 'data-noBrokenLinkCheck': noBrokenLinkCheck, autoAddBaseUrl = true, ...props }, forwardedRef) {
22
22
  const { siteConfig: { trailingSlash, baseUrl }, } = useDocusaurusContext();
23
23
  const { withBaseUrl } = useBaseUrlUtils();
24
- const linksCollector = useLinksCollector();
24
+ const brokenLinks = useBrokenLinks();
25
25
  const innerRef = useRef(null);
26
26
  useImperativeHandle(forwardedRef, () => innerRef.current);
27
27
  // IMPORTANT: using to or href should not change anything
@@ -97,10 +97,18 @@ function Link({ isNavLink, to, href, activeClassName, isActive, 'data-noBrokenLi
97
97
  }
98
98
  };
99
99
  }, [ioRef, targetLink, IOSupported, isInternal]);
100
+ // It is simple local anchor link targeting current page?
100
101
  const isAnchorLink = targetLink?.startsWith('#') ?? false;
101
- const isRegularHtmlLink = !targetLink || !isInternal || isAnchorLink;
102
- if (!isRegularHtmlLink && !noBrokenLinkCheck) {
103
- linksCollector.collectLink(targetLink);
102
+ // See also RR logic:
103
+ // https://github.com/remix-run/react-router/blob/v5/packages/react-router-dom/modules/Link.js#L47
104
+ const hasInternalTarget = !props.target || props.target === '_self';
105
+ // Should we use a regular <a> tag instead of React-Router Link component?
106
+ const isRegularHtmlLink = !targetLink || !isInternal || !hasInternalTarget || isAnchorLink;
107
+ if (!noBrokenLinkCheck && (isAnchorLink || !isRegularHtmlLink)) {
108
+ brokenLinks.collectLink(targetLink);
109
+ }
110
+ if (props.id) {
111
+ brokenLinks.collectAnchor(props.id);
104
112
  }
105
113
  return isRegularHtmlLink ? (
106
114
  // eslint-disable-next-line jsx-a11y/anchor-has-content, @docusaurus/no-html-links
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+ /// <reference types="@docusaurus/module-type-aliases" />
8
+ import type { BrokenLinks } from '@docusaurus/useBrokenLinks';
9
+ export default function useBrokenLinks(): BrokenLinks;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+ import { useBrokenLinksContext } from '../BrokenLinksContext';
8
+ export default function useBrokenLinks() {
9
+ return useBrokenLinksContext();
10
+ }
@@ -18,7 +18,7 @@ import { minify } from 'html-minifier-terser';
18
18
  import { renderStaticApp } from './serverRenderer';
19
19
  import preload from './preload';
20
20
  import App from './App';
21
- import { createStatefulLinksCollector, LinksCollectorProvider, } from './LinksCollector';
21
+ import { createStatefulBrokenLinks, BrokenLinksProvider, } from './BrokenLinksContext';
22
22
  const getCompiledSSRTemplate = _.memoize((template) => eta.compile(template.trim(), {
23
23
  rmWhitespace: true,
24
24
  }));
@@ -61,20 +61,24 @@ async function doRender(locals) {
61
61
  const modules = new Set();
62
62
  const routerContext = {};
63
63
  const helmetContext = {};
64
- const linksCollector = createStatefulLinksCollector();
64
+ const statefulBrokenLinks = createStatefulBrokenLinks();
65
65
  const app = (
66
66
  // @ts-expect-error: we are migrating away from react-loadable anyways
67
67
  <Loadable.Capture report={(moduleName) => modules.add(moduleName)}>
68
68
  <HelmetProvider context={helmetContext}>
69
69
  <StaticRouter location={location} context={routerContext}>
70
- <LinksCollectorProvider linksCollector={linksCollector}>
70
+ <BrokenLinksProvider brokenLinks={statefulBrokenLinks}>
71
71
  <App />
72
- </LinksCollectorProvider>
72
+ </BrokenLinksProvider>
73
73
  </StaticRouter>
74
74
  </HelmetProvider>
75
75
  </Loadable.Capture>);
76
76
  const appHtml = await renderStaticApp(app);
77
- onLinksCollected(location, linksCollector.getCollectedLinks());
77
+ onLinksCollected({
78
+ staticPagePath: location,
79
+ anchors: statefulBrokenLinks.getCollectedAnchors(),
80
+ links: statefulBrokenLinks.getCollectedLinks(),
81
+ });
78
82
  const { helmet } = helmetContext;
79
83
  const htmlAttributes = helmet.htmlAttributes.toString();
80
84
  const bodyAttributes = helmet.bodyAttributes.toString();
@@ -100,7 +100,7 @@ async function buildLocale({ siteDir, locale, cliOptions, forceTerminate, isLast
100
100
  localizePath: cliOptions.locale ? false : undefined,
101
101
  });
102
102
  // Apply user webpack config.
103
- const { outDir, generatedFilesDir, plugins, siteConfig: { baseUrl, onBrokenLinks, staticDirectories: staticDirectoriesOption, }, routes, } = props;
103
+ const { outDir, generatedFilesDir, plugins, siteConfig: { onBrokenLinks, onBrokenAnchors, staticDirectories: staticDirectoriesOption, }, routes, } = props;
104
104
  const clientManifestPath = path_1.default.join(generatedFilesDir, 'client-manifest.json');
105
105
  let clientConfig = (0, webpack_merge_1.default)(await (0, client_1.default)(props, cliOptions.minify, true), {
106
106
  plugins: [
@@ -115,12 +115,12 @@ async function buildLocale({ siteDir, locale, cliOptions, forceTerminate, isLast
115
115
  }),
116
116
  ].filter((x) => Boolean(x)),
117
117
  });
118
- const allCollectedLinks = {};
118
+ const collectedLinks = {};
119
119
  const headTags = {};
120
120
  let serverConfig = await (0, server_2.default)({
121
121
  props,
122
- onLinksCollected: (staticPagePath, links) => {
123
- allCollectedLinks[staticPagePath] = links;
122
+ onLinksCollected: ({ staticPagePath, links, anchors }) => {
123
+ collectedLinks[staticPagePath] = { links, anchors };
124
124
  },
125
125
  onHeadTagsCollected: (staticPagePath, tags) => {
126
126
  headTags[staticPagePath] = tags;
@@ -190,11 +190,10 @@ async function buildLocale({ siteDir, locale, cliOptions, forceTerminate, isLast
190
190
  });
191
191
  }));
192
192
  await (0, brokenLinks_1.handleBrokenLinks)({
193
- allCollectedLinks,
193
+ collectedLinks,
194
194
  routes,
195
195
  onBrokenLinks,
196
- outDir,
197
- baseUrl,
196
+ onBrokenAnchors,
198
197
  });
199
198
  logger_1.default.success `Generated static files in path=${path_1.default.relative(process.cwd(), outDir)}.`;
200
199
  if (isLastLocale) {
@@ -5,12 +5,16 @@
5
5
  * LICENSE file in the root directory of this source tree.
6
6
  */
7
7
  import type { RouteConfig, ReportingSeverity } from '@docusaurus/types';
8
- export declare function handleBrokenLinks({ allCollectedLinks, onBrokenLinks, routes, baseUrl, outDir, }: {
9
- allCollectedLinks: {
10
- [location: string]: string[];
8
+ type CollectedLinks = {
9
+ [pathname: string]: {
10
+ links: string[];
11
+ anchors: string[];
11
12
  };
13
+ };
14
+ export declare function handleBrokenLinks({ collectedLinks, onBrokenLinks, onBrokenAnchors, routes, }: {
15
+ collectedLinks: CollectedLinks;
12
16
  onBrokenLinks: ReportingSeverity;
17
+ onBrokenAnchors: ReportingSeverity;
13
18
  routes: RouteConfig[];
14
- baseUrl: string;
15
- outDir: string;
16
19
  }): Promise<void>;
20
+ export {};
@@ -8,36 +8,116 @@
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
9
  exports.handleBrokenLinks = void 0;
10
10
  const tslib_1 = require("tslib");
11
- const fs_extra_1 = tslib_1.__importDefault(require("fs-extra"));
12
- const path_1 = tslib_1.__importDefault(require("path"));
13
11
  const lodash_1 = tslib_1.__importDefault(require("lodash"));
14
12
  const logger_1 = tslib_1.__importDefault(require("@docusaurus/logger"));
15
- const combine_promises_1 = tslib_1.__importDefault(require("combine-promises"));
16
13
  const react_router_config_1 = require("react-router-config");
17
14
  const utils_1 = require("@docusaurus/utils");
18
15
  const utils_2 = require("./utils");
19
- // matchRoutes does not support qs/anchors, so we remove it!
20
- function onlyPathname(link) {
21
- return link.split('#')[0].split('?')[0];
16
+ function matchRoutes(routeConfig, pathname) {
17
+ // @ts-expect-error: React router types RouteConfig with an actual React
18
+ // component, but we load route components with string paths.
19
+ // We don't actually access component here, so it's fine.
20
+ return (0, react_router_config_1.matchRoutes)(routeConfig, pathname);
22
21
  }
23
- function getPageBrokenLinks({ pagePath, pageLinks, routes, }) {
24
- // ReactRouter is able to support links like ./../somePath but `matchRoutes`
25
- // does not do this resolution internally. We must resolve the links before
26
- // using `matchRoutes`. `resolvePathname` is used internally by React Router
27
- function resolveLink(link) {
28
- const resolvedLink = (0, utils_1.resolvePathname)(onlyPathname(link), pagePath);
29
- return { link, resolvedLink };
22
+ function createBrokenLinksHelper({ collectedLinks, routes, }) {
23
+ const validPathnames = new Set(collectedLinks.keys());
24
+ // IMPORTANT: this is an optimization
25
+ // See https://github.com/facebook/docusaurus/issues/9754
26
+ // Matching against the route array can be expensive
27
+ // If the route is already in the valid pathnames,
28
+ // we can avoid matching against it
29
+ const remainingRoutes = (function filterRoutes() {
30
+ // Goal: unit tests should behave the same with this enabled or disabled
31
+ const disableOptimization = false;
32
+ if (disableOptimization) {
33
+ return routes;
34
+ }
35
+ // We must consider the "exact" and "strict" match attribute
36
+ // We can only infer pre-validated pathnames from a route from exact routes
37
+ const [validPathnameRoutes, otherRoutes] = lodash_1.default.partition(routes, (route) => route.exact && validPathnames.has(route.path));
38
+ // If a route is non-strict (non-sensitive to trailing slashes)
39
+ // We must pre-validate all possible paths
40
+ validPathnameRoutes.forEach((validPathnameRoute) => {
41
+ if (!validPathnameRoute.strict) {
42
+ validPathnames.add((0, utils_1.addTrailingSlash)(validPathnameRoute.path));
43
+ validPathnames.add((0, utils_1.removeTrailingSlash)(validPathnameRoute.path));
44
+ }
45
+ });
46
+ return otherRoutes;
47
+ })();
48
+ function isPathnameMatchingAnyRoute(pathname) {
49
+ if (matchRoutes(remainingRoutes, pathname).length > 0) {
50
+ // IMPORTANT: this is an optimization
51
+ // See https://github.com/facebook/docusaurus/issues/9754
52
+ // Large Docusaurus sites have many routes!
53
+ // We try to minimize calls to a possibly expensive matchRoutes function
54
+ validPathnames.add(pathname);
55
+ return true;
56
+ }
57
+ return false;
58
+ }
59
+ function isPathBrokenLink(linkPath) {
60
+ const pathnames = [linkPath.pathname, decodeURI(linkPath.pathname)];
61
+ if (pathnames.some((p) => validPathnames.has(p))) {
62
+ return false;
63
+ }
64
+ if (pathnames.some(isPathnameMatchingAnyRoute)) {
65
+ return false;
66
+ }
67
+ return true;
30
68
  }
31
- function isBrokenLink(link) {
32
- const matchedRoutes = [link, decodeURI(link)]
33
- // @ts-expect-error: React router types RouteConfig with an actual React
34
- // component, but we load route components with string paths.
35
- // We don't actually access component here, so it's fine.
36
- .map((l) => (0, react_router_config_1.matchRoutes)(routes, l))
37
- .flat();
38
- return matchedRoutes.length === 0;
69
+ function isAnchorBrokenLink(linkPath) {
70
+ const { pathname, hash } = linkPath;
71
+ // Link has no hash: it can't be a broken anchor link
72
+ if (hash === undefined) {
73
+ return false;
74
+ }
75
+ // Link has empty hash ("#", "/page#"...): we do not report it as broken
76
+ // Empty hashes are used for various weird reasons, by us and other users...
77
+ // See for example: https://github.com/facebook/docusaurus/pull/6003
78
+ if (hash === '') {
79
+ return false;
80
+ }
81
+ const targetPage = collectedLinks.get(pathname) || collectedLinks.get(decodeURI(pathname));
82
+ // link with anchor to a page that does not exist (or did not collect any
83
+ // link/anchor) is considered as a broken anchor
84
+ if (!targetPage) {
85
+ return true;
86
+ }
87
+ // it's a not broken anchor if the anchor exists on the target page
88
+ if (targetPage.anchors.has(hash) ||
89
+ targetPage.anchors.has(decodeURIComponent(hash))) {
90
+ return false;
91
+ }
92
+ return true;
39
93
  }
40
- return pageLinks.map(resolveLink).filter((l) => isBrokenLink(l.resolvedLink));
94
+ return {
95
+ collectedLinks,
96
+ isPathBrokenLink,
97
+ isAnchorBrokenLink,
98
+ };
99
+ }
100
+ function getBrokenLinksForPage({ pagePath, helper, }) {
101
+ const pageData = helper.collectedLinks.get(pagePath);
102
+ const brokenLinks = [];
103
+ pageData.links.forEach((link) => {
104
+ const linkPath = (0, utils_1.parseURLPath)(link, pagePath);
105
+ if (helper.isPathBrokenLink(linkPath)) {
106
+ brokenLinks.push({
107
+ link,
108
+ resolvedLink: (0, utils_1.serializeURLPath)(linkPath),
109
+ anchor: false,
110
+ });
111
+ }
112
+ else if (helper.isAnchorBrokenLink(linkPath)) {
113
+ brokenLinks.push({
114
+ link,
115
+ resolvedLink: (0, utils_1.serializeURLPath)(linkPath),
116
+ anchor: true,
117
+ });
118
+ }
119
+ });
120
+ return brokenLinks;
41
121
  }
42
122
  /**
43
123
  * The route defs can be recursive, and have a parent match-all route. We don't
@@ -49,25 +129,60 @@ function filterIntermediateRoutes(routesInput) {
49
129
  const routesWithout404 = routesInput.filter((route) => route.path !== '*');
50
130
  return (0, utils_2.getAllFinalRoutes)(routesWithout404);
51
131
  }
52
- function getAllBrokenLinks({ allCollectedLinks, routes, }) {
132
+ function getBrokenLinks({ collectedLinks, routes, }) {
53
133
  const filteredRoutes = filterIntermediateRoutes(routes);
54
- const allBrokenLinks = lodash_1.default.mapValues(allCollectedLinks, (pageLinks, pagePath) => getPageBrokenLinks({ pageLinks, pagePath, routes: filteredRoutes }));
55
- return lodash_1.default.pickBy(allBrokenLinks, (brokenLinks) => brokenLinks.length > 0);
134
+ const helper = createBrokenLinksHelper({
135
+ collectedLinks,
136
+ routes: filteredRoutes,
137
+ });
138
+ const result = {};
139
+ collectedLinks.forEach((_unused, pagePath) => {
140
+ try {
141
+ result[pagePath] = getBrokenLinksForPage({
142
+ pagePath,
143
+ helper,
144
+ });
145
+ }
146
+ catch (e) {
147
+ throw new Error(`Unable to get broken links for page ${pagePath}.`, {
148
+ cause: e,
149
+ });
150
+ }
151
+ });
152
+ return result;
56
153
  }
57
- function getBrokenLinksErrorMessage(allBrokenLinks) {
58
- if (Object.keys(allBrokenLinks).length === 0) {
59
- return undefined;
60
- }
61
- function brokenLinkMessage(brokenLink) {
62
- const showResolvedLink = brokenLink.link !== brokenLink.resolvedLink;
63
- return `${brokenLink.link}${showResolvedLink ? ` (resolved as: ${brokenLink.resolvedLink})` : ''}`;
64
- }
65
- function pageBrokenLinksMessage(pagePath, brokenLinks) {
66
- return `
67
- - On source page path = ${pagePath}:
154
+ function brokenLinkMessage(brokenLink) {
155
+ const showResolvedLink = brokenLink.link !== brokenLink.resolvedLink;
156
+ return `${brokenLink.link}${showResolvedLink ? ` (resolved as: ${brokenLink.resolvedLink})` : ''}`;
157
+ }
158
+ function createBrokenLinksMessage(pagePath, brokenLinks) {
159
+ const type = brokenLinks[0]?.anchor === true ? 'anchor' : 'link';
160
+ const anchorMessage = brokenLinks.length > 0
161
+ ? `- Broken ${type} on source page path = ${pagePath}:
68
162
  -> linking to ${brokenLinks
69
163
  .map(brokenLinkMessage)
70
- .join('\n -> linking to ')}`;
164
+ .join('\n -> linking to ')}`
165
+ : '';
166
+ return `${anchorMessage}`;
167
+ }
168
+ function createBrokenAnchorsMessage(brokenAnchors) {
169
+ if (Object.keys(brokenAnchors).length === 0) {
170
+ return undefined;
171
+ }
172
+ return `Docusaurus found broken anchors!
173
+
174
+ Please check the pages of your site in the list below, and make sure you don't reference any anchor that does not exist.
175
+ Note: it's possible to ignore broken anchors with the 'onBrokenAnchors' Docusaurus configuration, and let the build pass.
176
+
177
+ Exhaustive list of all broken anchors found:
178
+ ${Object.entries(brokenAnchors)
179
+ .map(([pagePath, brokenLinks]) => createBrokenLinksMessage(pagePath, brokenLinks))
180
+ .join('\n')}
181
+ `;
182
+ }
183
+ function createBrokenPathsMessage(brokenPathsMap) {
184
+ if (Object.keys(brokenPathsMap).length === 0) {
185
+ return undefined;
71
186
  }
72
187
  /**
73
188
  * If there's a broken link appearing very often, it is probably a broken link
@@ -75,7 +190,7 @@ function getBrokenLinksErrorMessage(allBrokenLinks) {
75
190
  * this out. See https://github.com/facebook/docusaurus/issues/3567#issuecomment-706973805
76
191
  */
77
192
  function getLayoutBrokenLinksHelpMessage() {
78
- const flatList = Object.entries(allBrokenLinks).flatMap(([pagePage, brokenLinks]) => brokenLinks.map((brokenLink) => ({ pagePage, brokenLink })));
193
+ const flatList = Object.entries(brokenPathsMap).flatMap(([pagePage, brokenLinks]) => brokenLinks.map((brokenLink) => ({ pagePage, brokenLink })));
79
194
  const countedBrokenLinks = lodash_1.default.countBy(flatList, (item) => item.brokenLink.link);
80
195
  const FrequencyThreshold = 5; // Is this a good value?
81
196
  const frequentLinks = Object.entries(countedBrokenLinks)
@@ -97,60 +212,65 @@ Please check the pages of your site in the list below, and make sure you don't r
97
212
  Note: it's possible to ignore broken links with the 'onBrokenLinks' Docusaurus configuration, and let the build pass.${getLayoutBrokenLinksHelpMessage()}
98
213
 
99
214
  Exhaustive list of all broken links found:
100
- ${Object.entries(allBrokenLinks)
101
- .map(([pagePath, brokenLinks]) => pageBrokenLinksMessage(pagePath, brokenLinks))
215
+ ${Object.entries(brokenPathsMap)
216
+ .map(([pagePath, brokenPaths]) => createBrokenLinksMessage(pagePath, brokenPaths))
102
217
  .join('\n')}
103
218
  `;
104
219
  }
105
- async function isExistingFile(filePath) {
106
- try {
107
- return (await fs_extra_1.default.stat(filePath)).isFile();
108
- }
109
- catch {
110
- return false;
111
- }
112
- }
113
- // If a file actually exist on the file system, we know the link is valid
114
- // even if docusaurus does not know about this file, so we don't report it
115
- async function filterExistingFileLinks({ baseUrl, outDir, allCollectedLinks, }) {
116
- async function linkFileExists(link) {
117
- // /baseUrl/javadoc/ -> /outDir/javadoc
118
- const baseFilePath = onlyPathname((0, utils_1.removeSuffix)(`${outDir}/${(0, utils_1.removePrefix)(link, baseUrl)}`, '/'));
119
- // -> /outDir/javadoc
120
- // -> /outDir/javadoc.html
121
- // -> /outDir/javadoc/index.html
122
- const filePathsToTry = [baseFilePath];
123
- if (!path_1.default.extname(baseFilePath)) {
124
- filePathsToTry.push(`${baseFilePath}.html`, path_1.default.join(baseFilePath, 'index.html'));
220
+ function splitBrokenLinks(brokenLinks) {
221
+ const brokenPaths = {};
222
+ const brokenAnchors = {};
223
+ Object.entries(brokenLinks).forEach(([pathname, pageBrokenLinks]) => {
224
+ const [anchorBrokenLinks, pathBrokenLinks] = lodash_1.default.partition(pageBrokenLinks, (link) => link.anchor);
225
+ if (pathBrokenLinks.length > 0) {
226
+ brokenPaths[pathname] = pathBrokenLinks;
125
227
  }
126
- for (const file of filePathsToTry) {
127
- if (await isExistingFile(file)) {
128
- return true;
129
- }
228
+ if (anchorBrokenLinks.length > 0) {
229
+ brokenAnchors[pathname] = anchorBrokenLinks;
130
230
  }
131
- return false;
231
+ });
232
+ return { brokenPaths, brokenAnchors };
233
+ }
234
+ function reportBrokenLinks({ brokenLinks, onBrokenLinks, onBrokenAnchors, }) {
235
+ // We need to split the broken links reporting in 2 for better granularity
236
+ // This is because we need to report broken path/anchors independently
237
+ // For v3.x retro-compatibility, we can't throw by default for broken anchors
238
+ // TODO Docusaurus v4: make onBrokenAnchors throw by default?
239
+ const { brokenPaths, brokenAnchors } = splitBrokenLinks(brokenLinks);
240
+ const pathErrorMessage = createBrokenPathsMessage(brokenPaths);
241
+ if (pathErrorMessage) {
242
+ logger_1.default.report(onBrokenLinks)(pathErrorMessage);
132
243
  }
133
- return (0, combine_promises_1.default)(lodash_1.default.mapValues(allCollectedLinks, async (links) => (await Promise.all(links.map(async (link) => ((await linkFileExists(link)) ? '' : link)))).filter(Boolean)));
244
+ const anchorErrorMessage = createBrokenAnchorsMessage(brokenAnchors);
245
+ if (anchorErrorMessage) {
246
+ logger_1.default.report(onBrokenAnchors)(anchorErrorMessage);
247
+ }
248
+ }
249
+ // Users might use the useBrokenLinks() API in weird unexpected ways
250
+ // JS users might call "collectLink(undefined)" for example
251
+ // TS users might call "collectAnchor('#hash')" with/without #
252
+ // We clean/normalize the collected data to avoid obscure errors being thrown
253
+ // We also use optimized data structures for a faster algorithm
254
+ function normalizeCollectedLinks(collectedLinks) {
255
+ const result = new Map();
256
+ Object.entries(collectedLinks).forEach(([pathname, pageCollectedData]) => {
257
+ result.set(pathname, {
258
+ links: new Set(pageCollectedData.links.filter(lodash_1.default.isString)),
259
+ anchors: new Set(pageCollectedData.anchors
260
+ .filter(lodash_1.default.isString)
261
+ .map((anchor) => (anchor.startsWith('#') ? anchor.slice(1) : anchor))),
262
+ });
263
+ });
264
+ return result;
134
265
  }
135
- async function handleBrokenLinks({ allCollectedLinks, onBrokenLinks, routes, baseUrl, outDir, }) {
136
- if (onBrokenLinks === 'ignore') {
266
+ async function handleBrokenLinks({ collectedLinks, onBrokenLinks, onBrokenAnchors, routes, }) {
267
+ if (onBrokenLinks === 'ignore' && onBrokenAnchors === 'ignore') {
137
268
  return;
138
269
  }
139
- // If we link to a file like /myFile.zip, and the file actually exist for the
140
- // file system. It is not a broken link, it may simply be a link to an
141
- // existing static file...
142
- const allCollectedLinksFiltered = await filterExistingFileLinks({
143
- allCollectedLinks,
144
- baseUrl,
145
- outDir,
146
- });
147
- const allBrokenLinks = getAllBrokenLinks({
148
- allCollectedLinks: allCollectedLinksFiltered,
270
+ const brokenLinks = getBrokenLinks({
149
271
  routes,
272
+ collectedLinks: normalizeCollectedLinks(collectedLinks),
150
273
  });
151
- const errorMessage = getBrokenLinksErrorMessage(allBrokenLinks);
152
- if (errorMessage) {
153
- logger_1.default.report(onBrokenLinks)(errorMessage);
154
- }
274
+ reportBrokenLinks({ brokenLinks, onBrokenLinks, onBrokenAnchors });
155
275
  }
156
276
  exports.handleBrokenLinks = handleBrokenLinks;
@@ -5,8 +5,9 @@
5
5
  * LICENSE file in the root directory of this source tree.
6
6
  */
7
7
  import { Joi } from '@docusaurus/utils-validation';
8
- import type { DocusaurusConfig, I18nConfig } from '@docusaurus/types';
8
+ import type { DocusaurusConfig, I18nConfig, MarkdownConfig } from '@docusaurus/types';
9
9
  export declare const DEFAULT_I18N_CONFIG: I18nConfig;
10
- export declare const DEFAULT_CONFIG: Pick<DocusaurusConfig, 'i18n' | 'onBrokenLinks' | 'onBrokenMarkdownLinks' | 'onDuplicateRoutes' | 'plugins' | 'themes' | 'presets' | 'headTags' | 'stylesheets' | 'scripts' | 'clientModules' | 'customFields' | 'themeConfig' | 'titleDelimiter' | 'noIndex' | 'tagline' | 'baseUrlIssueBanner' | 'staticDirectories' | 'markdown'>;
10
+ export declare const DEFAULT_MARKDOWN_CONFIG: MarkdownConfig;
11
+ export declare const DEFAULT_CONFIG: Pick<DocusaurusConfig, 'i18n' | 'onBrokenLinks' | 'onBrokenAnchors' | 'onBrokenMarkdownLinks' | 'onDuplicateRoutes' | 'plugins' | 'themes' | 'presets' | 'headTags' | 'stylesheets' | 'scripts' | 'clientModules' | 'customFields' | 'themeConfig' | 'titleDelimiter' | 'noIndex' | 'tagline' | 'baseUrlIssueBanner' | 'staticDirectories' | 'markdown'>;
11
12
  export declare const ConfigSchema: Joi.ObjectSchema<DocusaurusConfig>;
12
13
  export declare function validateConfig(config: unknown, siteConfigPath: string): DocusaurusConfig;
@@ -6,7 +6,7 @@
6
6
  * LICENSE file in the root directory of this source tree.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.validateConfig = exports.ConfigSchema = exports.DEFAULT_CONFIG = exports.DEFAULT_I18N_CONFIG = void 0;
9
+ exports.validateConfig = exports.ConfigSchema = exports.DEFAULT_CONFIG = exports.DEFAULT_MARKDOWN_CONFIG = exports.DEFAULT_I18N_CONFIG = void 0;
10
10
  const utils_1 = require("@docusaurus/utils");
11
11
  const utils_validation_1 = require("@docusaurus/utils-validation");
12
12
  const DEFAULT_I18N_LOCALE = 'en';
@@ -16,9 +16,22 @@ exports.DEFAULT_I18N_CONFIG = {
16
16
  locales: [DEFAULT_I18N_LOCALE],
17
17
  localeConfigs: {},
18
18
  };
19
+ exports.DEFAULT_MARKDOWN_CONFIG = {
20
+ format: 'mdx',
21
+ mermaid: false,
22
+ preprocessor: undefined,
23
+ parseFrontMatter: utils_1.DEFAULT_PARSE_FRONT_MATTER,
24
+ mdx1Compat: {
25
+ comments: true,
26
+ admonitions: true,
27
+ headingIds: true,
28
+ },
29
+ remarkRehypeOptions: undefined,
30
+ };
19
31
  exports.DEFAULT_CONFIG = {
20
32
  i18n: exports.DEFAULT_I18N_CONFIG,
21
33
  onBrokenLinks: 'throw',
34
+ onBrokenAnchors: 'warn',
22
35
  onBrokenMarkdownLinks: 'warn',
23
36
  onDuplicateRoutes: 'warn',
24
37
  plugins: [],
@@ -35,25 +48,15 @@ exports.DEFAULT_CONFIG = {
35
48
  tagline: '',
36
49
  baseUrlIssueBanner: true,
37
50
  staticDirectories: [utils_1.DEFAULT_STATIC_DIR_NAME],
38
- markdown: {
39
- format: 'mdx',
40
- mermaid: false,
41
- preprocessor: undefined,
42
- mdx1Compat: {
43
- comments: true,
44
- admonitions: true,
45
- headingIds: true,
46
- },
47
- },
51
+ markdown: exports.DEFAULT_MARKDOWN_CONFIG,
48
52
  };
49
53
  function createPluginSchema(theme) {
50
- return (utils_validation_1.Joi.alternatives()
54
+ return utils_validation_1.Joi.alternatives()
51
55
  .try(utils_validation_1.Joi.function(), utils_validation_1.Joi.array()
52
56
  .ordered(utils_validation_1.Joi.function().required(), utils_validation_1.Joi.object().required())
53
57
  .length(2), utils_validation_1.Joi.string(), utils_validation_1.Joi.array()
54
58
  .ordered(utils_validation_1.Joi.string().required(), utils_validation_1.Joi.object().required())
55
59
  .length(2), utils_validation_1.Joi.any().valid(false, null))
56
- // @ts-expect-error: bad lib def, doesn't recognize an array of reports
57
60
  .error((errors) => {
58
61
  errors.forEach((error) => {
59
62
  const validConfigExample = theme
@@ -82,7 +85,7 @@ ${validConfigExample}
82
85
  `;
83
86
  });
84
87
  return errors;
85
- }));
88
+ });
86
89
  }
87
90
  const PluginSchema = createPluginSchema(false);
88
91
  const ThemeSchema = createPluginSchema(true);
@@ -147,6 +150,9 @@ exports.ConfigSchema = utils_validation_1.Joi.object({
147
150
  onBrokenLinks: utils_validation_1.Joi.string()
148
151
  .equal('ignore', 'log', 'warn', 'throw')
149
152
  .default(exports.DEFAULT_CONFIG.onBrokenLinks),
153
+ onBrokenAnchors: utils_validation_1.Joi.string()
154
+ .equal('ignore', 'log', 'warn', 'throw')
155
+ .default(exports.DEFAULT_CONFIG.onBrokenAnchors),
150
156
  onBrokenMarkdownLinks: utils_validation_1.Joi.string()
151
157
  .equal('ignore', 'log', 'warn', 'throw')
152
158
  .default(exports.DEFAULT_CONFIG.onBrokenMarkdownLinks),
@@ -214,6 +220,7 @@ exports.ConfigSchema = utils_validation_1.Joi.object({
214
220
  format: utils_validation_1.Joi.string()
215
221
  .equal('mdx', 'md', 'detect')
216
222
  .default(exports.DEFAULT_CONFIG.markdown.format),
223
+ parseFrontMatter: utils_validation_1.Joi.function().default(() => exports.DEFAULT_CONFIG.markdown.parseFrontMatter),
217
224
  mermaid: utils_validation_1.Joi.boolean().default(exports.DEFAULT_CONFIG.markdown.mermaid),
218
225
  preprocessor: utils_validation_1.Joi.function()
219
226
  .arity(1)
@@ -224,6 +231,11 @@ exports.ConfigSchema = utils_validation_1.Joi.object({
224
231
  admonitions: utils_validation_1.Joi.boolean().default(exports.DEFAULT_CONFIG.markdown.mdx1Compat.admonitions),
225
232
  headingIds: utils_validation_1.Joi.boolean().default(exports.DEFAULT_CONFIG.markdown.mdx1Compat.headingIds),
226
233
  }).default(exports.DEFAULT_CONFIG.markdown.mdx1Compat),
234
+ remarkRehypeOptions:
235
+ // add proper external options validation?
236
+ // Not sure if it's a good idea, validation is likely to become stale
237
+ // See https://github.com/remarkjs/remark-rehype#options
238
+ utils_validation_1.Joi.object().unknown(),
227
239
  }).default(exports.DEFAULT_CONFIG.markdown),
228
240
  }).messages({
229
241
  'docusaurus.configValidationWarning': 'Docusaurus config validation warning. Field {#label}: {#warningMessage}',
@@ -40,7 +40,10 @@ function getProcessForPort(port) {
40
40
  */
41
41
  async function choosePort(host, defaultPort) {
42
42
  try {
43
- const port = await (0, detect_port_1.default)({ port: defaultPort, hostname: host });
43
+ const port = await (0, detect_port_1.default)({
44
+ port: defaultPort,
45
+ ...(host !== 'localhost' && { hostname: host }),
46
+ });
44
47
  if (port === defaultPort) {
45
48
  return port;
46
49
  }
@@ -159,6 +159,14 @@ ${JSON.stringify(routeConfig)}`);
159
159
  props,
160
160
  });
161
161
  }
162
+ /**
163
+ * Old stuff
164
+ * As far as I understand, this is what permits to SSG the 404.html file
165
+ * This is rendered through the catch-all ComponentCreator("*") route
166
+ * Note CDNs only understand the 404.html file by convention
167
+ * The extension probably permits to avoid emitting "/404/index.html"
168
+ */
169
+ const NotFoundRoutePath = '/404.html';
162
170
  /**
163
171
  * Routes are prepared into three temp files:
164
172
  *
@@ -175,7 +183,7 @@ function loadRoutes(routeConfigs, baseUrl, onDuplicateRoutes) {
175
183
  routesConfig: '',
176
184
  routesChunkNames: {},
177
185
  registry: {},
178
- routesPaths: [(0, utils_1.normalizeUrl)([baseUrl, '404.html'])],
186
+ routesPaths: [(0, utils_1.normalizeUrl)([baseUrl, NotFoundRoutePath])],
179
187
  };
180
188
  // `genRouteCode` would mutate `res`
181
189
  const routeConfigSerialized = routeConfigs
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@docusaurus/core",
3
3
  "description": "Easy to Maintain Open Source Documentation Websites",
4
- "version": "3.0.1",
4
+ "version": "3.1.1",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -43,13 +43,13 @@
43
43
  "@babel/runtime": "^7.22.6",
44
44
  "@babel/runtime-corejs3": "^7.22.6",
45
45
  "@babel/traverse": "^7.22.8",
46
- "@docusaurus/cssnano-preset": "3.0.1",
47
- "@docusaurus/logger": "3.0.1",
48
- "@docusaurus/mdx-loader": "3.0.1",
46
+ "@docusaurus/cssnano-preset": "3.1.1",
47
+ "@docusaurus/logger": "3.1.1",
48
+ "@docusaurus/mdx-loader": "3.1.1",
49
49
  "@docusaurus/react-loadable": "5.5.2",
50
- "@docusaurus/utils": "3.0.1",
51
- "@docusaurus/utils-common": "3.0.1",
52
- "@docusaurus/utils-validation": "3.0.1",
50
+ "@docusaurus/utils": "3.1.1",
51
+ "@docusaurus/utils-common": "3.1.1",
52
+ "@docusaurus/utils-validation": "3.1.1",
53
53
  "@slorber/static-site-generator-webpack-plugin": "^4.0.7",
54
54
  "@svgr/webpack": "^6.5.1",
55
55
  "autoprefixer": "^10.4.14",
@@ -104,8 +104,8 @@
104
104
  "webpackbar": "^5.0.2"
105
105
  },
106
106
  "devDependencies": {
107
- "@docusaurus/module-type-aliases": "3.0.1",
108
- "@docusaurus/types": "3.0.1",
107
+ "@docusaurus/module-type-aliases": "3.1.1",
108
+ "@docusaurus/types": "3.1.1",
109
109
  "@types/detect-port": "^1.3.3",
110
110
  "@types/react-dom": "^18.2.7",
111
111
  "@types/react-router-config": "^5.0.7",
@@ -124,5 +124,5 @@
124
124
  "engines": {
125
125
  "node": ">=18.0"
126
126
  },
127
- "gitHead": "29d816067fd0022d4ca3bd5fdc42098dac9f7ffc"
127
+ "gitHead": "8017f6a6776ba1bd7065e630a52fe2c2654e2f1b"
128
128
  }
@@ -1,20 +0,0 @@
1
- /**
2
- * Copyright (c) Facebook, Inc. and its affiliates.
3
- *
4
- * This source code is licensed under the MIT license found in the
5
- * LICENSE file in the root directory of this source tree.
6
- */
7
- import { type ReactNode } from 'react';
8
- type LinksCollector = {
9
- collectLink: (link: string) => void;
10
- };
11
- type StatefulLinksCollector = LinksCollector & {
12
- getCollectedLinks: () => string[];
13
- };
14
- export declare const createStatefulLinksCollector: () => StatefulLinksCollector;
15
- export declare const useLinksCollector: () => LinksCollector;
16
- export declare function LinksCollectorProvider({ children, linksCollector, }: {
17
- children: ReactNode;
18
- linksCollector: LinksCollector;
19
- }): JSX.Element;
20
- export {};
@@ -1,26 +0,0 @@
1
- /**
2
- * Copyright (c) Facebook, Inc. and its affiliates.
3
- *
4
- * This source code is licensed under the MIT license found in the
5
- * LICENSE file in the root directory of this source tree.
6
- */
7
- import React, { useContext } from 'react';
8
- export const createStatefulLinksCollector = () => {
9
- // Set to dedup, as it's not useful to collect multiple times the same link
10
- const allLinks = new Set();
11
- return {
12
- collectLink: (link) => {
13
- allLinks.add(link);
14
- },
15
- getCollectedLinks: () => [...allLinks],
16
- };
17
- };
18
- const Context = React.createContext({
19
- collectLink: () => {
20
- // No-op for client. We only use the broken links checker server-side.
21
- },
22
- });
23
- export const useLinksCollector = () => useContext(Context);
24
- export function LinksCollectorProvider({ children, linksCollector, }) {
25
- return <Context.Provider value={linksCollector}>{children}</Context.Provider>;
26
- }