Merge pull request #29953 from backstage/rugvip/info

frontend-{app,plugin}-api: add support for plugin info and manifests
This commit is contained in:
Patrik Oldsberg
2025-05-27 13:59:58 +02:00
committed by GitHub
48 changed files with 1445 additions and 12 deletions
+21
View File
@@ -0,0 +1,21 @@
---
'@backstage/plugin-catalog-unprocessed-entities': patch
'@backstage/plugin-app-visualizer': patch
'@backstage/plugin-catalog-import': patch
'@backstage/plugin-catalog-graph': patch
'@backstage/plugin-notifications': patch
'@backstage/plugin-user-settings': patch
'@backstage/plugin-kubernetes': patch
'@backstage/plugin-scaffolder': patch
'@backstage/plugin-api-docs': patch
'@backstage/plugin-devtools': patch
'@backstage/plugin-techdocs': patch
'@backstage/plugin-catalog': patch
'@backstage/plugin-signals': patch
'@backstage/plugin-search': patch
'@backstage/plugin-home': patch
'@backstage/plugin-app': patch
'@backstage/plugin-org': patch
---
Added the `info.packageJson` option to the plugin instance for the new frontend system.
+29
View File
@@ -0,0 +1,29 @@
---
'@backstage/frontend-plugin-api': patch
---
Added a new optional `info` option to `createFrontendPlugin` that lets you provide a loaders for different sources of metadata information about the plugin.
There are two available loaders. The first one is `info.packageJson`, which can be used to point to a `package.json` file for the plugin. This is recommended for any plugin that is defined within its own package, especially all plugins that are published to a package registry. Typical usage looks like this:
```ts
export default createFrontendPlugin({
pluginId: '...',
info: {
packageJson: () => import('../package.json'),
},
});
```
The second loader is `info.manifest`, which can be used to point to an opaque plugin manifest. This **MUST ONLY** be used by plugins that are intended for use within a single organization. Plugins that are published to an open package registry should **NOT** use this loader. The loader is useful for adding additional internal metadata associated with the plugin, and it is up to the Backstage app to decide how these manifests are parsed and used. The default manifest parser in an app created with `createApp` from `@backstage/frontend-defaults` is able to parse the default `catalog-info.yaml` format and built-in fields such as `spec.owner`.
Typical usage looks like this:
```ts
export default createFrontendPlugin({
pluginId: '...',
info: {
manifest: () => import('../catalog-info.yaml'),
},
});
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': patch
---
Added a new `useAppNode` hook, which can be used to get a reference to the `AppNode` from by the closest `ExtensionBoundary`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-defaults': patch
---
Forwarded the new `pluginInfoResolver` option for `createApp`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-app-api': patch
---
Implemented support for the `plugin.info()` method in specialized apps with a default resolved for `package.json` and `catalog-info.yaml`. The default resolution logic can be overridden via the `pluginInfoResolver` option to `createSpecializedApp`, and plugin-specific overrides can be applied via the new `app.pluginOverrides` key in static configuration.
@@ -77,3 +77,98 @@ app:
```
Note that you do not need to manually exclude packages that you also import explicitly in code, since plugin instances are deduplicated by the app. You will never end up with duplicate plugin installations except if they are in fact two different plugin instances with different IDs.
## Plugin Info Resolution
When a plugin is installed in an app it may provide sources of information about the plugin that can be useful to end users and admins. This includes things like what version of a plugin is running, what team owns the plugin, and who to contact for support. You can read more about how the plugins provide this information in the [plugins `info` option section](./15-plugins.md#info).
By default the app will pick a few common fields from `package.json` files, and assume that the opaque manifests are `catalog-info.yaml` files that some information can be gathered from too. This information will then be available via the `info()` method on plugin instances, returning a structure of the `FrontendPluginInfo` type.
### Extending Plugin Info
The default plugin info is intended as a base to build upon. As part of setting up an app you can both customize the way that the plugin info is resolved, as well as extend the `FrontendPluginInfo` type to include more information.
In order to extend the `FrontendPluginInfo` type you use [TypeScript module augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation). This makes it possible to extend the `FrontendPluginInfo` interface with additional fields, which you can then add custom resolution logic for as well as access within the app. For example, you might add a `slackChannel` field as follows:
```ts
declare module '@backstage/frontend-plugin-api' {
interface FrontendPluginInfo {
/**
* The slack channel to use for support requests for this plugin.
*/
slackChannel?: string;
}
}
```
### Customizing Plugin Info Resolution
With the new `slackChannel` field in place, we now need to provide a custom resolver that knows how to extract this information from the plugin information sources. This is done by passing a custom `pluginInfoResolver` to `createApp`, which in our example is declared like this:
```ts title="pluginInfoResolver.ts"
import { createPluginInfoResolver } from '@backstage/frontend-plugin-api';
// It is recommended to keep the above module augmentation in this file too
export const pluginInfoResolver: FrontendPluginInfoResolver = async ctx => {
// In our particular example app we assume that all plugin manifests are catalog-info.yaml files
const manifest = (await ctx.manifest?.()) as Entity | undefined;
// Call the default resolver to populate common fields
const { info } = await ctx.defaultResolver({
packageJson: await ctx.packageJson(),
manifest: manifest,
});
// In this example the catalog model has been extended with a metadata.slackChannel field
const slackChannel = manifest?.metadata?.slackChannel?.toString();
if (slackChannel) {
info.slackChannel = slackChannel;
info.links = [
...(info.links ?? []),
{
title: 'Slack Channel',
url: `https://our-workspace.enterprise.slack.com/archives/${slackChannel}`,
},
];
}
return { info };
};
```
And included in the app as follows:
```ts title="App.tsx"
import { pluginInfoResolver } from './pluginInfoResolver';
const app = createApp({
pluginInfoResolver,
// ... other options
});
```
### Overriding Plugin Info
Another way to customize the plugin info is to use the `app.pluginOverrides` static configuration key. These overrides are applied after the plugin info has been resolved as a final step before making it available to users. These overrides are particularly useful to override information in third-party plugins. For example, if your organization has an individual team that is responsible for the maintenance of the Software Catalog, you might configure the following override:
```yaml
app:
pluginOverrides:
- match:
pluginId: catalog
info:
ownerEntityRefs: [catalog-owners]
```
You can match on both the `pluginId` and/or `packageName` of the plugin, although the `packageName` will only be supported if the plugin provides an loader for the `package.json` file. Using `/<pattern>/` you are also able to use a regex pattern for this matching. For example, if you wanted to override the owner for all plugins from the `@acme` namespace, you could do the following:
```yaml
app:
pluginOverrides:
- match:
packageName: /@acme/.*/
info:
ownerEntityRefs: [acme-owners]
```
@@ -53,6 +53,35 @@ These are the routes that the plugin exposes to the app. The `routes` option dec
This is a list of feature flag declarations that your plugin provides to the app. This makes sure that the feature flags are correctly registered and can be toggled in the app. To read a feature flag you can use the feature flags [Utility API](../architecture/33-utility-apis.md), accessible via `featureFlagsApiRef`.
### `info` option
This options is used to provide loaders for different sources of information about the plugin that may be useful to users and admins. The two available loaders are `packageJson` and `manifest`, and a plugin can use either or both as needed. The resulting information is available via the `info()` method on the plugin instance once it is installed in an app, but it is up to each app to decide how to derive the information from the provided sources.
The `info.packageJson` loader **MUST** be used by all plugins that are implemented within their own package, and it should load the `package.json` file for the plugin package. Typical usage looks like this:
```ts
export default createFrontendPlugin({
pluginId: 'my-plugin',
info: {
packageJson: () => import('../package.json'),
},
extensions: [...],
});
```
The `info.manifest` loader is used to point to an opaque plugin manifest. This **MUST ONLY** be used by plugins that are intended for use within a single organization. Plugins that are published to an open package registry should **NOT** use this loader. The loader is useful for adding additional internal metadata associated with the plugin, and it is up to the Backstage app to decide how these manifests are parsed and used. The default manifest parser in an app created with `createApp` from `@backstage/frontend-defaults` is able to parse the default `catalog-info.yaml` format and built-in fields such as `metadata.links` and `spec.owner`.
Typical usage looks like this:
```ts
export default createFrontendPlugin({
pluginId: '...',
info: {
manifest: () => import('../catalog-info.yaml'),
},
});
```
## Installing a Plugin in an App
A plugin instance is considered a frontend feature and can be installed directly in any Backstage frontend app. See the [app documentation](./10-app.md) for more information about the different ways in which you can install new features in an app.
+14
View File
@@ -8,6 +8,20 @@ app:
catalog.createComponent: catalog-import.importPage
org.catalogIndex: catalog.catalogIndex
pluginOverrides:
- match:
pluginId: pages
info:
description: 'This description was overridden in packages/app-next/app-config.yaml'
- match:
pluginId: /^catalog(-.*)?$/
info:
ownerEntityRefs: [cubic-belugas]
- match:
packageName: '@backstage/plugin-scaffolder'
info:
ownerEntityRefs: [cubic-belugas]
extensions:
# - apis.plugin.graphiql.browse.gitlab: true
# - graphiql-endpoint:graphiql/gitlab: true
+2
View File
@@ -43,6 +43,7 @@ import kubernetesPlugin from '@backstage/plugin-kubernetes/alpha';
import { convertLegacyPlugin } from '@backstage/core-compat-api';
import { convertLegacyPageExtension } from '@backstage/core-compat-api';
import { convertLegacyEntityContentExtension } from '@backstage/plugin-catalog-react/alpha';
import { pluginInfoResolver } from './pluginInfoResolver';
/*
@@ -132,6 +133,7 @@ const app = createApp({
customHomePageModule,
...collectedLegacyPlugins,
],
pluginInfoResolver,
/* Handled through config instead */
// bindRoutes({ bind }) {
// bind(pagesPlugin.externalRoutes, { pageX: pagesPlugin.routes.pageX });
@@ -21,7 +21,10 @@ import {
createExternalRouteRef,
useRouteRef,
PageBlueprint,
FrontendPluginInfo,
useAppNode,
} from '@backstage/frontend-plugin-api';
import { useEffect, useState } from 'react';
import { Route, Routes } from 'react-router-dom';
const indexRouteRef = createRouteRef();
@@ -36,6 +39,22 @@ export const pageXRouteRef = createRouteRef();
// path: '/page2',
// });
function PluginInfo() {
const node = useAppNode();
const [info, setInfo] = useState<FrontendPluginInfo | undefined>(undefined);
useEffect(() => {
node?.spec.source?.info().then(setInfo);
}, [node]);
return (
<div>
<h3>Plugin Info</h3>
<pre>{JSON.stringify(info, null, 2)}</pre>
</div>
);
}
const IndexPage = PageBlueprint.make({
name: 'index',
params: {
@@ -64,6 +83,7 @@ const IndexPage = PageBlueprint.make({
<div>
<Link to="/settings">Settings</Link>
</div>
<PluginInfo />
</div>
);
};
@@ -139,6 +159,10 @@ export const pagesPlugin = createFrontendPlugin({
// // OR
// // 'page1'
// },
info: {
packageJson: () => import('../../package.json'),
manifest: () => import('../../catalog-info.yaml'),
},
routes: {
page1: page1RouteRef,
pageX: pageXRouteRef,
@@ -0,0 +1,52 @@
/*
* Copyright 2025 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 { Entity } from '@backstage/catalog-model';
import { FrontendPluginInfoResolver } from '@backstage/frontend-app-api';
// This file shows an example of what it looks like to extend the plugin info
// resolution with custom logic and fields. In this case we're reading the
// `spec.type` field from the plugin manifest (catalog-info.yaml).
//
// Using module augmentation we extend the `FrontendPluginInfo` interface to
// include our custom fields. This makes these fields available throughout our project.
declare module '@backstage/frontend-plugin-api' {
export interface FrontendPluginInfo {
/**
* **DO NOT USE**
*
* This field is added in the example app to showcase module augmentation
* for extending the plugin info in internal apps. It only exists as an
* example in this project.
*/
exampleFieldDoNotUse?: string;
}
}
export const pluginInfoResolver: FrontendPluginInfoResolver = async ctx => {
const manifest = (await ctx.manifest?.()) as Entity | undefined;
const { info: defaultInfo } = await ctx.defaultResolver({
packageJson: await ctx.packageJson(),
manifest: manifest,
});
return {
info: {
...defaultInfo,
exampleFieldDoNotUse: manifest?.spec?.type?.toString(),
},
};
};
@@ -41,6 +41,8 @@ describe('convertLegacyPlugin', () => {
"featureFlags": [],
"getExtension": [Function],
"id": "test",
"info": [Function],
"infoOptions": undefined,
"routes": {},
"toString": [Function],
"version": "v1",
+66
View File
@@ -51,5 +51,71 @@ export interface Config {
};
}
>;
/**
* This section enables you to override certain properties of specific or
* groups of plugins.
*
* @remarks
* All matching entries will be applied to each plugin, with the later
* entries taking precedence.
*
* This configuration is intended to be used primarily to apply overrides
* for third-party plugins.
*
* @deepVisibility frontend
*/
pluginOverrides?: Array<{
/**
* The criteria for matching plugins to override.
*
* @remarks
* If no match criteria are provided, the override will be applied to
* all plugins.
*/
match?: {
/**
* A pattern that is matched against the plugin ID.
*
* @remarks
* By default the string is interpreted as a glob pattern, but if the
* string is surrounded by '/' it is interpreted as a regex.
*/
pluginId?: string;
/**
* A pattern that is matched against the package name.
*
* @remarks
* By default the string is interpreted as a glob pattern, but if the
* string is surrounded by '/' it is interpreted as a regex.
*
* Note that this will only work for plugins that provide a
* `package.json` info loader.
*/
packageName?: string;
};
/**
* Overrides individual top-level fields of the plugin info.
*/
info: {
/**
* Override the description of the plugin.
*/
description?: string;
/**
* Override the owner entity references of the plugin.
*
* @remarks
* The provided values are interpreted as entity references defaulting
* to Group entities in the default namespace.
*/
ownerEntityRefs?: string[];
/**
* Override the links of the plugin.
*/
links?: Array<{ title: string; url: string }>;
};
}>;
};
}
+18 -1
View File
@@ -9,6 +9,8 @@ import { ConfigApi } from '@backstage/core-plugin-api';
import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { FrontendFeature as FrontendFeature_2 } from '@backstage/frontend-plugin-api';
import { FrontendPluginInfo } from '@backstage/frontend-plugin-api';
import { JsonObject } from '@backstage/types';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { SubRouteRef } from '@backstage/frontend-plugin-api';
@@ -27,7 +29,7 @@ export type CreateAppRouteBinder = <
// @public
export function createSpecializedApp(options?: {
features?: FrontendFeature[];
features?: FrontendFeature_2[];
config?: ConfigApi;
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
apis?: ApiHolder;
@@ -37,6 +39,7 @@ export function createSpecializedApp(options?: {
flags?: {
allowUnknownExtensionConfig?: boolean;
};
pluginInfoResolver?: FrontendPluginInfoResolver;
}): {
apis: ApiHolder;
tree: AppTree;
@@ -44,4 +47,18 @@ export function createSpecializedApp(options?: {
// @public @deprecated (undocumented)
export type FrontendFeature = FrontendFeature_2;
// @public
export type FrontendPluginInfoResolver = (ctx: {
packageJson(): Promise<JsonObject | undefined>;
manifest(): Promise<JsonObject | undefined>;
defaultResolver(sources: {
packageJson: JsonObject | undefined;
manifest: JsonObject | undefined;
}): Promise<{
info: FrontendPluginInfo;
}>;
}) => Promise<{
info: FrontendPluginInfo;
}>;
```
@@ -0,0 +1,284 @@
/*
* Copyright 2025 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 { mockApis } from '@backstage/test-utils';
import { createPluginInfoAttacher } from './createPluginInfoAttacher';
import { OpaqueFrontendPlugin } from '@internal/frontend';
import {
createFrontendPlugin,
FrontendFeature,
} from '@backstage/frontend-plugin-api';
function getInfo(plugin: FrontendFeature) {
return OpaqueFrontendPlugin.toInternal(plugin).info();
}
describe('createPluginInfoAttacher', () => {
const mockConfig = mockApis.config({
data: {
app: {
pluginOverrides: [
{
match: {
pluginId: '/^.*-tester$/',
},
info: {
description: 'Overridden description',
},
},
{
match: {
pluginId: '/^not-.*-tester$/',
},
info: {
ownerEntityRefs: ['test-group'],
},
},
{
match: {
packageName: '@test/package',
},
info: {
description: 'Package name matched',
},
},
{
match: {
pluginId: 'info-tester',
},
info: {
links: [{ title: 'Custom Link', url: 'https://example.com' }],
},
},
],
},
},
});
describe('with default resolver', () => {
const attacher = createPluginInfoAttacher(mockConfig);
it('should return a new plugin instance', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
});
const newPlugin = attacher(plugin);
expect(newPlugin).not.toBe(plugin);
await expect(getInfo(newPlugin)).resolves.toEqual({});
});
it('should return non-plugin features unchanged', () => {
const nonPluginFeature = {
type: 'not-a-plugin',
} as unknown as FrontendFeature;
expect(attacher(nonPluginFeature)).toBe(nonPluginFeature);
});
it('should resolve plugin info from package.json and config overrides', async () => {
await expect(
getInfo(
attacher(
createFrontendPlugin({
pluginId: 'other-tester',
info: {
packageJson: async () => ({
name: '@test/package',
version: '1.0.0',
description: 'Original description',
homepage: 'https://homepage.com',
repository: {
url: 'https://github.com/test/project',
directory: 'packages/test',
},
}),
},
}),
),
),
).resolves.toEqual({
packageName: '@test/package',
version: '1.0.0',
description: 'Package name matched',
links: [
{
title: 'Homepage',
url: 'https://homepage.com',
},
{
title: 'Repository',
url: 'https://github.com/test/project/tree/-/packages/test',
},
],
});
await expect(
getInfo(
attacher(
createFrontendPlugin({
pluginId: 'info-tester',
info: {
packageJson: async () => ({
name: '@other/package',
description: 'Original description',
homepage: 'https://homepage.com',
}),
},
}),
),
),
).resolves.toEqual({
packageName: '@other/package',
description: 'Overridden description',
links: [
{
title: 'Custom Link',
url: 'https://example.com',
},
],
});
await expect(
getInfo(
attacher(
createFrontendPlugin({
pluginId: 'not-info-tester',
info: {
packageJson: async () => ({
name: '@other/package',
description: 'Original description',
repository: {
url: 'http://example.com',
directory: 'packages/test',
},
}),
},
}),
),
),
).resolves.toEqual({
packageName: '@other/package',
description: 'Overridden description',
ownerEntityRefs: ['group:default/test-group'],
links: [
{
title: 'Repository',
url: 'http://example.com/',
},
],
});
});
});
describe('with custom resolver', () => {
const plugin = createFrontendPlugin({
pluginId: 'custom-resolver',
info: {
packageJson: async () => ({
name: '@test/resolver',
version: '1.0.0',
}),
manifest: async () => ({
metadata: {
links: [{ title: 'Metadata link', url: 'https://example.com' }],
},
}),
},
});
it('should use the default resolver', async () => {
const attacher = createPluginInfoAttacher(mockConfig, async ctx =>
ctx.defaultResolver({
packageJson: await ctx.packageJson(),
manifest: await ctx.manifest(),
}),
);
await expect(getInfo(attacher(plugin))).resolves.toEqual({
packageName: '@test/resolver',
version: '1.0.0',
links: [
{
title: 'Metadata link',
url: 'https://example.com',
},
],
});
});
it('should override info sources passed to default resolver', async () => {
const attacher = createPluginInfoAttacher(mockConfig, ctx =>
ctx.defaultResolver({
packageJson: {
name: '@test/resolver-other',
version: '2.0.0',
},
manifest: {
metadata: {
links: [{ title: 'Other link', url: 'https://example.com' }],
},
spec: {
owner: 'test-group',
},
},
}),
);
await expect(getInfo(attacher(plugin))).resolves.toEqual({
packageName: '@test/resolver-other',
version: '2.0.0',
links: [
{
title: 'Other link',
url: 'https://example.com',
},
],
ownerEntityRefs: ['group:default/test-group'],
});
});
it('should use a completely custom resolver', async () => {
const attacher = createPluginInfoAttacher(mockConfig, async () => ({
info: { version: '0.1.0' },
}));
await expect(getInfo(attacher(plugin))).resolves.toEqual({
version: '0.1.0',
});
});
it('should handle unexpected input from the default resolver', async () => {
const attacher = createPluginInfoAttacher(mockConfig, ctx =>
ctx.defaultResolver({
packageJson: {
name: null,
version: {},
},
manifest: {
metadata: {
links: 'not an array',
},
spec: [],
},
}),
);
await expect(getInfo(attacher(plugin))).resolves.toEqual({
version: '[object Object]',
});
});
});
});
@@ -0,0 +1,247 @@
/*
* Copyright 2025 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 { ConfigApi } from '@backstage/core-plugin-api';
import {
FrontendFeature,
FrontendPluginInfo,
} from '@backstage/frontend-plugin-api';
import { OpaqueFrontendPlugin } from '@internal/frontend';
import { JsonObject, JsonValue } from '@backstage/types';
import once from 'lodash/once';
// Avoid full dependency on catalog-model
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import {
parseEntityRef,
stringifyEntityRef,
} from '../../../catalog-model/src/entity/ref';
/**
* A function that resolves plugin info from a plugin manifest and package.json.
*
* @public
*/
export type FrontendPluginInfoResolver = (ctx: {
packageJson(): Promise<JsonObject | undefined>;
manifest(): Promise<JsonObject | undefined>;
defaultResolver(sources: {
packageJson: JsonObject | undefined;
manifest: JsonObject | undefined;
}): Promise<{ info: FrontendPluginInfo }>;
}) => Promise<{ info: FrontendPluginInfo }>;
export function createPluginInfoAttacher(
config: ConfigApi,
infoResolver: FrontendPluginInfoResolver = async ctx =>
ctx.defaultResolver({
packageJson: await ctx.packageJson(),
manifest: await ctx.manifest(),
}),
): (feature: FrontendFeature) => FrontendFeature {
const applyInfoOverrides = createPluginInfoOverrider(config);
return (feature: FrontendFeature) => {
if (!OpaqueFrontendPlugin.isType(feature)) {
return feature;
}
const plugin = OpaqueFrontendPlugin.toInternal(feature);
return {
...plugin,
info: once(async () => {
const manifestLoader = plugin.infoOptions?.manifest;
const packageJsonLoader = plugin.infoOptions?.packageJson;
const { info: resolvedInfo } = await infoResolver({
manifest: async () => manifestLoader?.(),
packageJson: async () => packageJsonLoader?.(),
defaultResolver: async sources => ({
info: {
...resolvePackageInfo(sources.packageJson),
...resolveManifestInfo(sources.manifest),
},
}),
});
const infoWithOverrides = applyInfoOverrides(plugin.id, resolvedInfo);
return normalizePluginInfo(infoWithOverrides);
}),
};
};
}
function normalizePluginInfo(info: FrontendPluginInfo) {
return {
...info,
ownerEntityRefs: info.ownerEntityRefs?.map(ref =>
stringifyEntityRef(
parseEntityRef(ref, {
defaultKind: 'Group',
}),
),
),
};
}
function createPluginInfoOverrider(config: ConfigApi) {
const overrideConfigs =
config.getOptionalConfigArray('app.pluginOverrides') ?? [];
const overrideMatchers = overrideConfigs.map(overrideConfig => {
const pluginIdMatcher = makeStringMatcher(
overrideConfig.getOptionalString('match.pluginId'),
);
const packageNameMatcher = makeStringMatcher(
overrideConfig.getOptionalString('match.packageName'),
);
const description = overrideConfig.getOptionalString('info.description');
const ownerEntityRefs = overrideConfig.getOptionalStringArray(
'info.ownerEntityRefs',
);
const links = overrideConfig
.getOptionalConfigArray('info.links')
?.map(linkConfig => ({
title: linkConfig.getString('title'),
url: linkConfig.getString('url'),
}));
return {
test(pluginId: string, packageName?: string) {
return packageNameMatcher(packageName) && pluginIdMatcher(pluginId);
},
info: {
description,
ownerEntityRefs,
links,
},
};
});
return (pluginId: string, info: FrontendPluginInfo) => {
const { packageName } = info;
for (const matcher of overrideMatchers) {
if (matcher.test(pluginId, packageName)) {
if (matcher.info.description) {
info.description = matcher.info.description;
}
if (matcher.info.ownerEntityRefs) {
info.ownerEntityRefs = matcher.info.ownerEntityRefs;
}
if (matcher.info.links) {
info.links = matcher.info.links;
}
}
}
return info;
};
}
function resolveManifestInfo(manifest?: JsonValue) {
if (!isJsonObject(manifest) || !isJsonObject(manifest.metadata)) {
return undefined;
}
const info: FrontendPluginInfo = {};
if (isJsonObject(manifest.spec) && typeof manifest.spec.owner === 'string') {
info.ownerEntityRefs = [
stringifyEntityRef(
parseEntityRef(manifest.spec.owner, {
defaultKind: 'Group',
defaultNamespace: manifest.metadata.namespace?.toString(),
}),
),
];
}
if (Array.isArray(manifest.metadata.links)) {
info.links = manifest.metadata.links.filter(isJsonObject).map(link => ({
title: String(link.title),
url: String(link.url),
}));
}
return info;
}
function resolvePackageInfo(packageJson?: JsonObject) {
if (!packageJson) {
return undefined;
}
const info: FrontendPluginInfo = {
packageName: packageJson?.name?.toString(),
version: packageJson?.version?.toString(),
description: packageJson?.description?.toString(),
};
const links: { title: string; url: string }[] = [];
if (typeof packageJson.homepage === 'string') {
links.push({
title: 'Homepage',
url: packageJson.homepage,
});
}
if (
isJsonObject(packageJson.repository) &&
typeof packageJson.repository?.url === 'string'
) {
try {
const url = new URL(packageJson.repository?.url);
if (url.protocol === 'http:' || url.protocol === 'https:') {
// TODO(Rugvip): Support more variants
if (
url.hostname === 'github.com' &&
typeof packageJson.repository.directory === 'string'
) {
const path = `${url.pathname}/tree/-/${packageJson.repository.directory}`;
url.pathname = path.replaceAll('//', '/');
}
links.push({
title: 'Repository',
url: url.toString(),
});
}
} catch {
/* ignored */
}
}
if (links.length > 0) {
info.links = links;
}
return info;
}
function makeStringMatcher(pattern: string | undefined) {
if (!pattern) {
return () => true;
}
if (pattern.startsWith('/') && pattern.endsWith('/') && pattern.length > 2) {
const regex = new RegExp(pattern.slice(1, -1));
return (str?: string) => (str ? regex.test(str) : false);
}
return (str?: string) => str === pattern;
}
function isJsonObject(value?: JsonValue): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@@ -671,4 +671,123 @@ describe('createSpecializedApp', () => {
expect(render(root).container.textContent).toBe('1-2-test-1-2');
});
describe('plugin info', () => {
const testExtension = createExtension({
attachTo: { id: 'root', input: 'app' },
output: [coreExtensionData.reactElement],
factory: () => [coreExtensionData.reactElement(<div>Test</div>)],
});
it('should throw unless accessed via an app', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
extensions: [testExtension],
});
const errorMsg =
"Attempted to load plugin info for plugin 'test', but the plugin instance is not installed in an app";
await expect(plugin.info()).rejects.toThrow(errorMsg);
const app = createSpecializedApp({ features: [plugin] });
await expect(plugin.info()).rejects.toThrow(errorMsg);
const installedPlugin = app.tree.nodes.get('test')?.spec.source;
expect(installedPlugin).toBeDefined();
const info = await installedPlugin?.info();
expect(info).toEqual({});
});
it('should forward plugin info', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
info: {
packageJson: () => import('../../package.json'),
},
extensions: [testExtension],
});
const app = createSpecializedApp({ features: [plugin] });
const info = await app.tree.nodes.get('test')?.spec.source?.info();
expect(info).toMatchObject({
packageName: '@backstage/frontend-app-api',
});
});
it('should allow overriding plugin info per plugin', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
info: {
packageJson: () => import('../../package.json'),
},
extensions: [testExtension],
});
const overriddenPlugin = plugin.withOverrides({
extensions: [],
info: {
packageJson: () => Promise.resolve({ name: 'test-override' }),
},
});
const app = createSpecializedApp({ features: [overriddenPlugin] });
const info = await app.tree.nodes.get('test')?.spec.source?.info();
expect(info).toMatchObject({
packageName: 'test-override',
});
});
it('should merge with plugin info from manifest', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
info: {
packageJson: () => import('../../package.json'),
manifest: async () => ({
metadata: {
links: [{ title: 'Example', url: 'https://example.com' }],
},
spec: {
owner: 'cubic-belugas',
},
}),
},
extensions: [testExtension],
});
const app = createSpecializedApp({ features: [plugin] });
const info = await app.tree.nodes.get('test')?.spec.source?.info();
expect(info).toEqual({
packageName: '@backstage/frontend-app-api',
version: expect.any(String),
links: [{ title: 'Example', url: 'https://example.com' }],
ownerEntityRefs: ['group:default/cubic-belugas'],
});
});
it('should allow overriding of the plugin info resolver', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
info: {
packageJson: () => import('../../package.json'),
},
extensions: [testExtension],
});
const app = createSpecializedApp({
features: [plugin],
async pluginInfoResolver(ctx) {
const { info } = await ctx.defaultResolver({
packageJson: await ctx.packageJson(),
manifest: await ctx.manifest(),
});
return { info: { packageName: `decorated:${info.packageName}` } };
},
});
const info = await app.tree.nodes.get('test')?.spec.source?.info();
expect(info).toEqual({
packageName: 'decorated:@backstage/frontend-app-api',
});
});
});
});
@@ -31,6 +31,7 @@ import {
routeResolutionApiRef,
AppNode,
ExtensionFactoryMiddleware,
FrontendFeature,
} from '@backstage/frontend-plugin-api';
import {
AnyApiFactory,
@@ -74,8 +75,12 @@ import { ApiRegistry } from '../../../core-app-api/src/apis/system/ApiRegistry';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy';
import { BackstageRouteObject } from '../routing/types';
import { FrontendFeature, RouteInfo } from './types';
import { RouteInfo } from './types';
import { matchRoutes } from 'react-router-dom';
import {
createPluginInfoAttacher,
FrontendPluginInfoResolver,
} from './createPluginInfoAttacher';
function deduplicateFeatures(
allFeatures: FrontendFeature[],
@@ -209,9 +214,12 @@ export function createSpecializedApp(options?: {
| ExtensionFactoryMiddleware
| ExtensionFactoryMiddleware[];
flags?: { allowUnknownExtensionConfig?: boolean };
pluginInfoResolver?: FrontendPluginInfoResolver;
}): { apis: ApiHolder; tree: AppTree } {
const config = options?.config ?? new ConfigReader({}, 'empty-config');
const features = deduplicateFeatures(options?.features ?? []);
const features = deduplicateFeatures(options?.features ?? []).map(
createPluginInfoAttacher(config, options?.pluginInfoResolver),
);
const tree = resolveAppTree(
'root',
@@ -15,4 +15,5 @@
*/
export { createSpecializedApp } from './createSpecializedApp';
export { type FrontendPluginInfoResolver } from './createPluginInfoAttacher';
export * from './types';
+3
View File
@@ -9,6 +9,7 @@ import { CreateAppRouteBinder } from '@backstage/frontend-app-api';
import { ExtensionFactoryMiddleware } from '@backstage/frontend-plugin-api';
import { FrontendFeature } from '@backstage/frontend-plugin-api';
import { FrontendFeatureLoader } from '@backstage/frontend-plugin-api';
import { FrontendPluginInfoResolver } from '@backstage/frontend-app-api';
import { JSX as JSX_2 } from 'react';
import { ReactNode } from 'react';
@@ -44,6 +45,8 @@ export interface CreateAppOptions {
| CreateAppFeatureLoader
)[];
loadingComponent?: ReactNode;
// (undocumented)
pluginInfoResolver?: FrontendPluginInfoResolver;
}
// @public
@@ -23,12 +23,15 @@ import {
createFrontendPlugin,
ThemeBlueprint,
createFrontendModule,
useAppNode,
FrontendPluginInfo,
} from '@backstage/frontend-plugin-api';
import { screen, waitFor } from '@testing-library/react';
import { CreateAppFeatureLoader, createApp } from './createApp';
import { mockApis, renderWithEffects } from '@backstage/test-utils';
import { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api';
import { default as appPluginOriginal } from '@backstage/plugin-app';
import { useState, useEffect } from 'react';
describe('createApp', () => {
const appPlugin = appPluginOriginal.withOverrides({
@@ -119,6 +122,52 @@ describe('createApp', () => {
);
});
it('should allow overriding the plugin info resolver', async () => {
function TestComponent() {
const appNode = useAppNode();
const [info, setInfo] = useState<FrontendPluginInfo | undefined>(
undefined,
);
useEffect(() => {
appNode?.spec.source?.info().then(setInfo);
}, [appNode]);
return <div>Package name: {info?.packageName}</div>;
}
const app = createApp({
configLoader: async () => ({ config: mockApis.config() }),
features: [
appPlugin,
createFrontendPlugin({
pluginId: 'test',
extensions: [
PageBlueprint.make({
params: {
defaultPath: '/',
loader: async () => <TestComponent />,
},
}),
],
}),
],
pluginInfoResolver: async () => {
return {
info: {
packageName: '@test/test',
},
};
},
});
await renderWithEffects(app.createRoot());
await expect(
screen.findByText('Package name: @test/test'),
).resolves.toBeInTheDocument();
});
it('should support feature loaders', async () => {
const loader: CreateAppFeatureLoader = {
getLoaderName() {
@@ -30,6 +30,7 @@ import { ConfigReader } from '@backstage/config';
import {
CreateAppRouteBinder,
createSpecializedApp,
FrontendPluginInfoResolver,
} from '@backstage/frontend-app-api';
import appPlugin from '@backstage/plugin-app';
import { discoverAvailableFeatures } from './discovery';
@@ -78,6 +79,7 @@ export interface CreateAppOptions {
extensionFactoryMiddleware?:
| ExtensionFactoryMiddleware
| ExtensionFactoryMiddleware[];
pluginInfoResolver?: FrontendPluginInfoResolver;
}
/**
@@ -112,6 +114,7 @@ export function createApp(options?: CreateAppOptions): {
features: [appPlugin, ...loadedFeatures],
bindRoutes: options?.bindRoutes,
extensionFactoryMiddleware: options?.extensionFactoryMiddleware,
pluginInfoResolver: options?.pluginInfoResolver,
});
const rootEl = app.tree.root.instance!.getData(
@@ -19,6 +19,7 @@ import {
FeatureFlagConfig,
FrontendPlugin,
} from '@backstage/frontend-plugin-api';
import { JsonObject } from '@backstage/types';
import { OpaqueType } from '@internal/opaque';
export const OpaqueFrontendPlugin = OpaqueType.create<{
@@ -27,6 +28,10 @@ export const OpaqueFrontendPlugin = OpaqueType.create<{
readonly version: 'v1';
readonly extensions: Extension<unknown>[];
readonly featureFlags: FeatureFlagConfig[];
readonly infoOptions?: {
packageJson?: () => Promise<JsonObject>;
manifest?: () => Promise<JsonObject>;
};
};
}>({
type: '@backstage/FrontendPlugin',
@@ -1324,14 +1324,38 @@ export interface FrontendPlugin<
getExtension<TId extends keyof TExtensionMap>(id: TId): TExtensionMap[TId];
// (undocumented)
readonly id: string;
info(): Promise<FrontendPluginInfo>;
// (undocumented)
readonly routes: TRoutes;
// (undocumented)
withOverrides(options: {
extensions: Array<ExtensionDefinition>;
info?: FrontendPluginInfoOptions;
}): FrontendPlugin<TRoutes, TExternalRoutes, TExtensionMap>;
}
// @public
export interface FrontendPluginInfo {
description?: string;
links?: Array<{
title: string;
url: string;
}>;
ownerEntityRefs?: string[];
packageName?: string;
version?: string;
}
// @public
export type FrontendPluginInfoOptions = {
packageJson?: () => Promise<
{
name: string;
} & JsonObject
>;
manifest?: () => Promise<JsonObject>;
};
export { githubAuthApiRef };
export { gitlabAuthApiRef };
@@ -1516,6 +1540,8 @@ export interface PluginOptions<
// (undocumented)
featureFlags?: FeatureFlagConfig[];
// (undocumented)
info?: FrontendPluginInfoOptions;
// (undocumented)
pluginId: TId;
// (undocumented)
routes?: TRoutes;
@@ -1806,6 +1832,9 @@ export { useApi };
export { useApiHolder };
// @public
export function useAppNode(): AppNode | undefined;
// @public
export function useComponentRef<T extends {}>(
ref: ComponentRef<T>,
@@ -0,0 +1,84 @@
/*
* Copyright 2025 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 {
createVersionedContext,
createVersionedValueMap,
} from '@backstage/version-bridge';
import { AppNode } from '../apis';
import { renderHook } from '@testing-library/react';
import { AppNodeProvider, useAppNode } from './AppNodeProvider';
import { withLogCollector } from '@backstage/test-utils';
describe('AppNodeProvider', () => {
it('should provide app node context to children', () => {
const node = { id: 'test' } as unknown as AppNode;
const { result } = renderHook(() => useAppNode(), {
wrapper: ({ children }) => (
<AppNodeProvider node={node}>{children}</AppNodeProvider>
),
});
expect(result.current).toBe(node);
});
it('should return undefined when used outside provider', () => {
const { result } = renderHook(() => useAppNode());
expect(result.current).toBeUndefined();
});
it('should return the closest app node', () => {
const node1 = { id: 'test1' } as unknown as AppNode;
const node2 = { id: 'test2' } as unknown as AppNode;
const { result } = renderHook(() => useAppNode(), {
wrapper: ({ children }) => (
<AppNodeProvider node={node1}>
<AppNodeProvider node={node2}>{children}</AppNodeProvider>
</AppNodeProvider>
),
});
expect(result.current).toBe(node2);
});
it('should throw error for invalid context version', () => {
const node = { id: 'test' } as unknown as AppNode;
const Context = createVersionedContext('app-node-context');
const value = createVersionedValueMap({ 2: { node } });
const { error } = withLogCollector(() => {
expect(() =>
renderHook(() => useAppNode(), {
wrapper: ({ children }) => (
<Context.Provider value={value}>{children}</Context.Provider>
),
}),
).toThrow('AppNodeContext v1 not available');
});
expect(error).toEqual([
expect.objectContaining({
detail: new Error('AppNodeContext v1 not available'),
}),
expect.objectContaining({
detail: new Error('AppNodeContext v1 not available'),
}),
expect.stringContaining(
'The above error occurred in the <TestComponent> component:',
),
]);
});
});
@@ -0,0 +1,74 @@
/*
* Copyright 2025 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 {
createVersionedContext,
createVersionedValueMap,
useVersionedContext,
} from '@backstage/version-bridge';
import { AppNode } from '../apis';
import { ReactNode } from 'react';
const CONTEXT_KEY = 'app-node-context';
type AppNodeContextV1 = {
node?: AppNode;
};
type AppNodeContextMap = {
1: AppNodeContextV1;
};
const AppNodeContext = createVersionedContext<AppNodeContextMap>(CONTEXT_KEY);
/** @internal */
export function AppNodeProvider({
node,
children,
}: {
node: AppNode;
children: ReactNode;
}) {
const versionedValue = createVersionedValueMap({ 1: { node } });
return <AppNodeContext.Provider value={versionedValue} children={children} />;
}
/**
* React hook providing access to the current {@link AppNode}.
*
* @public
* @remarks
*
* This hook will return the {@link AppNode} for the closest extension. This
* relies on the extension using the {@link (ExtensionBoundary:function)} component in its
* implementation, which is included by default for all common blueprints.
*
* If the current component is not inside an {@link (ExtensionBoundary:function)}, it will
* return `undefined`.
*/
export function useAppNode(): AppNode | undefined {
const versionedContext = useVersionedContext<AppNodeContextMap>(CONTEXT_KEY);
if (!versionedContext) {
return undefined;
}
const context = versionedContext.atVersion(1);
if (!context) {
throw new Error('AppNodeContext v1 not available');
}
return context.node;
}
@@ -28,6 +28,7 @@ import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/ana
import { AppNode, useComponentRef } from '../apis';
import { coreComponentRefs } from './coreComponentRefs';
import { coreExtensionData } from '../wiring';
import { AppNodeProvider } from './AppNodeProvider';
type RouteTrackerProps = PropsWithChildren<{
disableTracking?: boolean;
@@ -80,15 +81,17 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) {
};
return (
<Suspense fallback={<Progress />}>
<ErrorBoundary plugin={plugin} Fallback={fallback}>
<AnalyticsContext attributes={attributes}>
<RouteTracker disableTracking={!(routable ?? doesOutputRoutePath)}>
{children}
</RouteTracker>
</AnalyticsContext>
</ErrorBoundary>
</Suspense>
<AppNodeProvider node={node}>
<Suspense fallback={<Progress />}>
<ErrorBoundary plugin={plugin} Fallback={fallback}>
<AnalyticsContext attributes={attributes}>
<RouteTracker disableTracking={!(routable ?? doesOutputRoutePath)}>
{children}
</RouteTracker>
</AnalyticsContext>
</ErrorBoundary>
</Suspense>
</AppNodeProvider>
);
}
@@ -20,3 +20,4 @@ export {
} from './ExtensionBoundary';
export { coreComponentRefs } from './coreComponentRefs';
export { createComponentRef, type ComponentRef } from './createComponentRef';
export { useAppNode } from './AppNodeProvider';
@@ -281,6 +281,60 @@ describe('createFrontendPlugin', () => {
).toThrow("Plugin 'test' provided duplicate extensions: test/2, test/3");
});
describe('info', () => {
it('should support reading info from package.json', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
info: { packageJson: () => Promise.resolve({ name: '@test/test' }) },
});
await expect((plugin as any).infoOptions?.packageJson()).resolves.toEqual(
{ name: '@test/test' },
);
});
it('should support reading info from actual package.json', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
info: { packageJson: () => import('../../package.json') },
});
await expect(
(plugin as any).infoOptions?.packageJson(),
).resolves.toMatchObject({ name: '@backstage/frontend-plugin-api' });
});
it('should support reading info from opaque manifest', async () => {
const plugin = createFrontendPlugin({
pluginId: 'test',
info: { manifest: () => Promise.resolve({ owner: 'me' }) },
});
await expect((plugin as any).infoOptions?.manifest()).resolves.toEqual({
owner: 'me',
});
});
it('should throw when trying to load info without installing in an app', async () => {
await expect(
createFrontendPlugin({
pluginId: 'test',
}).info(),
).rejects.toThrow(
"Attempted to load plugin info for plugin 'test', but the plugin instance is not installed in an app",
);
await expect(
createFrontendPlugin({
pluginId: 'test',
info: { packageJson: () => Promise.resolve({ name: '@test/test' }) },
}).info(),
).rejects.toThrow(
"Attempted to load plugin info for plugin 'test', but the plugin instance is not installed in an app",
);
});
});
describe('overrides', () => {
it('should return a plugin instance with the correct namespace', () => {
const plugin = createFrontendPlugin({
@@ -25,6 +25,67 @@ import {
} from './resolveExtensionDefinition';
import { AnyExternalRoutes, AnyRoutes, FeatureFlagConfig } from './types';
import { MakeSortedExtensionsMap } from './MakeSortedExtensionsMap';
import { JsonObject } from '@backstage/types';
/**
* Information about the plugin.
*
* @public
* @remarks
*
* This interface is intended to be extended via [module
* augmentation](https://www.typescriptlang.org/docs/handbook/declaration-merging.html#module-augmentation)
* in order to add fields that are specific to each project.
*
* For example, one might add a `slackChannel` field that is read from the
* opaque manifest file.
*
* See the options for `createApp` for more information about how to
* customize the parsing of manifest files.
*/
export interface FrontendPluginInfo {
/**
* The name of the package that implements the plugin.
*/
packageName?: string;
/**
* The version of the plugin, typically the version of the package.json file.
*/
version?: string;
/**
* As short description of the plugin, typically the description field in
* package.json.
*/
description?: string;
/**
* The owner entity references of the plugin.
*/
ownerEntityRefs?: string[];
/**
* Links related to the plugin.
*/
links?: Array<{ title: string; url: string }>;
}
/**
* Options for providing information for a plugin.
*
* @public
*/
export type FrontendPluginInfoOptions = {
/**
* A loader function for the package.json file for the plugin.
*/
packageJson?: () => Promise<{ name: string } & JsonObject>;
/**
* A loader function for an opaque manifest file for the plugin.
*/
manifest?: () => Promise<JsonObject>;
};
/** @public */
export interface FrontendPlugin<
@@ -38,9 +99,19 @@ export interface FrontendPlugin<
readonly id: string;
readonly routes: TRoutes;
readonly externalRoutes: TExternalRoutes;
/**
* Loads the plugin info.
*/
info(): Promise<FrontendPluginInfo>;
getExtension<TId extends keyof TExtensionMap>(id: TId): TExtensionMap[TId];
withOverrides(options: {
extensions: Array<ExtensionDefinition>;
/**
* Overrides the original info loaders of the plugin one by one.
*/
info?: FrontendPluginInfoOptions;
}): FrontendPlugin<TRoutes, TExternalRoutes, TExtensionMap>;
}
@@ -56,6 +127,7 @@ export interface PluginOptions<
externalRoutes?: TExternalRoutes;
extensions?: TExtensions;
featureFlags?: FeatureFlagConfig[];
info?: FrontendPluginInfoOptions;
}
/** @public */
@@ -150,6 +222,14 @@ export function createFrontendPlugin<
externalRoutes: options.externalRoutes ?? ({} as TExternalRoutes),
featureFlags: options.featureFlags ?? [],
extensions: extensions,
infoOptions: options.info,
// This method is overridden when the plugin instance is installed in an app
async info() {
throw new Error(
`Attempted to load plugin info for plugin '${pluginId}', but the plugin instance is not installed in an app`,
);
},
getExtension(id) {
const ext = extensionDefinitionsById.get(id);
if (!ext) {
@@ -178,6 +258,10 @@ export function createFrontendPlugin<
...options,
pluginId,
extensions: [...nonOverriddenExtensions, ...overrides.extensions],
info: {
...options.info,
...overrides.info,
},
});
},
});
@@ -40,6 +40,8 @@ export {
createFrontendPlugin,
type FrontendPlugin,
type PluginOptions,
type FrontendPluginInfo,
type FrontendPluginInfoOptions,
} from './createFrontendPlugin';
export {
createFrontendModule,
+1
View File
@@ -228,6 +228,7 @@ const apiDocsApisEntityContent = EntityContentBlueprint.make({
export default createFrontendPlugin({
pluginId: 'api-docs',
info: { packageJson: () => import('../package.json') },
routes: {
root: convertLegacyRouteRef(rootRoute),
},
+1
View File
@@ -46,5 +46,6 @@ export const appVisualizerNavItem = NavItemBlueprint.make({
/** @public */
export const visualizerPlugin = createFrontendPlugin({
pluginId: 'app-visualizer',
info: { packageJson: () => import('../package.json') },
extensions: [appVisualizerPage, appVisualizerNavItem],
});
+1
View File
@@ -42,6 +42,7 @@ import { apis } from './defaultApis';
/** @public */
export const appPlugin = createFrontendPlugin({
pluginId: 'app',
info: { packageJson: () => import('../package.json') },
extensions: [
...apis,
App,
+1
View File
@@ -87,6 +87,7 @@ const CatalogGraphPage = PageBlueprint.makeWithOverrides({
export default createFrontendPlugin({
pluginId: 'catalog-graph',
info: { packageJson: () => import('../package.json') },
routes: {
catalogGraph: convertLegacyRouteRef(catalogGraphRouteRef),
},
+1
View File
@@ -87,6 +87,7 @@ const catalogImportApi = ApiBlueprint.make({
/** @alpha */
export default createFrontendPlugin({
pluginId: 'catalog-import',
info: { packageJson: () => import('../package.json') },
extensions: [catalogImportApi, catalogImportPage],
routes: {
importPage: convertLegacyRouteRef(rootRouteRef),
@@ -74,6 +74,7 @@ export const catalogUnprocessedEntitiesNavItem = NavItemBlueprint.make({
/** @alpha */
export default createFrontendPlugin({
pluginId: 'catalog-unprocessed-entities',
info: { packageJson: () => import('../../package.json') },
routes: {
root: convertLegacyRouteRef(rootRouteRef),
},
+1
View File
@@ -39,6 +39,7 @@ import contextMenuItems from './contextMenuItems';
/** @alpha */
export default createFrontendPlugin({
pluginId: 'catalog',
info: { packageJson: () => import('../../package.json') },
routes: convertLegacyRouteRefs({
catalogIndex: rootRouteRef,
catalogEntity: entityRouteRef,
+1
View File
@@ -71,6 +71,7 @@ export const devToolsNavItem = NavItemBlueprint.make({
/** @alpha */
export default createFrontendPlugin({
pluginId: 'devtools',
info: { packageJson: () => import('../../package.json') },
routes: {
root: convertLegacyRouteRef(rootRouteRef),
},
+1
View File
@@ -68,6 +68,7 @@ const homePage = PageBlueprint.makeWithOverrides({
*/
export default createFrontendPlugin({
pluginId: 'home',
info: { packageJson: () => import('../package.json') },
extensions: [homePage],
routes: {
root: rootRouteRef,
+1
View File
@@ -28,6 +28,7 @@ import {
export default createFrontendPlugin({
pluginId: 'kubernetes',
info: { packageJson: () => import('../../package.json') },
extensions: [
kubernetesPage,
entityKubernetesContent,
+1
View File
@@ -54,6 +54,7 @@ const api = ApiBlueprint.make({
/** @alpha */
export default createFrontendPlugin({
pluginId: 'notifications',
info: { packageJson: () => import('../package.json') },
routes: convertLegacyRouteRefs({
root: rootRouteRef,
}),
+1
View File
@@ -87,6 +87,7 @@ const EntityUserProfileCard = EntityCardBlueprint.makeWithOverrides({
/** @alpha */
export default createFrontendPlugin({
pluginId: 'org',
info: { packageJson: () => import('../package.json') },
extensions: [
EntityGroupProfileCard,
EntityMembersListCard,
+1
View File
@@ -39,6 +39,7 @@ import { formDecoratorsApi } from './api';
/** @alpha */
export default createFrontendPlugin({
pluginId: 'scaffolder',
info: { packageJson: () => import('../../package.json') },
routes: convertLegacyRouteRefs({
root: rootRouteRef,
selectedTemplate: selectedTemplateRouteRef,
+1
View File
@@ -279,6 +279,7 @@ export const searchNavItem = NavItemBlueprint.make({
/** @alpha */
export default createFrontendPlugin({
pluginId: 'search',
info: { packageJson: () => import('../package.json') },
extensions: [searchApi, searchPage, searchNavItem],
routes: convertLegacyRouteRefs({
root: rootRouteRef,
+1
View File
@@ -45,5 +45,6 @@ const api = ApiBlueprint.make({
/** @alpha */
export default createFrontendPlugin({
pluginId: 'signals',
info: { packageJson: () => import('../package.json') },
extensions: [api],
});
+1
View File
@@ -236,6 +236,7 @@ const techDocsNavItem = NavItemBlueprint.make({
/** @alpha */
export default createFrontendPlugin({
pluginId: 'techdocs',
info: { packageJson: () => import('../package.json') },
extensions: [
techDocsClientApi,
techDocsStorageApi,
+1
View File
@@ -69,6 +69,7 @@ export const settingsNavItem = NavItemBlueprint.make({
*/
export default createFrontendPlugin({
pluginId: 'user-settings',
info: { packageJson: () => import('../package.json') },
extensions: [userSettingsPage, settingsNavItem],
routes: convertLegacyRouteRefs({
root: settingsRouteRef,