diff --git a/packages/frontend-plugin-api/src/extensions/PageBlueprint.test.tsx b/packages/frontend-plugin-api/src/extensions/PageBlueprint.test.tsx
new file mode 100644
index 0000000000..abe2d96a85
--- /dev/null
+++ b/packages/frontend-plugin-api/src/extensions/PageBlueprint.test.tsx
@@ -0,0 +1,154 @@
+/*
+ * Copyright 2024 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 { createRouteRef } from '../routing';
+import { PageBlueprint } from './PageBlueprint';
+import { createExtensionTester } from '@backstage/frontend-test-utils';
+import {
+ coreExtensionData,
+ createExtensionBlueprint,
+ createExtensionInput,
+} from '../wiring';
+import { waitFor } from '@testing-library/react';
+
+describe('PageBlueprint', () => {
+ const mockRouteRef = createRouteRef();
+
+ it('should return an extension when calling make with sensible defaults', () => {
+ const myPage = PageBlueprint.make({
+ name: 'test-page',
+ params: {
+ loader: () => Promise.resolve(
Test
),
+ defaultPath: '/test',
+ routeRef: mockRouteRef,
+ },
+ });
+
+ expect(myPage).toMatchInlineSnapshot(`
+ {
+ "$$type": "@backstage/ExtensionDefinition",
+ "attachTo": {
+ "id": "app/routes",
+ "input": "routes",
+ },
+ "configSchema": {
+ "parse": [Function],
+ "schema": {
+ "$schema": "http://json-schema.org/draft-07/schema#",
+ "additionalProperties": false,
+ "properties": {
+ "path": {
+ "type": "string",
+ },
+ },
+ "type": "object",
+ },
+ },
+ "disabled": false,
+ "factory": [Function],
+ "inputs": {},
+ "kind": "page",
+ "name": "test-page",
+ "namespace": undefined,
+ "output": [
+ [Function],
+ [Function],
+ {
+ "$$type": "@backstage/ExtensionDataRef",
+ "config": {
+ "optional": true,
+ },
+ "id": "core.routing.ref",
+ "optional": [Function],
+ "toString": [Function],
+ },
+ ],
+ "toString": [Function],
+ "version": "v2",
+ }
+ `);
+ });
+
+ it('should output a routeRef with the input routeRef', async () => {
+ const myPage = PageBlueprint.make({
+ name: 'test-page',
+ params: {
+ loader: () => Promise.resolve(Test
),
+ defaultPath: '/test',
+ routeRef: mockRouteRef,
+ },
+ });
+
+ const tester = createExtensionTester(myPage);
+
+ // TODO(blam): test for the routePath output doesn't work.
+ // expect(tester.data(coreExtensionData.routePath)).toBe('/test');
+
+ expect(tester.data(coreExtensionData.routeRef)).toBe(mockRouteRef);
+
+ const { getByTestId } = tester.render();
+
+ await waitFor(() => expect(getByTestId('test')).toBeInTheDocument());
+ });
+
+ it('should allow defining additional inputs to the extension', async () => {
+ const myPage = PageBlueprint.make({
+ name: 'test-page',
+ params: {
+ loader: async ({ inputs }) => {
+ return (
+
+ {/* todo(blam): need to fix the typescript here, as inputs is not the right type */}
+ {inputs.cards.map(c => c.get(coreExtensionData.reactElement))}
+
+ );
+ },
+ defaultPath: '/test',
+ routeRef: mockRouteRef,
+ },
+ inputs: {
+ cards: createExtensionInput([coreExtensionData.reactElement], {
+ optional: false,
+ singleton: false,
+ }),
+ },
+ });
+
+ const CardBlueprint = createExtensionBlueprint({
+ kind: 'card',
+ attachTo: { id: 'page:test-page', input: 'cards' },
+ output: [coreExtensionData.reactElement],
+ factory() {
+ return [
+ coreExtensionData.reactElement(
+ I'm a lovely card
,
+ ),
+ ];
+ },
+ });
+
+ const tester = createExtensionTester(myPage).add(
+ CardBlueprint.make({ name: 'card', params: {} }),
+ );
+
+ const { getByTestId, getByText } = tester.render();
+
+ await waitFor(() => expect(getByTestId('card')).toBeInTheDocument());
+ await waitFor(() =>
+ expect(getByText("I'm a lovely card")).toBeInTheDocument(),
+ );
+ });
+});
diff --git a/packages/frontend-plugin-api/src/extensions/PageBlueprint.tsx b/packages/frontend-plugin-api/src/extensions/PageBlueprint.tsx
new file mode 100644
index 0000000000..7ae26cf387
--- /dev/null
+++ b/packages/frontend-plugin-api/src/extensions/PageBlueprint.tsx
@@ -0,0 +1,71 @@
+/*
+ * Copyright 2024 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, { lazy } from 'react';
+import { RouteRef } from '../routing';
+import { coreExtensionData, createExtensionBlueprint } from '../wiring';
+import { ExtensionBoundary } from '../components';
+
+export const PageBlueprint = createExtensionBlueprint({
+ kind: 'page',
+ attachTo: { id: 'app/routes', input: 'routes' },
+ output: [
+ coreExtensionData.routePath,
+ coreExtensionData.reactElement,
+ coreExtensionData.routeRef.optional(),
+ ],
+ config: {
+ schema: {
+ path: z => z.string().optional(),
+ },
+ },
+ factory(
+ {
+ defaultPath,
+ loader,
+ routeRef,
+ }: {
+ defaultPath?: string;
+ loader: (opts: {
+ config: typeof config;
+ inputs: typeof inputs;
+ }) => Promise;
+ routeRef?: RouteRef;
+ },
+ { config, inputs, node },
+ ) {
+ const ExtensionComponent = lazy(() =>
+ loader({ config, inputs }).then(element => ({ default: () => element })),
+ );
+
+ // TODO(blam): this is a little awkward for optional returns.
+ // I wonder if we should be using generators or yield instead
+ // for a better API here.
+ const outputs = [
+ coreExtensionData.routePath(config.path ?? defaultPath!),
+ coreExtensionData.reactElement(
+
+
+ ,
+ ),
+ ];
+
+ if (routeRef) {
+ return [...outputs, coreExtensionData.routeRef(routeRef)];
+ }
+
+ return outputs;
+ },
+});