Skip to content

Commit

Permalink
feat(v2): add trailingSlash config option (#4908)
Browse files Browse the repository at this point in the history
* POC: add trailingSlash option

* integrate the preferFoldersOutput option of fork @slorber/static-site-generator-webpack-plugin

* Fix broken links when using trailing slash => using md links is more reliable

* fix TS issue

* minor polish

* fix doc page being sensitive to trailing slashes

* Add tests for applyTrailingSlash

* rename test files

* extract and test applyRouteTrailingSlash

* update snapshot

* add trailing slash config to serve command

* fix getSidebar() => still sensitive to trailing slash setting

* never apply trailing slash to an anchor link

* Add documentation for trailingSlash setting
  • Loading branch information
slorber committed Jun 9, 2021
1 parent 77264f1 commit df8a900
Show file tree
Hide file tree
Showing 28 changed files with 469 additions and 67 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,17 @@
"start:v2:baseUrl": "yarn workspace docusaurus-2-website start:baseUrl",
"start:v2:bootstrap": "yarn workspace docusaurus-2-website start:bootstrap",
"start:v2:blogOnly": "yarn workspace docusaurus-2-website start:blogOnly",
"start:v2:deployPreview": "cross-env NETLIFY=true CONTEXT='deploy-preview' yarn workspace docusaurus-2-website start",
"examples:generate": "node generateExamples",
"build": "yarn build:packages && yarn build:v2",
"build:packages": "lerna run build --no-private",
"build:v1": "yarn workspace docusaurus-1-website build",
"build:v2": "yarn workspace docusaurus-2-website build",
"build:v2:baseUrl": "yarn workspace docusaurus-2-website build:baseUrl",
"build:v2:blogOnly": "yarn workspace docusaurus-2-website build:blogOnly",
"build:v2:deployPreview": "cross-env NETLIFY=true CONTEXT='deploy-preview' yarn workspace docusaurus-2-website build",
"build:v2:en": "yarn workspace docusaurus-2-website build --locale en",
"clear:v2": "yarn workspace docusaurus-2-website clear",
"serve:v1": "serve website-1.x/build/docusaurus",
"serve:v2": "yarn workspace docusaurus-2-website serve",
"serve:v2:baseUrl": "serve website",
Expand Down
27 changes: 24 additions & 3 deletions packages/docusaurus-theme-classic/src/theme/DocPage/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,36 @@ type DocPageContentProps = {
readonly children: ReactNode;
};

function getSidebar({versionMetadata, currentDocRoute}) {
function addTrailingSlash(str: string): string {
return str.endsWith('/') ? str : `${str}/`;
}
function removeTrailingSlash(str: string): string {
return str.endsWith('/') ? str.slice(0, -1) : str;
}

const {permalinkToSidebar, docsSidebars} = versionMetadata;

// With/without trailingSlash, we should always be able to get the appropriate sidebar
// note: docs plugin permalinks currently never have trailing slashes
// trailingSlash is handled globally at the framework level, not plugin level
const sidebarName =
permalinkToSidebar[currentDocRoute.path] ||
permalinkToSidebar[addTrailingSlash(currentDocRoute.path)] ||
permalinkToSidebar[removeTrailingSlash(currentDocRoute.path)];

const sidebar = docsSidebars[sidebarName];
return {sidebar, sidebarName};
}

function DocPageContent({
currentDocRoute,
versionMetadata,
children,
}: DocPageContentProps): JSX.Element {
const {siteConfig, isClient} = useDocusaurusContext();
const {pluginId, permalinkToSidebar, docsSidebars, version} = versionMetadata;
const sidebarName = permalinkToSidebar[currentDocRoute.path];
const sidebar = docsSidebars[sidebarName];
const {pluginId, version} = versionMetadata;
const {sidebarName, sidebar} = getSidebar({versionMetadata, currentDocRoute});

const [hiddenSidebarContainer, setHiddenSidebarContainer] = useState(false);
const [hiddenSidebar, setHiddenSidebar] = useState(false);
Expand Down
2 changes: 2 additions & 0 deletions packages/docusaurus-types/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface DocusaurusConfig {
tagline?: string;
title: string;
url: string;
// trailingSlash undefined = legacy retrocompatible behavior => /file => /file/index.html
trailingSlash: boolean | undefined;
i18n: I18nConfig;
onBrokenLinks: ReportingSeverity;
onBrokenMarkdownLinks: ReportingSeverity;
Expand Down
2 changes: 1 addition & 1 deletion packages/docusaurus/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"@docusaurus/types": "2.0.0-beta.0",
"@docusaurus/utils": "2.0.0-beta.0",
"@docusaurus/utils-validation": "2.0.0-beta.0",
"@endiliey/static-site-generator-webpack-plugin": "^4.0.0",
"@slorber/static-site-generator-webpack-plugin": "^4.0.0",
"@svgr/webpack": "^5.5.0",
"autoprefixer": "^10.2.5",
"babel-loader": "^8.2.2",
Expand Down
11 changes: 10 additions & 1 deletion packages/docusaurus/src/client/exports/Link.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
import React, {useEffect, useRef} from 'react';

import {NavLink, Link as RRLink} from 'react-router-dom';
import useDocusaurusContext from './useDocusaurusContext';
import isInternalUrl from './isInternalUrl';
import ExecutionEnvironment from './ExecutionEnvironment';
import {useLinksCollector} from '../LinksCollector';
import {useBaseUrlUtils} from './useBaseUrl';
import applyTrailingSlash from './applyTrailingSlash';

import type {LinkProps} from '@docusaurus/Link';
import type docusaurus from '../docusaurus';
Expand Down Expand Up @@ -39,6 +41,9 @@ function Link({
autoAddBaseUrl = true,
...props
}: LinkProps): JSX.Element {
const {
siteConfig: {trailingSlash},
} = useDocusaurusContext();
const {withBaseUrl} = useBaseUrlUtils();
const linksCollector = useLinksCollector();

Expand Down Expand Up @@ -69,11 +74,15 @@ function Link({

// TODO we should use ReactRouter basename feature instead!
// Automatically apply base url in links that start with /
const targetLink =
let targetLink =
typeof targetLinkWithoutPathnameProtocol !== 'undefined'
? maybeAddBaseUrl(targetLinkWithoutPathnameProtocol)
: undefined;

if (targetLink && isInternal) {
targetLink = applyTrailingSlash(targetLink, trailingSlash);
}

const preloaded = useRef(false);
const LinkComponent = isNavLink ? NavLink : RRLink;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* 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 applyTrailingSlash from '../applyTrailingSlash';

describe('applyTrailingSlash', () => {
test('should apply to empty', () => {
expect(applyTrailingSlash('', true)).toEqual('/');
expect(applyTrailingSlash('', false)).toEqual('');
expect(applyTrailingSlash('', undefined)).toEqual('');
});

test('should apply to /', () => {
expect(applyTrailingSlash('/', true)).toEqual('/');
expect(applyTrailingSlash('/', false)).toEqual('');
expect(applyTrailingSlash('/', undefined)).toEqual('/');
});

test('should not apply to #anchor links ', () => {
expect(applyTrailingSlash('#', true)).toEqual('#');
expect(applyTrailingSlash('#', false)).toEqual('#');
expect(applyTrailingSlash('#', undefined)).toEqual('#');
expect(applyTrailingSlash('#anchor', true)).toEqual('#anchor');
expect(applyTrailingSlash('#anchor', false)).toEqual('#anchor');
expect(applyTrailingSlash('#anchor', undefined)).toEqual('#anchor');
});

test('should apply to simple paths', () => {
expect(applyTrailingSlash('abc', true)).toEqual('abc/');
expect(applyTrailingSlash('abc', false)).toEqual('abc');
expect(applyTrailingSlash('abc', undefined)).toEqual('abc');
expect(applyTrailingSlash('abc/', true)).toEqual('abc/');
expect(applyTrailingSlash('abc/', false)).toEqual('abc');
expect(applyTrailingSlash('abc/', undefined)).toEqual('abc/');
expect(applyTrailingSlash('/abc', true)).toEqual('/abc/');
expect(applyTrailingSlash('/abc', false)).toEqual('/abc');
expect(applyTrailingSlash('/abc', undefined)).toEqual('/abc');
expect(applyTrailingSlash('/abc/', true)).toEqual('/abc/');
expect(applyTrailingSlash('/abc/', false)).toEqual('/abc');
expect(applyTrailingSlash('/abc/', undefined)).toEqual('/abc/');
});

test('should apply to path with #anchor', () => {
expect(applyTrailingSlash('/abc#anchor', true)).toEqual('/abc/#anchor');
expect(applyTrailingSlash('/abc#anchor', false)).toEqual('/abc#anchor');
expect(applyTrailingSlash('/abc#anchor', undefined)).toEqual('/abc#anchor');
expect(applyTrailingSlash('/abc/#anchor', true)).toEqual('/abc/#anchor');
expect(applyTrailingSlash('/abc/#anchor', false)).toEqual('/abc#anchor');
expect(applyTrailingSlash('/abc/#anchor', undefined)).toEqual(
'/abc/#anchor',
);
});

test('should apply to path with ?search', () => {
expect(applyTrailingSlash('/abc?search', true)).toEqual('/abc/?search');
expect(applyTrailingSlash('/abc?search', false)).toEqual('/abc?search');
expect(applyTrailingSlash('/abc?search', undefined)).toEqual('/abc?search');
expect(applyTrailingSlash('/abc/?search', true)).toEqual('/abc/?search');
expect(applyTrailingSlash('/abc/?search', false)).toEqual('/abc?search');
expect(applyTrailingSlash('/abc/?search', undefined)).toEqual(
'/abc/?search',
);
});

test('should apply to path with ?search#anchor', () => {
expect(applyTrailingSlash('/abc?search#anchor', true)).toEqual(
'/abc/?search#anchor',
);
expect(applyTrailingSlash('/abc?search#anchor', false)).toEqual(
'/abc?search#anchor',
);
expect(applyTrailingSlash('/abc?search#anchor', undefined)).toEqual(
'/abc?search#anchor',
);
expect(applyTrailingSlash('/abc/?search#anchor', true)).toEqual(
'/abc/?search#anchor',
);
expect(applyTrailingSlash('/abc/?search#anchor', false)).toEqual(
'/abc?search#anchor',
);
expect(applyTrailingSlash('/abc/?search#anchor', undefined)).toEqual(
'/abc/?search#anchor',
);
});
});
34 changes: 34 additions & 0 deletions packages/docusaurus/src/client/exports/applyTrailingSlash.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* 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.
*/

export default function applyTrailingSlash(
path: string,
trailingSlash: boolean | undefined,
): string {
// Never apply trailing slash to an anchor link
if (path.startsWith('#')) {
return path;
}

function addTrailingSlash(str: string): string {
return str.endsWith('/') ? str : `${str}/`;
}
function removeTrailingSlash(str: string): string {
return str.endsWith('/') ? str.slice(0, -1) : str;
}
// undefined = legacy retrocompatible behavior
if (typeof trailingSlash === 'undefined') {
return path;
}

// The trailing slash should be handled before the ?search#hash !
const [pathname] = path.split(/[#?]/);
const newPathname = trailingSlash
? addTrailingSlash(pathname)
: removeTrailingSlash(pathname);
return path.replace(pathname, newPathname);
}
3 changes: 2 additions & 1 deletion packages/docusaurus/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export default async function serve(
}

const {
siteConfig: {baseUrl},
siteConfig: {baseUrl, trailingSlash},
} = await loadSiteConfig({
siteDir,
customConfigFilePath: cliOptions.config,
Expand All @@ -67,6 +67,7 @@ export default async function serve(
serveHandler(req, res, {
cleanUrls: true,
public: dir,
trailingSlash,
});
});

Expand Down
1 change: 1 addition & 0 deletions packages/docusaurus/src/server/configValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ const ConfigSchema = Joi.object({
favicon: Joi.string().required(),
title: Joi.string().required(),
url: URISchema.required(),
trailingSlash: Joi.boolean(), // No default value! undefined = retrocompatible legacy behavior!
i18n: I18N_CONFIG_SCHEMA,
onBrokenLinks: Joi.string()
.equal('ignore', 'log', 'warn', 'error', 'throw')
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* 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 applyRouteTrailingSlash from '../applyRouteTrailingSlash';
import {RouteConfig} from '@docusaurus/types';

function route(path: string, subRoutes?: string[]): RouteConfig {
const result: RouteConfig = {path, component: 'any'};

if (subRoutes) {
result.routes = subRoutes.map((subRoute) => route(subRoute));
}

return result;
}

describe('applyRouteTrailingSlash', () => {
test('apply to empty', () => {
expect(applyRouteTrailingSlash(route(''), true)).toEqual(route('/'));
expect(applyRouteTrailingSlash(route(''), false)).toEqual(route(''));
expect(applyRouteTrailingSlash(route(''), undefined)).toEqual(route(''));
});

test('apply to /', () => {
expect(applyRouteTrailingSlash(route('/'), true)).toEqual(route('/'));
expect(applyRouteTrailingSlash(route('/'), false)).toEqual(route('/'));
expect(applyRouteTrailingSlash(route('/'), undefined)).toEqual(route('/'));
});

test('apply to /abc', () => {
expect(applyRouteTrailingSlash(route('/abc'), true)).toEqual(
route('/abc/'),
);
expect(applyRouteTrailingSlash(route('/abc'), false)).toEqual(
route('/abc'),
);
expect(applyRouteTrailingSlash(route('/abc'), undefined)).toEqual(
route('/abc'),
);
});

test('apply to /abc/', () => {
expect(applyRouteTrailingSlash(route('/abc/'), true)).toEqual(
route('/abc/'),
);
expect(applyRouteTrailingSlash(route('/abc/'), false)).toEqual(
route('/abc'),
);
expect(applyRouteTrailingSlash(route('/abc/'), undefined)).toEqual(
route('/abc/'),
);
});

test('apply to /abc?search#anchor', () => {
expect(applyRouteTrailingSlash(route('/abc?search#anchor'), true)).toEqual(
route('/abc/?search#anchor'),
);
expect(applyRouteTrailingSlash(route('/abc?search#anchor'), false)).toEqual(
route('/abc?search#anchor'),
);
expect(
applyRouteTrailingSlash(route('/abc?search#anchor'), undefined),
).toEqual(route('/abc?search#anchor'));
});

test('apply to /abc/?search#anchor', () => {
expect(applyRouteTrailingSlash(route('/abc/?search#anchor'), true)).toEqual(
route('/abc/?search#anchor'),
);
expect(
applyRouteTrailingSlash(route('/abc/?search#anchor'), false),
).toEqual(route('/abc?search#anchor'));
expect(
applyRouteTrailingSlash(route('/abc/?search#anchor'), undefined),
).toEqual(route('/abc/?search#anchor'));
});

test('apply to subroutes', () => {
expect(
applyRouteTrailingSlash(route('/abc', ['/abc/1', '/abc/2']), true),
).toEqual(route('/abc/', ['/abc/1/', '/abc/2/']));
expect(
applyRouteTrailingSlash(route('/abc', ['/abc/1', '/abc/2']), false),
).toEqual(route('/abc', ['/abc/1', '/abc/2']));
expect(
applyRouteTrailingSlash(route('/abc', ['/abc/1', '/abc/2']), undefined),
).toEqual(route('/abc', ['/abc/1', '/abc/2']));
});

test('apply for complex case', () => {
expect(
applyRouteTrailingSlash(
route('/abc?search#anchor', ['/abc/1?search', '/abc/2#anchor']),
true,
),
).toEqual(
route('/abc/?search#anchor', ['/abc/1/?search', '/abc/2/#anchor']),
);
});
});

0 comments on commit df8a900

Please sign in to comment.