feat(v2): absolute slugs and slug resolution system (#3084)

* rework slug to allow absolute slugs and slug resolution

* add slug metadata tests

* refactor docs metadata test + fix slug bugs

* fix tests

* fix docs tests failing due to randomness + update snapshot

* add test for addLeadingSlash
This commit is contained in:
Sébastien Lorber 2020-07-21 18:26:30 +02:00 committed by GitHub
parent 6730590c1e
commit f4434b2e42
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 791 additions and 255 deletions

View file

@ -0,0 +1,5 @@
---
slug: /rootAbsoluteSlug
---
Lorem

View file

@ -0,0 +1,5 @@
---
slug: rootRelativeSlug
---
Lorem

View file

@ -0,0 +1,5 @@
---
slug: ./hey/ho/../rootResolvedSlug
---
Lorem

View file

@ -0,0 +1,5 @@
---
slug: ../../../../../../../../rootTryToEscapeSlug
---
Lorem

View file

@ -0,0 +1,5 @@
---
slug: /absoluteSlug
---
Lorem

View file

@ -0,0 +1,5 @@
---
slug: relativeSlug
---
Lorem

View file

@ -0,0 +1,5 @@
---
slug: ./hey/ho/../resolvedSlug
---
Lorem

View file

@ -0,0 +1,5 @@
---
slug: ../../../../../../../../tryToEscapeSlug
---
Lorem

View file

@ -0,0 +1,5 @@
---
slug: /absoluteSlug
---
Lorem

View file

@ -0,0 +1,5 @@
---
slug: relativeSlug
---
Lorem

View file

@ -0,0 +1,5 @@
---
slug: ./hey/ho/../resolvedSlug
---
Lorem

View file

@ -0,0 +1,5 @@
---
slug: ../../../../../../../../tryToEscapeSlug
---
Lorem

View file

@ -0,0 +1,5 @@
---
slug: ./hey/ho/../rootResolvedSlug
---
Lorem

View file

@ -0,0 +1,5 @@
---
slug: ../../../../../../../../rootTryToEscapeSlug
---
Lorem

View file

@ -0,0 +1,5 @@
---
slug: ../../../../../../../../tryToEscapeSlug
---
Lorem

View file

@ -1,4 +1,5 @@
[ [
"1.0.1", "1.0.1",
"1.0.0" "1.0.0",
"withSlugs"
] ]

View file

@ -72,6 +72,14 @@ Array [
}, },
"path": "/docs/", "path": "/docs/",
}, },
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/docs/slugs/absoluteSlug.md",
},
"path": "/docs/absoluteSlug",
},
Object { Object {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
@ -88,6 +96,14 @@ Array [
}, },
"path": "/docs/foo/bazSlug.html", "path": "/docs/foo/bazSlug.html",
}, },
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/docs/rootResolvedSlug.md",
},
"path": "/docs/hey/rootResolvedSlug",
},
Object { Object {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
@ -104,6 +120,54 @@ Array [
}, },
"path": "/docs/lorem", "path": "/docs/lorem",
}, },
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/docs/rootAbsoluteSlug.md",
},
"path": "/docs/rootAbsoluteSlug",
},
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/docs/rootRelativeSlug.md",
},
"path": "/docs/rootRelativeSlug",
},
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/docs/rootTryToEscapeSlug.md",
},
"path": "/docs/rootTryToEscapeSlug",
},
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/docs/slugs/resolvedSlug.md",
},
"path": "/docs/slugs/hey/resolvedSlug",
},
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/docs/slugs/relativeSlug.md",
},
"path": "/docs/slugs/relativeSlug",
},
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/docs/slugs/tryToEscapeSlug.md",
},
"path": "/docs/tryToEscapeSlug",
},
], ],
}, },
] ]
@ -138,6 +202,38 @@ Object {
"id": "lorem", "id": "lorem",
"path": "/docs/lorem", "path": "/docs/lorem",
}, },
Object {
"id": "rootAbsoluteSlug",
"path": "/docs/rootAbsoluteSlug",
},
Object {
"id": "rootRelativeSlug",
"path": "/docs/rootRelativeSlug",
},
Object {
"id": "rootResolvedSlug",
"path": "/docs/hey/rootResolvedSlug",
},
Object {
"id": "rootTryToEscapeSlug",
"path": "/docs/rootTryToEscapeSlug",
},
Object {
"id": "slugs/absoluteSlug",
"path": "/docs/absoluteSlug",
},
Object {
"id": "slugs/relativeSlug",
"path": "/docs/slugs/relativeSlug",
},
Object {
"id": "slugs/resolvedSlug",
"path": "/docs/slugs/hey/resolvedSlug",
},
Object {
"id": "slugs/tryToEscapeSlug",
"path": "/docs/tryToEscapeSlug",
},
], ],
"mainDocId": "hello", "mainDocId": "hello",
"name": null, "name": null,
@ -156,7 +252,15 @@ Available document ids=
- foo/baz - foo/baz
- hello - hello
- ipsum - ipsum
- lorem" - lorem
- rootAbsoluteSlug
- rootRelativeSlug
- rootResolvedSlug
- rootTryToEscapeSlug
- slugs/absoluteSlug
- slugs/relativeSlug
- slugs/resolvedSlug
- slugs/tryToEscapeSlug"
`; `;
exports[`versioned website content 1`] = ` exports[`versioned website content 1`] = `
@ -213,6 +317,14 @@ Array [
}, },
"path": "/docs/next/", "path": "/docs/next/",
}, },
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/docs/slugs/absoluteSlug.md",
},
"path": "/docs/next/absoluteSlug",
},
Object { Object {
"component": "@theme/DocItem", "component": "@theme/DocItem",
"exact": true, "exact": true,
@ -221,6 +333,105 @@ Array [
}, },
"path": "/docs/next/foo/barSlug", "path": "/docs/next/foo/barSlug",
}, },
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/docs/slugs/resolvedSlug.md",
},
"path": "/docs/next/slugs/hey/resolvedSlug",
},
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/docs/slugs/relativeSlug.md",
},
"path": "/docs/next/slugs/relativeSlug",
},
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/docs/slugs/tryToEscapeSlug.md",
},
"path": "/docs/next/tryToEscapeSlug",
},
],
},
Object {
"component": "@theme/DocPage",
"exact": false,
"modules": Object {
"docsMetadata": "~docs/docs-with-slugs-route-335.json",
},
"path": "/docs/withSlugs",
"priority": undefined,
"routes": Array [
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/versioned_docs/version-withSlugs/slugs/absoluteSlug.md",
},
"path": "/docs/withSlugs/absoluteSlug",
},
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/versioned_docs/version-withSlugs/rootResolvedSlug.md",
},
"path": "/docs/withSlugs/hey/rootResolvedSlug",
},
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/versioned_docs/version-withSlugs/rootAbsoluteSlug.md",
},
"path": "/docs/withSlugs/rootAbsoluteSlug",
},
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/versioned_docs/version-withSlugs/rootRelativeSlug.md",
},
"path": "/docs/withSlugs/rootRelativeSlug",
},
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/versioned_docs/version-withSlugs/rootTryToEscapeSlug.md",
},
"path": "/docs/withSlugs/rootTryToEscapeSlug",
},
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/versioned_docs/version-withSlugs/slugs/resolvedSlug.md",
},
"path": "/docs/withSlugs/slugs/hey/resolvedSlug",
},
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/versioned_docs/version-withSlugs/slugs/relativeSlug.md",
},
"path": "/docs/withSlugs/slugs/relativeSlug",
},
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/versioned_docs/version-withSlugs/slugs/tryToEscapeSlug.md",
},
"path": "/docs/withSlugs/tryToEscapeSlug",
},
], ],
}, },
Object { Object {
@ -270,6 +481,22 @@ Object {
"id": "hello", "id": "hello",
"path": "/docs/next/", "path": "/docs/next/",
}, },
Object {
"id": "slugs/absoluteSlug",
"path": "/docs/next/absoluteSlug",
},
Object {
"id": "slugs/relativeSlug",
"path": "/docs/next/slugs/relativeSlug",
},
Object {
"id": "slugs/resolvedSlug",
"path": "/docs/next/slugs/hey/resolvedSlug",
},
Object {
"id": "slugs/tryToEscapeSlug",
"path": "/docs/next/tryToEscapeSlug",
},
], ],
"mainDocId": "hello", "mainDocId": "hello",
"name": "next", "name": "next",
@ -309,6 +536,45 @@ Object {
"name": "1.0.0", "name": "1.0.0",
"path": "/docs/1.0.0", "path": "/docs/1.0.0",
}, },
Object {
"docs": Array [
Object {
"id": "rootAbsoluteSlug",
"path": "/docs/withSlugs/rootAbsoluteSlug",
},
Object {
"id": "rootRelativeSlug",
"path": "/docs/withSlugs/rootRelativeSlug",
},
Object {
"id": "rootResolvedSlug",
"path": "/docs/withSlugs/hey/rootResolvedSlug",
},
Object {
"id": "rootTryToEscapeSlug",
"path": "/docs/withSlugs/rootTryToEscapeSlug",
},
Object {
"id": "slugs/absoluteSlug",
"path": "/docs/withSlugs/absoluteSlug",
},
Object {
"id": "slugs/relativeSlug",
"path": "/docs/withSlugs/slugs/relativeSlug",
},
Object {
"id": "slugs/resolvedSlug",
"path": "/docs/withSlugs/slugs/hey/resolvedSlug",
},
Object {
"id": "slugs/tryToEscapeSlug",
"path": "/docs/withSlugs/tryToEscapeSlug",
},
],
"mainDocId": "rootAbsoluteSlug",
"name": "withSlugs",
"path": "/docs/withSlugs",
},
], ],
}, },
}, },

View file

@ -21,7 +21,11 @@ describe('loadEnv', () => {
const env = loadEnv(siteDir); const env = loadEnv(siteDir);
expect(env.versioning.enabled).toBe(true); expect(env.versioning.enabled).toBe(true);
expect(env.versioning.latestVersion).toBe('1.0.1'); expect(env.versioning.latestVersion).toBe('1.0.1');
expect(env.versioning.versions).toStrictEqual(['1.0.1', '1.0.0']); expect(env.versioning.versions).toStrictEqual([
'1.0.1',
'1.0.0',
'withSlugs',
]);
}); });
test('website with versioning but disabled', () => { test('website with versioning but disabled', () => {

View file

@ -269,8 +269,10 @@ describe('versioned website', () => {
"docs/**/*.{md,mdx}", "docs/**/*.{md,mdx}",
"versioned_sidebars/version-1.0.1-sidebars.json", "versioned_sidebars/version-1.0.1-sidebars.json",
"versioned_sidebars/version-1.0.0-sidebars.json", "versioned_sidebars/version-1.0.0-sidebars.json",
"versioned_sidebars/version-withSlugs-sidebars.json",
"versioned_docs/version-1.0.1/**/*.{md,mdx}", "versioned_docs/version-1.0.1/**/*.{md,mdx}",
"versioned_docs/version-1.0.0/**/*.{md,mdx}", "versioned_docs/version-1.0.0/**/*.{md,mdx}",
"versioned_docs/version-withSlugs/**/*.{md,mdx}",
"sidebars.json", "sidebars.json",
] ]
`); `);

View file

@ -9,136 +9,150 @@ import path from 'path';
import {loadContext} from '@docusaurus/core/src/server/index'; import {loadContext} from '@docusaurus/core/src/server/index';
import processMetadata from '../metadata'; import processMetadata from '../metadata';
import loadEnv from '../env'; import loadEnv from '../env';
import {MetadataRaw, Env, MetadataOptions} from '../types';
import {LoadContext} from '@docusaurus/types';
const fixtureDir = path.join(__dirname, '__fixtures__'); const fixtureDir = path.join(__dirname, '__fixtures__');
describe('simple site', () => { function createTestHelpers({
const simpleSiteDir = path.join(fixtureDir, 'simple-site'); siteDir,
const context = loadContext(simpleSiteDir); context,
const routeBasePath = 'docs'; env,
const docsDir = path.resolve(simpleSiteDir, routeBasePath); options,
}: {
siteDir: string;
context: LoadContext;
env: Env;
options: MetadataOptions;
}) {
async function testMeta(
refDir: string,
source: string,
expectedMetadata: Omit<MetadataRaw, 'source'>,
) {
const metadata = await processMetadata({
source,
refDir,
context,
options,
env,
});
expect(metadata).toEqual({
...expectedMetadata,
source: path.join('@site', path.relative(siteDir, refDir), source),
});
}
const env = loadEnv(simpleSiteDir); async function testSlug(
refDir: string,
source: string,
expectedPermalink: string,
) {
const metadata = await processMetadata({
source,
refDir,
context,
options,
env,
});
expect(metadata.permalink).toEqual(expectedPermalink);
}
return {testMeta, testSlug};
}
describe('simple site', () => {
const siteDir = path.join(fixtureDir, 'simple-site');
const context = loadContext(siteDir);
const routeBasePath = 'docs';
const docsDir = path.resolve(siteDir, routeBasePath);
const env = loadEnv(siteDir);
const options = {routeBasePath};
const {testMeta, testSlug} = createTestHelpers({
siteDir,
context,
options,
env,
});
test('normal docs', async () => { test('normal docs', async () => {
const sourceA = path.join('foo', 'bar.md'); await testMeta(docsDir, path.join('foo', 'bar.md'), {
const sourceB = path.join('hello.md');
const options = {
routeBasePath,
};
const [dataA, dataB] = await Promise.all([
processMetadata({
source: sourceA,
refDir: docsDir,
context,
options,
env,
}),
processMetadata({
source: sourceB,
refDir: docsDir,
context,
options,
env,
}),
]);
expect(dataA).toEqual({
id: 'foo/bar', id: 'foo/bar',
unversionedId: 'foo/bar', unversionedId: 'foo/bar',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/foo/bar', permalink: '/docs/foo/bar',
source: path.join('@site', routeBasePath, sourceA),
title: 'Bar', title: 'Bar',
description: 'This is custom description', description: 'This is custom description',
}); });
expect(dataB).toEqual({ await testMeta(docsDir, path.join('hello.md'), {
id: 'hello', id: 'hello',
unversionedId: 'hello', unversionedId: 'hello',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/hello', permalink: '/docs/hello',
source: path.join('@site', routeBasePath, sourceB),
title: 'Hello, World !', title: 'Hello, World !',
description: `Hi, Endilie here :)`, description: `Hi, Endilie here :)`,
}); });
}); });
test('homePageId doc', async () => { test('homePageId doc', async () => {
const source = path.join('hello.md'); const {testMeta: testMetaLocal} = createTestHelpers({
const options = { siteDir,
routeBasePath, options: {
homePageId: 'hello', routeBasePath,
}; homePageId: 'hello',
},
const data = await processMetadata({
source,
refDir: docsDir,
context, context,
options,
env, env,
}); });
expect(data).toEqual({ await testMetaLocal(docsDir, path.join('hello.md'), {
id: 'hello', id: 'hello',
unversionedId: 'hello', unversionedId: 'hello',
isDocsHomePage: true, isDocsHomePage: true,
permalink: '/docs/', permalink: '/docs/',
source: path.join('@site', routeBasePath, source),
title: 'Hello, World !', title: 'Hello, World !',
description: `Hi, Endilie here :)`, description: `Hi, Endilie here :)`,
}); });
}); });
test('homePageId doc nested', async () => { test('homePageId doc nested', async () => {
const source = path.join('foo', 'bar.md'); const {testMeta: testMetaLocal} = createTestHelpers({
const options = { siteDir,
routeBasePath, options: {
homePageId: 'foo/bar', routeBasePath,
}; homePageId: 'foo/bar',
},
const data = await processMetadata({
source,
refDir: docsDir,
context, context,
options,
env, env,
}); });
expect(data).toEqual({ await testMetaLocal(docsDir, path.join('foo', 'bar.md'), {
id: 'foo/bar', id: 'foo/bar',
unversionedId: 'foo/bar', unversionedId: 'foo/bar',
isDocsHomePage: true, isDocsHomePage: true,
permalink: '/docs/', permalink: '/docs/',
source: path.join('@site', routeBasePath, source),
title: 'Bar', title: 'Bar',
description: 'This is custom description', description: 'This is custom description',
}); });
}); });
test('docs with editUrl', async () => { test('docs with editUrl', async () => {
const editUrl = const {testMeta: testMetaLocal} = createTestHelpers({
'https://github.com/facebook/docusaurus/edit/master/website'; siteDir,
const source = path.join('foo', 'baz.md'); options: {
const options = { routeBasePath,
routeBasePath, editUrl: 'https://github.com/facebook/docusaurus/edit/master/website',
editUrl, },
};
const data = await processMetadata({
source,
refDir: docsDir,
context, context,
options,
env, env,
}); });
expect(data).toEqual({ await testMetaLocal(docsDir, path.join('foo', 'baz.md'), {
id: 'foo/baz', id: 'foo/baz',
unversionedId: 'foo/baz', unversionedId: 'foo/baz',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/foo/bazSlug.html', permalink: '/docs/foo/bazSlug.html',
source: path.join('@site', routeBasePath, source),
title: 'baz', title: 'baz',
editUrl: editUrl:
'https://github.com/facebook/docusaurus/edit/master/website/docs/foo/baz.md', 'https://github.com/facebook/docusaurus/edit/master/website/docs/foo/baz.md',
@ -147,57 +161,34 @@ describe('simple site', () => {
}); });
test('docs with custom editUrl & unrelated frontmatter', async () => { test('docs with custom editUrl & unrelated frontmatter', async () => {
const source = 'lorem.md'; await testMeta(docsDir, 'lorem.md', {
const options = {
routeBasePath,
};
const data = await processMetadata({
source,
refDir: docsDir,
context,
options,
env,
});
expect(data).toEqual({
id: 'lorem', id: 'lorem',
unversionedId: 'lorem', unversionedId: 'lorem',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/lorem', permalink: '/docs/lorem',
source: path.join('@site', routeBasePath, source),
title: 'lorem', title: 'lorem',
editUrl: 'https://github.com/customUrl/docs/lorem.md', editUrl: 'https://github.com/customUrl/docs/lorem.md',
description: 'Lorem ipsum.', description: 'Lorem ipsum.',
}); });
// unrelated frontmatter is not part of metadata
// @ts-expect-error: It doesn't exist, so the test will show it's undefined.
expect(data.unrelated_frontmatter).toBeUndefined();
}); });
test('docs with last update time and author', async () => { test('docs with last update time and author', async () => {
const source = 'lorem.md'; const {testMeta: testMetaLocal} = createTestHelpers({
const options = { siteDir,
routeBasePath, options: {
showLastUpdateAuthor: true, routeBasePath,
showLastUpdateTime: true, showLastUpdateAuthor: true,
}; showLastUpdateTime: true,
},
const data = await processMetadata({
source,
refDir: docsDir,
context, context,
options,
env, env,
}); });
expect(data).toEqual({ await testMetaLocal(docsDir, 'lorem.md', {
id: 'lorem', id: 'lorem',
unversionedId: 'lorem', unversionedId: 'lorem',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/lorem', permalink: '/docs/lorem',
source: path.join('@site', routeBasePath, source),
title: 'lorem', title: 'lorem',
editUrl: 'https://github.com/customUrl/docs/lorem.md', editUrl: 'https://github.com/customUrl/docs/lorem.md',
description: 'Lorem ipsum.', description: 'Lorem ipsum.',
@ -207,27 +198,22 @@ describe('simple site', () => {
}); });
test('docs with null custom_edit_url', async () => { test('docs with null custom_edit_url', async () => {
const source = 'ipsum.md'; const {testMeta: testMetaLocal} = createTestHelpers({
const options = { siteDir,
routeBasePath, options: {
showLastUpdateAuthor: true, routeBasePath,
showLastUpdateTime: true, showLastUpdateAuthor: true,
}; showLastUpdateTime: true,
},
const data = await processMetadata({
source,
refDir: docsDir,
context, context,
options,
env, env,
}); });
expect(data).toEqual({ await testMetaLocal(docsDir, 'ipsum.md', {
id: 'ipsum', id: 'ipsum',
unversionedId: 'ipsum', unversionedId: 'ipsum',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/ipsum', permalink: '/docs/ipsum',
source: path.join('@site', routeBasePath, source),
title: 'ipsum', title: 'ipsum',
editUrl: null, editUrl: null,
description: 'Lorem ipsum.', description: 'Lorem ipsum.',
@ -236,18 +222,61 @@ describe('simple site', () => {
}); });
}); });
test('docs with slugs', async () => {
await testSlug(
docsDir,
path.join('rootRelativeSlug.md'),
'/docs/rootRelativeSlug',
);
await testSlug(
docsDir,
path.join('rootAbsoluteSlug.md'),
'/docs/rootAbsoluteSlug',
);
await testSlug(
docsDir,
path.join('rootResolvedSlug.md'),
'/docs/hey/rootResolvedSlug',
);
await testSlug(
docsDir,
path.join('rootTryToEscapeSlug.md'),
'/docs/rootTryToEscapeSlug',
);
await testSlug(
docsDir,
path.join('slugs', 'absoluteSlug.md'),
'/docs/absoluteSlug',
);
await testSlug(
docsDir,
path.join('slugs', 'relativeSlug.md'),
'/docs/slugs/relativeSlug',
);
await testSlug(
docsDir,
path.join('slugs', 'resolvedSlug.md'),
'/docs/slugs/hey/resolvedSlug',
);
await testSlug(
docsDir,
path.join('slugs', 'tryToEscapeSlug.md'),
'/docs/tryToEscapeSlug',
);
});
test('docs with invalid id', async () => { test('docs with invalid id', async () => {
const badSiteDir = path.join(fixtureDir, 'bad-id-site'); const badSiteDir = path.join(fixtureDir, 'bad-id-site');
const options = {
routeBasePath,
};
await expect( await expect(
processMetadata({ processMetadata({
source: 'invalid-id.md', source: 'invalid-id.md',
refDir: path.join(badSiteDir, 'docs'), refDir: path.join(badSiteDir, 'docs'),
context, context,
options, options: {
routeBasePath,
},
env, env,
}), }),
).rejects.toThrowErrorMatchingInlineSnapshot( ).rejects.toThrowErrorMatchingInlineSnapshot(
@ -255,38 +284,18 @@ describe('simple site', () => {
); );
}); });
test('docs with invalid slug', async () => {
const badSiteDir = path.join(fixtureDir, 'bad-slug-site');
const options = {
routeBasePath,
};
await expect(
processMetadata({
source: 'invalid-slug.md',
refDir: path.join(badSiteDir, 'docs'),
context,
options,
env,
}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Document slug cannot include \\"/\\"."`,
);
});
test('docs with slug on doc home', async () => { test('docs with slug on doc home', async () => {
const badSiteDir = path.join(fixtureDir, 'bad-slug-on-doc-home-site'); const badSiteDir = path.join(fixtureDir, 'bad-slug-on-doc-home-site');
const options = {
routeBasePath,
homePageId: 'docWithSlug',
};
await expect( await expect(
processMetadata({ processMetadata({
source: 'docWithSlug.md', source: 'docWithSlug.md',
refDir: path.join(badSiteDir, 'docs'), refDir: path.join(badSiteDir, 'docs'),
context, context,
options, options: {
routeBasePath,
homePageId: 'docWithSlug',
},
env, env,
}), }),
).rejects.toThrowErrorMatchingInlineSnapshot( ).rejects.toThrowErrorMatchingInlineSnapshot(
@ -302,47 +311,30 @@ describe('versioned site', () => {
const docsDir = path.resolve(siteDir, routeBasePath); const docsDir = path.resolve(siteDir, routeBasePath);
const env = loadEnv(siteDir); const env = loadEnv(siteDir);
const {docsDir: versionedDir} = env.versioning; const {docsDir: versionedDir} = env.versioning;
const options = {routeBasePath};
test('master/next docs', async () => { const {testMeta, testSlug} = createTestHelpers({
const sourceA = path.join('foo', 'bar.md'); siteDir,
const sourceB = path.join('hello.md'); context,
const options = { options,
routeBasePath, env,
}; });
const [dataA, dataB] = await Promise.all([ test('next docs', async () => {
processMetadata({ await testMeta(docsDir, path.join('foo', 'bar.md'), {
source: sourceA,
refDir: docsDir,
context,
options,
env,
}),
processMetadata({
source: sourceB,
refDir: docsDir,
context,
options,
env,
}),
]);
expect(dataA).toEqual({
id: 'foo/bar', id: 'foo/bar',
unversionedId: 'foo/bar', unversionedId: 'foo/bar',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/next/foo/barSlug', permalink: '/docs/next/foo/barSlug',
source: path.join('@site', routeBasePath, sourceA),
title: 'bar', title: 'bar',
description: 'This is next version of bar.', description: 'This is next version of bar.',
version: 'next', version: 'next',
}); });
expect(dataB).toEqual({ await testMeta(docsDir, path.join('hello.md'), {
id: 'hello', id: 'hello',
unversionedId: 'hello', unversionedId: 'hello',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/next/hello', permalink: '/docs/next/hello',
source: path.join('@site', routeBasePath, sourceB),
title: 'hello', title: 'hello',
description: 'Hello next !', description: 'Hello next !',
version: 'next', version: 'next',
@ -350,84 +342,108 @@ describe('versioned site', () => {
}); });
test('versioned docs', async () => { test('versioned docs', async () => {
const sourceA = path.join('version-1.0.0', 'foo', 'bar.md'); await testMeta(versionedDir, path.join('version-1.0.0', 'foo', 'bar.md'), {
const sourceB = path.join('version-1.0.0', 'hello.md');
const sourceC = path.join('version-1.0.1', 'foo', 'bar.md');
const sourceD = path.join('version-1.0.1', 'hello.md');
const options = {
routeBasePath,
};
const [dataA, dataB, dataC, dataD] = await Promise.all([
processMetadata({
source: sourceA,
refDir: versionedDir,
context,
options,
env,
}),
processMetadata({
source: sourceB,
refDir: versionedDir,
context,
options,
env,
}),
processMetadata({
source: sourceC,
refDir: versionedDir,
context,
options,
env,
}),
processMetadata({
source: sourceD,
refDir: versionedDir,
context,
options,
env,
}),
]);
expect(dataA).toEqual({
id: 'version-1.0.0/foo/bar', id: 'version-1.0.0/foo/bar',
unversionedId: 'foo/bar', unversionedId: 'foo/bar',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/1.0.0/foo/barSlug', permalink: '/docs/1.0.0/foo/barSlug',
source: path.join('@site', path.relative(siteDir, versionedDir), sourceA),
title: 'bar', title: 'bar',
description: 'Bar 1.0.0 !', description: 'Bar 1.0.0 !',
version: '1.0.0', version: '1.0.0',
}); });
expect(dataB).toEqual({ await testMeta(versionedDir, path.join('version-1.0.0', 'hello.md'), {
id: 'version-1.0.0/hello', id: 'version-1.0.0/hello',
unversionedId: 'hello', unversionedId: 'hello',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/1.0.0/hello', permalink: '/docs/1.0.0/hello',
source: path.join('@site', path.relative(siteDir, versionedDir), sourceB),
title: 'hello', title: 'hello',
description: 'Hello 1.0.0 !', description: 'Hello 1.0.0 !',
version: '1.0.0', version: '1.0.0',
}); });
expect(dataC).toEqual({ await testMeta(versionedDir, path.join('version-1.0.1', 'foo', 'bar.md'), {
id: 'version-1.0.1/foo/bar', id: 'version-1.0.1/foo/bar',
unversionedId: 'foo/bar', unversionedId: 'foo/bar',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/foo/bar', permalink: '/docs/foo/bar',
source: path.join('@site', path.relative(siteDir, versionedDir), sourceC),
title: 'bar', title: 'bar',
description: 'Bar 1.0.1 !', description: 'Bar 1.0.1 !',
version: '1.0.1', version: '1.0.1',
}); });
expect(dataD).toEqual({ await testMeta(versionedDir, path.join('version-1.0.1', 'hello.md'), {
id: 'version-1.0.1/hello', id: 'version-1.0.1/hello',
unversionedId: 'hello', unversionedId: 'hello',
isDocsHomePage: false, isDocsHomePage: false,
permalink: '/docs/hello', permalink: '/docs/hello',
source: path.join('@site', path.relative(siteDir, versionedDir), sourceD),
title: 'hello', title: 'hello',
description: 'Hello 1.0.1 !', description: 'Hello 1.0.1 !',
version: '1.0.1', version: '1.0.1',
}); });
}); });
test('next doc slugs', async () => {
await testSlug(
docsDir,
path.join('slugs', 'absoluteSlug.md'),
'/docs/next/absoluteSlug',
);
await testSlug(
docsDir,
path.join('slugs', 'relativeSlug.md'),
'/docs/next/slugs/relativeSlug',
);
await testSlug(
docsDir,
path.join('slugs', 'resolvedSlug.md'),
'/docs/next/slugs/hey/resolvedSlug',
);
await testSlug(
docsDir,
path.join('slugs', 'tryToEscapeSlug.md'),
'/docs/next/tryToEscapeSlug',
);
});
test('versioned doc slugs', async () => {
await testSlug(
versionedDir,
path.join('version-withSlugs', 'rootAbsoluteSlug.md'),
'/docs/withSlugs/rootAbsoluteSlug',
);
await testSlug(
versionedDir,
path.join('version-withSlugs', 'rootRelativeSlug.md'),
'/docs/withSlugs/rootRelativeSlug',
);
await testSlug(
versionedDir,
path.join('version-withSlugs', 'rootResolvedSlug.md'),
'/docs/withSlugs/hey/rootResolvedSlug',
);
await testSlug(
versionedDir,
path.join('version-withSlugs', 'rootTryToEscapeSlug.md'),
'/docs/withSlugs/rootTryToEscapeSlug',
);
await testSlug(
versionedDir,
path.join('version-withSlugs', 'slugs', 'absoluteSlug.md'),
'/docs/withSlugs/absoluteSlug',
);
await testSlug(
versionedDir,
path.join('version-withSlugs', 'slugs', 'relativeSlug.md'),
'/docs/withSlugs/slugs/relativeSlug',
);
await testSlug(
versionedDir,
path.join('version-withSlugs', 'slugs', 'resolvedSlug.md'),
'/docs/withSlugs/slugs/hey/resolvedSlug',
);
await testSlug(
versionedDir,
path.join('version-withSlugs', 'slugs', 'tryToEscapeSlug.md'),
'/docs/withSlugs/tryToEscapeSlug',
);
});
}); });

View file

@ -0,0 +1,85 @@
/**
* 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 getSlug from '../slug';
describe('getSlug', () => {
test('should default to dirname/id', () => {
expect(getSlug({baseID: 'doc', dirName: '/dir'})).toEqual('/dir/doc');
expect(getSlug({baseID: 'doc', dirName: '/dir/subdir'})).toEqual(
'/dir/subdir/doc',
);
});
test('should handle current dir', () => {
expect(getSlug({baseID: 'doc', dirName: '.'})).toEqual('/doc');
expect(getSlug({baseID: 'doc', dirName: '/'})).toEqual('/doc');
});
test('should resolve absolute slug frontmatter', () => {
expect(
getSlug({baseID: 'any', dirName: '.', frontmatterSlug: '/abc/def'}),
).toEqual('/abc/def');
expect(
getSlug({baseID: 'any', dirName: './any', frontmatterSlug: '/abc/def'}),
).toEqual('/abc/def');
expect(
getSlug({
baseID: 'any',
dirName: './any/any',
frontmatterSlug: '/abc/def',
}),
).toEqual('/abc/def');
});
test('should resolve relative slug frontmatter', () => {
expect(
getSlug({baseID: 'any', dirName: '.', frontmatterSlug: 'abc/def'}),
).toEqual('/abc/def');
expect(
getSlug({baseID: 'any', dirName: '/dir', frontmatterSlug: 'abc/def'}),
).toEqual('/dir/abc/def');
expect(
getSlug({
baseID: 'any',
dirName: 'unslashedDir',
frontmatterSlug: 'abc/def',
}),
).toEqual('/unslashedDir/abc/def');
expect(
getSlug({
baseID: 'any',
dirName: 'dir/subdir',
frontmatterSlug: 'abc/def',
}),
).toEqual('/dir/subdir/abc/def');
expect(
getSlug({baseID: 'any', dirName: '/dir', frontmatterSlug: './abc/def'}),
).toEqual('/dir/abc/def');
expect(
getSlug({
baseID: 'any',
dirName: '/dir',
frontmatterSlug: './abc/../def',
}),
).toEqual('/dir/def');
expect(
getSlug({
baseID: 'any',
dirName: '/dir/subdir',
frontmatterSlug: '../abc/def',
}),
).toEqual('/dir/abc/def');
expect(
getSlug({
baseID: 'any',
dirName: '/dir/subdir',
frontmatterSlug: '../../../../../abc/../def',
}),
).toEqual('/def');
});
});

View file

@ -187,7 +187,7 @@ describe('docsVersion', () => {
), ),
); );
expect(versionsPath).toEqual(getVersionsJSONFile(versionedSiteDir)); expect(versionsPath).toEqual(getVersionsJSONFile(versionedSiteDir));
expect(versions).toEqual(['2.0.0', '1.0.1', '1.0.0']); expect(versions).toEqual(['2.0.0', '1.0.1', '1.0.0', 'withSlugs']);
expect(consoleMock).toHaveBeenCalledWith('Version 2.0.0 created!'); expect(consoleMock).toHaveBeenCalledWith('Version 2.0.0 created!');
copyMock.mockRestore(); copyMock.mockRestore();

View file

@ -436,7 +436,10 @@ Available document ids=
// to be by version and pick only needed base metadata. // to be by version and pick only needed base metadata.
if (versioning.enabled) { if (versioning.enabled) {
const docsMetadataByVersion = groupBy( const docsMetadataByVersion = groupBy(
Object.values(content.docsMetadata), // sort to ensure consistent output for tests
Object.values(content.docsMetadata).sort((a, b) =>
a.id.localeCompare(b.id),
),
'version', 'version',
); );

View file

@ -22,9 +22,11 @@ import {
Env, Env,
VersioningEnv, VersioningEnv,
} from './types'; } from './types';
import getSlug from './slug';
import {escapeRegExp} from 'lodash';
function removeVersionPrefix(str: string, version: string): string { function removeVersionPrefix(str: string, version: string): string {
return str.replace(new RegExp(`^version-${version}/`), ''); return str.replace(new RegExp(`^version-${escapeRegExp(version)}/?`), '');
} }
function inferVersion( function inferVersion(
@ -102,8 +104,12 @@ export default async function processMetadata({
const fileMarkdownPromise = parseMarkdownFile(filePath); const fileMarkdownPromise = parseMarkdownFile(filePath);
const lastUpdatedPromise = lastUpdated(filePath, options); const lastUpdatedPromise = lastUpdated(filePath, options);
const dirName = path.dirname(source); const dirNameWithVersion = path.dirname(source); // ex: version-1.0.0/foo
const version = inferVersion(dirName, versioning); const version = inferVersion(dirNameWithVersion, versioning); // ex: 1.0.0
const dirNameWithoutVersion = // ex: foo
version && version !== 'next'
? removeVersionPrefix(dirNameWithVersion, version)
: dirNameWithVersion;
// The version portion of the url path. Eg: 'next', '1.0.0', and ''. // The version portion of the url path. Eg: 'next', '1.0.0', and ''.
const versionPath = const versionPath =
@ -122,7 +128,9 @@ export default async function processMetadata({
if (baseID.includes('/')) { if (baseID.includes('/')) {
throw new Error('Document id cannot include "/".'); throw new Error('Document id cannot include "/".');
} }
const id = dirName !== '.' ? `${dirName}/${baseID}` : baseID;
const id =
dirNameWithVersion !== '.' ? `${dirNameWithVersion}/${baseID}` : baseID;
const unversionedId = version ? removeVersionPrefix(id, version) : id; const unversionedId = version ? removeVersionPrefix(id, version) : id;
const isDocsHomePage = unversionedId === homePageId; const isDocsHomePage = unversionedId === homePageId;
@ -132,34 +140,24 @@ export default async function processMetadata({
); );
} }
const baseSlug: string = frontMatter.slug || baseID; const docSlug = isDocsHomePage
if (baseSlug.includes('/')) { ? '/'
throw new Error('Document slug cannot include "/".'); : getSlug({
} baseID,
const slug = dirName !== '.' ? `${dirName}/${baseSlug}` : baseSlug; dirName: dirNameWithoutVersion,
frontmatterSlug: frontMatter.slug,
});
// Default title is the id. // Default title is the id.
const title: string = frontMatter.title || baseID; const title: string = frontMatter.title || baseID;
const description: string = frontMatter.description || excerpt; const description: string = frontMatter.description || excerpt;
// The last portion of the url path. Eg: 'foo/bar', 'bar'.
let routePath;
if (isDocsHomePage) {
// TODO can we remove this trailing / ?
// Seems it's not that easy...
// Related to https://github.com/facebook/docusaurus/issues/2917
routePath = '/';
} else {
routePath =
version && version !== 'next' ? removeVersionPrefix(slug, version) : slug;
}
const permalink = normalizeUrl([ const permalink = normalizeUrl([
baseUrl, baseUrl,
routeBasePath, routeBasePath,
versionPath, versionPath,
routePath, docSlug,
]); ]);
const {lastUpdatedAt, lastUpdatedBy} = await lastUpdatedPromise; const {lastUpdatedAt, lastUpdatedBy} = await lastUpdatedPromise;

View file

@ -0,0 +1,41 @@
/**
* 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 {
addLeadingSlash,
addTrailingSlash,
isValidPathname,
resolvePathname,
} from '@docusaurus/utils';
export default function getSlug({
baseID,
frontmatterSlug,
dirName,
}: {
baseID: string;
frontmatterSlug?: string;
dirName: string;
}) {
const baseSlug: string = frontmatterSlug || baseID;
let slug: string;
if (baseSlug.startsWith('/')) {
slug = baseSlug;
} else {
const resolveDirname =
dirName === '.' ? '/' : addLeadingSlash(addTrailingSlash(dirName));
slug = resolvePathname(baseSlug, resolveDirname);
}
if (!isValidPathname(slug)) {
throw new Error(
`Unable to resolve valid document slug. Maybe your slug frontmatter is incorrect? Doc id=${baseID} / dirName=${dirName} / frontmatterSlug=${frontmatterSlug} => bad result slug=${slug}`,
);
}
return slug;
}

View file

@ -126,7 +126,7 @@ export interface MetadataRaw extends LastUpdateData {
source: string; source: string;
permalink: string; permalink: string;
sidebar_label?: string; sidebar_label?: string;
editUrl?: string; editUrl?: string | null;
version?: string; version?: string;
} }

View file

@ -17,7 +17,8 @@
"fs-extra": "^8.1.0", "fs-extra": "^8.1.0",
"gray-matter": "^4.0.2", "gray-matter": "^4.0.2",
"lodash.camelcase": "^4.3.0", "lodash.camelcase": "^4.3.0",
"lodash.kebabcase": "^4.1.1" "lodash.kebabcase": "^4.1.1",
"resolve-pathname": "^3.0.0"
}, },
"engines": { "engines": {
"node": ">=10.15.1" "node": ">=10.15.1"

View file

@ -25,6 +25,7 @@ import {
removeSuffix, removeSuffix,
removePrefix, removePrefix,
getFilePathForRoutePath, getFilePathForRoutePath,
addLeadingSlash,
} from '../index'; } from '../index';
describe('load utils', () => { describe('load utils', () => {
@ -412,6 +413,15 @@ describe('addTrailingSlash', () => {
}); });
}); });
describe('addLeadingSlash', () => {
test('should no-op', () => {
expect(addLeadingSlash('/abc')).toEqual('/abc');
});
test('should add /', () => {
expect(addLeadingSlash('abc')).toEqual('/abc');
});
});
describe('removeTrailingSlash', () => { describe('removeTrailingSlash', () => {
test('should no-op', () => { test('should no-op', () => {
expect(removeTrailingSlash('/abcd')).toEqual('/abcd'); expect(removeTrailingSlash('/abcd')).toEqual('/abcd');

View file

@ -14,6 +14,9 @@ import escapeStringRegexp from 'escape-string-regexp';
import fs from 'fs-extra'; import fs from 'fs-extra';
import {URL} from 'url'; import {URL} from 'url';
// @ts-expect-error: no typedefs :s
import resolvePathnameUnsafe from 'resolve-pathname';
const fileHash = new Map(); const fileHash = new Map();
export async function generate( export async function generate(
generatedFilesDir: string, generatedFilesDir: string,
@ -361,12 +364,20 @@ export function isValidPathname(str: string): boolean {
return false; return false;
} }
try { try {
// weird, but is there a better way?
return new URL(str, 'https://domain.com').pathname === str; return new URL(str, 'https://domain.com').pathname === str;
} catch (e) { } catch (e) {
return false; return false;
} }
} }
// resolve pathname and fail fast if resolution fails
export function resolvePathname(to: string, from?: string) {
return resolvePathnameUnsafe(to, from);
}
export function addLeadingSlash(str: string): string {
return str.startsWith('/') ? str : `/${str}`;
}
export function addTrailingSlash(str: string): string { export function addTrailingSlash(str: string): string {
return str.endsWith('/') ? str : `${str}/`; return str.endsWith('/') ? str : `${str}/`;
} }

View file

@ -1,6 +1,7 @@
--- ---
id: resources id: resources
title: Awesome Resources title: Awesome Resources
slug: /resources
--- ---
A curated list of interesting Docusaurus community projects. A curated list of interesting Docusaurus community projects.

View file

@ -1,6 +1,7 @@
--- ---
id: support id: support
title: Support title: Support
slug: /support
--- ---
Docusaurus has a community of thousands of developers. Docusaurus has a community of thousands of developers.

View file

@ -1,6 +1,7 @@
--- ---
id: team id: team
title: Team title: Team
slug: /team
--- ---
## Active Team ## Active Team

View file

@ -52,9 +52,9 @@ module.exports = {
}, },
], ],
community: [ community: [
'support', 'community/support',
'team', 'community/team',
'resources', 'community/resources',
{ {
type: 'link', type: 'link',
href: '/showcase', href: '/showcase',