diff --git a/.changeset/mean-planes-smoke.md b/.changeset/mean-planes-smoke.md new file mode 100644 index 0000000000..b082023cde --- /dev/null +++ b/.changeset/mean-planes-smoke.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +Internal refactor diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index c94d900b21..a97c52f40f 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -2,6 +2,8 @@ app: experimental: packages: 'all' # ✨ + extensions: + - apis.plugin.graphiql.browse.gitlab: true # scmAuthExtension: >- # createScmAuthExtension({ diff --git a/packages/app-next/src/App.test.tsx b/packages/app-next/src/App.test.tsx index 047c9821b5..b3cef58fe1 100644 --- a/packages/app-next/src/App.test.tsx +++ b/packages/app-next/src/App.test.tsx @@ -14,9 +14,13 @@ * limitations under the License. */ -import React from 'react'; import { renderWithEffects } from '@backstage/test-utils'; +jest.mock('@backstage/plugin-graphiql', () => ({ + ...jest.requireActual('@backstage/plugin-graphiql'), + GraphiQLIcon: () => null, +})); + describe('App', () => { it('should render', async () => { process.env = { @@ -41,8 +45,8 @@ describe('App', () => { ] as any, }; - const { default: App } = await import('./App'); - const rendered = await renderWithEffects(); + const { default: app } = await import('./App'); + const rendered = await renderWithEffects(app); expect(rendered.baseElement).toBeInTheDocument(); }); }); diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index 0452cc3709..aa9bb985c2 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -14,8 +14,6 @@ * limitations under the License. */ -import { graphiqlPlugin as legacyGraphiqlPlugin } from '@backstage/plugin-graphiql'; -import { createApp as createLegacyApp } from '@backstage/app-defaults'; import { createApp } from '@backstage/frontend-app-api'; import { pagesPlugin } from './examples/pagesPlugin'; import graphiqlPlugin from '@backstage/plugin-graphiql/alpha'; @@ -61,9 +59,9 @@ const app = createApp({ // }, }); -const legacyApp = createLegacyApp({ plugins: [legacyGraphiqlPlugin] }); +// const legacyApp = createLegacyApp({ plugins: [legacyGraphiqlPlugin] }); -export default legacyApp.createRoot(app.createRoot()); +export default app.createRoot(); // const routes = ( // diff --git a/packages/app-next/src/index.tsx b/packages/app-next/src/index.tsx index b15bc4c102..3c354b06d0 100644 --- a/packages/app-next/src/index.tsx +++ b/packages/app-next/src/index.tsx @@ -15,8 +15,7 @@ */ import '@backstage/cli/asset-types'; -import React from 'react'; import ReactDOM from 'react-dom'; -import App from './App'; +import app from './App'; -ReactDOM.render(, document.getElementById('root')); +ReactDOM.render(app, document.getElementById('root')); diff --git a/packages/core-app-api/src/app/AppManager.tsx b/packages/core-app-api/src/app/AppManager.tsx index de078c0ffc..0bd495c03b 100644 --- a/packages/core-app-api/src/app/AppManager.tsx +++ b/packages/core-app-api/src/app/AppManager.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { AppConfig, Config } from '@backstage/config'; +import { Config } from '@backstage/config'; import React, { ComponentType, PropsWithChildren, @@ -81,6 +81,7 @@ import { InternalAppContext } from './InternalAppContext'; import { AppRouter, getBasePath } from './AppRouter'; import { AppTranslationProvider } from './AppTranslationProvider'; import { AppTranslationApiImpl } from '../apis/implementations/AppTranslationApi'; +import { overrideBaseUrlConfigs } from './overrideBaseUrlConfigs'; type CompatiblePlugin = | BackstagePlugin @@ -88,17 +89,6 @@ type CompatiblePlugin = output(): Array<{ type: 'feature-flag'; name: string }>; }); -/** - * Creates a base URL that uses to the current document origin. - */ -function createLocalBaseUrl(fullUrl: string): string { - const url = new URL(fullUrl); - url.protocol = document.location.protocol; - url.hostname = document.location.hostname; - url.port = document.location.port; - return url.toString().replace(/\/$/, ''); -} - function useConfigLoader( configLoader: AppConfigLoader | undefined, components: AppComponents, @@ -131,52 +121,9 @@ function useConfigLoader( }; } - let configReader; - /** - * config.value can be undefined or empty. If it's either, don't bother overriding anything. - */ - if (config.value?.length) { - const urlConfigReader = ConfigReader.fromConfigs(config.value); - - /** - * Test configs may not define `app.baseUrl` or `backend.baseUrl` and we - * don't want to enforce here. - */ - const appBaseUrl = urlConfigReader.getOptionalString('app.baseUrl'); - const backendBaseUrl = urlConfigReader.getOptionalString('backend.baseUrl'); - - let configs = config.value; - const relativeResolverConfig: AppConfig = { - data: {}, - context: 'relative-resolver', - }; - if (appBaseUrl && backendBaseUrl) { - const appOrigin = new URL(appBaseUrl).origin; - const backendOrigin = new URL(backendBaseUrl).origin; - - if (appOrigin === backendOrigin) { - const newBackendBaseUrl = createLocalBaseUrl(backendBaseUrl); - if (backendBaseUrl !== newBackendBaseUrl) { - relativeResolverConfig.data.backend = { baseUrl: newBackendBaseUrl }; - } - } - } - if (appBaseUrl) { - const newAppBaseUrl = createLocalBaseUrl(appBaseUrl); - if (appBaseUrl !== newAppBaseUrl) { - relativeResolverConfig.data.app = { baseUrl: newAppBaseUrl }; - } - } - /** - * Only add the relative config if there is actually data to add. - */ - if (Object.keys(relativeResolverConfig.data).length) { - configs = configs.concat([relativeResolverConfig]); - } - configReader = ConfigReader.fromConfigs(configs); - } else { - configReader = ConfigReader.fromConfigs([]); - } + const configReader = ConfigReader.fromConfigs( + config.value?.length ? overrideBaseUrlConfigs(config.value) : [], + ); return { api: configReader }; } diff --git a/packages/core-app-api/src/app/defaultConfigLoader.test.ts b/packages/core-app-api/src/app/defaultConfigLoader.test.ts index aebba92c43..7579e53dd2 100644 --- a/packages/core-app-api/src/app/defaultConfigLoader.test.ts +++ b/packages/core-app-api/src/app/defaultConfigLoader.test.ts @@ -14,40 +14,38 @@ * limitations under the License. */ -import { defaultConfigLoader } from './defaultConfigLoader'; +import { defaultConfigLoaderSync } from './defaultConfigLoader'; (process as any).env = { NODE_ENV: 'test' }; const anyEnv = process.env as any; const anyWindow = window as any; -describe('defaultConfigLoader', () => { +describe('defaultConfigLoaderSync', () => { afterEach(() => { delete anyEnv.APP_CONFIG; delete anyWindow.__APP_CONFIG__; }); - it('loads static config', async () => { + it('loads static config', () => { anyEnv.APP_CONFIG = [ { data: { my: 'config' }, context: 'a' }, { data: { my: 'override-config' }, context: 'b' }, ]; - const configs = await defaultConfigLoader(); + const configs = defaultConfigLoaderSync(); expect(configs).toEqual([ { data: { my: 'config' }, context: 'a' }, { data: { my: 'override-config' }, context: 'b' }, ]); }); - it('loads runtime config', async () => { + it('loads runtime config', () => { anyEnv.APP_CONFIG = [ { data: { my: 'override-config' }, context: 'a' }, { data: { my: 'config' }, context: 'b' }, ]; - const configs = await (defaultConfigLoader as any)( - '{"my":"runtime-config"}', - ); + const configs = (defaultConfigLoaderSync as any)('{"my":"runtime-config"}'); expect(configs).toEqual([ { data: { my: 'override-config' }, context: 'a' }, { data: { my: 'config' }, context: 'b' }, @@ -55,28 +53,28 @@ describe('defaultConfigLoader', () => { ]); }); - it('fails to load invalid missing config', async () => { - await expect(defaultConfigLoader()).rejects.toThrow( + it('fails to load invalid missing config', () => { + expect(() => defaultConfigLoaderSync()).toThrow( 'No static configuration provided', ); }); - it('fails to load invalid static config', async () => { + it('fails to load invalid static config', () => { anyEnv.APP_CONFIG = { my: 'invalid-config' }; - await expect(defaultConfigLoader()).rejects.toThrow( + expect(() => defaultConfigLoaderSync()).toThrow( 'Static configuration has invalid format', ); }); - it('fails to load bad runtime config', async () => { + it('fails to load bad runtime config', () => { anyEnv.APP_CONFIG = [{ data: { my: 'config' }, context: 'a' }]; - await expect((defaultConfigLoader as any)('}')).rejects.toThrow( + expect(() => defaultConfigLoaderSync('}')).toThrow( 'Failed to load runtime configuration, SyntaxError: Unexpected token } in JSON at position 0', ); }); - it('loads config from window.__APP_CONFIG__', async () => { + it('loads config from window.__APP_CONFIG__', () => { anyEnv.APP_CONFIG = [ { data: { my: 'config' }, context: 'a' }, { data: { my: 'override-config' }, context: 'b' }, @@ -84,7 +82,7 @@ describe('defaultConfigLoader', () => { const windowConfig = { app: { configKey: 'config-value' } }; anyWindow.__APP_CONFIG__ = windowConfig; - const configs = await defaultConfigLoader(); + const configs = defaultConfigLoaderSync(); expect(configs).toEqual([ ...anyEnv.APP_CONFIG, diff --git a/packages/core-app-api/src/app/defaultConfigLoader.ts b/packages/core-app-api/src/app/defaultConfigLoader.ts index 00aee2d367..301b64a865 100644 --- a/packages/core-app-api/src/app/defaultConfigLoader.ts +++ b/packages/core-app-api/src/app/defaultConfigLoader.ts @@ -30,12 +30,16 @@ import { AppConfigLoader } from './types'; * * @public */ -export const defaultConfigLoader: AppConfigLoader = async ( +export const defaultConfigLoader: AppConfigLoader = async () => + defaultConfigLoaderSync(); + +/** @internal */ +export function defaultConfigLoaderSync( // This string may be replaced at runtime to provide additional config. // It should be replaced by a JSON-serialized config object. // It's a param so we can test it, but at runtime this will always fall back to default. runtimeConfigJson: string = '__APP_INJECTED_RUNTIME_CONFIG__', -) => { +) { const appConfig = process.env.APP_CONFIG; if (!appConfig) { throw new Error('No static configuration provided'); @@ -70,4 +74,4 @@ export const defaultConfigLoader: AppConfigLoader = async ( }); } return configs; -}; +} diff --git a/packages/core-app-api/src/app/overrideBaseUrlConfigs.ts b/packages/core-app-api/src/app/overrideBaseUrlConfigs.ts new file mode 100644 index 0000000000..5dab6db127 --- /dev/null +++ b/packages/core-app-api/src/app/overrideBaseUrlConfigs.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { AppConfig, ConfigReader } from '@backstage/config'; + +/** + * Creates a base URL that uses to the current document origin. + */ +function createLocalBaseUrl(fullUrl: string): string { + const url = new URL(fullUrl); + url.protocol = document.location.protocol; + url.hostname = document.location.hostname; + url.port = document.location.port; + return url.toString().replace(/\/$/, ''); +} + +/** + * If we are able to override the app and backend base URLs to values that + * match the origin of the current location, then this function returns a + * new array of app configs that contain the overrides. + * + * @internal + */ +export function overrideBaseUrlConfigs(inputConfigs: AppConfig[]): AppConfig[] { + const urlConfigReader = ConfigReader.fromConfigs(inputConfigs); + + // In tests we may not have `app.baseUrl` or `backend.baseUrl`, to keep them optional + const appBaseUrl = urlConfigReader.getOptionalString('app.baseUrl'); + const backendBaseUrl = urlConfigReader.getOptionalString('backend.baseUrl'); + + let configs = inputConfigs; + + let newBackendBaseUrl: string | undefined = undefined; + let newAppBaseUrl: string | undefined = undefined; + + if (appBaseUrl && backendBaseUrl) { + const appOrigin = new URL(appBaseUrl).origin; + const backendOrigin = new URL(backendBaseUrl).origin; + + if (appOrigin === backendOrigin) { + const maybeNewBackendBaseUrl = createLocalBaseUrl(backendBaseUrl); + if (backendBaseUrl !== maybeNewBackendBaseUrl) { + newBackendBaseUrl = maybeNewBackendBaseUrl; + } + } + } + + if (appBaseUrl) { + const maybeNewAppBaseUrl = createLocalBaseUrl(appBaseUrl); + if (appBaseUrl !== maybeNewAppBaseUrl) { + newAppBaseUrl = maybeNewAppBaseUrl; + } + } + + // Only add the relative config if there is actually data to add. + if (newAppBaseUrl || newBackendBaseUrl) { + configs = configs.concat({ + data: { + app: newAppBaseUrl && { + baseUrl: newAppBaseUrl, + }, + backend: newBackendBaseUrl && { + baseUrl: newBackendBaseUrl, + }, + }, + context: 'relative-resolver', + }); + } + + return configs; +} diff --git a/packages/frontend-app-api/api-report.md b/packages/frontend-app-api/api-report.md index bf7db4451b..e16e4d4d97 100644 --- a/packages/frontend-app-api/api-report.md +++ b/packages/frontend-app-api/api-report.md @@ -6,9 +6,13 @@ /// import { BackstagePlugin } from '@backstage/frontend-plugin-api'; +import { ConfigApi } from '@backstage/core-plugin-api'; // @public (undocumented) -export function createApp(options: { plugins: BackstagePlugin[] }): { +export function createApp(options: { + plugins: BackstagePlugin[]; + config?: ConfigApi; +}): { createRoot(): JSX.Element; }; ``` diff --git a/packages/frontend-app-api/package.json b/packages/frontend-app-api/package.json index 451a1c0c5a..463a10fcae 100644 --- a/packages/frontend-app-api/package.json +++ b/packages/frontend-app-api/package.json @@ -34,10 +34,13 @@ ], "dependencies": { "@backstage/config": "workspace:^", + "@backstage/core-app-api": "workspace:^", + "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", "@backstage/frontend-plugin-api": "workspace:^", "@backstage/plugin-graphiql": "workspace:^", "@backstage/types": "workspace:^", + "@material-ui/core": "^4.12.4", "lodash": "^4.17.21" }, "peerDependencies": { diff --git a/packages/frontend-app-api/src/createApp.tsx b/packages/frontend-app-api/src/createApp.tsx index c50b507642..4882191206 100644 --- a/packages/frontend-app-api/src/createApp.tsx +++ b/packages/frontend-app-api/src/createApp.tsx @@ -20,7 +20,10 @@ import { BackstagePlugin, coreExtensionData, } from '@backstage/frontend-plugin-api'; -import { CoreRouter } from './extensions/CoreRouter'; +import { Core } from './extensions/Core'; +import { CoreRoutes } from './extensions/CoreRoutes'; +import { CoreLayout } from './extensions/CoreLayout'; +import { CoreNav } from './extensions/CoreNav'; import { createExtensionInstance, ExtensionInstance, @@ -31,22 +34,66 @@ import { readAppExtensionParameters, } from './wiring/parameters'; import { RoutingProvider } from './routing/RoutingContext'; -import { RouteRef } from '@backstage/core-plugin-api'; +import { + AnyApiFactory, + ApiHolder, + AppComponents, + AppContext, + appThemeApiRef, + ConfigApi, + configApiRef, + IconComponent, + RouteRef, + BackstagePlugin as LegacyBackstagePlugin, + featureFlagsApiRef, +} from '@backstage/core-plugin-api'; import { getAvailablePlugins } from './wiring/discovery'; +import { + ApiFactoryRegistry, + ApiProvider, + ApiResolver, + AppThemeSelector, +} from '@backstage/core-app-api'; + +// TODO: Get rid of all of these +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { AppThemeProvider } from '../../core-app-api/src/app/AppThemeProvider'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { AppContextProvider } from '../../core-app-api/src/app/AppContext'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { LocalStorageFeatureFlags } from '../../core-app-api/src/apis/implementations/FeatureFlagsApi/LocalStorageFeatureFlags'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { defaultConfigLoaderSync } from '../../core-app-api/src/app/defaultConfigLoader'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { overrideBaseUrlConfigs } from '../../core-app-api/src/app/overrideBaseUrlConfigs'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { + apis as defaultApis, + components as defaultComponents, + icons as defaultIcons, + themes as defaultThemes, +} from '../../app-defaults/src/defaults'; +import { BrowserRouter } from 'react-router-dom'; /** @public */ -export function createApp(options: { plugins: BackstagePlugin[] }): { +export function createApp(options: { + plugins: BackstagePlugin[]; + config?: ConfigApi; +}): { createRoot(): JSX.Element; } { - const appConfig = ConfigReader.fromConfigs(process.env.APP_CONFIG as any); + const appConfig = + options?.config ?? + ConfigReader.fromConfigs(overrideBaseUrlConfigs(defaultConfigLoaderSync())); - const builtinExtensions = [CoreRouter]; + const builtinExtensions = [Core, CoreRoutes, CoreNav, CoreLayout]; const discoveredPlugins = getAvailablePlugins(); + const allPlugins = [...discoveredPlugins, ...options.plugins]; // pull in default extension instance from discovered packages // apply config to adjust default extension instances and add more const extensionParams = mergeExtensionParameters({ - sources: [...options.plugins, ...discoveredPlugins], + sources: allPlugins, builtinExtensions, parameters: readAppExtensionParameters(appConfig), }); @@ -115,25 +162,144 @@ export function createApp(options: { plugins: BackstagePlugin[] }): { const routePaths = extractRouteInfoFromInstanceTree(rootInstances); + const coreInstance = rootInstances.find(({ id }) => id === 'core'); + if (!coreInstance) { + throw Error('Unable to find core extension instance'); + } + + const apiHolder = createApiHolder(coreInstance, appConfig); + + const appContext = createLegacyAppContext(allPlugins); + return { createRoot() { - const rootComponents = rootInstances.map( - e => - e.data.get( - coreExtensionData.reactComponent.id, - ) as typeof coreExtensionData.reactComponent.T, - ); + const rootComponents = rootInstances + .map( + e => + e.data.get( + coreExtensionData.reactComponent.id, + ) as typeof coreExtensionData.reactComponent.T, + ) + .filter(Boolean); return ( - - {rootComponents.map((Component, i) => ( - - ))} - + + + + + {/* TODO: set base path using the logic from AppRouter */} + + {rootComponents.map((Component, i) => ( + + ))} + + + + + ); }, }; } +function toLegacyPlugin(plugin: BackstagePlugin): LegacyBackstagePlugin { + const errorMsg = 'Not implemented in legacy plugin compatibility layer'; + const notImplemented = () => { + throw new Error(errorMsg); + }; + return { + getId(): string { + return plugin.id; + }, + get routes(): never { + throw new Error(errorMsg); + }, + get externalRoutes(): never { + throw new Error(errorMsg); + }, + getApis: notImplemented, + getFeatureFlags: notImplemented, + provide: notImplemented, + __experimentalReconfigure: notImplemented, + }; +} + +function createLegacyAppContext(plugins: BackstagePlugin[]): AppContext { + return { + getPlugins(): LegacyBackstagePlugin[] { + return plugins.map(toLegacyPlugin); + }, + + getSystemIcon(key: string): IconComponent | undefined { + return key in defaultIcons + ? defaultIcons[key as keyof typeof defaultIcons] + : undefined; + }, + + getSystemIcons(): Record { + return defaultIcons; + }, + + getComponents(): AppComponents { + return defaultComponents; + }, + }; +} + +function createApiHolder( + coreExtension: ExtensionInstance, + configApi: ConfigApi, +): ApiHolder { + const factoryRegistry = new ApiFactoryRegistry(); + + const apiFactories = + coreExtension.attachments + .get('apis') + ?.map( + e => + e.data.get( + coreExtensionData.apiFactory.id, + ) as typeof coreExtensionData.apiFactory.T, + ) + .filter(Boolean) ?? []; + + for (const factory of apiFactories) { + factoryRegistry.register('default', factory); + } + + // TODO: properly discovery feature flags, maybe rework the whole thing + factoryRegistry.register('default', { + api: featureFlagsApiRef, + deps: {}, + factory: () => new LocalStorageFeatureFlags(), + }); + + factoryRegistry.register('static', { + api: appThemeApiRef, + deps: {}, + // TODO: add extension for registering themes + factory: () => AppThemeSelector.createWithStorage(defaultThemes), + }); + + factoryRegistry.register('static', { + api: configApiRef, + deps: {}, + factory: () => configApi, + }); + + // TODO: ship these as default extensions instead + for (const factory of defaultApis as AnyApiFactory[]) { + if (!factoryRegistry.register('app', factory)) { + throw new Error( + `Duplicate or forbidden API factory for ${factory.api} in app`, + ); + } + } + + ApiResolver.validateFactories(factoryRegistry, factoryRegistry.getAllApis()); + + return new ApiResolver(factoryRegistry); +} + /** @internal */ export function extractRouteInfoFromInstanceTree( roots: ExtensionInstance[], diff --git a/packages/frontend-app-api/src/extensions/Core.tsx b/packages/frontend-app-api/src/extensions/Core.tsx new file mode 100644 index 0000000000..cb93b2888f --- /dev/null +++ b/packages/frontend-app-api/src/extensions/Core.tsx @@ -0,0 +1,34 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { + coreExtensionData, + createExtension, +} from '@backstage/frontend-plugin-api'; + +export const Core = createExtension({ + id: 'core', + at: 'root', + inputs: { + apis: { + extensionData: { + api: coreExtensionData.apiFactory, + }, + }, + }, + output: {}, + factory() {}, +}); diff --git a/packages/frontend-app-api/src/extensions/CoreLayout.tsx b/packages/frontend-app-api/src/extensions/CoreLayout.tsx new file mode 100644 index 0000000000..845bc8cf5b --- /dev/null +++ b/packages/frontend-app-api/src/extensions/CoreLayout.tsx @@ -0,0 +1,68 @@ +/* + * Copyright 2023 The Backstage Authors + * + * 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 { + createExtension, + coreExtensionData, +} from '@backstage/frontend-plugin-api'; +import { SidebarPage } from '@backstage/core-components'; + +export const CoreLayout = createExtension({ + id: 'core.layout', + at: 'root', + inputs: { + nav: { + extensionData: { + component: coreExtensionData.reactComponent, + }, + }, + content: { + extensionData: { + component: coreExtensionData.reactComponent, + }, + }, + }, + output: { + component: coreExtensionData.reactComponent, + }, + factory({ bind, inputs }) { + // TODO: Support this as part of the core system + if (inputs.nav.length !== 1) { + throw Error( + `Extension 'core.layout' did not receive exactly one 'nav' input, got ${inputs.nav.length}`, + ); + } + const Nav = inputs.nav[0].component; + + if (inputs.content.length !== 1) { + throw Error( + `Extension 'core.layout' did not receive exactly one 'content' input, got ${inputs.content.length}`, + ); + } + const Content = inputs.content[0].component; + + bind({ + // TODO: set base path using the logic from AppRouter + component: () => ( + +