Repository: backstage/backstage
Pull request #29502: add template extensions page
Author: login: mbenson
Labels: name: area:scaffolder
description: Everything and all things related to the scaffolder project area
Created: 2025-04-07T23:26:08Z | Merged: 2025-04-22T10:15:54Z

## Hey, I just made a Pull Request!

<!-- Please describe what you added, and add a screenshot if possible.
     That makes it easier to understand the change so we can :shipit: faster. -->

#### :heavy_check_mark: Checklist

<!--- Please include the following in your Pull Request when applicable: -->

- [X] A changeset describing the change and affected packages. ([more info](https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md#creating-changesets))
- [ ] Added or updated documentation
- [X] Tests for new functionality and regression tests for bug fixes
- [X] Screenshots attached (for UI changes)
- [X] All your commits have a `Signed-off-by` line in the message. ([more info](https://github.com/backstage/backstage/blob/master/CONTRIBUTING.md#developer-certificate-of-origin))



Diff:
diff --git a/.changeset/small-eggs-develop.md b/.changeset/small-eggs-develop.md
new file mode 100644
index 00000000000000..618b877fa727ee
--- /dev/null
+++ b/.changeset/small-eggs-develop.md
@@ -0,0 +1,6 @@
+---
+'@backstage/plugin-scaffolder-react': minor
+'@backstage/plugin-scaffolder': minor
+---
+
+add templating extensions page
diff --git a/lighthouserc.js b/lighthouserc.js
index 396bff5dc7872a..c7c1275cdb1624 100644
--- a/lighthouserc.js
+++ b/lighthouserc.js
@@ -29,6 +29,7 @@ module.exports = {
         'http://localhost:3000/create/tasks',
         'http://localhost:3000/create/actions',
         'http://localhost:3000/create/edit',
+        'http://localhost:3000/create/templating-extensions',
         'http://localhost:3000/create/templates/default/react-ssr-template',
         /** Search */
         'http://localhost:3000/search',
diff --git a/plugins/scaffolder-react/report-alpha.api.md b/plugins/scaffolder-react/report-alpha.api.md
index c4f4ecc990dec6..06769b29e231e0 100644
--- a/plugins/scaffolder-react/report-alpha.api.md
+++ b/plugins/scaffolder-react/report-alpha.api.md
@@ -317,6 +317,7 @@ export type ScaffolderPageContextMenuProps = {
   onActionsClicked?: () => void;
   onTasksClicked?: () => void;
   onCreateClicked?: () => void;
+  onTemplatingExtensionsClicked?: () => void;
 };
 
 // @alpha (undocumented)
@@ -339,6 +340,7 @@ export const scaffolderReactTranslationRef: TranslationRef<
     readonly 'scaffolderPageContextMenu.editorLabel': 'Manage Templates';
     readonly 'scaffolderPageContextMenu.actionsLabel': 'Installed Actions';
     readonly 'scaffolderPageContextMenu.tasksLabel': 'Task List';
+    readonly 'scaffolderPageContextMenu.templatingExtensionsLabel': 'Templating Extensions';
     readonly 'stepper.backButtonText': 'Back';
     readonly 'stepper.createButtonText': 'Create';
     readonly 'stepper.reviewButtonText': 'Review';
diff --git a/plugins/scaffolder-react/src/api/types.ts b/plugins/scaffolder-react/src/api/types.ts
index acc980afdcbc6f..d4a9bbe0f8a16d 100644
--- a/plugins/scaffolder-react/src/api/types.ts
+++ b/plugins/scaffolder-react/src/api/types.ts
@@ -86,7 +86,7 @@ export type Action = {
 export type ListActionsResponse = Array<Action>;
 
 /**
- * The response shape for a single filter in the `listTemplateExtensions` call to the `scaffolder-backend`
+ * The response shape for a single filter in the `listTemplatingExtensions` call to the `scaffolder-backend`
  *
  * @public
  */
@@ -101,7 +101,7 @@ export type TemplateFilter = {
 };
 
 /**
- * The response shape for a single global function in the `listTemplateExtensions` call to the `scaffolder-backend`
+ * The response shape for a single global function in the `listTemplatingExtensions` call to the `scaffolder-backend`
  *
  * @public
  */
@@ -115,7 +115,7 @@ export type TemplateGlobalFunction = {
 };
 
 /**
- * The response shape for a single global value in the `listTemplateExtensions` call to the `scaffolder-backend`
+ * The response shape for a single global value in the `listTemplatingExtensions` call to the `scaffolder-backend`
  *
  * @public
  */
@@ -125,7 +125,7 @@ export type TemplateGlobalValue = {
 };
 
 /**
- * The response shape for the `listTemplateExtensions` call to the `scaffolder-backend`
+ * The response shape for the `listTemplatingExtensions` call to the `scaffolder-backend`
  *
  * @public
  */
diff --git a/plugins/scaffolder-react/src/next/components/ScaffolderPageContextMenu/ScaffolderPageContextMenu.tsx b/plugins/scaffolder-react/src/next/components/ScaffolderPageContextMenu/ScaffolderPageContextMenu.tsx
index 8dc6f1d8efb1fc..954679298353a0 100644
--- a/plugins/scaffolder-react/src/next/components/ScaffolderPageContextMenu/ScaffolderPageContextMenu.tsx
+++ b/plugins/scaffolder-react/src/next/components/ScaffolderPageContextMenu/ScaffolderPageContextMenu.tsx
@@ -26,6 +26,7 @@ import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
 import Description from '@material-ui/icons/Description';
 import Edit from '@material-ui/icons/Edit';
 import List from '@material-ui/icons/List';
+import Functions from '@material-ui/icons/Functions';
 import MoreVert from '@material-ui/icons/MoreVert';
 import { SyntheticEvent, useState } from 'react';
 import { usePermission } from '@backstage/plugin-permission-react';
@@ -48,6 +49,7 @@ export type ScaffolderPageContextMenuProps = {
   onActionsClicked?: () => void;
   onTasksClicked?: () => void;
   onCreateClicked?: () => void;
+  onTemplatingExtensionsClicked?: () => void;
 };
 
 /**
@@ -57,8 +59,13 @@ export function ScaffolderPageContextMenu(
   props: ScaffolderPageContextMenuProps,
 ) {
   const { t } = useTranslationRef(scaffolderReactTranslationRef);
-  const { onEditorClicked, onActionsClicked, onTasksClicked, onCreateClicked } =
-    props;
+  const {
+    onEditorClicked,
+    onActionsClicked,
+    onTasksClicked,
+    onCreateClicked,
+    onTemplatingExtensionsClicked,
+  } = props;
   const classes = useStyles();
   const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
 
@@ -71,10 +78,13 @@ export function ScaffolderPageContextMenu(
   });
 
   if (
-    !onEditorClicked &&
-    !onActionsClicked &&
-    !onTasksClicked &&
-    !onCreateClicked
+    !(
+      onEditorClicked ||
+      onActionsClicked ||
+      onTasksClicked ||
+      onCreateClicked ||
+      onTemplatingExtensionsClicked
+    )
   ) {
     return null;
   }
@@ -132,6 +142,18 @@ export function ScaffolderPageContextMenu(
               />
             </MenuItem>
           )}
+          {onTemplatingExtensionsClicked && (
+            <MenuItem onClick={onTemplatingExtensionsClicked}>
+              <ListItemIcon>
+                <Functions fontSize="small" />
+              </ListItemIcon>
+              <ListItemText
+                primary={t(
+                  'scaffolderPageContextMenu.templatingExtensionsLabel',
+                )}
+              />
+            </MenuItem>
+          )}
           {onActionsClicked && (
             <MenuItem onClick={onActionsClicked}>
               <ListItemIcon>
diff --git a/plugins/scaffolder-react/src/translation.ts b/plugins/scaffolder-react/src/translation.ts
index c097d78e3e9fb8..18da2f58e73cae 100644
--- a/plugins/scaffolder-react/src/translation.ts
+++ b/plugins/scaffolder-react/src/translation.ts
@@ -29,6 +29,7 @@ export const scaffolderReactTranslationRef = createTranslationRef({
       editorLabel: 'Manage Templates',
       actionsLabel: 'Installed Actions',
       tasksLabel: 'Task List',
+      templatingExtensionsLabel: 'Templating Extensions',
     },
     stepper: {
       backButtonText: 'Back',
diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md
index d6c3487aab6cbc..de088224b8be57 100644
--- a/plugins/scaffolder/report-alpha.api.md
+++ b/plugins/scaffolder/report-alpha.api.md
@@ -41,6 +41,7 @@ const _default: FrontendPlugin<
     actions: SubRouteRef<undefined>;
     listTasks: SubRouteRef<undefined>;
     edit: SubRouteRef<undefined>;
+    templatingExtensions: SubRouteRef<undefined>;
   },
   {
     registerComponent: ExternalRouteRef<undefined>;
@@ -367,6 +368,26 @@ export const scaffolderTranslationRef: TranslationRef<
     readonly 'renderSchema.tableCell.type': 'Type';
     readonly 'renderSchema.tableCell.title': 'Title';
     readonly 'renderSchema.tableCell.description': 'Description';
+    readonly 'templatingExtensions.content.values.title': 'Values';
+    readonly 'templatingExtensions.content.values.notAvailable': 'There are no global template values defined.';
+    readonly 'templatingExtensions.content.emptyState.title': 'No information to display';
+    readonly 'templatingExtensions.content.emptyState.description': 'There are no templating extensions available or there was an issue communicating with the backend.';
+    readonly 'templatingExtensions.content.filters.title': 'Filters';
+    readonly 'templatingExtensions.content.filters.schema.input': 'Input';
+    readonly 'templatingExtensions.content.filters.schema.output': 'Output';
+    readonly 'templatingExtensions.content.filters.schema.arguments': 'Arguments';
+    readonly 'templatingExtensions.content.filters.examples': 'Examples';
+    readonly 'templatingExtensions.content.filters.notAvailable': 'There are no template filters defined.';
+    readonly 'templatingExtensions.content.filters.metadataAbsent': 'Filter metadata unavailable';
+    readonly 'templatingExtensions.content.searchFieldPlaceholder': 'Search for an extension';
+    readonly 'templatingExtensions.content.functions.title': 'Functions';
+    readonly 'templatingExtensions.content.functions.schema.output': 'Output';
+    readonly 'templatingExtensions.content.functions.schema.arguments': 'Arguments';
+    readonly 'templatingExtensions.content.functions.examples': 'Examples';
+    readonly 'templatingExtensions.content.functions.notAvailable': 'There are no global template functions defined.';
+    readonly 'templatingExtensions.title': 'Templating Extensions';
+    readonly 'templatingExtensions.subtitle': 'This is the collection of available templating extensions';
+    readonly 'templatingExtensions.pageTitle': 'Templating Extensions';
     readonly 'templateTypePicker.title': 'Categories';
     readonly 'templateIntroPage.title': 'Manage Templates';
     readonly 'templateIntroPage.subtitle': 'Edit, preview, and try out templates, forms, and custom fields';
@@ -422,6 +443,7 @@ export const scaffolderTranslationRef: TranslationRef<
     readonly 'templateWizardPage.pageContextMenu.editConfigurationTitle': 'Edit Configuration';
     readonly 'templateEditorToolbar.customFieldExplorerTooltip': 'Custom Fields Explorer';
     readonly 'templateEditorToolbar.installedActionsDocumentationTooltip': 'Installed Actions Documentation';
+    readonly 'templateEditorToolbar.templatingExtensionsDocumentationTooltip': 'Templating Extensions Documentation';
     readonly 'templateEditorToolbar.addToCatalogButton': 'Publish';
     readonly 'templateEditorToolbar.addToCatalogDialogTitle': 'Publish changes';
     readonly 'templateEditorToolbar.addToCatalogDialogContent.stepsIntroduction': 'Follow the instructions below to create or update a template:';
@@ -447,6 +469,7 @@ export type TemplateListPageProps = {
     editor?: boolean;
     actions?: boolean;
     tasks?: boolean;
+    templatingExtensions?: boolean;
   };
   headerOptions?: {
     pageTitleOverride?: string;
diff --git a/plugins/scaffolder/report.api.md b/plugins/scaffolder/report.api.md
index 927599b91f6fab..b929d7be5b8fcc 100644
--- a/plugins/scaffolder/report.api.md
+++ b/plugins/scaffolder/report.api.md
@@ -618,6 +618,7 @@ export const scaffolderPlugin: BackstagePlugin<
     editor: SubRouteRef<undefined>;
     customFields: SubRouteRef<undefined>;
     templateForm: SubRouteRef<undefined>;
+    templatingExtensions: SubRouteRef<undefined>;
   },
   {
     registerComponent: ExternalRouteRef<undefined, true>;
diff --git a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbar.tsx b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbar.tsx
index 06593a1c6f0daf..6a0d673088146d 100644
--- a/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbar.tsx
+++ b/plugins/scaffolder/src/alpha/components/TemplateEditorPage/TemplateEditorToolbar.tsx
@@ -30,6 +30,7 @@ import DialogContentText from '@material-ui/core/DialogContentText';
 import DialogActions from '@material-ui/core/DialogActions';
 import ExtensionIcon from '@material-ui/icons/Extension';
 import DescriptionIcon from '@material-ui/icons/Description';
+import FunctionsIcon from '@material-ui/icons/Functions';
 
 import { useTranslationRef } from '@backstage/frontend-plugin-api';
 import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
@@ -37,6 +38,7 @@ import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
 import { ActionPageContent } from '../../../components/ActionsPage/ActionsPage';
 import { scaffolderTranslationRef } from '../../../translation';
 import { CustomFieldPlaygroud } from './CustomFieldPlaygroud';
+import { TemplatingExtensionsPageContent } from '../../../components/TemplatingExtensionsPage/TemplatingExtensionsPage';
 
 const useStyles = makeStyles(
   theme => ({
@@ -83,6 +85,7 @@ export function TemplateEditorToolbar(props: {
   const { t } = useTranslationRef(scaffolderTranslationRef);
   const [showFieldsDrawer, setShowFieldsDrawer] = useState(false);
   const [showActionsDrawer, setShowActionsDrawer] = useState(false);
+  const [showExtensionsDrawer, setShowExtensionsDrawer] = useState(false);
   const [showPublishModal, setShowPublishModal] = useState(false);
 
   return (
@@ -106,6 +109,15 @@ export function TemplateEditorToolbar(props: {
               <DescriptionIcon />
             </Button>
           </Tooltip>
+          <Tooltip
+            title={t(
+              'templateEditorToolbar.templatingExtensionsDocumentationTooltip',
+            )}
+          >
+            <Button onClick={() => setShowExtensionsDrawer(true)}>
+              <FunctionsIcon />
+            </Button>
+          </Tooltip>
           <Button onClick={() => setShowPublishModal(true)}>
             {t('templateEditorToolbar.addToCatalogButton')}
           </Button>
@@ -126,6 +138,14 @@ export function TemplateEditorToolbar(props: {
         >
           <ActionPageContent />
         </Drawer>
+        <Drawer
+          classes={{ paper: classes.paper }}
+          anchor="right"
+          open={showExtensionsDrawer}
+          onClose={() => setShowExtensionsDrawer(false)}
+        >
+          <TemplatingExtensionsPageContent />
+        </Drawer>
         <Dialog
           onClose={() => setShowPublishModal(false)}
           open={showPublishModal}
diff --git a/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.test.tsx b/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.test.tsx
index e2be6508518d21..a16474a4cfbabc 100644
--- a/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.test.tsx
+++ b/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.test.tsx
@@ -204,6 +204,7 @@ describe('TemplateListPage', () => {
               editor: false,
               actions: false,
               tasks: false,
+              templatingExtensions: false,
             }}
           />
         </TestApiProvider>,
diff --git a/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.tsx b/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.tsx
index 072bc81d060001..1bc74e64c4a307 100644
--- a/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.tsx
+++ b/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.tsx
@@ -49,6 +49,7 @@ import {
   registerComponentRouteRef,
   scaffolderListTaskRouteRef,
   selectedTemplateRouteRef,
+  templatingExtensionsRouteRef,
   viewTechDocRouteRef,
 } from '../../../routes';
 import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model';
@@ -72,6 +73,7 @@ export type TemplateListPageProps = {
     editor?: boolean;
     actions?: boolean;
     tasks?: boolean;
+    templatingExtensions?: boolean;
   };
   headerOptions?: {
     pageTitleOverride?: string;
@@ -108,6 +110,7 @@ export const TemplateListPage = (props: TemplateListPageProps) => {
   const tasksLink = useRouteRef(scaffolderListTaskRouteRef);
   const viewTechDocsLink = useRouteRef(viewTechDocRouteRef);
   const templateRoute = useRouteRef(selectedTemplateRouteRef);
+  const templatingExtensionsLink = useRouteRef(templatingExtensionsRouteRef);
   const app = useApp();
   const { t } = useTranslationRef(scaffolderTranslationRef);
 
@@ -133,6 +136,10 @@ export const TemplateListPage = (props: TemplateListPageProps) => {
       props?.contextMenu?.tasks !== false
         ? () => navigate(tasksLink())
         : undefined,
+    onTemplatingExtensionsClicked:
+      props?.contextMenu?.templatingExtensions !== false
+        ? () => navigate(templatingExtensionsLink())
+        : undefined,
   };
 
   const additionalLinksForEntity = useCallback(
diff --git a/plugins/scaffolder/src/alpha/plugin.tsx b/plugins/scaffolder/src/alpha/plugin.tsx
index e281f51b0fea9e..90fc22e980f5c5 100644
--- a/plugins/scaffolder/src/alpha/plugin.tsx
+++ b/plugins/scaffolder/src/alpha/plugin.tsx
@@ -24,6 +24,7 @@ import {
   scaffolderListTaskRouteRef,
   scaffolderTaskRouteRef,
   selectedTemplateRouteRef,
+  templatingExtensionsRouteRef,
   viewTechDocRouteRef,
 } from '../routes';
 import {
@@ -45,6 +46,7 @@ export default createFrontendPlugin({
     actions: actionsRouteRef,
     listTasks: scaffolderListTaskRouteRef,
     edit: editRouteRef,
+    templatingExtensions: templatingExtensionsRouteRef,
   }),
   externalRoutes: convertLegacyRouteRefs({
     registerComponent: registerComponentRouteRef,
diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts
index 1121593e2d2821..4fb3627917b213 100644
--- a/plugins/scaffolder/src/api.ts
+++ b/plugins/scaffolder/src/api.ts
@@ -328,7 +328,7 @@ export class ScaffolderClient implements ScaffolderApi {
   async listTemplatingExtensions(): Promise<ListTemplatingExtensionsResponse> {
     const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder');
     const response = await this.fetchApi.fetch(
-      `${baseUrl}/v2/template-extensions`,
+      `${baseUrl}/v2/templating-extensions`,
     );
     if (!response.ok) {
       throw ResponseError.fromResponse(response);
diff --git a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx
index 8329d236c99424..402c0427dc4d25 100644
--- a/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx
+++ b/plugins/scaffolder/src/components/ActionsPage/ActionsPage.tsx
@@ -46,6 +46,7 @@ import {
   editRouteRef,
   rootRouteRef,
   scaffolderListTaskRouteRef,
+  templatingExtensionsRouteRef,
 } from '../../routes';
 import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
 import { scaffolderTranslationRef } from '../../translation';
@@ -247,6 +248,7 @@ export const ActionsPage = (props: ActionsPageProps) => {
   const editorLink = useRouteRef(editRouteRef);
   const tasksLink = useRouteRef(scaffolderListTaskRouteRef);
   const createLink = useRouteRef(rootRouteRef);
+  const templatingExtensionsLink = useRouteRef(templatingExtensionsRouteRef);
   const { t } = useTranslationRef(scaffolderTranslationRef);
 
   const scaffolderPageContextMenuProps = {
@@ -263,6 +265,7 @@ export const ActionsPage = (props: ActionsPageProps) => {
       props?.contextMenu?.create !== false
         ? () => navigate(createLink())
         : undefined,
+    onTemplatingExtensionsClicked: () => navigate(templatingExtensionsLink()),
   };
 
   return (
diff --git a/plugins/scaffolder/src/components/ListTasksPage/ListTasksPage.tsx b/plugins/scaffolder/src/components/ListTasksPage/ListTasksPage.tsx
index 03afa7cb104bb6..6d48cec125ae1c 100644
--- a/plugins/scaffolder/src/components/ListTasksPage/ListTasksPage.tsx
+++ b/plugins/scaffolder/src/components/ListTasksPage/ListTasksPage.tsx
@@ -38,7 +38,12 @@ import {
   TaskStatusColumn,
   TemplateTitleColumn,
 } from './columns';
-import { actionsRouteRef, editRouteRef, rootRouteRef } from '../../routes';
+import {
+  actionsRouteRef,
+  editRouteRef,
+  rootRouteRef,
+  templatingExtensionsRouteRef,
+} from '../../routes';
 import { ScaffolderPageContextMenu } from '@backstage/plugin-scaffolder-react/alpha';
 import { useNavigate } from 'react-router-dom';
 import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
@@ -164,6 +169,7 @@ export const ListTasksPage = (props: MyTaskPageProps) => {
   const actionsLink = useRouteRef(actionsRouteRef);
   const createLink = useRouteRef(rootRouteRef);
   const { t } = useTranslationRef(scaffolderTranslationRef);
+  const templatingExtensionsLink = useRouteRef(templatingExtensionsRouteRef);
 
   const scaffolderPageContextMenuProps = {
     onEditorClicked:
@@ -179,6 +185,7 @@ export const ListTasksPage = (props: MyTaskPageProps) => {
       props?.contextMenu?.create !== false
         ? () => navigate(createLink())
         : undefined,
+    onTemplatingExtensionsClicked: () => navigate(templatingExtensionsLink()),
   };
   return (
     <Page themeId="home">
diff --git a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx
index 21debf4915030b..8f967f8ee281cd 100644
--- a/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx
+++ b/plugins/scaffolder/src/components/RenderSchema/RenderSchema.tsx
@@ -14,11 +14,7 @@
  * limitations under the License.
  */
 import { MarkdownContent } from '@backstage/core-components';
-import {
-  TranslationFunction,
-  TranslationRef,
-  useTranslationRef,
-} from '@backstage/core-plugin-api/alpha';
+import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
 import Box from '@material-ui/core/Box';
 import Chip from '@material-ui/core/Chip';
 import Collapse from '@material-ui/core/Collapse';
@@ -48,6 +44,7 @@ import {
 import { FC, JSX, cloneElement, Fragment } from 'react';
 import { scaffolderTranslationRef } from '../../translation';
 import { SchemaRenderContext, SchemaRenderStrategy } from './types';
+import { TranslationMessages } from '../TemplatingExtensionsPage/types';
 
 const getTypes = (properties: JSONSchema7) => {
   if (!properties.type) {
@@ -149,13 +146,9 @@ type RenderColumn = (
   context: SchemaRenderContext,
 ) => JSX.Element;
 
-type Xlate<R> = R extends TranslationRef<any, infer M>
-  ? TranslationFunction<M>
-  : never;
-
 type Column = {
   key: string;
-  title: (t: Xlate<typeof scaffolderTranslationRef>) => string;
+  title: (t: TranslationMessages<typeof scaffolderTranslationRef>) => string;
   render: RenderColumn;
   className?: keyof ReturnType<typeof useColumnStyles>;
 };
diff --git a/plugins/scaffolder/src/components/Router/Router.tsx b/plugins/scaffolder/src/components/Router/Router.tsx
index 429ba7b1a69bdd..91567c1ddd197d 100644
--- a/plugins/scaffolder/src/components/Router/Router.tsx
+++ b/plugins/scaffolder/src/components/Router/Router.tsx
@@ -41,6 +41,7 @@ import {
   scaffolderTaskRouteRef,
   selectedTemplateRouteRef,
   templateFormRouteRef,
+  templatingExtensionsRouteRef,
 } from '../../routes';
 
 import { ActionsPage } from '../../components/ActionsPage';
@@ -66,6 +67,7 @@ import {
 import { useApp } from '@backstage/core-plugin-api';
 import { FormField, OpaqueFormField } from '@internal/scaffolder';
 import { useAsync, useMountEffect } from '@react-hookz/web';
+import { TemplatingExtensionsPage } from '../TemplatingExtensionsPage';
 
 /**
  * The Props for the Scaffolder Router
@@ -248,6 +250,10 @@ export const InternalRouter = (
           </RequirePermission>
         }
       />
+      <Route
+        path={templatingExtensionsRouteRef.path}
+        element={<TemplatingExtensionsPage />}
+      />
       <Route path="*" element={<NotFoundErrorPage />} />
     </Routes>
   );
diff --git a/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateFilters.tsx b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateFilters.tsx
new file mode 100644
index 00000000000000..e0c80c27cfd8ae
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateFilters.tsx
@@ -0,0 +1,190 @@
+/*
+ * 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 { Link, MarkdownContent } from '@backstage/core-components';
+import {
+  ListTemplatingExtensionsResponse,
+  TemplateFilter,
+} from '@backstage/plugin-scaffolder-react';
+import Accordion from '@material-ui/core/Accordion';
+import AccordionDetails from '@material-ui/core/AccordionDetails';
+import AccordionSummary from '@material-ui/core/AccordionSummary';
+import Box from '@material-ui/core/Box';
+import { ClassNameMap } from '@material-ui/core/styles/withStyles';
+import Typography from '@material-ui/core/Typography';
+import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
+import classNames from 'classnames';
+import React, { ReactElement, useState } from 'react';
+import { scaffolderTranslationRef } from '../../translation';
+import { Expanded, RenderSchema, SchemaRenderContext } from '../RenderSchema';
+import { ScaffolderUsageExamplesTable } from '../ScaffolderUsageExamplesTable';
+import { inspectFunctionArgSchema } from './functionArgs';
+import { Extension, renderFragment } from './navigation';
+import { StyleClasses, TranslationMessages } from './types';
+
+const FilterDetailContent = ({
+  t,
+  classes,
+  name,
+  filter,
+}: {
+  t: TranslationMessages<typeof scaffolderTranslationRef>;
+  classes: ClassNameMap;
+  name: string;
+  filter: TemplateFilter;
+}) => {
+  const expanded = useState<Expanded>({});
+  if (!Object.keys(filter).length) {
+    return (
+      <Typography style={{ fontStyle: 'italic' }}>
+        {t('templatingExtensions.content.filters.metadataAbsent')}
+      </Typography>
+    );
+  }
+  const schema = filter.schema;
+  const partialSchemaRenderContext: Omit<SchemaRenderContext, 'parentId'> = {
+    classes,
+    expanded,
+    headings: [<Typography variant="h6" component="h4" />],
+  };
+  return (
+    <React.Fragment key={`${name}.detail`}>
+      {filter.description && <MarkdownContent content={filter.description} />}
+      <Box pb={2}>
+        <Typography variant="h5" component="h3">
+          {t('templatingExtensions.content.filters.schema.input')}
+        </Typography>
+        <RenderSchema
+          strategy="root"
+          context={{
+            parentId: `${name}.input`,
+            ...partialSchemaRenderContext,
+          }}
+          schema={schema?.input ?? {}}
+        />
+      </Box>
+      {schema?.arguments?.length && (
+        <Box key={`${name}.args`} pb={2}>
+          <Typography variant="h5" component="h3">
+            {t('templatingExtensions.content.filters.schema.arguments')}
+          </Typography>
+          {schema.arguments.map((arg, i) => {
+            const [argSchema, required] = inspectFunctionArgSchema(arg);
+
+            return (
+              <React.Fragment key={i}>
+                <div
+                  className={classNames({ [classes.argRequired]: required })}
+                >
+                  <Typography variant="h6" component="h4">
+                    {`[${i}]`}
+                  </Typography>
+                </div>
+                <RenderSchema
+                  strategy="root"
+                  context={{
+                    parentId: `${name}.arg${i}`,
+                    ...partialSchemaRenderContext,
+                    headings: [<Typography variant="h6" component="h5" />],
+                  }}
+                  schema={argSchema}
+                />
+              </React.Fragment>
+            );
+          })}
+        </Box>
+      )}
+      <Box pb={2}>
+        <Typography variant="h5" component="h3">
+          {t('templatingExtensions.content.filters.schema.output')}
+        </Typography>
+        <RenderSchema
+          strategy="root"
+          context={{
+            parentId: `${name}.output`,
+            ...partialSchemaRenderContext,
+          }}
+          schema={schema?.output ?? {}}
+        />
+      </Box>
+      {filter.examples && (
+        <Accordion>
+          <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+            <Typography variant="h5" component="h3">
+              {t('templatingExtensions.content.filters.examples')}
+            </Typography>
+          </AccordionSummary>
+          <AccordionDetails>
+            <Box pb={2}>
+              <ScaffolderUsageExamplesTable examples={filter.examples} />
+            </Box>
+          </AccordionDetails>
+        </Accordion>
+      )}
+    </React.Fragment>
+  );
+};
+
+export const TemplateFilters = ({
+  t,
+  classes,
+  filters,
+  baseLink,
+  selectedItem,
+}: {
+  t: TranslationMessages<typeof scaffolderTranslationRef>;
+  classes: StyleClasses;
+  filters: ListTemplatingExtensionsResponse['filters'];
+  baseLink: ReactElement<Parameters<typeof Link>[0]>;
+  selectedItem: Extension | null;
+}) => {
+  if (selectedItem && selectedItem.kind !== 'filter') {
+    return <></>;
+  }
+  if (!Object.keys(filters).length) {
+    return (
+      <div data-testid="no-filters">
+        {t('templatingExtensions.content.filters.notAvailable')}
+      </div>
+    );
+  }
+  return (
+    <div data-testid="filters">
+      {Object.entries(
+        selectedItem
+          ? { [selectedItem.name]: filters[selectedItem.name] }
+          : filters,
+      ).map(([name, filter]) => {
+        const fragment = renderFragment({ kind: 'filter', name });
+        return (
+          <Box pb={4} key={name} data-testid={name}>
+            <Typography
+              id={fragment}
+              variant="h4"
+              component="h2"
+              className={classes.code}
+            >
+              {name}
+            </Typography>
+            {React.cloneElement(baseLink, {
+              to: `${baseLink.props.to}#${fragment}`,
+            })}
+            <FilterDetailContent {...{ t, classes, name, filter }} />
+          </Box>
+        );
+      })}
+    </div>
+  );
+};
diff --git a/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateGlobals.tsx b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateGlobals.tsx
new file mode 100644
index 00000000000000..329f7b9742b168
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplateGlobals.tsx
@@ -0,0 +1,237 @@
+/*
+ * 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 { CodeSnippet, Link, MarkdownContent } from '@backstage/core-components';
+import {
+  ListTemplatingExtensionsResponse,
+  TemplateGlobalFunction,
+} from '@backstage/plugin-scaffolder-react';
+import Accordion from '@material-ui/core/Accordion';
+import AccordionDetails from '@material-ui/core/AccordionDetails';
+import AccordionSummary from '@material-ui/core/AccordionSummary';
+import Box from '@material-ui/core/Box';
+import { ClassNameMap } from '@material-ui/core/styles/withStyles';
+import Typography from '@material-ui/core/Typography';
+import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
+import classNames from 'classnames';
+import React, { ReactElement, useState } from 'react';
+import { scaffolderTranslationRef } from '../../translation';
+import { Expanded, RenderSchema, SchemaRenderContext } from '../RenderSchema';
+import { ScaffolderUsageExamplesTable } from '../ScaffolderUsageExamplesTable';
+import { inspectFunctionArgSchema } from './functionArgs';
+import { Extension, renderFragment } from './navigation';
+import { TranslationMessages } from './types';
+
+const FunctionDetailContent = ({
+  classes,
+  name,
+  fn,
+  t,
+}: {
+  classes: ClassNameMap;
+  name: string;
+  fn: TemplateGlobalFunction;
+  t: TranslationMessages<typeof scaffolderTranslationRef>;
+}) => {
+  const expanded = useState<Expanded>({});
+  if (!Object.keys(fn).length) {
+    return (
+      <Typography style={{ fontStyle: 'italic' }}>
+        {t('templatingExtensions.content.functions.notAvailable')}
+      </Typography>
+    );
+  }
+  const schema = fn.schema;
+  const partialSchemaRenderContext: Omit<SchemaRenderContext, 'parentId'> = {
+    classes,
+    expanded,
+    headings: [<Typography variant="h6" component="h4" />],
+  };
+  return (
+    <React.Fragment key={`${name}.detail`}>
+      {fn.description && <MarkdownContent content={fn.description} />}
+      {schema?.arguments?.length && (
+        <Box key={`${name}.args`} pb={2}>
+          <Typography variant="h5" component="h3">
+            {t('templatingExtensions.content.functions.schema.arguments')}
+          </Typography>
+          {schema.arguments.map((arg, i) => {
+            const [argSchema, required] = inspectFunctionArgSchema(arg);
+
+            return (
+              <React.Fragment key={i}>
+                <div
+                  className={classNames({ [classes.argRequired]: required })}
+                >
+                  <Typography
+                    variant="h6"
+                    component="h4"
+                  >{`[${i}]`}</Typography>
+                </div>
+                <RenderSchema
+                  strategy="root"
+                  context={{
+                    parentId: `${name}.arg${i}`,
+                    ...partialSchemaRenderContext,
+                    headings: [<Typography variant="h6" component="h5" />],
+                  }}
+                  schema={argSchema}
+                />
+              </React.Fragment>
+            );
+          })}
+        </Box>
+      )}
+      <Box pb={2}>
+        <Typography variant="h5" component="h3">
+          {t('templatingExtensions.content.functions.schema.output')}
+        </Typography>
+        <RenderSchema
+          strategy="root"
+          context={{
+            parentId: `${name}.output`,
+            ...partialSchemaRenderContext,
+          }}
+          schema={schema?.output ?? {}}
+        />
+      </Box>
+      {fn.examples && (
+        <Accordion>
+          <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+            <Typography variant="h5" component="h3">
+              {t('templatingExtensions.content.functions.examples')}
+            </Typography>
+          </AccordionSummary>
+          <AccordionDetails>
+            <Box pb={2}>
+              <ScaffolderUsageExamplesTable examples={fn.examples} />
+            </Box>
+          </AccordionDetails>
+        </Accordion>
+      )}
+    </React.Fragment>
+  );
+};
+
+export const TemplateGlobalFunctions = ({
+  classes,
+  functions,
+  t,
+  baseLink,
+  selectedItem,
+}: {
+  classes: ClassNameMap;
+  functions: ListTemplatingExtensionsResponse['globals']['functions'];
+  t: TranslationMessages<typeof scaffolderTranslationRef>;
+  baseLink: ReactElement<Parameters<typeof Link>[0]>;
+  selectedItem: Extension | null;
+}) => {
+  if (selectedItem && selectedItem.kind !== 'function') {
+    return <></>;
+  }
+  if (!Object.keys(functions).length) {
+    return (
+      <div data-testid="no-functions">
+        {t('templatingExtensions.content.functions.notAvailable')}
+      </div>
+    );
+  }
+  return (
+    <div data-testid="functions">
+      {Object.entries(
+        selectedItem
+          ? { [selectedItem.name]: functions[selectedItem.name] }
+          : functions,
+      ).map(([name, fn]) => {
+        const fragment = renderFragment({ kind: 'function', name });
+        return (
+          <Box pb={4} key={name} data-testid={name}>
+            <Typography
+              id={fragment}
+              variant="h4"
+              component="h2"
+              className={classes.code}
+            >
+              {name}
+            </Typography>
+            {React.cloneElement(baseLink, {
+              to: `${baseLink.props.to}#${fragment}`,
+            })}
+            <FunctionDetailContent {...{ classes, name, fn, t }} />
+          </Box>
+        );
+      })}
+    </div>
+  );
+};
+
+export const TemplateGlobalValues = ({
+  classes,
+  t,
+  values,
+  baseLink,
+  selectedItem,
+}: {
+  classes: ClassNameMap;
+  t: TranslationMessages<typeof scaffolderTranslationRef>;
+  values: ListTemplatingExtensionsResponse['globals']['values'];
+  baseLink: ReactElement<Parameters<typeof Link>[0]>;
+  selectedItem: Extension | null;
+}) => {
+  if (selectedItem && selectedItem.kind !== 'value') {
+    return <></>;
+  }
+  if (!Object.keys(values).length) {
+    return (
+      <div data-testid="no-values">
+        {t('templatingExtensions.content.values.notAvailable')}
+      </div>
+    );
+  }
+  return (
+    <div data-testid="values">
+      {Object.entries(
+        selectedItem
+          ? { [selectedItem.name]: values[selectedItem.name] }
+          : values,
+      ).map(([name, gv]) => {
+        const fragment = renderFragment({ kind: 'value', name });
+        return (
+          <Box pb={4} key={name} data-testid={name}>
+            <Typography
+              id={fragment}
+              variant="h4"
+              component="h2"
+              className={classes.code}
+            >
+              {name}
+            </Typography>
+            {React.cloneElement(baseLink, {
+              to: `${baseLink.props.to}#${fragment}`,
+            })}
+            {gv.description && <MarkdownContent content={gv.description} />}
+            <Box padding={1} data-testid={`${name}.value`}>
+              <CodeSnippet
+                text={JSON.stringify(gv.value, null, 2)}
+                showCopyCodeButton
+                language="json"
+              />
+            </Box>
+          </Box>
+        );
+      })}
+    </div>
+  );
+};
diff --git a/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplatingExtensionsPage.test.tsx b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplatingExtensionsPage.test.tsx
new file mode 100644
index 00000000000000..d3d0eb737033b0
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplatingExtensionsPage.test.tsx
@@ -0,0 +1,431 @@
+/*
+ * 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 { ApiProvider } from '@backstage/core-app-api';
+import { permissionApiRef } from '@backstage/plugin-permission-react';
+import {
+  ListTemplatingExtensionsResponse,
+  ScaffolderApi,
+  scaffolderApiRef,
+} from '@backstage/plugin-scaffolder-react';
+import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
+import { fireEvent, within } from '@testing-library/react';
+import { capitalize } from 'lodash';
+import { rootRouteRef } from '../../routes';
+import { TemplatingExtensionsPage } from './TemplatingExtensionsPage';
+import { ExtensionKind } from './navigation';
+
+const listTemplatingExtensions = jest.fn();
+
+const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
+  scaffold: jest.fn(),
+  cancelTask: jest.fn(),
+  getTemplateParameterSchema: jest.fn(),
+  getIntegrationsList: jest.fn(),
+  getTask: jest.fn(),
+  streamLogs: jest.fn(),
+  listActions: jest.fn(),
+  listTemplatingExtensions,
+  listTasks: jest.fn(),
+  autocomplete: jest.fn(),
+};
+
+const mockPermissionApi = { authorize: jest.fn() };
+const apis = TestApiRegistry.from(
+  [scaffolderApiRef, scaffolderApiMock],
+  [permissionApiRef, mockPermissionApi],
+);
+
+const r = async () =>
+  renderInTestApp(
+    <ApiProvider apis={apis}>
+      <TemplatingExtensionsPage />
+    </ApiProvider>,
+    {
+      mountedRoutes: {
+        '/create/templating-extensions': rootRouteRef,
+      },
+    },
+  );
+
+const emptyExtensions: ListTemplatingExtensionsResponse = {
+  filters: {},
+  globals: {
+    functions: {},
+    values: {},
+  },
+};
+
+describe('TemplatingExtensionsPage', () => {
+  it('renders with error response', async () => {
+    listTemplatingExtensions.mockRejectedValue(new Error('contrived'));
+    const { getByTestId } = await r();
+
+    const empty = getByTestId('empty');
+    expect(empty).toBeInTheDocument();
+
+    expect(within(empty).getByText('contrived')).toBeInTheDocument();
+  });
+  it('renders tabs', async () => {
+    listTemplatingExtensions.mockResolvedValue(emptyExtensions);
+    const { getByRole, findByTestId, queryByTestId } = await r();
+
+    const tabList = getByRole('tablist');
+    expect(tabList).toBeInTheDocument();
+
+    const extensionKinds: ExtensionKind[] = ['filter', 'function', 'value'];
+
+    const tabs = Object.fromEntries(
+      extensionKinds.map(
+        t =>
+          [t, within(tabList).getByText(`${capitalize(t)}s`)] as [
+            ExtensionKind,
+            HTMLElement,
+          ],
+      ),
+    );
+
+    for (const k of extensionKinds) {
+      expect(tabs[k]).toBeInTheDocument();
+    }
+    const verifyActiveTab = async (k: ExtensionKind) => {
+      expect(await findByTestId(`no-${k}s`)).toBeInTheDocument();
+      extensionKinds
+        .filter(candidate => candidate !== k)
+        .forEach(nk =>
+          expect(queryByTestId(`no-${nk}s`)).not.toBeInTheDocument(),
+        );
+    };
+
+    await verifyActiveTab('filter');
+
+    for (let i = extensionKinds.length - 1; i >= 0; i--) {
+      fireEvent.click(tabs[extensionKinds[i]]);
+      await verifyActiveTab(extensionKinds[i]);
+    }
+  });
+  it('renders with no extensions', async () => {
+    listTemplatingExtensions.mockResolvedValue(emptyExtensions);
+    const { findByTestId, getByRole, getByTestId, queryByTestId } = await r();
+
+    expect(queryByTestId('empty')).not.toBeInTheDocument();
+
+    expect(getByTestId('no-filters')).toBeInTheDocument();
+
+    fireEvent.click(within(getByRole('tablist')).getByText('Functions'));
+
+    await expect(findByTestId('no-functions')).resolves.toBeInTheDocument();
+
+    fireEvent.click(within(getByRole('tablist')).getByText('Values'));
+
+    await expect(findByTestId('no-values')).resolves.toBeInTheDocument();
+  });
+  describe('renders filters', () => {
+    it('renders filter without metadata', async () => {
+      listTemplatingExtensions.mockResolvedValue({
+        ...emptyExtensions,
+        filters: {
+          bar: {},
+        },
+      });
+      const { getByTestId, queryByTestId } = await r();
+
+      const filters = getByTestId('filters');
+      expect(filters).toBeInTheDocument();
+
+      const bar = within(filters).getByTestId('bar');
+      expect(bar).toBeInTheDocument();
+
+      const title = within(bar).getByText('bar');
+      expect(title).toBeInTheDocument();
+      expect(title.id).toBe('filter_bar');
+
+      const link = within(bar).getByRole('link');
+      expect(link).toBeInTheDocument();
+      expect(link).toHaveAttribute(
+        'href',
+        expect.stringMatching(new RegExp(`#${title.id}$`)),
+      );
+
+      expect(queryByTestId('root_bar.input')).not.toBeInTheDocument();
+      expect(queryByTestId('root_bar.arg0')).not.toBeInTheDocument();
+      expect(queryByTestId('root_bar.output')).not.toBeInTheDocument();
+    });
+    it('renders input/output with empty filter schema', async () => {
+      listTemplatingExtensions.mockResolvedValue({
+        ...emptyExtensions,
+        filters: {
+          foo: {
+            schema: {},
+          },
+        },
+      });
+      const { getByTestId, queryByTestId } = await r();
+
+      const filters = getByTestId('filters');
+      expect(filters).toBeInTheDocument();
+
+      const foo = within(filters).getByTestId('foo');
+      expect(foo).toBeInTheDocument();
+
+      const title = within(foo).getByText('foo');
+      expect(title).toBeInTheDocument();
+      expect(title.id).toBe('filter_foo');
+
+      const link = within(foo).getByRole('link');
+      expect(link).toBeInTheDocument();
+      expect(link).toHaveAttribute(
+        'href',
+        expect.stringMatching(new RegExp(`#${title.id}$`)),
+      );
+
+      expect(getByTestId('root_foo.input')).toBeInTheDocument();
+      expect(queryByTestId('root_foo.arg0')).not.toBeInTheDocument();
+      expect(getByTestId('root_foo.output')).toBeInTheDocument();
+    });
+    it('renders fully specified filter metadata', async () => {
+      listTemplatingExtensions.mockResolvedValue({
+        ...emptyExtensions,
+        filters: {
+          foo: {
+            description: 'foo filter',
+            schema: {
+              input: {
+                description: 'a value',
+              },
+              arguments: [
+                {
+                  description: 'an arg',
+                },
+              ],
+              output: {
+                description: 'same value',
+              },
+            },
+          },
+        },
+      });
+      const { getByTestId } = await r();
+
+      const filters = getByTestId('filters');
+      expect(filters).toBeInTheDocument();
+
+      const foo = within(filters).getByTestId('foo');
+      expect(foo).toBeInTheDocument();
+
+      const title = within(foo).getByText('foo');
+      expect(title).toBeInTheDocument();
+      expect(title.id).toBe('filter_foo');
+
+      const link = within(foo).getByRole('link');
+      expect(link).toBeInTheDocument();
+      expect(link).toHaveAttribute(
+        'href',
+        expect.stringMatching(new RegExp(`#${title.id}$`)),
+      );
+
+      expect(within(foo).getByText('foo filter')).toBeInTheDocument();
+      expect(within(foo).getByTestId('root_foo.input')).toBeInTheDocument();
+      expect(within(foo).getByTestId('root_foo.arg0')).toBeInTheDocument();
+      expect(
+        within(foo).queryByTestId('root_foo.arg1'),
+      ).not.toBeInTheDocument();
+      expect(within(foo).getByTestId('root_foo.output')).toBeInTheDocument();
+    });
+    it('renders multiple args', async () => {
+      listTemplatingExtensions.mockResolvedValue({
+        ...emptyExtensions,
+        filters: {
+          baz: {
+            schema: {
+              arguments: [
+                {
+                  type: 'number',
+                },
+                {
+                  type: 'string',
+                },
+              ],
+            },
+          },
+        },
+      });
+      const { getByTestId } = await r();
+
+      const filters = getByTestId('filters');
+      expect(filters).toBeInTheDocument();
+
+      const baz = within(filters).getByTestId('baz');
+      expect(baz).toBeInTheDocument();
+
+      const title = within(baz).getByText('baz');
+      expect(title).toBeInTheDocument();
+      expect(title.id).toBe('filter_baz');
+
+      const link = within(baz).getByRole('link');
+      expect(link).toBeInTheDocument();
+      expect(link).toHaveAttribute(
+        'href',
+        expect.stringMatching(new RegExp(`#${title.id}$`)),
+      );
+
+      expect(within(baz).getByTestId('root_baz.input')).toBeInTheDocument();
+      expect(within(baz).getByTestId('root_baz.arg0')).toBeInTheDocument();
+      expect(within(baz).getByTestId('root_baz.arg1')).toBeInTheDocument();
+      expect(within(baz).getByTestId('root_baz.output')).toBeInTheDocument();
+    });
+    it('renders examples', async () => {
+      listTemplatingExtensions.mockResolvedValue({
+        ...emptyExtensions,
+        filters: {
+          wut: {
+            examples: [
+              {
+                description: 'thing 1',
+                example: 'let me show you',
+              },
+              {
+                description: 'thing 2',
+                example: "how it's done",
+              },
+            ],
+          },
+        },
+      });
+      const { getByTestId } = await r();
+
+      const filters = getByTestId('filters');
+      expect(filters).toBeInTheDocument();
+
+      const wut = within(filters).getByTestId('wut');
+      expect(wut).toBeInTheDocument();
+      expect(within(wut).getByTestId('examples')).toBeInTheDocument();
+    });
+  });
+  describe('renders global', () => {
+    it('renders global functions', async () => {
+      listTemplatingExtensions.mockResolvedValue({
+        ...emptyExtensions,
+        globals: {
+          ...emptyExtensions.globals,
+          functions: {
+            truthy: {
+              description: 'evaluate truthiness',
+              schema: {
+                arguments: [
+                  {
+                    title: 'input',
+                  },
+                ],
+                output: {
+                  type: 'boolean',
+                },
+              },
+              examples: [
+                {
+                  description: 'basic usage',
+                  example: "truthy('foo')",
+                  notes: 'yields `true`',
+                },
+              ],
+            },
+          },
+        },
+      });
+      const { findByTestId, getByRole } = await r();
+
+      fireEvent.click(within(getByRole('tablist')).getByText('Functions'));
+
+      const functions = await findByTestId('functions');
+
+      const truthy = within(functions).getByTestId('truthy');
+      const title = within(truthy).getByText('truthy');
+      expect(title).toBeInTheDocument();
+      expect(title.id).toBe('function_truthy');
+
+      const link = within(truthy).getByRole('link');
+      expect(link).toBeInTheDocument();
+      expect(link).toHaveAttribute(
+        'href',
+        expect.stringMatching(new RegExp(`#${title.id}$`)),
+      );
+
+      expect(
+        within(truthy).getByText('evaluate truthiness'),
+      ).toBeInTheDocument();
+
+      expect(within(truthy).getByText('[0]')).toBeInTheDocument();
+      expect(
+        within(truthy).getByTestId('root_truthy.arg0'),
+      ).toBeInTheDocument();
+      expect(
+        within(truthy).queryByTestId('root_truthy.arg1'),
+      ).not.toBeInTheDocument();
+      expect(
+        within(truthy).getByTestId('root_truthy.output'),
+      ).toBeInTheDocument();
+
+      const x = within(truthy).getByTestId('examples');
+      expect(x).toBeInTheDocument();
+      const xd0 = within(x).getByTestId('example_desc0');
+      expect(xd0).toBeInTheDocument();
+      expect(xd0).toHaveTextContent(/basic usage\s*yields\s*true/);
+
+      const xc0 = within(x).getByTestId('example_code0');
+      expect(within(xc0).getByText("truthy('foo')")).toBeInTheDocument();
+    });
+    it('renders global values', async () => {
+      const msvValue = ['foo', 'bar', 'baz'];
+      listTemplatingExtensions.mockResolvedValue({
+        ...emptyExtensions,
+        globals: {
+          ...emptyExtensions.globals,
+          values: {
+            msv: {
+              description: 'metasyntactic variables',
+              value: msvValue,
+            },
+          },
+        },
+      });
+      const { findByTestId, getByRole } = await r();
+
+      fireEvent.click(within(getByRole('tablist')).getByText('Values'));
+
+      const values = await findByTestId('values');
+      const msv = within(values).getByTestId('msv');
+      expect(msv).toBeInTheDocument();
+
+      const title = within(msv).getByText('msv');
+      expect(title).toBeInTheDocument();
+      expect(title.id).toBe('value_msv');
+
+      const link = within(msv).getByRole('link');
+      expect(link).toBeInTheDocument();
+      expect(link).toHaveAttribute(
+        'href',
+        expect.stringMatching(new RegExp(`#${title.id}$`)),
+      );
+
+      expect(
+        within(msv).getByText('metasyntactic variables'),
+      ).toBeInTheDocument();
+
+      const msvValueElement = within(msv).getByTestId('msv.value');
+      expect(msvValueElement).toBeInTheDocument();
+      expect(JSON.parse(msvValueElement.textContent!)).toEqual(msvValue);
+    });
+  });
+});
diff --git a/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplatingExtensionsPage.tsx b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplatingExtensionsPage.tsx
new file mode 100644
index 00000000000000..720d6204ea4314
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplatingExtensionsPage/TemplatingExtensionsPage.tsx
@@ -0,0 +1,329 @@
+/*
+ * 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 { useApi, useRouteRef } from '@backstage/core-plugin-api';
+
+import {
+  actionsRouteRef,
+  editRouteRef,
+  rootRouteRef,
+  scaffolderListTaskRouteRef,
+  templatingExtensionsRouteRef,
+} from '../../routes';
+
+import { makeStyles } from '@material-ui/core/styles';
+
+import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
+import { scaffolderTranslationRef } from '../../translation';
+
+import {
+  Content,
+  EmptyState,
+  ErrorPanel,
+  Header,
+  Link,
+  Page,
+  Progress,
+} from '@backstage/core-components';
+import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
+import {
+  ScaffolderPageContextMenu,
+  ScaffolderPageContextMenuProps,
+} from '@backstage/plugin-scaffolder-react/alpha';
+import Box from '@material-ui/core/Box';
+import InputAdornment from '@material-ui/core/InputAdornment';
+import ListItemText from '@material-ui/core/ListItemText';
+import Tab from '@material-ui/core/Tab';
+import Tabs from '@material-ui/core/Tabs';
+import TextField from '@material-ui/core/TextField';
+import AllInclusiveIcon from '@material-ui/icons/AllInclusive';
+import FilterListIcon from '@material-ui/icons/FilterList';
+import FunctionsIcon from '@material-ui/icons/Functions';
+import LinkIcon from '@material-ui/icons/Link';
+import SearchIcon from '@material-ui/icons/Search';
+import Autocomplete from '@material-ui/lab/Autocomplete';
+import { useEffect, useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import useAsync from 'react-use/esm/useAsync';
+import {
+  Extension,
+  ExtensionKind,
+  listTemplatingExtensions,
+  parseFragment,
+} from './navigation';
+import { TemplateFilters } from './TemplateFilters';
+import {
+  TemplateGlobalFunctions,
+  TemplateGlobalValues,
+} from './TemplateGlobals';
+
+const useStyles = makeStyles(theme => ({
+  code: {
+    fontFamily: 'Menlo, monospace',
+    padding: theme.spacing(1),
+    backgroundColor:
+      theme.palette.type === 'dark'
+        ? theme.palette.grey[700]
+        : theme.palette.grey[300],
+    display: 'inline-block',
+    borderRadius: 5,
+    border: `1px solid ${theme.palette.grey[500]}`,
+    position: 'relative',
+  },
+
+  codeRequired: {
+    '&::after': {
+      position: 'absolute',
+      content: '"*"',
+      top: 0,
+      right: theme.spacing(0.5),
+      fontWeight: 'bolder',
+      color: theme.palette.error.light,
+    },
+  },
+
+  argRequired: {
+    position: 'relative',
+    '& > *': {
+      display: 'inline',
+      position: 'relative',
+      '&::after': {
+        position: 'absolute',
+        content: '"*"',
+        top: 0,
+        right: theme.spacing(-1),
+        fontWeight: 'bolder',
+        color: theme.palette.error.light,
+      },
+    },
+  },
+
+  link: {
+    paddingLeft: theme.spacing(1),
+    cursor: 'pointer',
+  },
+
+  tabs: {
+    display: 'block',
+    minHeight: 'initial',
+    overflow: 'initial',
+  },
+}));
+
+export const TemplatingExtensionsPageContent = ({
+  linkLocal,
+}: {
+  linkLocal?: boolean;
+}) => {
+  const api = useApi(scaffolderApiRef);
+  const classes = useStyles();
+  const { t } = useTranslationRef(scaffolderTranslationRef);
+
+  const { loading, value, error } = useAsync(async () => {
+    if (api.listTemplatingExtensions) {
+      return api.listTemplatingExtensions();
+    }
+    // eslint-disable-next-line no-console
+    console.warn(
+      'listTemplatingExtensions is not implemented in the scaffolderApi; please make sure to implement this method.',
+    );
+    return Promise.resolve({
+      filters: {},
+      globals: { functions: {}, values: {} },
+    });
+  }, [api]);
+
+  const [tab, selectTab] = useState<ExtensionKind>('filter');
+  const [selectedItem, setSelectedItem] = useState<Extension | null>(null);
+  const [input, setInput] = useState<string>('');
+
+  const handleTab = (_event: any, kind: ExtensionKind) => {
+    if (selectedItem?.kind !== kind) {
+      setSelectedItem(null);
+      setInput('');
+    }
+    selectTab(kind);
+  };
+
+  const selectItem = (item: Extension | null) => {
+    setSelectedItem(item);
+    if (item) {
+      selectTab(item.kind);
+    }
+  };
+
+  useEffect(() => {
+    if (value && window.location.hash) {
+      try {
+        selectTab(parseFragment(window.location.hash.substring(1)).kind);
+        document.querySelector(window.location.hash)?.scrollIntoView();
+      } catch (e) {
+        // ignore bad link
+      }
+    }
+  }, [value]);
+
+  const extensionKinds = useMemo(
+    () => ({
+      filter: {
+        icon: <FilterListIcon />,
+        label: t('templatingExtensions.content.filters.title'),
+      },
+      function: {
+        icon: <FunctionsIcon />,
+        label: t('templatingExtensions.content.functions.title'),
+      },
+      value: {
+        icon: <AllInclusiveIcon />,
+        label: t('templatingExtensions.content.values.title'),
+      },
+    }),
+    [t],
+  );
+
+  const templatingExtensionsLink = useRouteRef(templatingExtensionsRouteRef);
+
+  if (loading) {
+    return <Progress />;
+  }
+  if (error || !value) {
+    return (
+      <div data-testid="empty">
+        {error && <ErrorPanel error={error} />}
+        <EmptyState
+          missing="info"
+          title={t('templatingExtensions.content.emptyState.title')}
+          description={t('templatingExtensions.content.emptyState.description')}
+        />
+      </div>
+    );
+  }
+  const { filters, globals } = value;
+
+  const baseLink = (
+    <Link
+      className={classes.link}
+      to={templatingExtensionsLink()}
+      {...(linkLocal ? {} : { target: '_blank', rel: 'noopener noreferrer' })}
+    >
+      <LinkIcon />
+    </Link>
+  );
+
+  return (
+    <>
+      <Autocomplete
+        renderInput={params => (
+          <TextField
+            {...params}
+            aria-label={t(
+              'templatingExtensions.content.searchFieldPlaceholder',
+            )}
+            placeholder={t(
+              'templatingExtensions.content.searchFieldPlaceholder',
+            )}
+            variant="outlined"
+            InputProps={{
+              ...params.InputProps,
+              startAdornment: (
+                <InputAdornment position="start">
+                  <SearchIcon />
+                </InputAdornment>
+              ),
+            }}
+          />
+        )}
+        getOptionLabel={option => option.name}
+        getOptionSelected={(lhs, rhs) => lhs === rhs}
+        options={listTemplatingExtensions(value)}
+        groupBy={option => option.kind}
+        renderGroup={params => (
+          <>
+            <Box display="flex" alignItems="center">
+              {extensionKinds[params.group as ExtensionKind].icon}
+              <Box sx={{ ml: 1 }}>
+                {extensionKinds[params.group as ExtensionKind].label}
+              </Box>
+            </Box>
+            <ul>{params.children}</ul>
+          </>
+        )}
+        renderOption={(option: Extension) => (
+          <ListItemText primary={option.name} />
+        )}
+        onChange={(_event: any, option: Extension | null) => {
+          selectItem(option);
+        }}
+        inputValue={input}
+        onInputChange={(_event: any, s: string) => setInput(s)}
+        loading={loading}
+        fullWidth
+        clearOnEscape
+      />
+      <Tabs value={tab} onChange={handleTab} centered className={classes.tabs}>
+        {Object.entries(extensionKinds).map(([k, v]) => (
+          <Tab key={k} value={k} {...v} />
+        ))}
+      </Tabs>
+      {tab === 'filter' && (
+        <TemplateFilters {...{ baseLink, t, classes, filters, selectedItem }} />
+      )}
+      {tab === 'function' && (
+        <TemplateGlobalFunctions
+          functions={globals.functions}
+          {...{ baseLink, t, classes, selectedItem }}
+        />
+      )}
+      {tab === 'value' && (
+        <TemplateGlobalValues
+          values={globals.values}
+          {...{ baseLink, t, classes, selectedItem }}
+        />
+      )}
+    </>
+  );
+};
+
+export const TemplatingExtensionsPage = () => {
+  const navigate = useNavigate();
+  const editorLink = useRouteRef(editRouteRef);
+  const tasksLink = useRouteRef(scaffolderListTaskRouteRef);
+  const createLink = useRouteRef(rootRouteRef);
+  const actionsLink = useRouteRef(actionsRouteRef);
+
+  const scaffolderPageContextMenuProps: ScaffolderPageContextMenuProps = {
+    onEditorClicked: () => navigate(editorLink()),
+    onActionsClicked: () => navigate(actionsLink()),
+    onTasksClicked: () => navigate(tasksLink()),
+    onCreateClicked: () => navigate(createLink()),
+  };
+
+  const { t } = useTranslationRef(scaffolderTranslationRef);
+
+  return (
+    <Page themeId="home">
+      <Header
+        pageTitleOverride={t('templatingExtensions.pageTitle')}
+        title={t('templatingExtensions.title')}
+        subtitle={t('templatingExtensions.subtitle')}
+      >
+        <ScaffolderPageContextMenu {...scaffolderPageContextMenuProps} />
+      </Header>
+      <Content>
+        <TemplatingExtensionsPageContent linkLocal />
+      </Content>
+    </Page>
+  );
+};
diff --git a/plugins/scaffolder/src/components/TemplatingExtensionsPage/functionArgs.ts b/plugins/scaffolder/src/components/TemplatingExtensionsPage/functionArgs.ts
new file mode 100644
index 00000000000000..9acc13f1a4a630
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplatingExtensionsPage/functionArgs.ts
@@ -0,0 +1,42 @@
+/*
+ * 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 { JSONSchema7, JSONSchema7Definition } from 'json-schema';
+
+function isSchema(d: JSONSchema7Definition): d is JSONSchema7 {
+  return typeof d === 'object';
+}
+
+export function inspectFunctionArgSchema(
+  schema: JSONSchema7Definition,
+): [schema: JSONSchema7Definition, required: boolean] {
+  const hasOnlyKey = (k: string) => (o: any) =>
+    k in o && Object.keys(o).every(ok => ok.startsWith('$') || ok === k);
+
+  if (
+    isSchema(schema) &&
+    hasOnlyKey('anyOf')(schema) &&
+    schema.anyOf?.length === 2
+  ) {
+    const alt0 = schema.anyOf[0];
+    if (isSchema(alt0) && hasOnlyKey('not')(alt0)) {
+      const not = alt0.not!;
+      if (isSchema(not) && !Object.keys(not).length) {
+        return [schema.anyOf[1], false];
+      }
+    }
+  }
+  return [schema, true];
+}
diff --git a/plugins/scaffolder/src/components/TemplatingExtensionsPage/index.ts b/plugins/scaffolder/src/components/TemplatingExtensionsPage/index.ts
new file mode 100644
index 00000000000000..2573c9f0b52080
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplatingExtensionsPage/index.ts
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
+export { TemplatingExtensionsPage } from './TemplatingExtensionsPage';
diff --git a/plugins/scaffolder/src/components/TemplatingExtensionsPage/navigation.ts b/plugins/scaffolder/src/components/TemplatingExtensionsPage/navigation.ts
new file mode 100644
index 00000000000000..7b85e270bc0b34
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplatingExtensionsPage/navigation.ts
@@ -0,0 +1,56 @@
+/*
+ * 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 { ListTemplatingExtensionsResponse } from '@backstage/plugin-scaffolder-react';
+
+const kinds = ['filter', 'function', 'value'] as const;
+
+export type ExtensionKind = (typeof kinds)[number];
+
+export type Extension = {
+  kind: ExtensionKind;
+  name: string;
+};
+
+export const listTemplatingExtensions = (
+  data: Partial<Pick<ListTemplatingExtensionsResponse, 'filters'>> &
+    Partial<{ globals: Partial<ListTemplatingExtensionsResponse['globals']> }>,
+): Extension[] => {
+  const exts = (
+    kind: ExtensionKind,
+    record: Record<string, any> | undefined,
+  ): Extension[] =>
+    record ? Object.keys(record).map((name: string) => ({ kind, name })) : [];
+
+  return [
+    ...exts('filter', data.filters),
+    ...exts('function', data.globals?.functions),
+    ...exts('value', data.globals?.values),
+  ];
+};
+
+export const renderFragment = (e: Extension) => `${e.kind}_${e.name}`;
+
+export const parseFragment = (fragment: string): Extension => {
+  const [k, name] = fragment.split('_', 2);
+  const kind = k as ExtensionKind;
+  if (kinds.includes(kind)) {
+    return {
+      kind,
+      name,
+    };
+  }
+  throw Error(fragment);
+};
diff --git a/plugins/scaffolder/src/components/TemplatingExtensionsPage/types.ts b/plugins/scaffolder/src/components/TemplatingExtensionsPage/types.ts
new file mode 100644
index 00000000000000..982e1f62115f59
--- /dev/null
+++ b/plugins/scaffolder/src/components/TemplatingExtensionsPage/types.ts
@@ -0,0 +1,28 @@
+/*
+ * 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 {
+  TranslationFunction,
+  TranslationRef,
+} from '@backstage/core-plugin-api/alpha';
+import { ClassNameMap } from '@material-ui/core/styles/withStyles';
+
+export type StyleClasses = ClassNameMap<
+  'code' | 'codeRequired' | 'argRequired' | 'link'
+>;
+
+export type TranslationMessages<R> = R extends TranslationRef<any, infer M>
+  ? TranslationFunction<M>
+  : never;
diff --git a/plugins/scaffolder/src/plugin.tsx b/plugins/scaffolder/src/plugin.tsx
index 17592b582699cd..cda4ee461ce228 100644
--- a/plugins/scaffolder/src/plugin.tsx
+++ b/plugins/scaffolder/src/plugin.tsx
@@ -71,6 +71,7 @@ import {
   editorRouteRef,
   customFieldsRouteRef,
   templateFormRouteRef,
+  templatingExtensionsRouteRef,
 } from './routes';
 import {
   MyGroupsPicker,
@@ -126,6 +127,7 @@ export const scaffolderPlugin = createPlugin({
     editor: editorRouteRef,
     customFields: customFieldsRouteRef,
     templateForm: templateFormRouteRef,
+    templatingExtensions: templatingExtensionsRouteRef,
   },
   externalRoutes: {
     registerComponent: registerComponentRouteRef,
diff --git a/plugins/scaffolder/src/routes.ts b/plugins/scaffolder/src/routes.ts
index 55344a8b0ac17f..2e336757b8edd6 100644
--- a/plugins/scaffolder/src/routes.ts
+++ b/plugins/scaffolder/src/routes.ts
@@ -96,3 +96,9 @@ export const templateFormRouteRef = createSubRouteRef({
   parent: rootRouteRef,
   path: '/template-form',
 });
+
+export const templatingExtensionsRouteRef = createSubRouteRef({
+  id: 'scaffolder/templating-extensions',
+  parent: rootRouteRef,
+  path: '/templating-extensions',
+});
diff --git a/plugins/scaffolder/src/translation.ts b/plugins/scaffolder/src/translation.ts
index 4efd2fe9f72986..a5419dd6fab516 100644
--- a/plugins/scaffolder/src/translation.ts
+++ b/plugins/scaffolder/src/translation.ts
@@ -194,6 +194,43 @@ export const scaffolderTranslationRef = createTranslationRef({
       },
       undefined: 'No schema defined',
     },
+    templatingExtensions: {
+      title: 'Templating Extensions',
+      pageTitle: 'Templating Extensions',
+      subtitle: 'This is the collection of available templating extensions',
+      content: {
+        emptyState: {
+          title: 'No information to display',
+          description:
+            'There are no templating extensions available or there was an issue communicating with the backend.',
+        },
+        searchFieldPlaceholder: 'Search for an extension',
+        filters: {
+          title: 'Filters',
+          notAvailable: 'There are no template filters defined.',
+          metadataAbsent: 'Filter metadata unavailable',
+          schema: {
+            input: 'Input',
+            arguments: 'Arguments',
+            output: 'Output',
+          },
+          examples: 'Examples',
+        },
+        functions: {
+          title: 'Functions',
+          notAvailable: 'There are no global template functions defined.',
+          schema: {
+            arguments: 'Arguments',
+            output: 'Output',
+          },
+          examples: 'Examples',
+        },
+        values: {
+          title: 'Values',
+          notAvailable: 'There are no global template values defined.',
+        },
+      },
+    },
     templateTypePicker: {
       title: 'Categories',
     },
@@ -316,6 +353,8 @@ export const scaffolderTranslationRef = createTranslationRef({
     templateEditorToolbar: {
       customFieldExplorerTooltip: 'Custom Fields Explorer',
       installedActionsDocumentationTooltip: 'Installed Actions Documentation',
+      templatingExtensionsDocumentationTooltip:
+        'Templating Extensions Documentation',
       addToCatalogButton: 'Publish',
       addToCatalogDialogTitle: 'Publish changes',
       addToCatalogDialogContent: {


Reviews:
user: login: camilaibs
body: Thanks again for contributing to us. It is looking great, I left some initial feedback 🙂
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T07:19:50Z
user: login: mbenson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:46:08Z
user: login: mbenson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:50:19Z
user: login: mbenson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-08T14:51:58Z
user: login: mbenson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T02:22:45Z
user: login: camilaibs
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T10:04:46Z
user: login: camilaibs
body: Thanks for the contribution. Overall, it looks great. I'll hand over the final components review to @backstage/scaffolder-maintainers 🙂
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T10:07:15Z
user: login: mbenson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-09T14:18:07Z
user: login: benjdlambert
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-10T06:09:26Z
user: login: benjdlambert
body: Hey @mbenson :wave:

Sorry for the delay in getting round to review this, been a busy few weeks.

So I'm feeling that we should move to `templatingExtensions` everywhere instead of `templateExtensions` as it's a little cleaner and clearer that it's customizing the templating logic rather than the `kind: Template` itself.

Does this make sense to you too?

There's quite a few references to `templateExtensions` throughout this PR, not just the below, but thinking its worth catching this early.
state: CHANGES_REQUESTED
author_association: MEMBER
submitted_at: 2025-04-15T10:30:10Z
user: login: mbenson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-15T13:02:19Z
user: login: benjdlambert
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T13:06:05Z
user: login: benjdlambert
body: Nice! Let's get this in :tada: thanks for all the effort :pray:
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-22T10:02:23Z

Review comments:
diff_hunk: @@ -132,6 +141,14 @@ export function ScaffolderPageContextMenu(
               />
             </MenuItem>
           )}
+          {onTemplateExtensionsClicked && (
+            <MenuItem onClick={onTemplateExtensionsClicked}>
+              <ListItemIcon>
+                <Functions fontSize="small" />
+              </ListItemIcon>
+              <ListItemText primary="Template Extensions" />
path: plugins/scaffolder-react/src/next/components/ScaffolderPageContextMenu/ScaffolderPageContextMenu.tsx
user: login: camilaibs
body: Could you please add translation for the primary text?
created_at: 2025-04-08T07:04:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,321 @@
+/*
+ * 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 { useApi, useRouteRef } from '@backstage/core-plugin-api';
+
+import {
+  actionsRouteRef,
+  editRouteRef,
+  rootRouteRef,
+  scaffolderListTaskRouteRef,
+  templateExtensionsRouteRef,
+} from '../../routes';
+
+import { makeStyles } from '@material-ui/core/styles';
+
+import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
+import { scaffolderTranslationRef } from '../../translation';
+
+import {
+  Content,
+  EmptyState,
+  ErrorPanel,
+  Header,
+  Page,
+  Progress,
+} from '@backstage/core-components';
+import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
+import {
+  ScaffolderPageContextMenu,
+  ScaffolderPageContextMenuProps,
+} from '@backstage/plugin-scaffolder-react/alpha';
+import Box from '@material-ui/core/Box';
+import InputAdornment from '@material-ui/core/InputAdornment';
+import ListItemText from '@material-ui/core/ListItemText';
+import Tab from '@material-ui/core/Tab';
+import Tabs from '@material-ui/core/Tabs';
+import TextField from '@material-ui/core/TextField';
+import AllInclusiveIcon from '@material-ui/icons/AllInclusive';
+import FilterListIcon from '@material-ui/icons/FilterList';
+import FunctionsIcon from '@material-ui/icons/Functions';
+import SearchIcon from '@material-ui/icons/Search';
+import Autocomplete from '@material-ui/lab/Autocomplete';
+import { trimStart } from 'lodash';
+import React, { useEffect, useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import useAsync from 'react-use/esm/useAsync';
+import {
+  Extension,
+  ExtensionKind,
+  listExtensions,
+  parseLink,
+} from './navigation';
+import { TemplateFilters } from './TemplateFilters';
+import {
+  TemplateGlobalFunctions,
+  TemplateGlobalValues,
+} from './TemplateGlobals';
+
+const useStyles = makeStyles(theme => ({
+  code: {
+    fontFamily: 'Menlo, monospace',
+    padding: theme.spacing(1),
+    backgroundColor:
+      theme.palette.type === 'dark'
+        ? theme.palette.grey[700]
+        : theme.palette.grey[300],
+    display: 'inline-block',
+    borderRadius: 5,
+    border: `1px solid ${theme.palette.grey[500]}`,
+    position: 'relative',
+  },
+
+  codeRequired: {
+    '&::after': {
+      position: 'absolute',
+      content: '"*"',
+      top: 0,
+      right: theme.spacing(0.5),
+      fontWeight: 'bolder',
+      color: theme.palette.error.light,
+    },
+  },
+
+  argRequired: {
+    position: 'relative',
+    '& > *': {
+      display: 'inline',
+      position: 'relative',
+      '&::after': {
+        position: 'absolute',
+        content: '"*"',
+        top: 0,
+        right: theme.spacing(-1),
+        fontWeight: 'bolder',
+        color: theme.palette.error.light,
+      },
+    },
+  },
+
+  link: {
+    paddingLeft: theme.spacing(1),
+    cursor: 'pointer',
+  },
+
+  tabs: {
+    display: 'block',
+    minHeight: 'initial',
+    overflow: 'initial',
+  },
+}));
+
+export const TemplateExtensionsPageContent = ({
+  linkPage,
+}: {
+  linkPage?: string;
+}) => {
+  const api = useApi(scaffolderApiRef);
+  const classes = useStyles();
+  const { t } = useTranslationRef(scaffolderTranslationRef);
+
+  const { loading, value, error } = useAsync(async () => {
+    if (api.listTemplateExtensions) {
+      return api.listTemplateExtensions();
+    }
+    // eslint-disable-next-line no-console
+    console.warn(
+      'listTemplateExtensions is not implemented in the scaffolderApi; please make sure to implement this method.',
+    );
+    return Promise.resolve({
+      filters: {},
+      globals: { functions: {}, values: {} },
+    });
+  }, [api]);
+
+  const [tab, selectTab] = useState<ExtensionKind>('filter');
+  const [selectedItem, setSelectedItem] = useState<Extension | null>(null);
+  const [input, setInput] = useState<string>('');
+
+  const handleTab = (_event: any, kind: ExtensionKind) => {
+    if (selectedItem?.kind !== kind) {
+      setSelectedItem(null);
+      setInput('');
+    }
+    selectTab(kind);
+  };
+
+  const selectItem = (item: Extension | null) => {
+    setSelectedItem(item);
+    if (item) {
+      selectTab(item.kind);
+    }
+  };
+
+  useEffect(() => {
+    if (value && window.location.hash) {
+      try {
+        selectTab(parseLink(trimStart(window.location.hash, '#')).kind);
+        document.querySelector(window.location.hash)?.scrollIntoView();
+      } catch (e) {
+        // ignore bad link
+      }
+    }
+  }, [value]);
+
+  const extensionKinds = useMemo(
+    () => ({
+      filter: {
+        icon: <FilterListIcon />,
+        label: t('templateExtensions.content.filters.title'),
+      },
+      function: {
+        icon: <FunctionsIcon />,
+        label: t('templateExtensions.content.functions.title'),
+      },
+      value: {
+        icon: <AllInclusiveIcon />,
+        label: t('templateExtensions.content.values.title'),
+      },
+    }),
+    [t],
+  );
+
+  const extensionsLink = useRouteRef(templateExtensionsRouteRef);
+
+  if (loading) {
+    return <Progress />;
+  }
+  if (error || !value) {
+    return (
+      <div data-testid="empty">
+        {error && <ErrorPanel error={error} />}
+        <EmptyState
+          missing="info"
+          title={t('templateExtensions.content.emptyState.title')}
+          description={t('templateExtensions.content.emptyState.description')}
+        />
+      </div>
+    );
+  }
+  const { filters, globals } = value;
+  const effectiveLinkPage =
+    linkPage === undefined ? extensionsLink() : linkPage;
+
+  return (
+    <>
+      <Autocomplete
+        renderInput={params => (
+          <TextField
+            {...params}
+            aria-label={t('templateExtensions.content.searchFieldPlaceholder')}
+            placeholder={t('templateExtensions.content.searchFieldPlaceholder')}
+            variant="outlined"
+            InputProps={{
+              ...params.InputProps,
+              startAdornment: (
+                <InputAdornment position="start">
+                  <SearchIcon />
+                </InputAdornment>
+              ),
+            }}
+          />
+        )}
+        getOptionLabel={option => option.name}
+        getOptionSelected={(lhs, rhs) => lhs === rhs}
+        options={listExtensions(value)}
+        groupBy={option => option.kind}
+        renderGroup={params => (
+          <>
+            <Box display="flex" alignItems="center">
+              {extensionKinds[params.group as ExtensionKind].icon}
+              <Box sx={{ ml: 1 }}>
+                {extensionKinds[params.group as ExtensionKind].label}
+              </Box>
+            </Box>
+            <ul>{params.children}</ul>
+          </>
+        )}
+        renderOption={(option: Extension) => (
+          <ListItemText primary={option.name} />
+        )}
+        onChange={(_event: any, option: Extension | null) => {
+          selectItem(option);
+        }}
+        inputValue={input}
+        onInputChange={(_event: any, s: string) => setInput(s)}
+        loading={loading}
+        fullWidth
+        clearOnEscape
+      />
+      <Tabs value={tab} onChange={handleTab} centered className={classes.tabs}>
+        {Object.entries(extensionKinds).map(([k, v]) => (
+          <Tab key={k} value={k} {...v} />
+        ))}
+      </Tabs>
+      {tab === 'filter' && (
+        <TemplateFilters
+          linkPage={effectiveLinkPage}
+          {...{ t, classes, filters, selectedItem }}
+        />
+      )}
+      {tab === 'function' && (
+        <TemplateGlobalFunctions
+          functions={globals.functions}
+          linkPage={effectiveLinkPage}
+          {...{ t, classes, selectedItem }}
+        />
+      )}
+      {tab === 'value' && (
+        <TemplateGlobalValues
+          values={globals.values}
+          linkPage={effectiveLinkPage}
+          {...{ t, classes, selectedItem }}
+        />
+      )}
+    </>
+  );
+};
+
+export const TemplateExtensionsPage = () => {
+  const navigate = useNavigate();
+  const editorLink = useRouteRef(editRouteRef);
+  const tasksLink = useRouteRef(scaffolderListTaskRouteRef);
+  const createLink = useRouteRef(rootRouteRef);
+  const actionsLink = useRouteRef(actionsRouteRef);
+
+  const scaffolderPageContextMenuProps: ScaffolderPageContextMenuProps = {
+    onEditorClicked: () => navigate(editorLink()),
+    onActionsClicked: () => navigate(actionsLink()),
+    onTasksClicked: () => navigate(tasksLink()),
+    onCreateClicked: () => navigate(createLink()),
+  };
+
+  const { t } = useTranslationRef(scaffolderTranslationRef);
+
+  return (
+    <Page themeId="home">
+      <Header
+        pageTitleOverride={t('templateExtensions.pageTitle')}
+        title={t('templateExtensions.title')}
+        subtitle={t('templateExtensions.subtitle')}
+      >
+        <ScaffolderPageContextMenu {...scaffolderPageContextMenuProps} />
+      </Header>
+      <Content>
+        <TemplateExtensionsPageContent linkPage="" />
path: plugins/scaffolder/src/components/TemplateExtensionsPage/TemplateExtensionsPage.tsx
user: login: camilaibs
body: Could we omit this empty `linkPage` prop?
created_at: 2025-04-08T07:08:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,321 @@
+/*
+ * 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 { useApi, useRouteRef } from '@backstage/core-plugin-api';
+
+import {
+  actionsRouteRef,
+  editRouteRef,
+  rootRouteRef,
+  scaffolderListTaskRouteRef,
+  templateExtensionsRouteRef,
+} from '../../routes';
+
+import { makeStyles } from '@material-ui/core/styles';
+
+import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
+import { scaffolderTranslationRef } from '../../translation';
+
+import {
+  Content,
+  EmptyState,
+  ErrorPanel,
+  Header,
+  Page,
+  Progress,
+} from '@backstage/core-components';
+import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
+import {
+  ScaffolderPageContextMenu,
+  ScaffolderPageContextMenuProps,
+} from '@backstage/plugin-scaffolder-react/alpha';
+import Box from '@material-ui/core/Box';
+import InputAdornment from '@material-ui/core/InputAdornment';
+import ListItemText from '@material-ui/core/ListItemText';
+import Tab from '@material-ui/core/Tab';
+import Tabs from '@material-ui/core/Tabs';
+import TextField from '@material-ui/core/TextField';
+import AllInclusiveIcon from '@material-ui/icons/AllInclusive';
+import FilterListIcon from '@material-ui/icons/FilterList';
+import FunctionsIcon from '@material-ui/icons/Functions';
+import SearchIcon from '@material-ui/icons/Search';
+import Autocomplete from '@material-ui/lab/Autocomplete';
+import { trimStart } from 'lodash';
+import React, { useEffect, useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import useAsync from 'react-use/esm/useAsync';
+import {
+  Extension,
+  ExtensionKind,
+  listExtensions,
+  parseLink,
+} from './navigation';
+import { TemplateFilters } from './TemplateFilters';
+import {
+  TemplateGlobalFunctions,
+  TemplateGlobalValues,
+} from './TemplateGlobals';
+
+const useStyles = makeStyles(theme => ({
+  code: {
+    fontFamily: 'Menlo, monospace',
+    padding: theme.spacing(1),
+    backgroundColor:
+      theme.palette.type === 'dark'
+        ? theme.palette.grey[700]
+        : theme.palette.grey[300],
+    display: 'inline-block',
+    borderRadius: 5,
+    border: `1px solid ${theme.palette.grey[500]}`,
+    position: 'relative',
+  },
+
+  codeRequired: {
+    '&::after': {
+      position: 'absolute',
+      content: '"*"',
+      top: 0,
+      right: theme.spacing(0.5),
+      fontWeight: 'bolder',
+      color: theme.palette.error.light,
+    },
+  },
+
+  argRequired: {
+    position: 'relative',
+    '& > *': {
+      display: 'inline',
+      position: 'relative',
+      '&::after': {
+        position: 'absolute',
+        content: '"*"',
+        top: 0,
+        right: theme.spacing(-1),
+        fontWeight: 'bolder',
+        color: theme.palette.error.light,
+      },
+    },
+  },
+
+  link: {
+    paddingLeft: theme.spacing(1),
+    cursor: 'pointer',
+  },
+
+  tabs: {
+    display: 'block',
+    minHeight: 'initial',
+    overflow: 'initial',
+  },
+}));
+
+export const TemplateExtensionsPageContent = ({
+  linkPage,
+}: {
+  linkPage?: string;
+}) => {
+  const api = useApi(scaffolderApiRef);
+  const classes = useStyles();
+  const { t } = useTranslationRef(scaffolderTranslationRef);
+
+  const { loading, value, error } = useAsync(async () => {
+    if (api.listTemplateExtensions) {
+      return api.listTemplateExtensions();
+    }
+    // eslint-disable-next-line no-console
+    console.warn(
+      'listTemplateExtensions is not implemented in the scaffolderApi; please make sure to implement this method.',
path: plugins/scaffolder/src/components/TemplateExtensionsPage/TemplateExtensionsPage.tsx
user: login: camilaibs
body: Just to double check, the method exists but is not implemented in the default Scaffolder API implementation, right? I would recommend adding this note to the changeset file as well 🙂 
created_at: 2025-04-08T07:11:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -126,6 +127,7 @@ export const scaffolderPlugin = createPlugin({
     editor: editorRouteRef,
     customFields: customFieldsRouteRef,
     templateForm: templateFormRouteRef,
+    templateExtensions: templateExtensionsRouteRef,
path: plugins/scaffolder/src/plugin.tsx
user: login: camilaibs
body: Should we add the route ref here as well?
https://github.com/backstage/backstage/blob/30471f7661cbbc8fca1d195f4b21c000ef4e45c7/plugins/scaffolder/src/alpha/plugin.tsx#L41
created_at: 2025-04-08T07:18:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,321 @@
+/*
+ * 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 { useApi, useRouteRef } from '@backstage/core-plugin-api';
+
+import {
+  actionsRouteRef,
+  editRouteRef,
+  rootRouteRef,
+  scaffolderListTaskRouteRef,
+  templateExtensionsRouteRef,
+} from '../../routes';
+
+import { makeStyles } from '@material-ui/core/styles';
+
+import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
+import { scaffolderTranslationRef } from '../../translation';
+
+import {
+  Content,
+  EmptyState,
+  ErrorPanel,
+  Header,
+  Page,
+  Progress,
+} from '@backstage/core-components';
+import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
+import {
+  ScaffolderPageContextMenu,
+  ScaffolderPageContextMenuProps,
+} from '@backstage/plugin-scaffolder-react/alpha';
+import Box from '@material-ui/core/Box';
+import InputAdornment from '@material-ui/core/InputAdornment';
+import ListItemText from '@material-ui/core/ListItemText';
+import Tab from '@material-ui/core/Tab';
+import Tabs from '@material-ui/core/Tabs';
+import TextField from '@material-ui/core/TextField';
+import AllInclusiveIcon from '@material-ui/icons/AllInclusive';
+import FilterListIcon from '@material-ui/icons/FilterList';
+import FunctionsIcon from '@material-ui/icons/Functions';
+import SearchIcon from '@material-ui/icons/Search';
+import Autocomplete from '@material-ui/lab/Autocomplete';
+import { trimStart } from 'lodash';
+import React, { useEffect, useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import useAsync from 'react-use/esm/useAsync';
+import {
+  Extension,
+  ExtensionKind,
+  listExtensions,
+  parseLink,
+} from './navigation';
+import { TemplateFilters } from './TemplateFilters';
+import {
+  TemplateGlobalFunctions,
+  TemplateGlobalValues,
+} from './TemplateGlobals';
+
+const useStyles = makeStyles(theme => ({
+  code: {
+    fontFamily: 'Menlo, monospace',
+    padding: theme.spacing(1),
+    backgroundColor:
+      theme.palette.type === 'dark'
+        ? theme.palette.grey[700]
+        : theme.palette.grey[300],
+    display: 'inline-block',
+    borderRadius: 5,
+    border: `1px solid ${theme.palette.grey[500]}`,
+    position: 'relative',
+  },
+
+  codeRequired: {
+    '&::after': {
+      position: 'absolute',
+      content: '"*"',
+      top: 0,
+      right: theme.spacing(0.5),
+      fontWeight: 'bolder',
+      color: theme.palette.error.light,
+    },
+  },
+
+  argRequired: {
+    position: 'relative',
+    '& > *': {
+      display: 'inline',
+      position: 'relative',
+      '&::after': {
+        position: 'absolute',
+        content: '"*"',
+        top: 0,
+        right: theme.spacing(-1),
+        fontWeight: 'bolder',
+        color: theme.palette.error.light,
+      },
+    },
+  },
+
+  link: {
+    paddingLeft: theme.spacing(1),
+    cursor: 'pointer',
+  },
+
+  tabs: {
+    display: 'block',
+    minHeight: 'initial',
+    overflow: 'initial',
+  },
+}));
+
+export const TemplateExtensionsPageContent = ({
+  linkPage,
+}: {
+  linkPage?: string;
+}) => {
+  const api = useApi(scaffolderApiRef);
+  const classes = useStyles();
+  const { t } = useTranslationRef(scaffolderTranslationRef);
+
+  const { loading, value, error } = useAsync(async () => {
+    if (api.listTemplateExtensions) {
+      return api.listTemplateExtensions();
+    }
+    // eslint-disable-next-line no-console
+    console.warn(
+      'listTemplateExtensions is not implemented in the scaffolderApi; please make sure to implement this method.',
+    );
+    return Promise.resolve({
+      filters: {},
+      globals: { functions: {}, values: {} },
+    });
+  }, [api]);
+
+  const [tab, selectTab] = useState<ExtensionKind>('filter');
+  const [selectedItem, setSelectedItem] = useState<Extension | null>(null);
+  const [input, setInput] = useState<string>('');
+
+  const handleTab = (_event: any, kind: ExtensionKind) => {
+    if (selectedItem?.kind !== kind) {
+      setSelectedItem(null);
+      setInput('');
+    }
+    selectTab(kind);
+  };
+
+  const selectItem = (item: Extension | null) => {
+    setSelectedItem(item);
+    if (item) {
+      selectTab(item.kind);
+    }
+  };
+
+  useEffect(() => {
+    if (value && window.location.hash) {
+      try {
+        selectTab(parseLink(trimStart(window.location.hash, '#')).kind);
+        document.querySelector(window.location.hash)?.scrollIntoView();
+      } catch (e) {
+        // ignore bad link
+      }
+    }
+  }, [value]);
+
+  const extensionKinds = useMemo(
+    () => ({
+      filter: {
+        icon: <FilterListIcon />,
+        label: t('templateExtensions.content.filters.title'),
+      },
+      function: {
+        icon: <FunctionsIcon />,
+        label: t('templateExtensions.content.functions.title'),
+      },
+      value: {
+        icon: <AllInclusiveIcon />,
+        label: t('templateExtensions.content.values.title'),
+      },
+    }),
+    [t],
+  );
+
+  const extensionsLink = useRouteRef(templateExtensionsRouteRef);
+
+  if (loading) {
+    return <Progress />;
+  }
+  if (error || !value) {
+    return (
+      <div data-testid="empty">
+        {error && <ErrorPanel error={error} />}
+        <EmptyState
+          missing="info"
+          title={t('templateExtensions.content.emptyState.title')}
+          description={t('templateExtensions.content.emptyState.description')}
+        />
+      </div>
+    );
+  }
+  const { filters, globals } = value;
+  const effectiveLinkPage =
+    linkPage === undefined ? extensionsLink() : linkPage;
+
+  return (
+    <>
+      <Autocomplete
+        renderInput={params => (
+          <TextField
+            {...params}
+            aria-label={t('templateExtensions.content.searchFieldPlaceholder')}
+            placeholder={t('templateExtensions.content.searchFieldPlaceholder')}
+            variant="outlined"
+            InputProps={{
+              ...params.InputProps,
+              startAdornment: (
+                <InputAdornment position="start">
+                  <SearchIcon />
+                </InputAdornment>
+              ),
+            }}
+          />
+        )}
+        getOptionLabel={option => option.name}
+        getOptionSelected={(lhs, rhs) => lhs === rhs}
+        options={listExtensions(value)}
+        groupBy={option => option.kind}
+        renderGroup={params => (
+          <>
+            <Box display="flex" alignItems="center">
+              {extensionKinds[params.group as ExtensionKind].icon}
+              <Box sx={{ ml: 1 }}>
+                {extensionKinds[params.group as ExtensionKind].label}
+              </Box>
+            </Box>
+            <ul>{params.children}</ul>
+          </>
+        )}
+        renderOption={(option: Extension) => (
+          <ListItemText primary={option.name} />
+        )}
+        onChange={(_event: any, option: Extension | null) => {
+          selectItem(option);
+        }}
+        inputValue={input}
+        onInputChange={(_event: any, s: string) => setInput(s)}
+        loading={loading}
+        fullWidth
+        clearOnEscape
+      />
+      <Tabs value={tab} onChange={handleTab} centered className={classes.tabs}>
+        {Object.entries(extensionKinds).map(([k, v]) => (
+          <Tab key={k} value={k} {...v} />
+        ))}
+      </Tabs>
+      {tab === 'filter' && (
+        <TemplateFilters
+          linkPage={effectiveLinkPage}
+          {...{ t, classes, filters, selectedItem }}
+        />
+      )}
+      {tab === 'function' && (
+        <TemplateGlobalFunctions
+          functions={globals.functions}
+          linkPage={effectiveLinkPage}
+          {...{ t, classes, selectedItem }}
+        />
+      )}
+      {tab === 'value' && (
+        <TemplateGlobalValues
+          values={globals.values}
+          linkPage={effectiveLinkPage}
+          {...{ t, classes, selectedItem }}
+        />
+      )}
+    </>
+  );
+};
+
+export const TemplateExtensionsPage = () => {
+  const navigate = useNavigate();
+  const editorLink = useRouteRef(editRouteRef);
+  const tasksLink = useRouteRef(scaffolderListTaskRouteRef);
+  const createLink = useRouteRef(rootRouteRef);
+  const actionsLink = useRouteRef(actionsRouteRef);
+
+  const scaffolderPageContextMenuProps: ScaffolderPageContextMenuProps = {
+    onEditorClicked: () => navigate(editorLink()),
+    onActionsClicked: () => navigate(actionsLink()),
+    onTasksClicked: () => navigate(tasksLink()),
+    onCreateClicked: () => navigate(createLink()),
+  };
+
+  const { t } = useTranslationRef(scaffolderTranslationRef);
+
+  return (
+    <Page themeId="home">
+      <Header
+        pageTitleOverride={t('templateExtensions.pageTitle')}
+        title={t('templateExtensions.title')}
+        subtitle={t('templateExtensions.subtitle')}
+      >
+        <ScaffolderPageContextMenu {...scaffolderPageContextMenuProps} />
+      </Header>
+      <Content>
+        <TemplateExtensionsPageContent linkPage="" />
path: plugins/scaffolder/src/components/TemplateExtensionsPage/TemplateExtensionsPage.tsx
user: login: mbenson
body: This is making it possible to render links properly such that if the template extensions content is rendered as part of the template editor, links will return to the actual template extensions page, so the link page is overridden to `''` on the actual page causing _its_ links to be rendered pointing back to the current page. I'm quite new to React still so I'm entirely receptive to any advice you have on a cleaner-looking way to achieve this. 😄 
created_at: 2025-04-08T14:46:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,321 @@
+/*
+ * 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 { useApi, useRouteRef } from '@backstage/core-plugin-api';
+
+import {
+  actionsRouteRef,
+  editRouteRef,
+  rootRouteRef,
+  scaffolderListTaskRouteRef,
+  templateExtensionsRouteRef,
+} from '../../routes';
+
+import { makeStyles } from '@material-ui/core/styles';
+
+import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
+import { scaffolderTranslationRef } from '../../translation';
+
+import {
+  Content,
+  EmptyState,
+  ErrorPanel,
+  Header,
+  Page,
+  Progress,
+} from '@backstage/core-components';
+import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
+import {
+  ScaffolderPageContextMenu,
+  ScaffolderPageContextMenuProps,
+} from '@backstage/plugin-scaffolder-react/alpha';
+import Box from '@material-ui/core/Box';
+import InputAdornment from '@material-ui/core/InputAdornment';
+import ListItemText from '@material-ui/core/ListItemText';
+import Tab from '@material-ui/core/Tab';
+import Tabs from '@material-ui/core/Tabs';
+import TextField from '@material-ui/core/TextField';
+import AllInclusiveIcon from '@material-ui/icons/AllInclusive';
+import FilterListIcon from '@material-ui/icons/FilterList';
+import FunctionsIcon from '@material-ui/icons/Functions';
+import SearchIcon from '@material-ui/icons/Search';
+import Autocomplete from '@material-ui/lab/Autocomplete';
+import { trimStart } from 'lodash';
+import React, { useEffect, useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import useAsync from 'react-use/esm/useAsync';
+import {
+  Extension,
+  ExtensionKind,
+  listExtensions,
+  parseLink,
+} from './navigation';
+import { TemplateFilters } from './TemplateFilters';
+import {
+  TemplateGlobalFunctions,
+  TemplateGlobalValues,
+} from './TemplateGlobals';
+
+const useStyles = makeStyles(theme => ({
+  code: {
+    fontFamily: 'Menlo, monospace',
+    padding: theme.spacing(1),
+    backgroundColor:
+      theme.palette.type === 'dark'
+        ? theme.palette.grey[700]
+        : theme.palette.grey[300],
+    display: 'inline-block',
+    borderRadius: 5,
+    border: `1px solid ${theme.palette.grey[500]}`,
+    position: 'relative',
+  },
+
+  codeRequired: {
+    '&::after': {
+      position: 'absolute',
+      content: '"*"',
+      top: 0,
+      right: theme.spacing(0.5),
+      fontWeight: 'bolder',
+      color: theme.palette.error.light,
+    },
+  },
+
+  argRequired: {
+    position: 'relative',
+    '& > *': {
+      display: 'inline',
+      position: 'relative',
+      '&::after': {
+        position: 'absolute',
+        content: '"*"',
+        top: 0,
+        right: theme.spacing(-1),
+        fontWeight: 'bolder',
+        color: theme.palette.error.light,
+      },
+    },
+  },
+
+  link: {
+    paddingLeft: theme.spacing(1),
+    cursor: 'pointer',
+  },
+
+  tabs: {
+    display: 'block',
+    minHeight: 'initial',
+    overflow: 'initial',
+  },
+}));
+
+export const TemplateExtensionsPageContent = ({
+  linkPage,
+}: {
+  linkPage?: string;
+}) => {
+  const api = useApi(scaffolderApiRef);
+  const classes = useStyles();
+  const { t } = useTranslationRef(scaffolderTranslationRef);
+
+  const { loading, value, error } = useAsync(async () => {
+    if (api.listTemplateExtensions) {
+      return api.listTemplateExtensions();
+    }
+    // eslint-disable-next-line no-console
+    console.warn(
+      'listTemplateExtensions is not implemented in the scaffolderApi; please make sure to implement this method.',
path: plugins/scaffolder/src/components/TemplateExtensionsPage/TemplateExtensionsPage.tsx
user: login: mbenson
body: It is in fact implemented in the default implementation; I honestly can't remember but I suspect that in the earlier iteration of these changes we must have made that method optional for reasons having to do with API versioning and/or backwards compatibility (particularly if there are a substantial number of custom Scaffolder API implementations out there) and this warning simply acknowledges the possibility that the method may not exist. Should we include a note to the effect that custom Scaffolder API impls will need to implement this in order to derive usefulness from this page? Should we omit menu links, etc. if the available API lacks the method?
created_at: 2025-04-08T14:50:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -126,6 +127,7 @@ export const scaffolderPlugin = createPlugin({
     editor: editorRouteRef,
     customFields: customFieldsRouteRef,
     templateForm: templateFormRouteRef,
+    templateExtensions: templateExtensionsRouteRef,
path: plugins/scaffolder/src/plugin.tsx
user: login: mbenson
body: Will do.
created_at: 2025-04-08T14:51:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,321 @@
+/*
+ * 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 { useApi, useRouteRef } from '@backstage/core-plugin-api';
+
+import {
+  actionsRouteRef,
+  editRouteRef,
+  rootRouteRef,
+  scaffolderListTaskRouteRef,
+  templateExtensionsRouteRef,
+} from '../../routes';
+
+import { makeStyles } from '@material-ui/core/styles';
+
+import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
+import { scaffolderTranslationRef } from '../../translation';
+
+import {
+  Content,
+  EmptyState,
+  ErrorPanel,
+  Header,
+  Page,
+  Progress,
+} from '@backstage/core-components';
+import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
+import {
+  ScaffolderPageContextMenu,
+  ScaffolderPageContextMenuProps,
+} from '@backstage/plugin-scaffolder-react/alpha';
+import Box from '@material-ui/core/Box';
+import InputAdornment from '@material-ui/core/InputAdornment';
+import ListItemText from '@material-ui/core/ListItemText';
+import Tab from '@material-ui/core/Tab';
+import Tabs from '@material-ui/core/Tabs';
+import TextField from '@material-ui/core/TextField';
+import AllInclusiveIcon from '@material-ui/icons/AllInclusive';
+import FilterListIcon from '@material-ui/icons/FilterList';
+import FunctionsIcon from '@material-ui/icons/Functions';
+import SearchIcon from '@material-ui/icons/Search';
+import Autocomplete from '@material-ui/lab/Autocomplete';
+import { trimStart } from 'lodash';
+import React, { useEffect, useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import useAsync from 'react-use/esm/useAsync';
+import {
+  Extension,
+  ExtensionKind,
+  listExtensions,
+  parseLink,
+} from './navigation';
+import { TemplateFilters } from './TemplateFilters';
+import {
+  TemplateGlobalFunctions,
+  TemplateGlobalValues,
+} from './TemplateGlobals';
+
+const useStyles = makeStyles(theme => ({
+  code: {
+    fontFamily: 'Menlo, monospace',
+    padding: theme.spacing(1),
+    backgroundColor:
+      theme.palette.type === 'dark'
+        ? theme.palette.grey[700]
+        : theme.palette.grey[300],
+    display: 'inline-block',
+    borderRadius: 5,
+    border: `1px solid ${theme.palette.grey[500]}`,
+    position: 'relative',
+  },
+
+  codeRequired: {
+    '&::after': {
+      position: 'absolute',
+      content: '"*"',
+      top: 0,
+      right: theme.spacing(0.5),
+      fontWeight: 'bolder',
+      color: theme.palette.error.light,
+    },
+  },
+
+  argRequired: {
+    position: 'relative',
+    '& > *': {
+      display: 'inline',
+      position: 'relative',
+      '&::after': {
+        position: 'absolute',
+        content: '"*"',
+        top: 0,
+        right: theme.spacing(-1),
+        fontWeight: 'bolder',
+        color: theme.palette.error.light,
+      },
+    },
+  },
+
+  link: {
+    paddingLeft: theme.spacing(1),
+    cursor: 'pointer',
+  },
+
+  tabs: {
+    display: 'block',
+    minHeight: 'initial',
+    overflow: 'initial',
+  },
+}));
+
+export const TemplateExtensionsPageContent = ({
+  linkPage,
+}: {
+  linkPage?: string;
+}) => {
+  const api = useApi(scaffolderApiRef);
+  const classes = useStyles();
+  const { t } = useTranslationRef(scaffolderTranslationRef);
+
+  const { loading, value, error } = useAsync(async () => {
+    if (api.listTemplateExtensions) {
+      return api.listTemplateExtensions();
+    }
+    // eslint-disable-next-line no-console
+    console.warn(
+      'listTemplateExtensions is not implemented in the scaffolderApi; please make sure to implement this method.',
+    );
+    return Promise.resolve({
+      filters: {},
+      globals: { functions: {}, values: {} },
+    });
+  }, [api]);
+
+  const [tab, selectTab] = useState<ExtensionKind>('filter');
+  const [selectedItem, setSelectedItem] = useState<Extension | null>(null);
+  const [input, setInput] = useState<string>('');
+
+  const handleTab = (_event: any, kind: ExtensionKind) => {
+    if (selectedItem?.kind !== kind) {
+      setSelectedItem(null);
+      setInput('');
+    }
+    selectTab(kind);
+  };
+
+  const selectItem = (item: Extension | null) => {
+    setSelectedItem(item);
+    if (item) {
+      selectTab(item.kind);
+    }
+  };
+
+  useEffect(() => {
+    if (value && window.location.hash) {
+      try {
+        selectTab(parseLink(trimStart(window.location.hash, '#')).kind);
+        document.querySelector(window.location.hash)?.scrollIntoView();
+      } catch (e) {
+        // ignore bad link
+      }
+    }
+  }, [value]);
+
+  const extensionKinds = useMemo(
+    () => ({
+      filter: {
+        icon: <FilterListIcon />,
+        label: t('templateExtensions.content.filters.title'),
+      },
+      function: {
+        icon: <FunctionsIcon />,
+        label: t('templateExtensions.content.functions.title'),
+      },
+      value: {
+        icon: <AllInclusiveIcon />,
+        label: t('templateExtensions.content.values.title'),
+      },
+    }),
+    [t],
+  );
+
+  const extensionsLink = useRouteRef(templateExtensionsRouteRef);
+
+  if (loading) {
+    return <Progress />;
+  }
+  if (error || !value) {
+    return (
+      <div data-testid="empty">
+        {error && <ErrorPanel error={error} />}
+        <EmptyState
+          missing="info"
+          title={t('templateExtensions.content.emptyState.title')}
+          description={t('templateExtensions.content.emptyState.description')}
+        />
+      </div>
+    );
+  }
+  const { filters, globals } = value;
+  const effectiveLinkPage =
+    linkPage === undefined ? extensionsLink() : linkPage;
+
+  return (
+    <>
+      <Autocomplete
+        renderInput={params => (
+          <TextField
+            {...params}
+            aria-label={t('templateExtensions.content.searchFieldPlaceholder')}
+            placeholder={t('templateExtensions.content.searchFieldPlaceholder')}
+            variant="outlined"
+            InputProps={{
+              ...params.InputProps,
+              startAdornment: (
+                <InputAdornment position="start">
+                  <SearchIcon />
+                </InputAdornment>
+              ),
+            }}
+          />
+        )}
+        getOptionLabel={option => option.name}
+        getOptionSelected={(lhs, rhs) => lhs === rhs}
+        options={listExtensions(value)}
+        groupBy={option => option.kind}
+        renderGroup={params => (
+          <>
+            <Box display="flex" alignItems="center">
+              {extensionKinds[params.group as ExtensionKind].icon}
+              <Box sx={{ ml: 1 }}>
+                {extensionKinds[params.group as ExtensionKind].label}
+              </Box>
+            </Box>
+            <ul>{params.children}</ul>
+          </>
+        )}
+        renderOption={(option: Extension) => (
+          <ListItemText primary={option.name} />
+        )}
+        onChange={(_event: any, option: Extension | null) => {
+          selectItem(option);
+        }}
+        inputValue={input}
+        onInputChange={(_event: any, s: string) => setInput(s)}
+        loading={loading}
+        fullWidth
+        clearOnEscape
+      />
+      <Tabs value={tab} onChange={handleTab} centered className={classes.tabs}>
+        {Object.entries(extensionKinds).map(([k, v]) => (
+          <Tab key={k} value={k} {...v} />
+        ))}
+      </Tabs>
+      {tab === 'filter' && (
+        <TemplateFilters
+          linkPage={effectiveLinkPage}
+          {...{ t, classes, filters, selectedItem }}
+        />
+      )}
+      {tab === 'function' && (
+        <TemplateGlobalFunctions
+          functions={globals.functions}
+          linkPage={effectiveLinkPage}
+          {...{ t, classes, selectedItem }}
+        />
+      )}
+      {tab === 'value' && (
+        <TemplateGlobalValues
+          values={globals.values}
+          linkPage={effectiveLinkPage}
+          {...{ t, classes, selectedItem }}
+        />
+      )}
+    </>
+  );
+};
+
+export const TemplateExtensionsPage = () => {
+  const navigate = useNavigate();
+  const editorLink = useRouteRef(editRouteRef);
+  const tasksLink = useRouteRef(scaffolderListTaskRouteRef);
+  const createLink = useRouteRef(rootRouteRef);
+  const actionsLink = useRouteRef(actionsRouteRef);
+
+  const scaffolderPageContextMenuProps: ScaffolderPageContextMenuProps = {
+    onEditorClicked: () => navigate(editorLink()),
+    onActionsClicked: () => navigate(actionsLink()),
+    onTasksClicked: () => navigate(tasksLink()),
+    onCreateClicked: () => navigate(createLink()),
+  };
+
+  const { t } = useTranslationRef(scaffolderTranslationRef);
+
+  return (
+    <Page themeId="home">
+      <Header
+        pageTitleOverride={t('templateExtensions.pageTitle')}
+        title={t('templateExtensions.title')}
+        subtitle={t('templateExtensions.subtitle')}
+      >
+        <ScaffolderPageContextMenu {...scaffolderPageContextMenuProps} />
+      </Header>
+      <Content>
+        <TemplateExtensionsPageContent linkPage="" />
path: plugins/scaffolder/src/components/TemplateExtensionsPage/TemplateExtensionsPage.tsx
user: login: mbenson
body: I have refactored this to trigger the behavior using a `linkLocal: boolean` property, and handle all the consequential `Link` customization in the `TemplateExtensionsPageContent` component, making the subordinate components less fiddly IMO. See how this hits you, if you would, @camilaibs 🙏 
created_at: 2025-04-09T02:22:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,321 @@
+/*
+ * 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 { useApi, useRouteRef } from '@backstage/core-plugin-api';
+
+import {
+  actionsRouteRef,
+  editRouteRef,
+  rootRouteRef,
+  scaffolderListTaskRouteRef,
+  templateExtensionsRouteRef,
+} from '../../routes';
+
+import { makeStyles } from '@material-ui/core/styles';
+
+import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
+import { scaffolderTranslationRef } from '../../translation';
+
+import {
+  Content,
+  EmptyState,
+  ErrorPanel,
+  Header,
+  Page,
+  Progress,
+} from '@backstage/core-components';
+import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
+import {
+  ScaffolderPageContextMenu,
+  ScaffolderPageContextMenuProps,
+} from '@backstage/plugin-scaffolder-react/alpha';
+import Box from '@material-ui/core/Box';
+import InputAdornment from '@material-ui/core/InputAdornment';
+import ListItemText from '@material-ui/core/ListItemText';
+import Tab from '@material-ui/core/Tab';
+import Tabs from '@material-ui/core/Tabs';
+import TextField from '@material-ui/core/TextField';
+import AllInclusiveIcon from '@material-ui/icons/AllInclusive';
+import FilterListIcon from '@material-ui/icons/FilterList';
+import FunctionsIcon from '@material-ui/icons/Functions';
+import SearchIcon from '@material-ui/icons/Search';
+import Autocomplete from '@material-ui/lab/Autocomplete';
+import { trimStart } from 'lodash';
+import React, { useEffect, useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import useAsync from 'react-use/esm/useAsync';
+import {
+  Extension,
+  ExtensionKind,
+  listExtensions,
+  parseLink,
+} from './navigation';
+import { TemplateFilters } from './TemplateFilters';
+import {
+  TemplateGlobalFunctions,
+  TemplateGlobalValues,
+} from './TemplateGlobals';
+
+const useStyles = makeStyles(theme => ({
+  code: {
+    fontFamily: 'Menlo, monospace',
+    padding: theme.spacing(1),
+    backgroundColor:
+      theme.palette.type === 'dark'
+        ? theme.palette.grey[700]
+        : theme.palette.grey[300],
+    display: 'inline-block',
+    borderRadius: 5,
+    border: `1px solid ${theme.palette.grey[500]}`,
+    position: 'relative',
+  },
+
+  codeRequired: {
+    '&::after': {
+      position: 'absolute',
+      content: '"*"',
+      top: 0,
+      right: theme.spacing(0.5),
+      fontWeight: 'bolder',
+      color: theme.palette.error.light,
+    },
+  },
+
+  argRequired: {
+    position: 'relative',
+    '& > *': {
+      display: 'inline',
+      position: 'relative',
+      '&::after': {
+        position: 'absolute',
+        content: '"*"',
+        top: 0,
+        right: theme.spacing(-1),
+        fontWeight: 'bolder',
+        color: theme.palette.error.light,
+      },
+    },
+  },
+
+  link: {
+    paddingLeft: theme.spacing(1),
+    cursor: 'pointer',
+  },
+
+  tabs: {
+    display: 'block',
+    minHeight: 'initial',
+    overflow: 'initial',
+  },
+}));
+
+export const TemplateExtensionsPageContent = ({
+  linkPage,
+}: {
+  linkPage?: string;
+}) => {
+  const api = useApi(scaffolderApiRef);
+  const classes = useStyles();
+  const { t } = useTranslationRef(scaffolderTranslationRef);
+
+  const { loading, value, error } = useAsync(async () => {
+    if (api.listTemplateExtensions) {
+      return api.listTemplateExtensions();
+    }
+    // eslint-disable-next-line no-console
+    console.warn(
+      'listTemplateExtensions is not implemented in the scaffolderApi; please make sure to implement this method.',
path: plugins/scaffolder/src/components/TemplateExtensionsPage/TemplateExtensionsPage.tsx
user: login: camilaibs
body: > Should we include a note to the effect that custom Scaffolder API impls will need to implement this in order to derive usefulness from this page?

I would say yes, the text in the changeset is the same one people will see in the changelog file. I think it would be frustrating to add the page and have it not work as expected without knowing that this is necessary. But it seems that the default Scaffolder API client already implements this method by default, so maybe the warning is no longer necessary?
created_at: 2025-04-09T10:04:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,321 @@
+/*
+ * 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 { useApi, useRouteRef } from '@backstage/core-plugin-api';
+
+import {
+  actionsRouteRef,
+  editRouteRef,
+  rootRouteRef,
+  scaffolderListTaskRouteRef,
+  templateExtensionsRouteRef,
+} from '../../routes';
+
+import { makeStyles } from '@material-ui/core/styles';
+
+import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
+import { scaffolderTranslationRef } from '../../translation';
+
+import {
+  Content,
+  EmptyState,
+  ErrorPanel,
+  Header,
+  Page,
+  Progress,
+} from '@backstage/core-components';
+import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
+import {
+  ScaffolderPageContextMenu,
+  ScaffolderPageContextMenuProps,
+} from '@backstage/plugin-scaffolder-react/alpha';
+import Box from '@material-ui/core/Box';
+import InputAdornment from '@material-ui/core/InputAdornment';
+import ListItemText from '@material-ui/core/ListItemText';
+import Tab from '@material-ui/core/Tab';
+import Tabs from '@material-ui/core/Tabs';
+import TextField from '@material-ui/core/TextField';
+import AllInclusiveIcon from '@material-ui/icons/AllInclusive';
+import FilterListIcon from '@material-ui/icons/FilterList';
+import FunctionsIcon from '@material-ui/icons/Functions';
+import SearchIcon from '@material-ui/icons/Search';
+import Autocomplete from '@material-ui/lab/Autocomplete';
+import { trimStart } from 'lodash';
+import React, { useEffect, useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import useAsync from 'react-use/esm/useAsync';
+import {
+  Extension,
+  ExtensionKind,
+  listExtensions,
+  parseLink,
+} from './navigation';
+import { TemplateFilters } from './TemplateFilters';
+import {
+  TemplateGlobalFunctions,
+  TemplateGlobalValues,
+} from './TemplateGlobals';
+
+const useStyles = makeStyles(theme => ({
+  code: {
+    fontFamily: 'Menlo, monospace',
+    padding: theme.spacing(1),
+    backgroundColor:
+      theme.palette.type === 'dark'
+        ? theme.palette.grey[700]
+        : theme.palette.grey[300],
+    display: 'inline-block',
+    borderRadius: 5,
+    border: `1px solid ${theme.palette.grey[500]}`,
+    position: 'relative',
+  },
+
+  codeRequired: {
+    '&::after': {
+      position: 'absolute',
+      content: '"*"',
+      top: 0,
+      right: theme.spacing(0.5),
+      fontWeight: 'bolder',
+      color: theme.palette.error.light,
+    },
+  },
+
+  argRequired: {
+    position: 'relative',
+    '& > *': {
+      display: 'inline',
+      position: 'relative',
+      '&::after': {
+        position: 'absolute',
+        content: '"*"',
+        top: 0,
+        right: theme.spacing(-1),
+        fontWeight: 'bolder',
+        color: theme.palette.error.light,
+      },
+    },
+  },
+
+  link: {
+    paddingLeft: theme.spacing(1),
+    cursor: 'pointer',
+  },
+
+  tabs: {
+    display: 'block',
+    minHeight: 'initial',
+    overflow: 'initial',
+  },
+}));
+
+export const TemplateExtensionsPageContent = ({
+  linkPage,
+}: {
+  linkPage?: string;
+}) => {
+  const api = useApi(scaffolderApiRef);
+  const classes = useStyles();
+  const { t } = useTranslationRef(scaffolderTranslationRef);
+
+  const { loading, value, error } = useAsync(async () => {
+    if (api.listTemplateExtensions) {
+      return api.listTemplateExtensions();
+    }
+    // eslint-disable-next-line no-console
+    console.warn(
+      'listTemplateExtensions is not implemented in the scaffolderApi; please make sure to implement this method.',
path: plugins/scaffolder/src/components/TemplateExtensionsPage/TemplateExtensionsPage.tsx
user: login: mbenson
body: I guess to my thinking, as long as the method is optional the warning is necessary.
created_at: 2025-04-09T14:18:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,321 @@
+/*
+ * 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 { useApi, useRouteRef } from '@backstage/core-plugin-api';
+
+import {
+  actionsRouteRef,
+  editRouteRef,
+  rootRouteRef,
+  scaffolderListTaskRouteRef,
+  templateExtensionsRouteRef,
+} from '../../routes';
+
+import { makeStyles } from '@material-ui/core/styles';
+
+import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
+import { scaffolderTranslationRef } from '../../translation';
+
+import {
+  Content,
+  EmptyState,
+  ErrorPanel,
+  Header,
+  Page,
+  Progress,
+} from '@backstage/core-components';
+import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
+import {
+  ScaffolderPageContextMenu,
+  ScaffolderPageContextMenuProps,
+} from '@backstage/plugin-scaffolder-react/alpha';
+import Box from '@material-ui/core/Box';
+import InputAdornment from '@material-ui/core/InputAdornment';
+import ListItemText from '@material-ui/core/ListItemText';
+import Tab from '@material-ui/core/Tab';
+import Tabs from '@material-ui/core/Tabs';
+import TextField from '@material-ui/core/TextField';
+import AllInclusiveIcon from '@material-ui/icons/AllInclusive';
+import FilterListIcon from '@material-ui/icons/FilterList';
+import FunctionsIcon from '@material-ui/icons/Functions';
+import SearchIcon from '@material-ui/icons/Search';
+import Autocomplete from '@material-ui/lab/Autocomplete';
+import { trimStart } from 'lodash';
+import React, { useEffect, useMemo, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import useAsync from 'react-use/esm/useAsync';
+import {
+  Extension,
+  ExtensionKind,
+  listExtensions,
+  parseLink,
+} from './navigation';
+import { TemplateFilters } from './TemplateFilters';
+import {
+  TemplateGlobalFunctions,
+  TemplateGlobalValues,
+} from './TemplateGlobals';
+
+const useStyles = makeStyles(theme => ({
+  code: {
+    fontFamily: 'Menlo, monospace',
+    padding: theme.spacing(1),
+    backgroundColor:
+      theme.palette.type === 'dark'
+        ? theme.palette.grey[700]
+        : theme.palette.grey[300],
+    display: 'inline-block',
+    borderRadius: 5,
+    border: `1px solid ${theme.palette.grey[500]}`,
+    position: 'relative',
+  },
+
+  codeRequired: {
+    '&::after': {
+      position: 'absolute',
+      content: '"*"',
+      top: 0,
+      right: theme.spacing(0.5),
+      fontWeight: 'bolder',
+      color: theme.palette.error.light,
+    },
+  },
+
+  argRequired: {
+    position: 'relative',
+    '& > *': {
+      display: 'inline',
+      position: 'relative',
+      '&::after': {
+        position: 'absolute',
+        content: '"*"',
+        top: 0,
+        right: theme.spacing(-1),
+        fontWeight: 'bolder',
+        color: theme.palette.error.light,
+      },
+    },
+  },
+
+  link: {
+    paddingLeft: theme.spacing(1),
+    cursor: 'pointer',
+  },
+
+  tabs: {
+    display: 'block',
+    minHeight: 'initial',
+    overflow: 'initial',
+  },
+}));
+
+export const TemplateExtensionsPageContent = ({
+  linkPage,
+}: {
+  linkPage?: string;
+}) => {
+  const api = useApi(scaffolderApiRef);
+  const classes = useStyles();
+  const { t } = useTranslationRef(scaffolderTranslationRef);
+
+  const { loading, value, error } = useAsync(async () => {
+    if (api.listTemplateExtensions) {
+      return api.listTemplateExtensions();
+    }
+    // eslint-disable-next-line no-console
+    console.warn(
+      'listTemplateExtensions is not implemented in the scaffolderApi; please make sure to implement this method.',
path: plugins/scaffolder/src/components/TemplateExtensionsPage/TemplateExtensionsPage.tsx
user: login: benjdlambert
body: The reason that things are marked as optional is if adopters are providing their own instance of the `scaffolderApiRef` and ultimately `ScaffolderClient`. 

Marking members and functions as required on the implementation is a breaking change unfortunately, so that's why we mark as optional.

I think it's fine to leave as such and we'll bring everything to required in the next breaking change to the scaffolder.
created_at: 2025-04-10T06:09:26Z
author_association: MEMBER
diff_hunk: @@ -317,6 +317,7 @@ export type ScaffolderPageContextMenuProps = {
   onActionsClicked?: () => void;
   onTasksClicked?: () => void;
   onCreateClicked?: () => void;
+  onTemplateExtensionsClicked?: () => void;
path: plugins/scaffolder-react/report-alpha.api.md
user: login: benjdlambert
body: I'm thinking that we call this `templatingExtensions` instead.

`Template Extensions` is a bit ambiguous in naming I'm feeling.
created_at: 2025-04-15T10:28:19Z
author_association: MEMBER
diff_hunk: @@ -367,6 +368,26 @@ export const scaffolderTranslationRef: TranslationRef<
     readonly 'renderSchema.tableCell.type': 'Type';
     readonly 'renderSchema.tableCell.title': 'Title';
     readonly 'renderSchema.tableCell.description': 'Description';
+    readonly 'templateExtensions.content.values.title': 'Values';
path: plugins/scaffolder/report-alpha.api.md
user: login: benjdlambert
body: ```suggestion
    readonly 'templatingExtensions.content.values.title': 'Values';
```
created_at: 2025-04-15T10:28:41Z
author_association: MEMBER
diff_hunk: @@ -618,6 +618,7 @@ export const scaffolderPlugin: BackstagePlugin<
     editor: SubRouteRef<undefined>;
     customFields: SubRouteRef<undefined>;
     templateForm: SubRouteRef<undefined>;
+    templateExtensions: SubRouteRef<undefined>;
path: plugins/scaffolder/report.api.md
user: login: benjdlambert
body: ```suggestion
    templatingExtensions: SubRouteRef<undefined>;
```
created_at: 2025-04-15T10:28:52Z
author_association: MEMBER
diff_hunk: @@ -618,6 +618,7 @@ export const scaffolderPlugin: BackstagePlugin<
     editor: SubRouteRef<undefined>;
     customFields: SubRouteRef<undefined>;
     templateForm: SubRouteRef<undefined>;
+    templateExtensions: SubRouteRef<undefined>;
path: plugins/scaffolder/report.api.md
user: login: mbenson
body: Sure, I'm not picky about that. I can't remember offhand but I'm guessing we may already have used that terminology on the backend. If so do we want to go ahead and change those as well since they should have been alpha?
created_at: 2025-04-15T13:02:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -618,6 +618,7 @@ export const scaffolderPlugin: BackstagePlugin<
     editor: SubRouteRef<undefined>;
     customFields: SubRouteRef<undefined>;
     templateForm: SubRouteRef<undefined>;
+    templateExtensions: SubRouteRef<undefined>;
path: plugins/scaffolder/report.api.md
user: login: benjdlambert
body: Yeah if there's anything that stands out as sounds better with `templating` instead then lets go ahead and update that. `minor` is fine for making breaking changes to `/alpha` export also.

i didn't see so much earlier when I searched so maybe there's not too much.
created_at: 2025-04-15T13:06:05Z
author_association: MEMBER

PR comments:
user: login: backstage-goalie[bot]
created_at: 2025-04-07T23:26:35Z
body: > [!IMPORTANT]
> This PR includes changes that affect public-facing API. Please ensure you are adding/updating documentation for new features or behavior.

## Changed Packages

| Package Name | Package Path | Changeset Bump | Current Version |
|:-------------|:-------------|:--------------:|:----------------|
| @backstage/plugin-scaffolder-react | plugins/scaffolder-react | **minor** | `v1.15.0` |
| @backstage/plugin-scaffolder | plugins/scaffolder | **minor** | `v1.30.0` |


<!-- changeset-feedback -->
author_association: CONTRIBUTOR
user: login: mbenson
created_at: 2025-04-07T23:36:12Z
body: ![sctx-menu.png](https://github.com/user-attachments/assets/60dadf0d-281f-428b-99ab-192ce1cbc4d4)
![sctx-filters.png](https://github.com/user-attachments/assets/11e8fae5-2df8-4965-bc31-d69f3170d5d7)

![sctx-fns.png](https://github.com/user-attachments/assets/c0763927-78f0-493f-9f59-7c8d382511b0)

![sctx-values.png](https://github.com/user-attachments/assets/3deecec4-2262-45f0-b322-7616239920fa)



author_association: CONTRIBUTOR
user: login: mbenson
created_at: 2025-04-15T17:50:19Z
body: @benjdlambert it's close. I knew the 1.38.0 release was supposed to be today, but it hadn't happened yet so I didn't know if we'd be able to get in under the wire the scaffolder-backend API rename from `template-extensions` to `templating-extensions` but it looks like you've been doing some release tasks while I've been reworking this, so IDK whether we want to do anything special since that endpoint will have been released and doesn't seem to have any kind of alpha qualifier around it.

Other than this, do I need to move the `TemplatingExtensionsPage` from `src/components` to `src/alpha/components`?
author_association: CONTRIBUTOR
user: login: benjdlambert
created_at: 2025-04-17T21:09:08Z
body: @mbenson soery for the late reply here, but I managed to ship a patch release today with the change to `templating-extensions` instead.

Had a chat amongst other maintainers and came to the conclusion that if we do it fast enough we can probably get away with it being a bug fix as the usage should be pretty limited.

Hope that's OK that I cherry picked the bits out of here that we would need to do ASAP.

Might need a rebase here but I'll try and get a review through of the remaining stuff tomorrow - holidays permitting.


author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-04-22T10:16:23Z
body: Thank you for contributing to Backstage! The changes in this pull request will be part of the `1.39.0` release, scheduled for Tue, 20 May 2025.
author_association: CONTRIBUTOR
user: login: mbenson
created_at: 2025-04-22T13:56:19Z
body: Thanks @benjdlambert ... I do wonder, though, whether we want the page-neutral `RenderSchema` to rely on a type defined with `TemplatingExtensionsPage`.
author_association: CONTRIBUTOR
user: login: benjdlambert
created_at: 2025-04-22T13:58:45Z
body: @mbenson yeah can probably fix that up in a followup, it's all internal types at least, but yeah probably poorly placed.
author_association: MEMBER
user: login: mbenson
created_at: 2025-04-22T14:04:12Z
body: I guess we've got a bit of time before it releases, maybe discuss on 5/2?
author_association: CONTRIBUTOR