fix(v2): fix docs homepage permalink issues (#2905)

* better fixes for docs homepage

* fix tests

* create special route for docs homepage + cleanup existing code

* no need to create multiple docs parent paths

* useful comment

* add test for slug + doc home usage at the same time error

* remove confusing variable name

* fix tests by using same suffix as before for docs base metadata path

* metadata: use homePageId correctly for nested docs: the full docId (including /) should be used to compare against homePageId

* add folder/testNested test doc

* refactor a bit processMetadata, the home should be handled correctly for all versions

* Workaround to fix issue when parent layout route (DocPage) has same path as the child route (DocItem): see https://github.com/facebook/docusaurus/issues/2917

* revert homePageId

* remove test doc

* remove test doc

* add useful comment
This commit is contained in:
Sébastien Lorber 2020-06-17 14:54:08 +02:00 committed by GitHub
parent a3f54d747d
commit f6b1c85b01
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 264 additions and 218 deletions

View file

@ -0,0 +1,5 @@
---
slug: docWithSlug.html
---
Lorem

View file

@ -29,7 +29,7 @@ Object {
"type": "link",
},
Object {
"href": "/docs",
"href": "/docs/",
"label": "Hello, World !",
"type": "link",
},
@ -41,7 +41,7 @@ Object {
"collapsed": true,
"items": Array [
Object {
"href": "/docs",
"href": "/docs/",
"label": "Hello, World !",
"type": "link",
},
@ -57,21 +57,21 @@ exports[`simple website content 2`] = `
Array [
Object {
"component": "@theme/DocPage",
"exact": true,
"modules": Object {
"content": "@site/docs/hello.md",
"docsMetadata": "~docs/site-docs-hello-md-9df-base.json",
},
"path": "/docs",
},
Object {
"component": "@theme/DocPage",
"exact": false,
"modules": Object {
"docsMetadata": "~docs/docs-route-ff2.json",
},
"path": "/docs/:route",
"path": "/docs",
"priority": undefined,
"routes": Array [
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/docs/hello.md",
},
"path": "/docs/",
},
Object {
"component": "@theme/DocItem",
"exact": true,
@ -123,39 +123,21 @@ exports[`versioned website content 1`] = `
Array [
Object {
"component": "@theme/DocPage",
"exact": true,
"modules": Object {
"content": "@site/versioned_docs/version-1.0.1/hello.md",
"docsMetadata": "~docs/site-versioned-docs-version-1-0-1-hello-md-0c7-base.json",
},
"path": "/docs",
},
Object {
"component": "@theme/DocPage",
"exact": true,
"modules": Object {
"content": "@site/versioned_docs/version-1.0.0/hello.md",
"docsMetadata": "~docs/site-versioned-docs-version-1-0-0-hello-md-3ef-base.json",
},
"path": "/docs/1.0.0",
},
Object {
"component": "@theme/DocPage",
"exact": true,
"modules": Object {
"content": "@site/docs/hello.md",
"docsMetadata": "~docs/site-docs-hello-md-9df-base.json",
},
"path": "/docs/next",
},
Object {
"component": "@theme/DocPage",
"exact": false,
"modules": Object {
"docsMetadata": "~docs/docs-1-0-0-route-660.json",
},
"path": "/docs/1.0.0/:route",
"path": "/docs/1.0.0",
"priority": undefined,
"routes": Array [
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/versioned_docs/version-1.0.0/hello.md",
},
"path": "/docs/1.0.0/",
},
Object {
"component": "@theme/DocItem",
"exact": true,
@ -176,12 +158,21 @@ Array [
},
Object {
"component": "@theme/DocPage",
"exact": false,
"modules": Object {
"docsMetadata": "~docs/docs-next-route-1c8.json",
},
"path": "/docs/next/:route",
"path": "/docs/next",
"priority": undefined,
"routes": Array [
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/docs/hello.md",
},
"path": "/docs/next/",
},
Object {
"component": "@theme/DocItem",
"exact": true,
@ -194,12 +185,21 @@ Array [
},
Object {
"component": "@theme/DocPage",
"exact": false,
"modules": Object {
"docsMetadata": "~docs/docs-route-ff2.json",
},
"path": "/docs/:route",
"path": "/docs",
"priority": -1,
"routes": Array [
Object {
"component": "@theme/DocItem",
"exact": true,
"modules": Object {
"content": "@site/versioned_docs/version-1.0.1/hello.md",
},
"path": "/docs/",
},
Object {
"component": "@theme/DocItem",
"exact": true,
@ -232,7 +232,7 @@ Object {
"collapsed": true,
"items": Array [
Object {
"href": "/docs/next",
"href": "/docs/next/",
"label": "hello",
"type": "link",
},
@ -263,7 +263,7 @@ Object {
"collapsed": true,
"items": Array [
Object {
"href": "/docs/1.0.0",
"href": "/docs/1.0.0/",
"label": "hello",
"type": "link",
},
@ -289,7 +289,7 @@ Object {
"collapsed": true,
"items": Array [
Object {
"href": "/docs",
"href": "/docs/",
"label": "hello",
"type": "link",
},
@ -326,7 +326,7 @@ Object {
"collapsed": true,
"items": Array [
Object {
"href": "/docs/1.0.0",
"href": "/docs/1.0.0/",
"label": "hello",
"type": "link",
},
@ -337,9 +337,9 @@ Object {
],
},
"permalinkToSidebar": Object {
"/docs/1.0.0/": "version-1.0.0/docs",
"/docs/1.0.0/foo/barSlug": "version-1.0.0/docs",
"/docs/1.0.0/foo/baz": "version-1.0.0/docs",
"/docs/1.0.0/hello": "version-1.0.0/docs",
},
"version": "1.0.0",
}
@ -365,7 +365,7 @@ Object {
"collapsed": true,
"items": Array [
Object {
"href": "/docs",
"href": "/docs/",
"label": "hello",
"type": "link",
},
@ -376,8 +376,8 @@ Object {
],
},
"permalinkToSidebar": Object {
"/docs/": "version-1.0.1/docs",
"/docs/foo/bar": "version-1.0.1/docs",
"/docs/hello": "version-1.0.1/docs",
},
"version": "1.0.1",
}
@ -403,7 +403,7 @@ Object {
"collapsed": true,
"items": Array [
Object {
"href": "/docs/next",
"href": "/docs/next/",
"label": "hello",
"type": "link",
},
@ -414,8 +414,8 @@ Object {
],
},
"permalinkToSidebar": Object {
"/docs/next/": "docs",
"/docs/next/foo/barSlug": "docs",
"/docs/next/hello": "docs",
},
"version": "next",
}

View file

@ -154,7 +154,8 @@ describe('simple website', () => {
expect(versionToSidebars).toEqual({});
expect(docsMetadata.hello).toEqual({
id: 'hello',
permalink: '/docs/hello',
isDocsHomePage: true,
permalink: '/docs/',
previous: {
title: 'baz',
permalink: '/docs/foo/bazSlug.html',
@ -168,6 +169,7 @@ describe('simple website', () => {
expect(docsMetadata['foo/bar']).toEqual({
id: 'foo/bar',
isDocsHomePage: false,
next: {
title: 'baz',
permalink: '/docs/foo/bazSlug.html',
@ -296,6 +298,7 @@ describe('versioned website', () => {
expect(docsMetadata['version-1.0.1/foo/baz']).toBeUndefined();
expect(docsMetadata['foo/bar']).toEqual({
id: 'foo/bar',
isDocsHomePage: false,
permalink: '/docs/next/foo/barSlug',
source: path.join('@site', routeBasePath, 'foo', 'bar.md'),
title: 'bar',
@ -304,12 +307,13 @@ describe('versioned website', () => {
sidebar: 'docs',
next: {
title: 'hello',
permalink: '/docs/next/hello',
permalink: '/docs/next/',
},
});
expect(docsMetadata['hello']).toEqual({
id: 'hello',
permalink: '/docs/next/hello',
isDocsHomePage: true,
permalink: '/docs/next/',
source: path.join('@site', routeBasePath, 'hello.md'),
title: 'hello',
description: 'Hello next !',
@ -322,7 +326,8 @@ describe('versioned website', () => {
});
expect(docsMetadata['version-1.0.1/hello']).toEqual({
id: 'version-1.0.1/hello',
permalink: '/docs/hello',
isDocsHomePage: true,
permalink: '/docs/',
source: path.join(
'@site',
path.relative(siteDir, versionedDir),
@ -341,6 +346,7 @@ describe('versioned website', () => {
});
expect(docsMetadata['version-1.0.0/foo/baz']).toEqual({
id: 'version-1.0.0/foo/baz',
isDocsHomePage: false,
permalink: '/docs/1.0.0/foo/baz',
source: path.join(
'@site',
@ -356,7 +362,7 @@ describe('versioned website', () => {
sidebar: 'version-1.0.0/docs',
next: {
title: 'hello',
permalink: '/docs/1.0.0/hello',
permalink: '/docs/1.0.0/',
},
previous: {
title: 'bar',

View file

@ -46,6 +46,7 @@ describe('simple site', () => {
expect(dataA).toEqual({
id: 'foo/bar',
isDocsHomePage: false,
permalink: '/docs/foo/bar',
source: path.join('@site', routeBasePath, sourceA),
title: 'Bar',
@ -54,6 +55,7 @@ describe('simple site', () => {
});
expect(dataB).toEqual({
id: 'hello',
isDocsHomePage: false,
permalink: '/docs/hello',
source: path.join('@site', routeBasePath, sourceB),
title: 'Hello, World !',
@ -62,6 +64,56 @@ describe('simple site', () => {
});
});
test('homePageId doc', async () => {
const source = path.join('hello.md');
const options = {
routeBasePath,
homePageId: 'hello',
};
const data = await processMetadata({
source,
refDir: docsDir,
context,
options,
env,
});
expect(data).toEqual({
id: 'hello',
isDocsHomePage: true,
permalink: '/docs/',
source: path.join('@site', routeBasePath, source),
title: 'Hello, World !',
description: `Hi, Endilie here :)`,
});
});
test('homePageId doc nested', async () => {
const source = path.join('foo', 'bar.md');
const options = {
routeBasePath,
homePageId: 'foo/bar',
};
const data = await processMetadata({
source,
refDir: docsDir,
context,
options,
env,
});
expect(data).toEqual({
id: 'foo/bar',
isDocsHomePage: true,
permalink: '/docs/',
source: path.join('@site', routeBasePath, source),
title: 'Bar',
description: 'This is custom description',
});
});
test('docs with editUrl', async () => {
const editUrl =
'https://github.com/facebook/docusaurus/edit/master/website';
@ -81,6 +133,7 @@ describe('simple site', () => {
expect(data).toEqual({
id: 'foo/baz',
isDocsHomePage: false,
permalink: '/docs/foo/bazSlug.html',
source: path.join('@site', routeBasePath, source),
title: 'baz',
@ -107,6 +160,7 @@ describe('simple site', () => {
expect(data).toEqual({
id: 'lorem',
isDocsHomePage: false,
permalink: '/docs/lorem',
source: path.join('@site', routeBasePath, source),
title: 'lorem',
@ -137,6 +191,7 @@ describe('simple site', () => {
expect(data).toEqual({
id: 'lorem',
isDocsHomePage: false,
permalink: '/docs/lorem',
source: path.join('@site', routeBasePath, source),
title: 'lorem',
@ -166,6 +221,7 @@ describe('simple site', () => {
expect(data).toEqual({
id: 'ipsum',
isDocsHomePage: false,
permalink: '/docs/ipsum',
source: path.join('@site', routeBasePath, source),
title: 'ipsum',
@ -214,6 +270,26 @@ describe('simple site', () => {
`"Document slug cannot include \\"/\\"."`,
);
});
test('docs with slug on doc home', async () => {
const badSiteDir = path.join(fixtureDir, 'bad-slug-on-doc-home-site');
const options = {
routeBasePath,
homePageId: 'docWithSlug',
};
await expect(
processMetadata({
source: 'docWithSlug.md',
refDir: path.join(badSiteDir, 'docs'),
context,
options,
env,
}),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"The docs homepage (homePageId=docWithSlug) is not allowed to have a frontmatter slug=docWithSlug.html => you have to chooser either homePageId or slug, not both"`,
);
});
});
describe('versioned site', () => {
@ -250,6 +326,7 @@ describe('versioned site', () => {
expect(dataA).toEqual({
id: 'foo/bar',
isDocsHomePage: false,
permalink: '/docs/next/foo/barSlug',
source: path.join('@site', routeBasePath, sourceA),
title: 'bar',
@ -258,6 +335,7 @@ describe('versioned site', () => {
});
expect(dataB).toEqual({
id: 'hello',
isDocsHomePage: false,
permalink: '/docs/next/hello',
source: path.join('@site', routeBasePath, sourceB),
title: 'hello',
@ -308,6 +386,7 @@ describe('versioned site', () => {
expect(dataA).toEqual({
id: 'version-1.0.0/foo/bar',
isDocsHomePage: false,
permalink: '/docs/1.0.0/foo/barSlug',
source: path.join('@site', path.relative(siteDir, versionedDir), sourceA),
title: 'bar',
@ -316,6 +395,7 @@ describe('versioned site', () => {
});
expect(dataB).toEqual({
id: 'version-1.0.0/hello',
isDocsHomePage: false,
permalink: '/docs/1.0.0/hello',
source: path.join('@site', path.relative(siteDir, versionedDir), sourceB),
title: 'hello',
@ -324,6 +404,7 @@ describe('versioned site', () => {
});
expect(dataC).toEqual({
id: 'version-1.0.1/foo/bar',
isDocsHomePage: false,
permalink: '/docs/foo/bar',
source: path.join('@site', path.relative(siteDir, versionedDir), sourceC),
title: 'bar',
@ -332,6 +413,7 @@ describe('versioned site', () => {
});
expect(dataD).toEqual({
id: 'version-1.0.1/hello',
isDocsHomePage: false,
permalink: '/docs/hello',
source: path.join('@site', path.relative(siteDir, versionedDir), sourceD),
title: 'hello',

View file

@ -85,7 +85,9 @@ export default function pluginContentDocs(
context: LoadContext,
opts: Partial<PluginOptions>,
): Plugin<LoadedContent | null> {
const options = {...DEFAULT_OPTIONS, ...opts};
const options: PluginOptions = {...DEFAULT_OPTIONS, ...opts};
const homePageDocsRoutePath =
options.routeBasePath === '' ? '/' : options.routeBasePath;
if (options.admonitions) {
options.remarkPlugins = options.remarkPlugins.concat([
@ -112,24 +114,6 @@ export default function pluginContentDocs(
} = versioning;
const versionsNames = versions.map((version) => `version-${version}`);
// Docs home page.
const homePageDocsRoutePath =
options.routeBasePath === '' ? '/' : options.routeBasePath;
const isDocsHomePagePath = (permalink: string) => {
const documentIdMatch = new RegExp(
`^\/(?:${homePageDocsRoutePath}\/)?(?:(?:${versions.join(
'|',
)}|next)\/)?(.*)`,
'i',
).exec(permalink);
if (documentIdMatch) {
return documentIdMatch[1] === options.homePageId;
}
return false;
};
return {
name: 'docusaurus-plugin-content-docs',
@ -307,9 +291,7 @@ Available document ids=
return {
type: 'link',
label: sidebar_label || title,
href: isDocsHomePagePath(permalink)
? permalink.replace(`/${options.homePageId}`, '')
: permalink,
href: permalink,
};
};
@ -376,50 +358,8 @@ Available document ids=
const genRoutes = async (
metadataItems: Metadata[],
): Promise<RouteConfig[]> => {
const versionsRegex = new RegExp(versionsNames.join('|'), 'i');
const routes = await Promise.all(
metadataItems.map(async (metadataItem) => {
const isDocsHomePage =
metadataItem.id.replace(versionsRegex, '').replace(/^\//, '') ===
options.homePageId;
if (isDocsHomePage) {
const versionDocsPathPrefix =
(metadataItem?.version === versioning.latestVersion
? ''
: metadataItem.version!) ?? '';
const docsBaseMetadata = createDocsBaseMetadata(
metadataItem.version!,
);
docsBaseMetadata.isHomePage = true;
docsBaseMetadata.homePagePath = normalizeUrl([
baseUrl,
homePageDocsRoutePath,
versionDocsPathPrefix,
]);
const docsBaseMetadataPath = await createData(
`${docuHash(metadataItem.source)}-base.json`,
JSON.stringify(docsBaseMetadata, null, 2),
);
// Add a route for docs home page.
addRoute({
path: normalizeUrl([
baseUrl,
homePageDocsRoutePath,
versionDocsPathPrefix,
]),
component: docLayoutComponent,
exact: true,
modules: {
docsMetadata: aliasedSource(docsBaseMetadataPath),
content: metadataItem.source,
},
});
}
await createData(
// Note that this created data path must be in sync with
// metadataPath provided to mdx-loader.
@ -438,15 +378,14 @@ Available document ids=
}),
);
return (
routes
// Do not create a route for a document serve as docs home page.
// TODO: need way to do this filtering when generating routes for better perf.
.filter(({path}) => !isDocsHomePagePath(path))
.sort((a, b) => (a.path > b.path ? 1 : b.path > a.path ? -1 : 0))
return routes.sort((a, b) =>
a.path > b.path ? 1 : b.path > a.path ? -1 : 0,
);
};
// This is the base route of the document root (for a doc given version)
// (/docs, /docs/next, /docs/1.0 etc...)
// The component applies the layout and renders the appropriate doc
const addBaseRoute = async (
docsBaseRoute: string,
docsBaseMetadata: DocsBaseMetadata,
@ -454,14 +393,20 @@ Available document ids=
priority?: number,
) => {
const docsBaseMetadataPath = await createData(
`${docuHash(docsBaseRoute)}.json`,
`${docuHash(normalizeUrl([docsBaseRoute, ':route']))}.json`,
JSON.stringify(docsBaseMetadata, null, 2),
);
// Important: the layout component should not end with /,
// as it conflicts with the home doc
// Workaround fix for https://github.com/facebook/docusaurus/issues/2917
const path = docsBaseRoute === '/' ? '' : docsBaseRoute;
addRoute({
path: docsBaseRoute,
component: docLayoutComponent,
routes,
path,
exact: false, // allow matching /docs/* as well
component: docLayoutComponent, // main docs component (DocPage)
routes, // subroute for each doc
modules: {
docsMetadata: aliasedSource(docsBaseMetadataPath),
},
@ -499,21 +444,20 @@ Available document ids=
);
const isLatestVersion = version === versioning.latestVersion;
const docsBasePermalink = normalizeUrl([
const docsBaseRoute = normalizeUrl([
baseUrl,
routeBasePath,
isLatestVersion ? '' : version,
]);
const docsBaseRoute = normalizeUrl([docsBasePermalink, ':route']);
const docsBaseMetadata = createDocsBaseMetadata(version);
// We want latest version route config to be placed last in the
// generated routeconfig. Otherwise, `/docs/next/foo` will match
// `/docs/:route` instead of `/docs/next/:route`.
return addBaseRoute(
docsBaseRoute,
docsBaseMetadata,
routes,
// We want latest version route config to be placed last in the
// generated routeconfig. Otherwise, `/docs/next/foo` will match
// `/docs/:route` instead of `/docs/next/:route`.
isLatestVersion ? -1 : undefined,
);
}),
@ -521,8 +465,7 @@ Available document ids=
} else {
const routes = await genRoutes(Object.values(content.docsMetadata));
const docsBaseMetadata = createDocsBaseMetadata();
const docsBaseRoute = normalizeUrl([baseUrl, routeBasePath, ':route']);
const docsBaseRoute = normalizeUrl([baseUrl, routeBasePath]);
return addBaseRoute(docsBaseRoute, docsBaseMetadata, routes);
}
},

View file

@ -15,7 +15,43 @@ import {
import {LoadContext} from '@docusaurus/types';
import lastUpdate from './lastUpdate';
import {MetadataRaw, LastUpdateData, MetadataOptions, Env} from './types';
import {
MetadataRaw,
LastUpdateData,
MetadataOptions,
Env,
VersioningEnv,
} from './types';
function removeVersionPrefix(str: string, version: string): string {
return str.replace(new RegExp(`^version-${version}/`), '');
}
function inferVersion(
dirName: string,
versioning: VersioningEnv,
): string | undefined {
if (!versioning.enabled) {
return undefined;
}
if (/^version-/.test(dirName)) {
const inferredVersion = dirName
.split('/', 1)
.shift()!
.replace(/^version-/, '');
if (inferredVersion && versioning.versions.includes(inferredVersion)) {
return inferredVersion;
} else {
throw new Error(
`Can't infer version from folder=${dirName}
Expected versions:
- ${versioning.versions.join('- ')}`,
);
}
} else {
return 'next';
}
}
type Args = {
source: string;
@ -59,7 +95,7 @@ export default async function processMetadata({
options,
env,
}: Args): Promise<MetadataRaw> {
const {routeBasePath, editUrl} = options;
const {routeBasePath, editUrl, homePageId} = options;
const {siteDir, baseUrl} = context;
const {versioning} = env;
const filePath = path.join(refDir, source);
@ -67,21 +103,8 @@ export default async function processMetadata({
const fileMarkdownPromise = parseMarkdownFile(filePath);
const lastUpdatedPromise = lastUpdated(filePath, options);
let version;
const dirName = path.dirname(source);
if (versioning.enabled) {
if (/^version-/.test(dirName)) {
const inferredVersion = dirName
.split('/', 1)
.shift()!
.replace(/^version-/, '');
if (inferredVersion && versioning.versions.includes(inferredVersion)) {
version = inferredVersion;
}
} else {
version = 'next';
}
}
const version = inferVersion(dirName, versioning);
// The version portion of the url path. Eg: 'next', '1.0.0', and ''.
const versionPath =
@ -100,14 +123,20 @@ export default async function processMetadata({
if (baseID.includes('/')) {
throw new Error('Document id cannot include "/".');
}
const id = dirName !== '.' ? `${dirName}/${baseID}` : baseID;
const idWithoutVersion = version ? removeVersionPrefix(id, version) : id;
const isDocsHomePage = idWithoutVersion === homePageId;
if (frontMatter.slug && isDocsHomePage) {
throw new Error(
`The docs homepage (homePageId=${homePageId}) is not allowed to have a frontmatter slug=${frontMatter.slug} => you have to chooser either homePageId or slug, not both`,
);
}
const baseSlug: string = frontMatter.slug || baseID;
if (baseSlug.includes('/')) {
throw new Error('Document slug cannot include "/".');
}
// Append subdirectory as part of id/slug.
const id = dirName !== '.' ? `${dirName}/${baseID}` : baseID;
const slug = dirName !== '.' ? `${dirName}/${baseSlug}` : baseSlug;
// Default title is the id.
@ -116,10 +145,16 @@ export default async function processMetadata({
const description: string = frontMatter.description || excerpt;
// The last portion of the url path. Eg: 'foo/bar', 'bar'.
const routePath =
version && version !== 'next'
? slug.replace(new RegExp(`^version-${version}/`), '')
: slug;
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([
baseUrl,
@ -136,6 +171,7 @@ export default async function processMetadata({
// class transitions.
const metadata: MetadataRaw = {
id,
isDocsHomePage,
title,
description,
source: aliasedSitePath(filePath, siteDir),

View file

@ -7,6 +7,7 @@
export interface MetadataOptions {
routeBasePath: string;
homePageId?: string;
editUrl?: string;
showLastUpdateTime?: boolean;
showLastUpdateAuthor?: boolean;
@ -24,7 +25,6 @@ export interface PluginOptions extends MetadataOptions, PathOptions {
remarkPlugins: ([Function, object] | Function)[];
rehypePlugins: string[];
admonitions: any;
homePageId: string;
}
export type SidebarItemDoc = {
@ -111,6 +111,7 @@ export interface LastUpdateData {
export interface MetadataRaw extends LastUpdateData {
id: string;
isDocsHomePage: boolean;
title: string;
description: string;
source: string;
@ -165,8 +166,6 @@ export type DocsBaseMetadata = Pick<
'docsSidebars' | 'permalinkToSidebar'
> & {
version?: string;
isHomePage?: boolean;
homePagePath?: string;
};
export type VersioningEnv = {