feat(explore): customizable explore page

Signed-off-by: Andrew Thauer <athauer@wealthsimple.com>
This commit is contained in:
Andrew Thauer
2021-06-16 19:46:25 -04:00
parent 3ce6f80632
commit 5c4e6aee25
21 changed files with 615 additions and 81 deletions
+54
View File
@@ -0,0 +1,54 @@
---
'@backstage/plugin-explore': patch
---
Refactors the explore plugin to be more customizable. This includes the following non-breaking changes:
- Introduce new `ExploreLayout` page which can be used to create a custom `ExplorePage`
- Refactor `ExplorePage` to use a new `ExploreLayout` component
- Exports existing `DomainExplorerContent`, `GroupsExplorerContent`, & `ToolExplorerContent` components
- Allows `title` props to be customized
Create a custom explore page in `packages/app/src/components/explore/ExplorePage.tsx`.
```tsx
import {
DomainExplorerContent,
ExploreLayout,
} from '@backstage/plugin-explore';
import React from 'react';
import { InnserSourceExplorerContent } from './InnserSourceExplorerContent';
export const ExplorePage = () => {
return (
<ExploreLayout
title="Explore the ACME corp ecosystem"
subtitle="Browse our ecosystem"
>
<ExploreLayout.Route path="domains" title="Domains">
<DomainExplorerContent />
</ExploreLayout.Route>
<ExploreLayout.Route path="inner-source" title="InnerSource">
<AcmeInnserSourceExplorerContent />
</ExploreLayout.Route>
</ExploreLayout>
);
};
export const explorePage = <ExplorePage />;
```
Now register the new explore page in `packages/app/src/App.tsx`.
```diff
+ import { explorePage } from './components/explore/ExplorePage';
const routes = (
<FlatRoutes>
- <Route path="/explore" element={<ExplorePage />} />
+ <Route path="/explore" element={<ExplorePage />}>
+ {explorePage}
+ </Route>
</FlatRoutes>
);
```
+46
View File
@@ -33,3 +33,49 @@ import LayersIcon from '@material-ui/icons/Layers';
<SidebarItem icon={LayersIcon} to="explore" text="Explore" />
```
## Customization
Create a custom explore page in `packages/app/src/components/explore/ExplorePage.tsx`.
```tsx
import {
DomainExplorerContent,
ExploreLayout,
} from '@backstage/plugin-explore';
import React from 'react';
import { InnserSourceExplorerContent } from './InnserSourceExplorerContent';
export const ExplorePage = () => {
return (
<ExploreLayout
title="Explore the ACME corp ecosystem"
subtitle="Browse our ecosystem"
>
<ExploreLayout.Route path="domains" title="Domains">
<DomainExplorerContent />
</ExploreLayout.Route>
<ExploreLayout.Route path="inner-source" title="InnerSource">
<AcmeInnserSourceExplorerContent />
</ExploreLayout.Route>
</ExploreLayout>
);
};
export const explorePage = <ExplorePage />;
```
Now register the new explore page in `packages/app/src/App.tsx`.
```diff
+ import { explorePage } from './components/explore/ExplorePage';
const routes = (
<FlatRoutes>
- <Route path="/explore" element={<ExplorePage />} />
+ <Route path="/explore" element={<ExplorePage />}>
+ {explorePage}
+ </Route>
</FlatRoutes>
);
```
+2
View File
@@ -38,9 +38,11 @@
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/react": "^16.9",
"classnames": "^2.2.6",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4"
},
@@ -0,0 +1,63 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ApiProvider, ApiRegistry } from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { renderInTestApp } from '@backstage/test-utils';
import { waitFor, getByText } from '@testing-library/react';
import React from 'react';
import { DefaultExplorePage } from './DefaultExplorePage';
describe('<DefaultExplorePage />', () => {
const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
addLocation: jest.fn(_a => new Promise(() => {})),
getEntities: jest.fn(),
getOriginLocationByEntity: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
removeLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
getEntityByName: jest.fn(),
};
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider apis={ApiRegistry.with(catalogApiRef, catalogApi)}>
{children}
</ApiProvider>
);
beforeEach(() => {
jest.resetAllMocks();
});
it('renders the default explore page', async () => {
catalogApi.getEntities.mockResolvedValue({ items: [] });
const { getAllByRole } = await renderInTestApp(
<Wrapper>
<DefaultExplorePage />
</Wrapper>,
);
await waitFor(() => {
const elements = getAllByRole('tab');
expect(elements.length).toBe(3);
expect(getByText(elements[0], 'Domains')).toBeInTheDocument();
expect(getByText(elements[1], 'Groups')).toBeInTheDocument();
expect(getByText(elements[2], 'Tools')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,45 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { configApiRef, useApi } from '@backstage/core';
import { DomainExplorerContent } from '../DomainExplorerContent';
import { ExploreLayout } from '../ExploreLayout';
import { GroupsExplorerContent } from '../GroupsExplorerContent';
import { ToolExplorerContent } from '../ToolExplorerContent';
export const DefaultExplorePage = () => {
const configApi = useApi(configApiRef);
const organizationName =
configApi.getOptionalString('organization.name') ?? 'Backstage';
return (
<ExploreLayout
title={`Explore the ${organizationName} ecosystem`}
subtitle="Discover solutions available in your ecosystem"
>
<ExploreLayout.Route path="domains" title="Domains">
<DomainExplorerContent />
</ExploreLayout.Route>
<ExploreLayout.Route path="groups" title="Groups">
<GroupsExplorerContent />
</ExploreLayout.Route>
<ExploreLayout.Route path="tools" title="Tools">
<ToolExplorerContent />
</ExploreLayout.Route>
</ExploreLayout>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { DefaultExplorePage } from './DefaultExplorePage';
@@ -41,6 +41,12 @@ describe('<DomainExplorerContent />', () => {
</ApiProvider>
);
const mountedRoutes = {
mountedRoutes: {
'/catalog/:namespace/:kind/:name': catalogEntityRouteRef,
},
};
beforeEach(() => {
jest.resetAllMocks();
});
@@ -74,11 +80,7 @@ describe('<DomainExplorerContent />', () => {
<Wrapper>
<DomainExplorerContent />
</Wrapper>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': catalogEntityRouteRef,
},
},
mountedRoutes,
);
await waitFor(() => {
@@ -87,6 +89,19 @@ describe('<DomainExplorerContent />', () => {
});
});
it('renders a custom title', async () => {
catalogApi.getEntities.mockResolvedValue({ items: [] });
const { getByText } = await renderInTestApp(
<Wrapper>
<DomainExplorerContent title="Our Areas" />
</Wrapper>,
mountedRoutes,
);
await waitFor(() => expect(getByText('Our Areas')).toBeInTheDocument());
});
it('renders empty state', async () => {
catalogApi.getEntities.mockResolvedValue({ items: [] });
@@ -94,11 +109,7 @@ describe('<DomainExplorerContent />', () => {
<Wrapper>
<DomainExplorerContent />
</Wrapper>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': catalogEntityRouteRef,
},
},
mountedRoutes,
);
await waitFor(() =>
@@ -114,11 +125,7 @@ describe('<DomainExplorerContent />', () => {
<Wrapper>
<DomainExplorerContent />
</Wrapper>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': catalogEntityRouteRef,
},
},
mountedRoutes,
);
await waitFor(() =>
@@ -79,10 +79,16 @@ const Body = () => {
);
};
export const DomainExplorerContent = () => {
type DomainExplorerContentProps = {
title?: string;
};
export const DomainExplorerContent = ({
title,
}: DomainExplorerContentProps) => {
return (
<Content noPadding>
<ContentHeader title="Domains">
<ContentHeader title={title ?? 'Domains'}>
<SupportButton>Discover the domains in your ecosystem.</SupportButton>
</ContentHeader>
<Body />
@@ -0,0 +1,81 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { renderInTestApp } from '@backstage/test-utils';
import { waitFor } from '@testing-library/react';
import React from 'react';
import { ExploreLayout } from './ExploreLayout';
describe('<ExploreLayout />', () => {
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<>{children}</>
);
beforeEach(() => {
jest.resetAllMocks();
});
it('renders an explore tabbed layout page with defaults', async () => {
const { getByText } = await renderInTestApp(
<Wrapper>
<ExploreLayout>
<ExploreLayout.Route path="/tools" title="Tools">
<div>Tools Content</div>
</ExploreLayout.Route>
</ExploreLayout>
</Wrapper>,
);
await waitFor(() => {
expect(getByText('Explore our ecosystem')).toBeInTheDocument();
expect(
getByText('Discover solutions available in our ecosystem'),
).toBeInTheDocument();
});
});
it('renders a custom page title', async () => {
const { getByText } = await renderInTestApp(
<Wrapper>
<ExploreLayout title="Explore our universe">
<ExploreLayout.Route path="/tools" title="Tools">
<div>Tools Content</div>
</ExploreLayout.Route>
</ExploreLayout>
</Wrapper>,
);
await waitFor(() =>
expect(getByText('Explore our universe')).toBeInTheDocument(),
);
});
it('renders a custom page subtitle', async () => {
const { getByText } = await renderInTestApp(
<Wrapper>
<ExploreLayout subtitle="Browse the ACME Corp ecosystem">
<ExploreLayout.Route path="/tools" title="Tools">
<div>Tools Content</div>
</ExploreLayout.Route>
</ExploreLayout>
</Wrapper>,
);
await waitFor(() =>
expect(getByText('Browse the ACME Corp ecosystem')).toBeInTheDocument(),
);
});
});
@@ -0,0 +1,102 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { attachComponentData, Header, Page, RoutedTabs } from '@backstage/core';
import { TabProps } from '@material-ui/core';
import { Children, default as React, Fragment, isValidElement } from 'react';
// TODO: This layout could be a shared based component if it was possible to create custom TabbedLayouts
// A generalized version of createSubRoutesFromChildren, etc. would be required
type SubRoute = {
path: string;
title: string;
children: JSX.Element;
tabProps?: TabProps<React.ElementType, { component?: React.ElementType }>;
};
const Route: (props: SubRoute) => null = () => null;
// This causes all mount points that are discovered within this route to use the path of the route itself
attachComponentData(Route, 'core.gatherMountPoints', true);
function createSubRoutesFromChildren(
childrenProps: React.ReactNode,
): SubRoute[] {
// Directly comparing child.type with Route will not work with in
// combination with react-hot-loader in storybook
// https://github.com/gaearon/react-hot-loader/issues/304
const routeType = (
<Route path="" title="">
<div />
</Route>
).type;
return Children.toArray(childrenProps).flatMap(child => {
if (!isValidElement(child)) {
return [];
}
if (child.type === Fragment) {
return createSubRoutesFromChildren(child.props.children);
}
if (child.type !== routeType) {
throw new Error('Child of ExploreLayout must be an ExploreLayout.Route');
}
const { path, title, children, tabProps } = child.props;
return [{ path, title, children, tabProps }];
});
}
type ExploreLayoutProps = {
title?: string;
subtitle?: string;
children?: React.ReactNode;
};
/**
* Explore is a compound component, which allows you to define a custom layout
*
* @example
* ```jsx
* <ExploreLayout title="Explore ACME's ecosystem">
* <ExploreLayout.Route path="/example" title="Example tab">
* <div>This is rendered under /example/anything-here route</div>
* </ExploreLayout.Route>
* </ExploreLayout>
* ```
*/
export const ExploreLayout = ({
title,
subtitle,
children,
}: ExploreLayoutProps) => {
const routes = createSubRoutesFromChildren(children);
return (
<Page themeId="home">
<Header
title={title ?? 'Explore our ecosystem'}
subtitle={subtitle ?? 'Discover solutions available in our ecosystem'}
/>
<RoutedTabs routes={routes} />
</Page>
);
};
ExploreLayout.Route = Route;
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ExploreLayout } from './ExploreLayout';
@@ -0,0 +1,48 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { useOutlet } from 'react-router';
import { ExplorePage } from './ExplorePage';
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
useLocation: jest.fn().mockReturnValue({
search: '',
}),
useOutlet: jest.fn().mockReturnValue('Route Children'),
}));
jest.mock('../DefaultExplorePage', () => ({
...jest.requireActual('../DefaultExplorePage'),
DefaultExplorePage: jest.fn().mockReturnValue('DefaultExplorePageMock'),
}));
describe('ExplorePage', () => {
it('renders provided router element', async () => {
const { getByText } = await renderInTestApp(<ExplorePage />);
expect(getByText('Route Children')).toBeInTheDocument();
});
it('renders default explorer page when no router children are provided', async () => {
(useOutlet as jest.Mock).mockReturnValueOnce(null);
const { getByText } = await renderInTestApp(<ExplorePage />);
expect(getByText('DefaultExplorePageMock')).toBeInTheDocument();
});
});
@@ -13,22 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { configApiRef, Header, Page, useApi } from '@backstage/core';
import React from 'react';
import { ExploreTabs } from './ExploreTabs';
import { useOutlet } from 'react-router';
import { DefaultExplorePage } from '../DefaultExplorePage';
export const ExplorePage = () => {
const configApi = useApi(configApiRef);
const organizationName =
configApi.getOptionalString('organization.name') ?? 'Backstage';
return (
<Page themeId="home">
<Header
title={`Explore the ${organizationName} ecosystem`}
subtitle="Discover solutions available in your ecosystem"
/>
const outlet = useOutlet();
<ExploreTabs />
</Page>
);
return <>{outlet || <DefaultExplorePage />}</>;
};
@@ -1,34 +0,0 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TabbedLayout } from '@backstage/core';
import React from 'react';
import { DomainExplorerContent } from '../DomainExplorerContent';
import { GroupsExplorerContent } from '../GroupsExplorerContent';
import { ToolExplorerContent } from '../ToolExplorerContent';
export const ExploreTabs = () => (
<TabbedLayout>
<TabbedLayout.Route path="domains" title="Domains">
<DomainExplorerContent />
</TabbedLayout.Route>
<TabbedLayout.Route path="groups" title="Groups">
<GroupsExplorerContent />
</TabbedLayout.Route>
<TabbedLayout.Route path="tools" title="Tools">
<ToolExplorerContent />
</TabbedLayout.Route>
</TabbedLayout>
);
@@ -40,6 +40,12 @@ describe('<GroupsExplorerContent />', () => {
</ApiProvider>
);
const mountedRoutes = {
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
};
beforeEach(() => {
jest.resetAllMocks();
@@ -69,11 +75,7 @@ describe('<GroupsExplorerContent />', () => {
<Wrapper>
<GroupsExplorerContent />
</Wrapper>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
},
mountedRoutes,
);
await waitFor(() => {
@@ -81,6 +83,19 @@ describe('<GroupsExplorerContent />', () => {
});
});
it('renders a custom title', async () => {
catalogApi.getEntities.mockResolvedValue({ items: [] });
const { getByText } = await renderInTestApp(
<Wrapper>
<GroupsExplorerContent title="Our Teams" />
</Wrapper>,
mountedRoutes,
);
await waitFor(() => expect(getByText('Our Teams')).toBeInTheDocument());
});
it('renders a friendly error if it cannot collect domains', async () => {
const catalogError = new Error('Network timeout');
catalogApi.getEntities.mockRejectedValueOnce(catalogError);
@@ -89,11 +104,7 @@ describe('<GroupsExplorerContent />', () => {
<Wrapper>
<GroupsExplorerContent />
</Wrapper>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
},
mountedRoutes,
);
await waitFor(() =>
@@ -13,14 +13,21 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Content, ContentHeader, SupportButton } from '@backstage/core';
import React from 'react';
import { GroupsDiagram } from './GroupsDiagram';
export const GroupsExplorerContent = () => {
type GroupsExplorerContentProps = {
title?: string;
};
export const GroupsExplorerContent = ({
title,
}: GroupsExplorerContentProps) => {
return (
<Content noPadding>
<ContentHeader title="Groups">
<ContentHeader title={title ?? 'Groups'}>
<SupportButton>Explore your groups.</SupportButton>
</ContentHeader>
@@ -80,6 +80,18 @@ describe('<ToolExplorerContent />', () => {
});
});
it('renders a custom title', async () => {
exploreToolsConfigApi.getTools.mockResolvedValue([]);
const { getByText } = await renderInTestApp(
<Wrapper>
<ToolExplorerContent title="Our Tools" />
</Wrapper>,
);
await waitFor(() => expect(getByText('Our Tools')).toBeInTheDocument());
});
it('renders empty state', async () => {
exploreToolsConfigApi.getTools.mockResolvedValue([]);
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Content,
ContentHeader,
@@ -61,9 +62,13 @@ const Body = () => {
);
};
export const ToolExplorerContent = () => (
type ToolExplorerContentProps = {
title?: string;
};
export const ToolExplorerContent = ({ title }: ToolExplorerContentProps) => (
<Content noPadding>
<ContentHeader title="Tools">
<ContentHeader title={title ?? 'Tools'}>
<SupportButton>Discover the tools in your ecosystem.</SupportButton>
</ContentHeader>
<Body />
+17
View File
@@ -0,0 +1,17 @@
/*
* Copyright 2021 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ExploreLayout } from './ExploreLayout';
+37 -1
View File
@@ -14,7 +14,10 @@
* limitations under the License.
*/
import { createRoutableExtension } from '@backstage/core';
import {
createComponentExtension,
createRoutableExtension,
} from '@backstage/core';
import { explorePlugin } from './plugin';
import { exploreRouteRef } from './routes';
@@ -25,3 +28,36 @@ export const ExplorePage = explorePlugin.provide(
mountPoint: exploreRouteRef,
}),
);
export const DomainExplorerContent = explorePlugin.provide(
createComponentExtension({
component: {
lazy: () =>
import('./components/DomainExplorerContent').then(
m => m.DomainExplorerContent,
),
},
}),
);
export const GroupsExplorerContent = explorePlugin.provide(
createComponentExtension({
component: {
lazy: () =>
import('./components/GroupsExplorerContent').then(
m => m.GroupsExplorerContent,
),
},
}),
);
export const ToolExplorerContent = explorePlugin.provide(
createComponentExtension({
component: {
lazy: () =>
import('./components/ToolExplorerContent').then(
m => m.ToolExplorerContent,
),
},
}),
);
+2 -1
View File
@@ -14,6 +14,7 @@
* limitations under the License.
*/
export { ExploreLayout } from './components';
export * from './extensions';
export { explorePlugin } from './plugin';
export { explorePlugin, explorePlugin as plugin } from './plugin';
export * from './routes';